@ziggs-ai/agent-sdk 0.3.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-07-23
4
+
5
+ ### Added
6
+
7
+ - New exports: `createStaleBacklogGate`, `resolveReplayPolicy`, `newestActionableAt`, `parseReplayMaxAge` (stale-backlog gating), and `decodeOperatorKeyClaims`, `boundKeyFleetWarning` (operator-key claims).
8
+
9
+ ### Changed (breaking)
10
+
11
+ - Removed legacy socket-frame aliases; wire frames use canonical names only (ZIG-913, ZIG-915).
12
+ - Delivery-law: classify inbound messages from the `service` wire shape (ZIG-936).
13
+ - Bumped `@ziggs-ai/api-client` to `^0.3.0`.
14
+
15
+ ## 0.3.0 — 2026-07-22
16
+
17
+ ### Changed (breaking)
18
+
19
+ - Agent delivery law: backend targeting policy, idempotent writes, and long-poll / multiplexed inbox.
20
+
3
21
  ## 0.2.0 — 2026-07-16
4
22
 
5
23
  ### Added (ZIG-784)
package/dist/AgentHost.js CHANGED
@@ -306,14 +306,17 @@ export class AgentHost {
306
306
  }
307
307
  if (messageId)
308
308
  this.seenMessages.mark(messageId);
309
- const ownAgentId = this._resolveOwnAgentId(metadata);
309
+ const ownAgentId = this._resolveOwnAgentId();
310
310
  if (!ownAgentId) {
311
311
  runtimeLog.warn('AgentHost', 'handleMessage: ownAgentId missing — dropping event');
312
312
  return;
313
313
  }
314
314
  let enrichedText = typeof text === 'string' ? text : '';
315
315
  if (!enrichedText.trim()) {
316
- const agreement = metadata.agreement;
316
+ // ZIG-936: agreement rides under `service` on the inbox-pull wire; fall
317
+ // back to top-level for hand-built metadata.
318
+ const svc = metadata.service;
319
+ const agreement = (svc?.agreement ?? metadata.agreement);
317
320
  const terms = agreement?.terms;
318
321
  if (terms?.description) {
319
322
  enrichedText = String(terms.description);
@@ -449,10 +452,11 @@ export class AgentHost {
449
452
  return;
450
453
  this.stateTimers.arm(laneKey, stateName, sd.timeoutMs);
451
454
  }
452
- _resolveOwnAgentId(metadata = {}) {
453
- const self = metadata.self;
454
- const agent = metadata.agent;
455
- return this.options.agentId || self?.id || agent?.id || metadata.ziggsAgentId || null;
455
+ _resolveOwnAgentId() {
456
+ // The host is constructed for exactly one agent; its own id is the only
457
+ // source. (The socket-frame-era metadata fallbacks — self/agent/ziggsAgentId
458
+ // — are gone: nothing on the inbox wire ever carried them.)
459
+ return this.options.agentId || null;
456
460
  }
457
461
  async connectAsync(_timeout) {
458
462
  // Bootstrap active assigned tasks once (HTTP pull → lane), then start the
package/dist/index.d.ts CHANGED
@@ -40,6 +40,8 @@ export { default } from './AgentHost.js';
40
40
  export { validateContext, ContextWarning } from './cognition/validateContext.js';
41
41
  export { runLauncher } from './server/runLauncher.js';
42
42
  export { ConnectionPool } from './server/ConnectionPool.js';
43
+ export { createStaleBacklogGate, resolveReplayPolicy, newestActionableAt, parseReplayMaxAge, } from './server/staleBacklogGate.js';
44
+ export { decodeOperatorKeyClaims, boundKeyFleetWarning } from './shared/operatorKeyClaims.js';
43
45
  export { createHealthServer } from './server/createHealthServer.js';
44
46
  export { TaskService } from './server/tasks/TaskService.js';
45
47
  export { AgreementService } from './server/agreements/AgreementService.js';
package/dist/index.js CHANGED
@@ -34,6 +34,8 @@ export { default } from './AgentHost.js';
34
34
  export { validateContext, ContextWarning } from './cognition/validateContext.js';
35
35
  export { runLauncher } from './server/runLauncher.js';
36
36
  export { ConnectionPool } from './server/ConnectionPool.js';
37
+ export { createStaleBacklogGate, resolveReplayPolicy, newestActionableAt, parseReplayMaxAge, } from './server/staleBacklogGate.js';
38
+ export { decodeOperatorKeyClaims, boundKeyFleetWarning } from './shared/operatorKeyClaims.js';
37
39
  export { createHealthServer } from './server/createHealthServer.js';
38
40
  export { TaskService } from './server/tasks/TaskService.js';
39
41
  export { AgreementService } from './server/agreements/AgreementService.js';
@@ -40,12 +40,10 @@ interface WireTask {
40
40
  }
41
41
  interface WireMetadata {
42
42
  chatId?: string;
43
- chat_id?: string;
44
43
  messageId?: string;
45
44
  entryType?: string;
46
45
  content_type?: string;
47
46
  contentType?: string;
48
- receiverId?: string;
49
47
  operation?: string;
50
48
  agreementId?: string;
51
49
  sender?: {
@@ -55,10 +53,13 @@ interface WireMetadata {
55
53
  receiver?: {
56
54
  id?: string;
57
55
  };
58
- to?: {
59
- id?: string;
60
- };
61
56
  task?: WireTask;
57
+ service?: {
58
+ task?: WireTask;
59
+ operation?: string;
60
+ agreement?: unknown;
61
+ agreementId?: string;
62
+ };
62
63
  [key: string]: unknown;
63
64
  }
64
65
  interface NormalizeArgs {
@@ -5,10 +5,10 @@ const PROPOSAL_RESOLVED_OPERATIONS = new Set([
5
5
  'proposal_approved_execute',
6
6
  ]);
7
7
  function normalizeChatId(metadata) {
8
- return metadata.chatId || metadata.chat_id || null;
8
+ return metadata.chatId || null;
9
9
  }
10
10
  function normalizeReceiverId(metadata, taskData) {
11
- return metadata.receiver?.id || metadata.receiverId || metadata.to?.id || taskData?.executorId || taskData?.proposedTo || null;
11
+ return metadata.receiver?.id || taskData?.executorId || taskData?.proposedTo || null;
12
12
  }
13
13
  function isTaskRelated(metadata, taskData) {
14
14
  if (!taskData?.taskId)
@@ -21,7 +21,7 @@ function isTaskRelated(metadata, taskData) {
21
21
  }
22
22
  function isAgreementLifecycleNotification(metadata) {
23
23
  const entryType = metadata.entryType || 'message';
24
- const operation = metadata.operation;
24
+ const operation = metadata.service?.operation ?? metadata.operation;
25
25
  return (entryType === 'notification' &&
26
26
  typeof operation === 'string' &&
27
27
  PROPOSAL_RESOLVED_OPERATIONS.has(operation));
@@ -36,7 +36,9 @@ function isAgreementLifecycleNotification(metadata) {
36
36
  * but no longer gates anything.
37
37
  */
38
38
  export function normalizeIncomingEvent({ text, metadata = {} }) {
39
- const taskData = metadata.task || null;
39
+ // ZIG-936: classifier data lives under `service` on the inbox-pull wire;
40
+ // fall back to top-level for hand-built (direct-injection) metadata.
41
+ const taskData = (metadata.service?.task ?? metadata.task) || null;
40
42
  const chatId = normalizeChatId(metadata);
41
43
  const receiverId = normalizeReceiverId(metadata, taskData);
42
44
  const senderType = metadata.sender?.type ? String(metadata.sender.type).toUpperCase() : undefined;
@@ -48,13 +50,15 @@ export function normalizeIncomingEvent({ text, metadata = {} }) {
48
50
  timestamp: Date.now(),
49
51
  };
50
52
  if (isAgreementLifecycleNotification(metadata)) {
51
- const agreementId = typeof metadata.agreementId === 'string' ? metadata.agreementId : '';
53
+ const operation = metadata.service?.operation ?? metadata.operation;
54
+ const rawAgreementId = metadata.service?.agreementId ?? metadata.agreementId;
55
+ const agreementId = typeof rawAgreementId === 'string' ? rawAgreementId : '';
52
56
  return {
53
57
  chatId,
54
58
  event: {
55
59
  ...base,
56
60
  type: 'agreement_lifecycle',
57
- operation: metadata.operation,
61
+ operation,
58
62
  agreementId,
59
63
  text,
60
64
  },
@@ -421,8 +421,8 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
421
421
  for (let i = history.length - 1; i >= 0; i--) {
422
422
  const row = history[i];
423
423
  const sender = row.sender;
424
- const id = String(sender?.id || row.senderId || '');
425
- const type = String(sender?.type || row.senderType || '').toUpperCase();
424
+ const id = String(sender?.id || '');
425
+ const type = String(sender?.type || '').toUpperCase();
426
426
  if (id && id !== myId && !isNonRoutableSenderType(type)) {
427
427
  defaultReplyReceiverId = id;
428
428
  break;
@@ -809,8 +809,8 @@ function pickPrimaryUserIdFromContext(context) {
809
809
  for (let i = hist.length - 1; i >= 0; i--) {
810
810
  const row = hist[i];
811
811
  const sender = row?.sender;
812
- const type = String(sender?.type || row?.senderType || '').toUpperCase();
813
- const id = (sender?.id ?? row?.senderId);
812
+ const type = String(sender?.type || '').toUpperCase();
813
+ const id = sender?.id;
814
814
  if (type === 'USER' && id)
815
815
  return id;
816
816
  }
@@ -1,3 +1,17 @@
1
+ /**
2
+ * ConnectionPool — multi-agent fleet manager.
3
+ *
4
+ * Holds N `AgentHostOptions` configs but only constructs/wakes an
5
+ * `AgentHost` (one WS connection) when needed. Idle hosts are slept after
6
+ * `idleTimeoutMs`. At most `maxActive` hosts are connected at a time; LRU
7
+ * eviction drops the oldest if the cap is reached. With `startControl`, a
8
+ * separate "launcher" socket receives backend-driven wake hints so the pool
9
+ * spins up the right host on demand.
10
+ *
11
+ * This is how a single Node process serves a fleet of agents without
12
+ * holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
13
+ */
14
+ import { type InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
1
15
  import { AgentHost, type AgentHostOptions } from '../AgentHost.js';
2
16
  interface ConnectionManagerMeta {
3
17
  domain?: string;
@@ -13,6 +27,11 @@ interface QueryFilter {
13
27
  export interface PoolOptions {
14
28
  maxActive?: number;
15
29
  idleTimeoutMs?: number;
30
+ /**
31
+ * Gate on the operator-poll lazy start (ZIG-910): return false to leave an
32
+ * actionable agent's host down this pass. Explicit startAgent()s are not gated.
33
+ */
34
+ shouldLazyStart?: (entry: InboxOperatorAgentEntry) => boolean;
16
35
  }
17
36
  export interface SendToOptions {
18
37
  timeoutMs?: number;
@@ -25,17 +44,17 @@ export declare class ConnectionPool {
25
44
  private readonly manager;
26
45
  private readonly configs;
27
46
  private readonly responseListeners;
28
- constructor({ maxActive, idleTimeoutMs }?: PoolOptions);
47
+ constructor({ maxActive, idleTimeoutMs, shouldLazyStart }?: PoolOptions);
29
48
  /**
30
- * Register N agent configs. Each will be lazily woken (a fresh
49
+ * Register N agent configs. Each will be lazily started (a fresh
31
50
  * `AgentHost` constructed + connected) the first time it's referenced.
32
51
  */
33
52
  register(configs: AgentHostOptions[], metaArr?: ConnectionManagerMeta[]): void;
34
- wake(agentId: string): Promise<AgentHost>;
53
+ startAgent(agentId: string): Promise<AgentHost>;
35
54
  sleep(agentId: string): Promise<void>;
36
55
  disconnectAll(): Promise<void>;
37
56
  /**
38
- * Wake the named agent, deliver a message, and wait for its first outbound
57
+ * Start the named agent, deliver a message, and wait for its first outbound
39
58
  * `send-message` effect — i.e. the agent's reply. Returns the reply text.
40
59
  *
41
60
  * The effectTap injected in `register` drains a per-agent listener queue;
@@ -44,7 +63,7 @@ export declare class ConnectionPool {
44
63
  */
45
64
  requestFrom(agentId: string, text: string, metadata?: Record<string, unknown>, { timeoutMs }?: SendToOptions): Promise<string>;
46
65
  /**
47
- * Wake the named agent and deliver one wire frame to it. Returns once the
66
+ * Start the named agent and deliver one wire frame to it. Returns once the
48
67
  * host has finished processing.
49
68
  *
50
69
  * Note: does not return the agent's response. For request/response
@@ -19,11 +19,11 @@ export class ConnectionPool {
19
19
  configs = new Map();
20
20
  // Listeners enqueued by requestFrom — drained on outbound send-message effects.
21
21
  responseListeners = new Map();
22
- constructor({ maxActive = 50, idleTimeoutMs = 60_000 } = {}) {
23
- this.manager = new ConnectionManager({ maxActive, idleTimeoutMs });
22
+ constructor({ maxActive = 50, idleTimeoutMs = 60_000, shouldLazyStart } = {}) {
23
+ this.manager = new ConnectionManager({ maxActive, idleTimeoutMs, shouldLazyStart });
24
24
  }
25
25
  /**
26
- * Register N agent configs. Each will be lazily woken (a fresh
26
+ * Register N agent configs. Each will be lazily started (a fresh
27
27
  * `AgentHost` constructed + connected) the first time it's referenced.
28
28
  */
29
29
  register(configs, metaArr = []) {
@@ -56,7 +56,7 @@ export class ConnectionPool {
56
56
  },
57
57
  });
58
58
  await host.connectAsync();
59
- runtimeLog.debug('ConnectionPool', `woke "${agentId}" (active: ${this.manager.listActive().length}/${this.maxActive})`);
59
+ runtimeLog.debug('ConnectionPool', `started "${agentId}" (active: ${this.manager.listActive().length}/${this.maxActive})`);
60
60
  return host;
61
61
  }, async (host) => {
62
62
  host.disconnect();
@@ -64,8 +64,8 @@ export class ConnectionPool {
64
64
  }, metaByAgentId.get(agentId));
65
65
  }
66
66
  }
67
- async wake(agentId) {
68
- return await this.manager.wake(agentId);
67
+ async startAgent(agentId) {
68
+ return await this.manager.startAgent(agentId);
69
69
  }
70
70
  async sleep(agentId) {
71
71
  return this.manager.sleep(agentId);
@@ -74,7 +74,7 @@ export class ConnectionPool {
74
74
  return this.manager.sleepAll();
75
75
  }
76
76
  /**
77
- * Wake the named agent, deliver a message, and wait for its first outbound
77
+ * Start the named agent, deliver a message, and wait for its first outbound
78
78
  * `send-message` effect — i.e. the agent's reply. Returns the reply text.
79
79
  *
80
80
  * The effectTap injected in `register` drains a per-agent listener queue;
@@ -82,7 +82,7 @@ export class ConnectionPool {
82
82
  * does not reply within `timeoutMs` (default 60 s for LLM agents).
83
83
  */
84
84
  async requestFrom(agentId, text, metadata = {}, { timeoutMs = 60_000 } = {}) {
85
- const host = await this.wake(agentId);
85
+ const host = await this.startAgent(agentId);
86
86
  return new Promise((resolve, reject) => {
87
87
  let settled = false;
88
88
  const timer = setTimeout(() => {
@@ -123,14 +123,14 @@ export class ConnectionPool {
123
123
  });
124
124
  }
125
125
  /**
126
- * Wake the named agent and deliver one wire frame to it. Returns once the
126
+ * Start the named agent and deliver one wire frame to it. Returns once the
127
127
  * host has finished processing.
128
128
  *
129
129
  * Note: does not return the agent's response. For request/response
130
130
  * semantics use `requestFrom`.
131
131
  */
132
132
  async sendTo(agentId, text, metadata = {}, { timeoutMs = 30_000 } = {}) {
133
- const host = await this.wake(agentId);
133
+ const host = await this.startAgent(agentId);
134
134
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`sendTo timeout for "${agentId}" after ${timeoutMs}ms`)), timeoutMs));
135
135
  await Promise.race([host.handleMessage(text, metadata), timeoutPromise]);
136
136
  this.manager.touch(agentId);
@@ -22,6 +22,12 @@ export declare class AgreementService {
22
22
  counter(agreementId: string, counter: CounterAgreementData): Promise<Agreement>;
23
23
  getStatus(agreementId: string): Promise<unknown | null>;
24
24
  list(filters?: ListAgreementsFilters): Promise<Agreement[]>;
25
+ /**
26
+ * ZIG-942: despite the name, the default returns every agreement the
27
+ * caller's grant can read in the active org — including ones the caller is
28
+ * not a party to (`isYou` all-false). Pass `{ partyOnly: true }` for only
29
+ * agreements where the caller is a party.
30
+ */
25
31
  mine(filters?: GetMyAgreementsFilters): Promise<Agreement[]>;
26
32
  get(agreementId: string): Promise<Agreement | null>;
27
33
  create(body: CreateAgreementBody): Promise<{
@@ -46,6 +46,12 @@ export class AgreementService {
46
46
  list(filters = {}) {
47
47
  return listAgreements(filters, this.creds);
48
48
  }
49
+ /**
50
+ * ZIG-942: despite the name, the default returns every agreement the
51
+ * caller's grant can read in the active org — including ones the caller is
52
+ * not a party to (`isYou` all-false). Pass `{ partyOnly: true }` for only
53
+ * agreements where the caller is a party.
54
+ */
49
55
  mine(filters = {}) {
50
56
  return getMyAgreements(filters, this.creds);
51
57
  }
@@ -19,11 +19,11 @@ function readJsonBody(req) {
19
19
  export function createHealthServer({ port = Number(process.env.PORT) || 8080, label = 'agents', pool, } = {}) {
20
20
  const server = createServer(async (req, res) => {
21
21
  const url = req.url ?? '/';
22
- const wakeMatch = url.match(/^\/fleet-wake\/([^/?]+)/);
23
- if (req.method === 'POST' && wakeMatch && pool) {
24
- const agentId = decodeURIComponent(wakeMatch[1]);
22
+ const startMatch = url.match(/^\/fleet-wake\/([^/?]+)/);
23
+ if (req.method === 'POST' && startMatch && pool) {
24
+ const agentId = decodeURIComponent(startMatch[1]);
25
25
  try {
26
- await pool.wake(agentId);
26
+ await pool.startAgent(agentId);
27
27
  res.writeHead(200, { 'Content-Type': 'application/json' });
28
28
  res.end(JSON.stringify({ ok: true, agentId, active: pool.listActive().includes(agentId) }));
29
29
  }
@@ -45,7 +45,7 @@ export function createHealthServer({ port = Number(process.env.PORT) || 8080, la
45
45
  res.end(JSON.stringify({ ok: false, error: 'agentId, chatId, text, senderId required' }));
46
46
  return;
47
47
  }
48
- const host = (await pool.wake(agentId));
48
+ const host = (await pool.startAgent(agentId));
49
49
  await host.handleMessage(text, {
50
50
  chatId,
51
51
  entryType: 'message',
@@ -7,33 +7,6 @@ function log(label, msg) {
7
7
  function warn(label, msg) {
8
8
  runtimeLog.warn(label, msg);
9
9
  }
10
- /** Map Mongo message row → wire metadata for normalizeIncomingEvent. */
11
- function rowToMetadata(row, chatId) {
12
- const senderRaw = row.sender;
13
- const senderId = String(senderRaw?.agentId ?? senderRaw?.id ?? '');
14
- const receiver = row.receiver;
15
- const senderType = senderRaw?.type ??
16
- (receiver?.type === 'agent' ? 'agent' : 'user');
17
- return {
18
- chatId,
19
- chat_id: chatId,
20
- messageId: row.messageId,
21
- sender: { id: senderId, type: senderType },
22
- senderId,
23
- senderType: String(senderType).toUpperCase(),
24
- receiver: receiver ?? null,
25
- receiverId: receiver?.id ?? null,
26
- entryType: row.entryType ?? 'message',
27
- content_type: row.content_type ?? row.contentType ?? 'text',
28
- contentType: row.content_type ?? row.contentType ?? 'text',
29
- task: row.task ?? null,
30
- agreement: row.agreement ?? null,
31
- operation: row.operation ?? null,
32
- agreementId: row.agreementId ?? null,
33
- timestamp: row.timestamp,
34
- text: row.text,
35
- };
36
- }
37
10
  async function chatIdsForScope(scope, creds, grants) {
38
11
  const { kind, id } = scope.scope;
39
12
  switch (kind) {
@@ -82,8 +55,7 @@ async function deliverMessagesForScope(deps, scope, chatIds, reader, countCap) {
82
55
  const text = typeof row.text === 'string' ? row.text : '';
83
56
  if (!text.trim())
84
57
  continue;
85
- const metadata = rowToMetadata(row, chatId);
86
- await deps.onMessage(text, metadata);
58
+ await deps.onMessage(text, { ...row, chatId });
87
59
  delivered++;
88
60
  if (remaining !== Number.POSITIVE_INFINITY)
89
61
  remaining--;
@@ -18,7 +18,7 @@ interface FleetOptions extends SingleOptions {
18
18
  wsUrl?: string;
19
19
  agentMeta?: Record<string, unknown>[];
20
20
  primary?: AgentConfig | ((pool: ConnectionPool) => AgentConfig);
21
- preWake?: string[];
21
+ preStart?: string[];
22
22
  poolOptions?: {
23
23
  maxActive?: number;
24
24
  idleTimeoutMs?: number;
@@ -1,6 +1,8 @@
1
1
  import { ConnectionPool } from './ConnectionPool.js';
2
2
  import { AgentHost } from '../AgentHost.js';
3
3
  import { createHealthServer } from './createHealthServer.js';
4
+ import { createStaleBacklogGate, resolveReplayPolicy } from './staleBacklogGate.js';
5
+ import { boundKeyFleetWarning } from '../shared/operatorKeyClaims.js';
4
6
  import { runtimeLog } from '../shared/runtimeLog.js';
5
7
  export async function runLauncher(configOrConfigs, opts = {}) {
6
8
  if (Array.isArray(configOrConfigs))
@@ -34,8 +36,14 @@ async function runSingle(config, { operatorKey, label = 'agent', port = process.
34
36
  });
35
37
  return { agent, healthServer: server ?? undefined };
36
38
  }
37
- async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port = process.env.PORT || 8080, healthServer = true, label = 'launcher', agentMeta, primary: primaryConfig, preWake = [], poolOptions, onShutdown, } = {}) {
38
- const pool = new ConnectionPool(poolOptions);
39
+ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port = process.env.PORT || 8080, healthServer = true, label = 'launcher', agentMeta, primary: primaryConfig, preStart = [], poolOptions, onShutdown, } = {}) {
40
+ // ZIG-910: don't resurrect stale backlogs at bootstrap. Env-driven default
41
+ // (FLEET_REPLAY_MAX_AGE / FLEET_REPLAY_STALE); an explicit
42
+ // poolOptions.shouldLazyStart wins.
43
+ const pool = new ConnectionPool({
44
+ shouldLazyStart: createStaleBacklogGate({ ...resolveReplayPolicy(), label }),
45
+ ...poolOptions,
46
+ });
39
47
  const effectiveKey = operatorKey ?? process.env.ZIGGS_OPERATOR_KEY;
40
48
  const decoratedConfigs = effectiveKey
41
49
  ? configs.map(c => (c.operatorKey ? c : { ...c, operatorKey: effectiveKey }))
@@ -50,6 +58,11 @@ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port
50
58
  }
51
59
  const controlKey = effectiveKey ?? configs.find(c => c.operatorKey)?.operatorKey ?? resolvedPrimaryConfig?.operatorKey;
52
60
  if (controlKey) {
61
+ // ZIG-911: an agent-bound key half-works in a confusing way — say the big
62
+ // thing once at startup instead of letting N-1 per-agent loops 403.
63
+ const boundWarning = boundKeyFleetWarning(controlKey, pool.size);
64
+ if (boundWarning)
65
+ runtimeLog.warn(label, boundWarning);
53
66
  pool.startControl({ wsUrl, operatorKey: controlKey });
54
67
  }
55
68
  else {
@@ -68,10 +81,10 @@ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port
68
81
  runtimeLog.warn(label, `Primary agent "${resolvedPrimaryConfig.name || resolvedPrimaryConfig.ownAgentId || 'primary'}" failed to connect at startup: ${err.message} — will retry`);
69
82
  }
70
83
  }
71
- if (preWake.length > 0) {
72
- await Promise.all(preWake.map(id => pool.wake(id)
73
- .then(() => runtimeLog.debug(label, `Pre-woke "${id}"`))
74
- .catch(err => runtimeLog.warn(label, `Could not pre-wake "${id}": ${err.message}`))));
84
+ if (preStart.length > 0) {
85
+ await Promise.all(preStart.map(id => pool.startAgent(id)
86
+ .then(() => runtimeLog.debug(label, `Pre-started "${id}"`))
87
+ .catch(err => runtimeLog.warn(label, `Could not pre-start "${id}": ${err.message}`))));
75
88
  }
76
89
  runtimeLog.info(label, `Ready — ${pool.size} agent(s) registered, ${pool.listActive().length} connected`);
77
90
  process.on('SIGTERM', async () => {
@@ -0,0 +1,53 @@
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 { InboxEnvelope, 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 timestamp across an agent's slice: scope activity,
37
+ * proposals, and connection/MCP requests. Null when nothing carries a
38
+ * timestamp (e.g. a joined-but-idle chat) — age unprovable, treated as fresh.
39
+ */
40
+ export declare function newestActionableAt(inbox: InboxEnvelope): number | null;
41
+ export declare function formatAge(ms: number): string;
42
+ export interface StaleBacklogGateOptions extends ReplayPolicy {
43
+ label?: string;
44
+ now?: () => number;
45
+ log?: (line: string) => void;
46
+ }
47
+ /**
48
+ * Build the `shouldLazyStart` gate for the operator poll. Skipped agents
49
+ * resurface in every envelope (reading never acks), so announcements are
50
+ * per-agent-once: each poll pass that skips agents not yet announced emits one
51
+ * aggregated warn line covering them.
52
+ */
53
+ export declare function createStaleBacklogGate({ maxAgeMs, replayAll, label, now, log, }: StaleBacklogGateOptions): (entry: InboxOperatorAgentEntry) => boolean;
@@ -0,0 +1,120 @@
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 timestamp across an agent's slice: scope activity,
34
+ * proposals, and connection/MCP requests. Null when nothing carries a
35
+ * timestamp (e.g. a joined-but-idle chat) — age unprovable, treated as fresh.
36
+ */
37
+ export function newestActionableAt(inbox) {
38
+ let newest = null;
39
+ const consider = (iso) => {
40
+ if (!iso)
41
+ return;
42
+ const t = Date.parse(iso);
43
+ if (!Number.isNaN(t) && (newest == null || t > newest))
44
+ newest = t;
45
+ };
46
+ for (const scope of inbox.scopes ?? [])
47
+ consider(scope.latestAt);
48
+ for (const p of inbox.proposalsAwaitingMe ?? [])
49
+ consider(p.proposedAt);
50
+ for (const r of inbox.connectionRequestsAwaitingMe ?? [])
51
+ consider(r.requestedAt);
52
+ for (const r of inbox.mcpServerRequestsAwaitingMe ?? [])
53
+ consider(r.requestedAt);
54
+ return newest;
55
+ }
56
+ /** Unread items in a slice, for the summary line. Counts are backend-capped. */
57
+ function countItems(inbox) {
58
+ let items = 0;
59
+ for (const scope of inbox.scopes ?? []) {
60
+ items += (scope.newMessages ?? 0) + (scope.newArtifacts ?? 0);
61
+ }
62
+ items += (inbox.proposalsAwaitingMe ?? []).length;
63
+ items += (inbox.connectionRequestsAwaitingMe ?? []).length;
64
+ items += (inbox.mcpServerRequestsAwaitingMe ?? []).length;
65
+ return items;
66
+ }
67
+ export function formatAge(ms) {
68
+ if (ms >= 86_400_000)
69
+ return `${trimNum(ms / 86_400_000)}d`;
70
+ if (ms >= 3_600_000)
71
+ return `${trimNum(ms / 3_600_000)}h`;
72
+ if (ms >= 60_000)
73
+ return `${trimNum(ms / 60_000)}m`;
74
+ return `${trimNum(ms / 1_000)}s`;
75
+ }
76
+ function trimNum(n) {
77
+ return Number(n.toFixed(1)).toString();
78
+ }
79
+ /**
80
+ * Build the `shouldLazyStart` gate for the operator poll. Skipped agents
81
+ * resurface in every envelope (reading never acks), so announcements are
82
+ * per-agent-once: each poll pass that skips agents not yet announced emits one
83
+ * aggregated warn line covering them.
84
+ */
85
+ export function createStaleBacklogGate({ maxAgeMs, replayAll, label = 'launcher', now = Date.now, log, }) {
86
+ const emit = log ?? ((line) => runtimeLog.warn(label, line));
87
+ const announced = new Set();
88
+ let pendingAgents = [];
89
+ let pendingItems = 0;
90
+ let flushScheduled = false;
91
+ // One envelope's entries are gated synchronously; a microtask-deferred
92
+ // flush turns a whole boot flood into a single summary line.
93
+ const flush = () => {
94
+ flushScheduled = false;
95
+ if (pendingAgents.length === 0)
96
+ return;
97
+ const preview = pendingAgents.slice(0, 5).join(', ');
98
+ const more = pendingAgents.length > 5 ? `, +${pendingAgents.length - 5} more` : '';
99
+ emit(`startup backlog policy: skipped ${pendingItems} stale item(s) across ${pendingAgents.length} agent(s) older than ${formatAge(maxAgeMs)} (${preview}${more}) — set FLEET_REPLAY_STALE=1 to replay them`);
100
+ pendingAgents = [];
101
+ pendingItems = 0;
102
+ };
103
+ return (entry) => {
104
+ if (replayAll)
105
+ return true;
106
+ const newest = newestActionableAt(entry.inbox);
107
+ if (newest == null || now() - newest <= maxAgeMs)
108
+ return true;
109
+ if (!announced.has(entry.agentId)) {
110
+ announced.add(entry.agentId);
111
+ pendingAgents.push(entry.agentId);
112
+ pendingItems += countItems(entry.inbox);
113
+ if (!flushScheduled) {
114
+ flushScheduled = true;
115
+ queueMicrotask(flush);
116
+ }
117
+ }
118
+ return false;
119
+ };
120
+ }
@@ -129,7 +129,7 @@ export const contextDiscoverGrantableTool = defineTool('context_discover_grantab
129
129
  const items = await new ContextDiscoveryClient(operatorKey, agentId).discoverGrantable();
130
130
  return { count: items.length, items };
131
131
  }, {
132
- description: 'See what context EXISTS in your orgs that you CANNOT read yet — the inverse of list_grants. Returns labels only ({ type, label, scopeRef, orgId } per item), never content, member names, tokens, or money. Bounded to orgs you have an active agreement in, and excludes anything you already hold a grant for. Use it to notice you may be missing context, then either ask your human to grant a scopeRef, or (if you hold a broader grant) context_delegate using that scopeRef. Pair with list_grants (what you hold) and context_expand_reach (what a held grant covers).',
132
+ description: 'See what context EXISTS in your orgs that you CANNOT read yet — the inverse of list_grants. Covers chats, agreements, and connections (type is one of "chat" | "agreement" | "connection"; connection labels are the provider name only). Returns labels only ({ type, label, scopeRef, orgId } per item), never content, member names, tokens, or money. Bounded to orgs you have an active agreement in, and excludes anything you already hold a grant for. Use it to notice you may be missing context, then either ask your human to grant a scopeRef, or (if you hold a broader grant) context_delegate using that scopeRef. Pair with list_grants (what you hold) and context_expand_reach (what a held grant covers).',
133
133
  });
134
134
  export const CONTEXT_TOOLS = [
135
135
  contextReadTool,
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Client-side peek at an operator key's JWT payload (signature NOT verified —
3
+ * identity hint only, the backend remains the authority). Used by the fleet
4
+ * launcher to warn when the key is agent-bound (ZIG-911): a bound key
5
+ * collapses the operator multiplex to its one agent and 403s every other
6
+ * per-agent loop, so a large fleet quietly watches a single agent.
7
+ */
8
+ export interface OperatorKeyClaims {
9
+ type?: string;
10
+ keyId?: string;
11
+ ownerId?: string;
12
+ boundAgentId?: string | null;
13
+ exp?: number;
14
+ }
15
+ export declare function decodeOperatorKeyClaims(token: string): OperatorKeyClaims | null;
16
+ /**
17
+ * The ZIG-911 startup check: warn (never refuse — a one-agent launcher on a
18
+ * bound key is legitimate) when the fleet's control key is bound to a single
19
+ * agent while more than one agent is registered. Returns the warning line, or
20
+ * null when the setup is coherent.
21
+ */
22
+ export declare function boundKeyFleetWarning(operatorKey: string, registeredAgents: number): string | null;
@@ -0,0 +1,35 @@
1
+ export function decodeOperatorKeyClaims(token) {
2
+ const parts = token.trim().split('.');
3
+ if (parts.length !== 3)
4
+ return null;
5
+ try {
6
+ const json = Buffer.from(parts[1], 'base64url').toString('utf8');
7
+ const payload = JSON.parse(json);
8
+ return {
9
+ type: payload.type,
10
+ keyId: payload.keyId,
11
+ ownerId: payload.ownerId,
12
+ boundAgentId: payload.boundAgentId ?? null,
13
+ exp: payload.exp,
14
+ };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * The ZIG-911 startup check: warn (never refuse — a one-agent launcher on a
22
+ * bound key is legitimate) when the fleet's control key is bound to a single
23
+ * agent while more than one agent is registered. Returns the warning line, or
24
+ * null when the setup is coherent.
25
+ */
26
+ export function boundKeyFleetWarning(operatorKey, registeredAgents) {
27
+ if (registeredAgents <= 1)
28
+ return null;
29
+ const bound = decodeOperatorKeyClaims(operatorKey)?.boundAgentId;
30
+ if (!bound)
31
+ return null;
32
+ return (`fleet key is bound to agent "${bound}": the operator multiplex covers 1 of ${registeredAgents} registered agents, ` +
33
+ `and per-agent inbox loops for the rest will 403 ("cannot impersonate"). ` +
34
+ `Use an unbound owner key with agents:impersonate to run a fleet.`);
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.2.0",
29
+ "@ziggs-ai/api-client": "^0.3.1",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",