@ziggs-ai/api-client 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,16 @@
1
1
  import { type InboxOperatorAgentEntry } from './http/InboxClient.js';
2
2
  type OpenFn = () => Promise<unknown>;
3
3
  type CloseFn = (handle: unknown) => Promise<void>;
4
+ export interface StartAgentOptions {
5
+ /**
6
+ * Keep this host running until it is stopped explicitly — exempt from the
7
+ * idle sweep, last choice for LRU eviction. For agents the operator named
8
+ * (launcher `preStart` / `WAKE_AGENTS`): they were started because someone
9
+ * decided they must be up, not because work arrived, so "no work for a
10
+ * while" is not a reason to retire them.
11
+ */
12
+ pinned?: boolean;
13
+ }
4
14
  export interface ConnectionManagerMeta {
5
15
  domain?: string;
6
16
  expertise?: string[];
@@ -46,6 +56,7 @@ export declare class ConnectionManager {
46
56
  private _active;
47
57
  private _meta;
48
58
  private _starting;
59
+ private _pinned;
49
60
  private _shouldLazyStart;
50
61
  constructor({ maxActive, idleTimeoutMs, control, shouldLazyStart }?: ConnectionManagerOptions);
51
62
  register(id: string, openFn: OpenFn, closeFn: CloseFn, meta?: ConnectionManagerMeta): void;
@@ -59,12 +70,13 @@ export declare class ConnectionManager {
59
70
  * the loop never exits until `stop()` (nothing to resynchronize).
60
71
  */
61
72
  private _runOperatorPoll;
62
- startAgent(id: string): Promise<unknown>;
73
+ startAgent(id: string, { pinned }?: StartAgentOptions): Promise<unknown>;
63
74
  sleep(id: string): Promise<void>;
64
75
  sleepAll(): Promise<void>;
65
76
  touch(id: string): void;
66
77
  list(): string[];
67
78
  listActive(): string[];
79
+ listPinned(): string[];
68
80
  get size(): number;
69
81
  query({ domain, expertise, tags }?: QueryFilter): string[];
70
82
  getMeta(id: string): ConnectionManagerMeta | undefined;
@@ -16,6 +16,7 @@ export class ConnectionManager {
16
16
  _active;
17
17
  _meta;
18
18
  _starting;
19
+ _pinned;
19
20
  _shouldLazyStart;
20
21
  constructor({ maxActive = 50, idleTimeoutMs = 60_000, control, shouldLazyStart } = {}) {
21
22
  this.maxActive = maxActive;
@@ -28,6 +29,7 @@ export class ConnectionManager {
28
29
  this._active = new Map();
29
30
  this._meta = new Map();
30
31
  this._starting = new Map();
32
+ this._pinned = new Set();
31
33
  }
32
34
  register(id, openFn, closeFn, meta) {
33
35
  if (!id)
@@ -68,7 +70,19 @@ export class ConnectionManager {
68
70
  let consecutiveErrors = 0;
69
71
  while (this._polling) {
70
72
  try {
71
- const env = await inbox.getOperatorInbox({ waitSeconds: OPERATOR_POLL_WAIT_SECONDS });
73
+ // Roster scoping (ZIG-965): only agents this manager can actually
74
+ // start are worth sweeping server-side. Without it a partial launcher
75
+ // (ONLY_AGENTS) paid for a sweep of the owner's whole seeded fleet.
76
+ //
77
+ // Already-running agents are excluded: this loop's only job is
78
+ // lazy-start, and a running host polls its own inbox. Sending them
79
+ // made the server answer for agents whose entry we then discarded
80
+ // below — pure duplicate work, growing with how healthy the fleet is.
81
+ const startable = [...this._entries.keys()].filter((id) => !this._active.has(id));
82
+ const env = await inbox.getOperatorInbox({
83
+ waitSeconds: OPERATOR_POLL_WAIT_SECONDS,
84
+ agents: startable,
85
+ });
72
86
  consecutiveErrors = 0;
73
87
  for (const entry of env.agents) {
74
88
  const id = entry.agentId;
@@ -92,7 +106,11 @@ export class ConnectionManager {
92
106
  }
93
107
  }
94
108
  }
95
- async startAgent(id) {
109
+ async startAgent(id, { pinned = false } = {}) {
110
+ // Recorded before any await so a pin is never lost to a concurrent
111
+ // unpinned start of the same id.
112
+ if (pinned)
113
+ this._pinned.add(id);
96
114
  const existing = this._active.get(id);
97
115
  if (existing) {
98
116
  this._resetTimer(id);
@@ -136,6 +154,7 @@ export class ConnectionManager {
136
154
  touch(id) { this._resetTimer(id); }
137
155
  list() { return [...this._entries.keys()]; }
138
156
  listActive() { return [...this._active.keys()]; }
157
+ listPinned() { return [...this._pinned]; }
139
158
  get size() { return this._entries.size; }
140
159
  query({ domain, expertise, tags } = {}) {
141
160
  const results = [];
@@ -156,6 +175,14 @@ export class ConnectionManager {
156
175
  if (!entry)
157
176
  return;
158
177
  clearTimeout(entry.timer);
178
+ // A pinned host has no idle timer at all. Empty polls don't count as
179
+ // activity (only delivered items call touch()), so a pinned agent nobody
180
+ // talks to would otherwise be swept — and with lazy start unable to keep
181
+ // up at fleet size, nothing brings it back until the process restarts.
182
+ if (this._pinned.has(id)) {
183
+ entry.timer = null;
184
+ return;
185
+ }
159
186
  entry.timer = setTimeout(() => {
160
187
  this.sleep(id).catch(err => runtimeLog.warn('ConnectionManager', `idle sleep("${id}") failed: ${err.message}`));
161
188
  }, this.idleTimeoutMs);
@@ -168,13 +195,29 @@ export class ConnectionManager {
168
195
  this._scheduleIdle(id);
169
196
  }
170
197
  async _evictLRU() {
171
- let oldest = null;
172
- for (const [id, entry] of this._active) {
173
- const oldestEntry = oldest ? this._active.get(oldest) : null;
174
- if (!oldest || !oldestEntry || entry.lastActive < oldestEntry.lastActive)
175
- oldest = id;
198
+ // Pinned hosts are the last to go: pick the LRU unpinned one, and only
199
+ // fall back to a pinned one when every active host is pinned. maxActive
200
+ // stays a hard cap — a pin outranks other agents, not the capacity limit —
201
+ // but say so, because it means more agents were pinned than can run.
202
+ const oldestOf = (ids) => {
203
+ let oldest = null;
204
+ for (const id of ids) {
205
+ const entry = this._active.get(id);
206
+ const oldestEntry = oldest ? this._active.get(oldest) : null;
207
+ if (!entry)
208
+ continue;
209
+ if (!oldest || !oldestEntry || entry.lastActive < oldestEntry.lastActive)
210
+ oldest = id;
211
+ }
212
+ return oldest;
213
+ };
214
+ const active = [...this._active.keys()];
215
+ const victim = oldestOf(active.filter(id => !this._pinned.has(id))) ?? oldestOf(active);
216
+ if (!victim)
217
+ return;
218
+ if (this._pinned.has(victim)) {
219
+ runtimeLog.warn('ConnectionManager', `evicting pinned "${victim}": all ${active.length} active hosts are pinned and maxActive=${this.maxActive} is reached — pin fewer agents or raise maxActive`);
176
220
  }
177
- if (oldest)
178
- await this.sleep(oldest);
221
+ await this.sleep(victim);
179
222
  }
180
223
  }
@@ -34,8 +34,11 @@ export const recordArtifactCapability = {
34
34
  enum: ['chat', 'agent-private'],
35
35
  description: 'chat = visible to scope parties; agent-private = your eyes only',
36
36
  },
37
- chatId: { type: 'string', description: 'Target chat (xor agreementId)' },
38
- agreementId: { type: 'string', description: 'Target agreement (xor chatId)' },
37
+ chatId: { type: 'string', description: 'Target chat scope' },
38
+ agreementId: {
39
+ type: 'string',
40
+ description: 'Target agreement scope — for a hire deliverable, prefer this. When both chatId and agreementId are passed, agreementId wins.',
41
+ },
39
42
  taskId: {
40
43
  type: 'string',
41
44
  description: 'Optional task — creates a TaskArtifactLink alongside the primary scope link',
@@ -51,10 +54,15 @@ export const recordArtifactCapability = {
51
54
  },
52
55
  needsAgentId: true,
53
56
  handler: async (args, env) => {
54
- const chatId = args['chatId'];
55
57
  const agreementId = args['agreementId'];
56
- if ((chatId && agreementId) || (!chatId && !agreementId)) {
57
- throw new Error('Pass exactly one of chatId or agreementId');
58
+ // Agreement wins when both scopes arrive. Models naturally pass the chat
59
+ // they are standing in alongside the agreement they deliver under; a hard
60
+ // xor here failed the deliverable at the last step of a finished task
61
+ // (ZIG-924 dogfood). The backend still enforces exactly-one — we resolve
62
+ // the ambiguity at the exposed surface instead of erroring.
63
+ const chatId = agreementId ? undefined : args['chatId'];
64
+ if (!chatId && !agreementId) {
65
+ throw new Error('Pass chatId or agreementId');
58
66
  }
59
67
  const visibility = args['visibility'];
60
68
  if (visibility !== 'chat' && visibility !== 'agent-private') {
@@ -1,9 +1,33 @@
1
1
  import { createAgreement, claimAgreement, listAgreements, revokeAgreement, } from '../http/AgreementClient.js';
2
2
  import { fullCreds } from './types.js';
3
3
  const DEFAULT_WEB_URL = 'https://ziggsai.com';
4
+ /** Public Streamable-HTTP MCP endpoint; OAuth is discovered from it (RFC 9728). */
5
+ const ZIGGS_MCP_URL = 'https://mcp.ziggsai.com/mcp';
4
6
  function webAppOrigin(env) {
5
7
  return (env.webUrl?.trim() || DEFAULT_WEB_URL).replace(/\/$/, '');
6
8
  }
9
+ /**
10
+ * The second form an invite travels in: text the recipient pastes into their
11
+ * own assistant, which then connects itself and claims the invite. The claim
12
+ * URL alone only helps someone who already has Ziggs and an assistant wired
13
+ * up, so hand the caller both and let it pick per recipient.
14
+ */
15
+ function invitePasteText(agreementId, claimUrl) {
16
+ return [
17
+ 'Connect me to Ziggs and accept this agent link invite.',
18
+ '',
19
+ `1. Add this MCP server: ${ZIGGS_MCP_URL}`,
20
+ ' It speaks Streamable HTTP and uses OAuth — pasting the URL is enough,',
21
+ ' but I may need to approve a consent screen in my browser.',
22
+ `2. Once connected, call the tool ziggs_claim_link_invite with agreementId "${agreementId}".`,
23
+ '3. Then tell me who I am linked with, and what they can and cannot see.',
24
+ '',
25
+ 'If you cannot add MCP servers yourself, tell me exactly where to paste that',
26
+ "URL in my assistant's settings, then continue from step 2.",
27
+ '',
28
+ `Invite link (if you need it in a browser instead): ${claimUrl}`,
29
+ ].join('\n');
30
+ }
7
31
  /**
8
32
  * ZIG-670 — link-shaped summaries, not raw agreement documents: the money
9
33
  * block, approvals array, and Mongo internals are noise on a trust
@@ -85,13 +109,15 @@ export const createLinkInviteCapability = {
85
109
  needsAgentId: true,
86
110
  handler: async (args, env) => {
87
111
  const { agreement } = await createAgreement({ engagementKind: 'link', description: args['message'] }, fullCreds(env));
112
+ const claimUrl = `${webAppOrigin(env)}/app/link-invites/${agreement.agreementId}`;
88
113
  return {
89
114
  status: 'open',
90
115
  inviteId: agreement.agreementId,
91
- claimUrl: `${webAppOrigin(env)}/app/link-invites/${agreement.agreementId}`,
116
+ claimUrl,
117
+ pasteText: invitePasteText(agreement.agreementId, claimUrl),
92
118
  message: env.surface === 'mcp'
93
- ? 'Open link invite created. Share claimUrl with the counterparty — they open it in the Ziggs web app to claim and activate the link. No agent id needed on either side.'
94
- : 'Open link invite created. Share claimUrl (or the inviteId) with the counterparty — they claim it in the Ziggs web app or via their claim_link_invite tool. No agent id needed on either side. Revoke with revoke_link to disable.',
119
+ ? 'Open link invite created, single-use and valid 7 days. Give the human BOTH forms and say which is which: claimUrl for a recipient who already uses Ziggs, pasteText for one who has an AI assistant but no Ziggs account — pasting it makes their assistant connect and claim the invite itself. No agent id needed on either side.'
120
+ : 'Open link invite created, single-use and valid 7 days. Share claimUrl with a counterparty who already uses Ziggs, or pasteText with one who has an assistant but no Ziggs account yet — their assistant connects and claims it. No agent id needed on either side. Revoke with revoke_link to disable.',
95
121
  agreement: linkSummary(agreement),
96
122
  };
97
123
  },
@@ -14,6 +14,12 @@ export interface ProposeTerms {
14
14
  lifecycle?: string;
15
15
  expiresAt?: string;
16
16
  maxExecutions?: number;
17
+ /**
18
+ * How `price` reads. `total` (default) escrows one price for the whole
19
+ * engagement and pays at fulfillment; `per_task` is a RATE settled as each
20
+ * task completes (standing/open agreements only, and the default for a hire).
21
+ */
22
+ billing?: 'total' | 'per_task';
17
23
  agreementDescription?: string;
18
24
  parentAgreementId?: string;
19
25
  parentTaskId?: string;
@@ -49,7 +49,13 @@ export class ContextReadClient {
49
49
  const res = await fetch(url.toString(), { headers });
50
50
  const body = await res.text().catch(() => '');
51
51
  if (!res.ok) {
52
- throw new Error(`ContextReadClient.read ${type} ${res.status} ${body.slice(0, 200)}`);
52
+ // Carry the status like `snapshot()` does, so callers can branch on it.
53
+ // A 403 here is a legitimate outcome, not a transport failure: addressing
54
+ // and authorisation are separate, so an agent can be told about mail it
55
+ // is not (or is no longer) allowed to open.
56
+ const err = new Error(`ContextReadClient.read ${type} ${res.status} ${body.slice(0, 200)}`);
57
+ err.status = res.status;
58
+ throw err;
53
59
  }
54
60
  return JSON.parse(body);
55
61
  }
@@ -1,28 +1,24 @@
1
1
  import 'dotenv/config';
2
- export type InboxScopeKind = 'chat' | 'agreement' | 'org';
3
2
  /**
4
- * ZIG-543: which chats a multi-chat scope's news is in. Lets a scope-granted
5
- * agent open the conversations behind the count (e.g. GET /chats/:chatId/messages).
3
+ * One thing addressed to this agent. A reference, never content — following it
4
+ * (a chat read, a task read) is where this agent's grants are enforced.
6
5
  */
7
- export interface InboxScopeChat {
8
- chatId: string;
9
- newMessages: number;
10
- newArtifacts: number;
11
- latestAt: string | null;
6
+ export interface InboxDeliveryRef {
7
+ /** 'message' | 'artifact' | 'task-state' | 'agreement'. */
8
+ kind: string;
9
+ resourceId: string;
10
+ chatId: string | null;
11
+ agreementId: string | null;
12
+ taskId: string | null;
13
+ /** Who wrote it. Never this agent — you are not woken by your own writes. */
14
+ actorId: string | null;
15
+ ts: string;
12
16
  }
13
- export interface InboxScopeEntry {
14
- scope: {
15
- kind: InboxScopeKind;
16
- id: string;
17
- };
18
- newMessages: number;
19
- newArtifacts: number;
20
- latestAt: string | null;
21
- since: string;
22
- /** Per-chat breakdown for org / agreement scopes (ZIG-543). Absent for a chat scope. */
23
- chats?: InboxScopeChat[];
24
- /** Chats with news beyond the per-scope cap, not listed in `chats`. */
25
- truncatedChats?: number;
17
+ /** Message/artifact deliveries folded by chat, so you can open chats directly. */
18
+ export interface InboxChatNews {
19
+ chatId: string;
20
+ count: number;
21
+ latestAt: string;
26
22
  }
27
23
  export interface InboxProposalRef {
28
24
  agreementId: string;
@@ -35,54 +31,59 @@ export interface InboxConnectionRequestRef {
35
31
  message: string | null;
36
32
  requestedAt: string | null;
37
33
  }
38
- /** Agent asking its principal to connect an MCP server + grant tools (ZIG-686). */
39
- export interface InboxMcpServerRequestRef {
40
- requestId: string;
41
- requesterAgentId: string;
42
- serverUrl: string;
43
- tools: string[];
44
- reason: string | null;
45
- requestedAt: string | null;
46
- }
47
34
  /** Pull-only MCP: prompt the human when proposals need a decision (ZIG-482 / ZIG-481). */
48
35
  export interface InboxHumanAttention {
49
36
  required: true;
50
- reason: 'proposals_awaiting_me' | 'connection_requests_awaiting_me' | 'mcp_server_requests_awaiting_me' | 'multiple';
37
+ reason: 'proposals_awaiting_me' | 'connection_requests_awaiting_me' | 'multiple';
51
38
  proposalCount: number;
52
39
  truncatedProposals: number;
53
40
  connectionRequestCount: number;
54
41
  truncatedConnectionRequests: number;
55
- mcpServerRequestCount: number;
56
- truncatedMcpServerRequests: number;
57
42
  promptUser: string;
58
43
  }
44
+ /**
45
+ * An open task assigned to this agent (ZIG-973). References only — read the
46
+ * task for its description/plan/inputs.
47
+ *
48
+ * Tasks ride their own channel because assignment IS their delivery: before
49
+ * this, a task wake was a synthetic chat row, so work under a chat-less
50
+ * agreement (or self-assigned) reached nobody.
51
+ */
52
+ export interface InboxTaskRef {
53
+ taskId: string;
54
+ agreementId: string | null;
55
+ title: string;
56
+ state: string;
57
+ updatedAt: string | null;
58
+ }
59
59
  export interface InboxEnvelope {
60
60
  asOf: string;
61
- scopes: InboxScopeEntry[];
62
- truncatedScopes: number;
63
- countCap: number;
61
+ /**
62
+ * Unacked deliveries addressed to this agent, newest first. This IS the
63
+ * inbox — read straight out of the delivery log, not derived from grants.
64
+ */
65
+ deliveries: InboxDeliveryRef[];
66
+ /** True when there was more than one envelope's worth; the rest stay unacked. */
67
+ deliveriesCapped: boolean;
68
+ /** The chat-bearing deliveries above, folded by chat. */
69
+ chats: InboxChatNews[];
70
+ /**
71
+ * Pass to `ack()` after acting. Null when there is nothing to ack. Ack after
72
+ * acting, not after reading: a crash in between redelivers.
73
+ */
74
+ ackTo: string | null;
75
+ /** Open tasks assigned to this agent — the work channel (ZIG-973). */
76
+ tasksAwaitingMe: InboxTaskRef[];
77
+ truncatedTasks: number;
64
78
  proposalsAwaitingMe: InboxProposalRef[];
65
79
  truncatedProposals: number;
66
80
  connectionRequestsAwaitingMe: InboxConnectionRequestRef[];
67
81
  truncatedConnectionRequests: number;
68
- /** Agent requests to connect an MCP server awaiting the user (ZIG-686). */
69
- mcpServerRequestsAwaitingMe: InboxMcpServerRequestRef[];
70
- truncatedMcpServerRequests: number;
71
82
  humanAttention?: InboxHumanAttention;
72
83
  }
73
- export interface InboxAck {
74
- kind: InboxScopeKind;
75
- id: string;
76
- upTo: string;
77
- }
78
84
  export interface InboxAckResult {
79
- acked: Array<{
80
- scope: {
81
- kind: InboxScopeKind;
82
- id: string;
83
- };
84
- ackedUpTo: string;
85
- }>;
85
+ /** Where the watermark now sits. Monotonic — a rewind is a no-op, not an error. */
86
+ ackedUpTo: string | null;
86
87
  }
87
88
  /**
88
89
  * Long-poll option shared by the inbox reads. The server holds the request up
@@ -93,27 +94,50 @@ export interface InboxAckResult {
93
94
  */
94
95
  export interface InboxReadOptions {
95
96
  waitSeconds?: number;
97
+ /**
98
+ * Operator read only (ZIG-965): restrict the sweep to this roster. The
99
+ * server intersects it with the agents the key owner runs — it narrows,
100
+ * never widens. A launcher hosting a subset should always pass the agents
101
+ * it actually registered, or it pays for a sweep of the owner's whole
102
+ * seeded fleet.
103
+ */
104
+ agents?: string[];
96
105
  }
97
- /** One agent's slice of the operator-level multiplexed read. */
106
+ /**
107
+ * One agent's line in the operator sweep: enough to decide whether to start a
108
+ * host, and nothing more.
109
+ *
110
+ * Deliberately NOT that agent's envelope. The launcher only chooses who to
111
+ * run; the host it starts reads its own inbox on its own credentials a moment
112
+ * later, so building N envelopes here was work thrown away.
113
+ */
98
114
  export interface InboxOperatorAgentEntry {
99
115
  agentId: string;
100
- /** That agent's own inbox — fenced to its grants, floored by its cursors. */
101
- inbox: InboxEnvelope;
116
+ /** Newest delivery addressed to this agent, or null if it never had one. */
117
+ deliveredUpTo: string | null;
118
+ /** How far it has acked. Mail exists when `deliveredUpTo > ackedUpTo`. */
119
+ ackedUpTo: string | null;
120
+ /**
121
+ * True when this agent holds open assigned work. Independent of the
122
+ * watermark: a host that died mid-task already acked past the task's
123
+ * delivery, and the open task is the only durable trace it needs restarting.
124
+ */
125
+ hasOpenTasks: boolean;
102
126
  }
103
- /** GET /inbox/operator: one poll across every agent the key's owner runs. */
127
+ /** GET /inbox/operator: which of the key owner's agents have mail or open work. */
104
128
  export interface InboxOperatorEnvelope {
105
129
  asOf: string;
106
- /** Agents with actionable content only. */
130
+ /** Agents with mail or open work. */
107
131
  agents: InboxOperatorAgentEntry[];
108
- /** Agents examined whose inboxes were empty. */
132
+ /** Agents examined that had neither. */
109
133
  idleAgents: number;
110
134
  /** Owned agents beyond the server's per-pass cap — not examined. */
111
135
  truncatedAgents: number;
112
136
  }
113
137
  /**
114
- * The doorbell, not the door (ZIG-434): references and counts since the
115
- * agent's last ack — never content. Flow: inbox → read → act → ack (ZIG-446).
116
- * Wraps `GET /inbox` and `POST /inbox/ack`.
138
+ * The doorbell, not the door (ZIG-434): references addressed to this agent
139
+ * since its last ack — never content. Flow: inbox → read → act → ack
140
+ * (ZIG-446). Wraps `GET /inbox` and `POST /inbox/ack`.
117
141
  */
118
142
  export declare class InboxClient {
119
143
  private readonly operatorKey;
@@ -128,11 +152,17 @@ export declare class InboxClient {
128
152
  private inboxUrl;
129
153
  getInbox(opts?: InboxReadOptions): Promise<InboxEnvelope>;
130
154
  /**
131
- * Operator-level multiplexed read: one poll for every agent this key's
132
- * owner runs, each slice fenced to its own agent (an agent-scoped key
133
- * collapses to its one agent). Ack stays per agent — use a per-agent
134
- * client's `ack()` after acting on that agent's slice.
155
+ * Operator-level multiplexed read: which of this key owner's agents have
156
+ * mail or open work (an agent-scoped key collapses to its one agent).
157
+ *
158
+ * Returns who to start, never what they were sent — the host you start
159
+ * reads its own inbox on its own identity. Ack stays per agent.
135
160
  */
136
161
  getOperatorInbox(opts?: InboxReadOptions): Promise<InboxOperatorEnvelope>;
137
- ack(scopes: InboxAck[]): Promise<InboxAckResult>;
162
+ /**
163
+ * Advance this agent's watermark — pass the envelope's `ackTo`. Monotonic
164
+ * server-side: an older value is a no-op, so a replayed ack can never
165
+ * redeliver handled work.
166
+ */
167
+ ack(upTo: string): Promise<InboxAckResult>;
138
168
  }
@@ -1,9 +1,9 @@
1
1
  import 'dotenv/config';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
3
  /**
4
- * The doorbell, not the door (ZIG-434): references and counts since the
5
- * agent's last ack — never content. Flow: inbox → read → act → ack (ZIG-446).
6
- * Wraps `GET /inbox` and `POST /inbox/ack`.
4
+ * The doorbell, not the door (ZIG-434): references addressed to this agent
5
+ * since its last ack — never content. Flow: inbox → read → act → ack
6
+ * (ZIG-446). Wraps `GET /inbox` and `POST /inbox/ack`.
7
7
  */
8
8
  export class InboxClient {
9
9
  operatorKey;
@@ -34,6 +34,9 @@ export class InboxClient {
34
34
  if (opts.waitSeconds != null && opts.waitSeconds > 0) {
35
35
  url.searchParams.set('wait', String(opts.waitSeconds));
36
36
  }
37
+ if (opts.agents?.length) {
38
+ url.searchParams.set('agents', opts.agents.join(','));
39
+ }
37
40
  return url.toString();
38
41
  }
39
42
  async getInbox(opts = {}) {
@@ -47,28 +50,53 @@ export class InboxClient {
47
50
  return JSON.parse(body);
48
51
  }
49
52
  /**
50
- * Operator-level multiplexed read: one poll for every agent this key's
51
- * owner runs, each slice fenced to its own agent (an agent-scoped key
52
- * collapses to its one agent). Ack stays per agent — use a per-agent
53
- * client's `ack()` after acting on that agent's slice.
53
+ * Operator-level multiplexed read: which of this key owner's agents have
54
+ * mail or open work (an agent-scoped key collapses to its one agent).
55
+ *
56
+ * Returns who to start, never what they were sent — the host you start
57
+ * reads its own inbox on its own identity. Ack stays per agent.
54
58
  */
55
59
  async getOperatorInbox(opts = {}) {
56
- const res = await fetch(this.inboxUrl('/inbox/operator', opts), {
57
- headers: this.headers(),
58
- });
60
+ // Bounded wait (ZIG-965): the server holds at most `waitSeconds` (+ sweep
61
+ // time); a poll that outlives that by a wide margin is a dead sweep, and
62
+ // without a timeout it blocked the launcher's whole poll loop — lazy wake
63
+ // simply stopped. Abort and let the caller's retry loop take over.
64
+ const timeoutMs = ((opts.waitSeconds ?? 0) + 30) * 1000;
65
+ const ac = new AbortController();
66
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
67
+ let res;
68
+ try {
69
+ res = await fetch(this.inboxUrl('/inbox/operator', opts), {
70
+ headers: this.headers(),
71
+ signal: ac.signal,
72
+ });
73
+ }
74
+ catch (err) {
75
+ throw ac.signal.aborted
76
+ ? new Error(`InboxClient.getOperatorInbox timed out after ${timeoutMs}ms`)
77
+ : err;
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ }
59
82
  const body = await res.text().catch(() => '');
60
83
  if (!res.ok) {
61
84
  throw new Error(`InboxClient.getOperatorInbox ${res.status} ${body.slice(0, 200)}`);
62
85
  }
63
86
  return JSON.parse(body);
64
87
  }
65
- async ack(scopes) {
66
- if (!scopes.length)
67
- throw new Error('InboxClient.ack: scopes are required');
88
+ /**
89
+ * Advance this agent's watermark — pass the envelope's `ackTo`. Monotonic
90
+ * server-side: an older value is a no-op, so a replayed ack can never
91
+ * redeliver handled work.
92
+ */
93
+ async ack(upTo) {
94
+ if (!upTo)
95
+ throw new Error('InboxClient.ack: upTo is required');
68
96
  const res = await fetch(`${this.baseUrl}/inbox/ack`, {
69
97
  method: 'POST',
70
98
  headers: this.headers(),
71
- body: JSON.stringify({ scopes }),
99
+ body: JSON.stringify({ upTo }),
72
100
  });
73
101
  const body = await res.text().catch(() => '');
74
102
  if (!res.ok) {
@@ -8,6 +8,12 @@ export interface PublishOfferPayload {
8
8
  maxExecutions?: number;
9
9
  /** `hire` = claimer becomes the provider's principal on claim. Defaults to `service` server-side. */
10
10
  engagementKind?: 'hire' | 'service';
11
+ /**
12
+ * How `price` reads. `total` (default) is one price for the whole engagement,
13
+ * escrowed on claim and paid at fulfillment; `per_task` is a RATE settled as
14
+ * each task completes (open/standing only; the default for a hire).
15
+ */
16
+ billing?: 'total' | 'per_task';
11
17
  /** Broadcast audience: 'everyone' (default, fully public) or 'org' (members of your active org only). */
12
18
  audience?: BroadcastAudience;
13
19
  metadata?: Record<string, unknown>;
@@ -20,6 +26,8 @@ export interface PullOffersOptions {
20
26
  export declare function pullOffers(options: PullOffersOptions | undefined, creds: Creds): Promise<Agreement[]>;
21
27
  export declare function claimOffer(agreementId: string, creds: Creds): Promise<Agreement>;
22
28
  export interface PublishQuestPayload {
29
+ /** See PublishOfferPayload.billing. */
30
+ billing?: 'total' | 'per_task';
23
31
  description: string;
24
32
  chatId?: string;
25
33
  payerId?: string;
@@ -25,4 +25,4 @@ export type { MyOrg, OrgResolution } from './OrgsClient.js';
25
25
  export { AgentSearchClient } from './AgentSearchClient.js';
26
26
  export { TelemetryClient } from './TelemetryClient.js';
27
27
  export { InboxClient } from './InboxClient.js';
28
- export type { InboxScopeKind, InboxScopeEntry, InboxProposalRef, InboxConnectionRequestRef, InboxMcpServerRequestRef, InboxHumanAttention, InboxEnvelope, InboxAck, InboxAckResult, InboxOperatorAgentEntry, InboxOperatorEnvelope, } from './InboxClient.js';
28
+ export type { InboxDeliveryRef, InboxChatNews, InboxProposalRef, InboxTaskRef, InboxConnectionRequestRef, InboxHumanAttention, InboxEnvelope, InboxAckResult, InboxOperatorAgentEntry, InboxOperatorEnvelope, } from './InboxClient.js';
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './http/index.js';
2
2
  export * from './capabilities/index.js';
3
3
  export * from './relay/provisionRelayWorkers.js';
4
4
  export { ConnectionManager } from './ConnectionManager.js';
5
+ export type { StartAgentOptions } from './ConnectionManager.js';
5
6
  export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, AGREEMENT_ENGAGEMENT_KIND, isValidContentType } from './types.js';
6
7
  export { getBackendUrl, getWebSocketUrl } from './utils/urlUtils.js';
7
8
  export { runtimeLog, resetRuntimeLogLevelCache } from './shared/runtimeLog.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",