@ziggs-ai/api-client 0.1.29 → 0.2.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.
package/README.md CHANGED
@@ -10,25 +10,11 @@ npm install @ziggs-ai/api-client
10
10
 
11
11
  ## Usage
12
12
 
13
- ### WebSocket Client
14
-
15
- Real-time bidirectional communication with the Ziggs backend:
16
-
17
- ```javascript
18
- import { WebSocketClient } from '@ziggs-ai/api-client';
19
-
20
- const client = new WebSocketClient({
21
- operatorKey: process.env.ZIGGS_OPERATOR_KEY,
22
- agentId: 'my-agent-id',
23
- });
24
-
25
- client.setMessageHandler(async (text, metadata) => {
26
- console.log('Received:', text);
27
- console.log('From:', metadata.senderId);
28
- });
29
-
30
- client.connect();
31
- ```
13
+ Delivery is a stateless HTTP pull — there is no WebSocket client. An agent
14
+ receives work by long-polling its inbox: `GET /inbox?wait=25` (held open until
15
+ something passes the caller's cursor) → act → `POST /inbox/ack`. Because the
16
+ cursor lives server-side, any HTTP client following this loop is both correct
17
+ and live. See `docs/unified-inbox-wake-queue.md` for the reference loop.
32
18
 
33
19
  ### HTTP Clients
34
20
 
@@ -1,4 +1,3 @@
1
- import { type ControlSocketOptions } from './websocket/ControlSocket.js';
2
1
  type OpenFn = () => Promise<unknown>;
3
2
  type CloseFn = (handle: unknown) => Promise<void>;
4
3
  export interface ConnectionManagerMeta {
@@ -10,7 +9,18 @@ export interface ConnectionManagerMeta {
10
9
  export interface ConnectionManagerOptions {
11
10
  maxActive?: number;
12
11
  idleTimeoutMs?: number;
13
- control?: Pick<ControlSocketOptions, 'wsUrl' | 'operatorKey'>;
12
+ /**
13
+ * Fleet control (delivery law, phase 4): the operator key whose owned agents
14
+ * this manager lazily starts. `start()` long-polls `GET /inbox/operator` with
15
+ * this key; when an agent's slice shows actionable content, it starts that
16
+ * agent's host (which then consumes via its own per-agent inbox loop). No
17
+ * socket, no wake push — lazy process management driven by the poll. `wsUrl`
18
+ * is accepted for back-compat but unused (the poll is HTTP).
19
+ */
20
+ control?: {
21
+ wsUrl?: string;
22
+ operatorKey?: string;
23
+ };
14
24
  }
15
25
  export interface QueryFilter {
16
26
  domain?: string;
@@ -20,16 +30,25 @@ export interface QueryFilter {
20
30
  export declare class ConnectionManager {
21
31
  private maxActive;
22
32
  private idleTimeoutMs;
23
- private _controlOpts;
24
- private _controlHandle;
33
+ private _operatorKey;
34
+ private _polling;
35
+ private _pollDone;
25
36
  private _entries;
26
37
  private _active;
27
38
  private _meta;
28
39
  private _waking;
29
40
  constructor({ maxActive, idleTimeoutMs, control }?: ConnectionManagerOptions);
30
41
  register(id: string, openFn: OpenFn, closeFn: CloseFn, meta?: ConnectionManagerMeta): void;
31
- start(): void;
42
+ start(operatorKey?: string): void;
32
43
  stop(): Promise<void>;
44
+ /**
45
+ * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
46
+ * owned agent whose slice is actionable and not already running. The started
47
+ * host consumes + acks via its own per-agent inbox loop, so this loop never
48
+ * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
49
+ * the loop never exits until `stop()` (nothing to resynchronize).
50
+ */
51
+ private _runOperatorPoll;
33
52
  wake(id: string): Promise<unknown>;
34
53
  sleep(id: string): Promise<void>;
35
54
  sleepAll(): Promise<void>;
@@ -1,10 +1,17 @@
1
- import { createControlSocket } from './websocket/ControlSocket.js';
1
+ import { InboxClient } from './http/InboxClient.js';
2
2
  import { runtimeLog } from './shared/runtimeLog.js';
3
+ const OPERATOR_POLL_WAIT_SECONDS = 25;
4
+ const POLL_BACKOFF_BASE_MS = 1_000;
5
+ const POLL_BACKOFF_MAX_MS = 30_000;
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
3
9
  export class ConnectionManager {
4
10
  maxActive;
5
11
  idleTimeoutMs;
6
- _controlOpts;
7
- _controlHandle;
12
+ _operatorKey;
13
+ _polling;
14
+ _pollDone;
8
15
  _entries;
9
16
  _active;
10
17
  _meta;
@@ -12,8 +19,9 @@ export class ConnectionManager {
12
19
  constructor({ maxActive = 50, idleTimeoutMs = 60_000, control } = {}) {
13
20
  this.maxActive = maxActive;
14
21
  this.idleTimeoutMs = idleTimeoutMs;
15
- this._controlOpts = control ?? null;
16
- this._controlHandle = null;
22
+ this._operatorKey = control?.operatorKey ?? null;
23
+ this._polling = false;
24
+ this._pollDone = null;
17
25
  this._entries = new Map();
18
26
  this._active = new Map();
19
27
  this._meta = new Map();
@@ -30,20 +38,56 @@ export class ConnectionManager {
30
38
  if (meta)
31
39
  this._meta.set(id, meta);
32
40
  }
33
- start() {
34
- if (this._controlHandle || !this._controlOpts)
41
+ start(operatorKey) {
42
+ if (operatorKey)
43
+ this._operatorKey = operatorKey;
44
+ if (this._polling || !this._operatorKey)
35
45
  return;
36
- this._controlHandle = createControlSocket({
37
- ...this._controlOpts,
38
- agentIds: () => this.list(),
39
- onWake: (id) => this.wake(id).catch(err => runtimeLog.warn('ConnectionManager', `wake("${id}") failed: ${err.message}`)),
40
- });
46
+ this._polling = true;
47
+ this._pollDone = this._runOperatorPoll(this._operatorKey);
41
48
  }
42
49
  async stop() {
43
- this._controlHandle?.close();
44
- this._controlHandle = null;
50
+ this._polling = false;
51
+ if (this._pollDone)
52
+ await this._pollDone.catch(() => { });
53
+ this._pollDone = null;
45
54
  await this.sleepAll();
46
55
  }
56
+ /**
57
+ * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
58
+ * owned agent whose slice is actionable and not already running. The started
59
+ * host consumes + acks via its own per-agent inbox loop, so this loop never
60
+ * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
61
+ * the loop never exits until `stop()` (nothing to resynchronize).
62
+ */
63
+ async _runOperatorPoll(operatorKey) {
64
+ // No agentId → operator-level multiplex read (transport auth only).
65
+ const inbox = new InboxClient(operatorKey);
66
+ let consecutiveErrors = 0;
67
+ while (this._polling) {
68
+ try {
69
+ const env = await inbox.getOperatorInbox({ waitSeconds: OPERATOR_POLL_WAIT_SECONDS });
70
+ consecutiveErrors = 0;
71
+ for (const entry of env.agents) {
72
+ const id = entry.agentId;
73
+ // Only slices with actionable content are listed; start the host if
74
+ // it isn't already running and we know how to open it.
75
+ if (!id || this._active.has(id) || !this._entries.has(id))
76
+ continue;
77
+ this.wake(id).catch((err) => runtimeLog.warn('ConnectionManager', `lazy-start("${id}") failed: ${err.message}`));
78
+ }
79
+ }
80
+ catch (err) {
81
+ if (!this._polling)
82
+ break;
83
+ consecutiveErrors++;
84
+ const backoff = Math.min(POLL_BACKOFF_MAX_MS, POLL_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
85
+ const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14);
86
+ runtimeLog.warn('ConnectionManager', `operator poll failed (#${consecutiveErrors}): ${err.message} — retrying in ~${Math.round(jitter)}ms`);
87
+ await sleep(jitter);
88
+ }
89
+ }
90
+ }
47
91
  async wake(id) {
48
92
  const existing = this._active.get(id);
49
93
  if (existing) {
@@ -23,6 +23,13 @@ export interface WriteArtifactInput {
23
23
  /** Optional task binding — creates a TaskArtifactLink alongside the primary scope link. */
24
24
  taskId?: string;
25
25
  service?: Record<string, unknown>;
26
+ /**
27
+ * Optional idempotency key. A redelivered write with the same key (same
28
+ * sender) no-ops server-side and returns the original artifact. Derive it
29
+ * deterministically from durable inputs so a stateless crash-replay
30
+ * reproduces it.
31
+ */
32
+ idempotencyKey?: string;
26
33
  }
27
34
  /**
28
35
  * Replaces `ContextReader`'s artifact reads + `ContextWriter`'s breadcrumb
@@ -47,7 +54,20 @@ export declare class ArtifactsClient {
47
54
  * endpoint is updated to accept `visibility`, this falls back to logging
48
55
  * locally (operators can still wire their own sink).
49
56
  */
50
- recordThought(chatId: string, text: string): Promise<void>;
57
+ recordThought(chatId: string, text: string, opts?: {
58
+ idempotencyKey?: string;
59
+ }): Promise<void>;
51
60
  write(input: WriteArtifactInput): Promise<void>;
61
+ /**
62
+ * ZIG-899 — the deliberate-record variant: a deliverable the model chose to
63
+ * record must fail loudly and hand back the artifactId, unlike `write`'s
64
+ * soft-fail breadcrumb contract. This is the one wire call for both agent
65
+ * surfaces (the SDK record_artifact tool and ziggs-mcp's
66
+ * ziggs_record_artifact).
67
+ */
68
+ writeStrict(input: WriteArtifactInput): Promise<{
69
+ artifactId?: string;
70
+ }>;
71
+ private _assertScopeXor;
52
72
  private _headers;
53
73
  }
@@ -49,20 +49,40 @@ export class ArtifactsClient {
49
49
  * endpoint is updated to accept `visibility`, this falls back to logging
50
50
  * locally (operators can still wire their own sink).
51
51
  */
52
- async recordThought(chatId, text) {
52
+ async recordThought(chatId, text, opts = {}) {
53
53
  return this.write({
54
54
  chatId,
55
55
  text,
56
56
  content_type: 'thought',
57
57
  visibility: 'agent-private',
58
+ idempotencyKey: opts.idempotencyKey,
58
59
  });
59
60
  }
60
61
  async write(input) {
61
62
  if (!input.text || !input.text.trim())
62
63
  return;
63
- if ((input.chatId && input.agreementId) || (!input.chatId && !input.agreementId)) {
64
- throw new Error('ArtifactsClient.write: pass exactly one of chatId or agreementId');
64
+ this._assertScopeXor(input);
65
+ try {
66
+ await this.writeStrict(input);
67
+ }
68
+ catch (e) {
69
+ // Soft-fail on the wire only: breadcrumb writes are not load-bearing.
70
+ // Caller mistakes (scope xor, empty text) still throw above.
71
+ runtimeLog.warn('ArtifactsClient', `⚠️ ${e.message}`);
72
+ }
73
+ }
74
+ /**
75
+ * ZIG-899 — the deliberate-record variant: a deliverable the model chose to
76
+ * record must fail loudly and hand back the artifactId, unlike `write`'s
77
+ * soft-fail breadcrumb contract. This is the one wire call for both agent
78
+ * surfaces (the SDK record_artifact tool and ziggs-mcp's
79
+ * ziggs_record_artifact).
80
+ */
81
+ async writeStrict(input) {
82
+ if (!input.text || !input.text.trim()) {
83
+ throw new Error('ArtifactsClient.writeStrict: text is required');
65
84
  }
85
+ this._assertScopeXor(input);
66
86
  const url = `${getBackendUrl()}/artifacts`;
67
87
  const res = await fetch(url, {
68
88
  method: 'POST',
@@ -75,12 +95,24 @@ export class ArtifactsClient {
75
95
  agreementId: input.agreementId,
76
96
  taskId: input.taskId,
77
97
  service: input.service,
98
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
78
99
  }),
79
100
  });
101
+ const body = await res.text().catch(() => '');
80
102
  if (!res.ok) {
81
- const body = await res.text().catch(() => '');
82
- // Soft-fail: artifact writes are breadcrumbs, not load-bearing.
83
- runtimeLog.warn('ArtifactsClient', `⚠️ write failed ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
103
+ throw new Error(`POST /artifacts ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
104
+ }
105
+ try {
106
+ const parsed = body ? JSON.parse(body) : {};
107
+ return { artifactId: parsed.artifactId };
108
+ }
109
+ catch {
110
+ return {};
111
+ }
112
+ }
113
+ _assertScopeXor(input) {
114
+ if ((input.chatId && input.agreementId) || (!input.chatId && !input.agreementId)) {
115
+ throw new Error('ArtifactsClient.write: pass exactly one of chatId or agreementId');
84
116
  }
85
117
  }
86
118
  _headers() {
@@ -0,0 +1,89 @@
1
+ import 'dotenv/config';
2
+ export declare function assertNoLeakedConnectionSecret(serialized: string): void;
3
+ /** Thrown by ConnectionsClient with the HTTP status and raw body attached. */
4
+ export interface ConnectionsError extends Error {
5
+ status?: number;
6
+ body?: string;
7
+ }
8
+ export interface ConnectionProxyParams {
9
+ connectionId: string;
10
+ grantId: string;
11
+ action: string;
12
+ payload?: unknown;
13
+ }
14
+ export interface McpConnectionRequestParams {
15
+ serverUrl: string;
16
+ tools: string[];
17
+ reason?: string;
18
+ /**
19
+ * Working chat the consent card is opened into. Required by the backend for
20
+ * the agent-tool path; the per-user gateway trigger (ZIG-771) omits it.
21
+ */
22
+ chatId?: string;
23
+ /**
24
+ * Target the consent request at this end-user (X-On-Behalf-Of-User) instead
25
+ * of the operator — the per-user MCP gateway's connect prompt (ZIG-771).
26
+ */
27
+ onBehalfOfUserId?: string;
28
+ }
29
+ /**
30
+ * ZIG-894 — the one connections client for every surface. Consolidates the
31
+ * former agent-sdk private ZiggsConnectClient and ziggs-mcp's inline fetches
32
+ * (proxyConnection, createMcpConnectionRequest). Proxied provider calls never
33
+ * return the raw OAuth token; every proxy response is additionally scanned by
34
+ * the client-side leak guard before it reaches the caller. Grant *listing*
35
+ * across connections is the unified GET /grants; the per-connection grant
36
+ * mutations (issue / attenuate / revoke) live here.
37
+ */
38
+ export declare class ConnectionsClient {
39
+ private readonly operatorKey;
40
+ private readonly agentId?;
41
+ private readonly baseUrl;
42
+ constructor(operatorKey: string, agentId?: string, baseUrl?: string);
43
+ /**
44
+ * Present a ConnectionGrant to the broker and perform a provider action.
45
+ * Returns the provider result — never the raw OAuth token. Requires an
46
+ * impersonated agent (the grant holder).
47
+ */
48
+ proxy({ connectionId, grantId, action, payload }: ConnectionProxyParams): Promise<unknown>;
49
+ /**
50
+ * List ConnectionGrants on a connection. When impersonating an agent the
51
+ * server already scopes rows to that holder; the client-side filter is kept
52
+ * as defense-in-depth (same behavior as the former ZiggsConnectClient).
53
+ */
54
+ listGrants({ connectionId }: {
55
+ connectionId: string;
56
+ }): Promise<unknown[]>;
57
+ /** Issue a connection grant to an agent holder (connection owner side). */
58
+ issueGrant({ connectionId, holderId, caveats, }: {
59
+ connectionId: string;
60
+ holderId: string;
61
+ caveats?: unknown;
62
+ }): Promise<unknown>;
63
+ /**
64
+ * Attenuate (re-delegate) a connection grant with tighter caveats. Narrowing
65
+ * a grant whose connection you own mints immediately; a grant whose owner is
66
+ * a different party returns `{ status: 'pending_approval', agreementId }`
67
+ * (ZIG-701 consent flow) and no grant is minted yet.
68
+ */
69
+ attenuateGrant({ connectionId, grantId, holderId, caveats, }: {
70
+ connectionId: string;
71
+ grantId: string;
72
+ holderId: string;
73
+ caveats?: unknown;
74
+ }): Promise<unknown>;
75
+ /** Revoke a single connection grant. */
76
+ revokeGrant({ connectionId, grantId, }: {
77
+ connectionId: string;
78
+ grantId: string;
79
+ }): Promise<unknown>;
80
+ /**
81
+ * ZIG-686 — agent-initiated MCP connection request: ask the principal to
82
+ * connect a remote MCP server and grant this agent the listed tools. Opens a
83
+ * connection-consent agreement in the working chat (ZIG-798); on approval the
84
+ * server is connected (if needed) and the agent is granted the tools.
85
+ */
86
+ requestMcpConnection({ serverUrl, tools, reason, chatId, onBehalfOfUserId, }: McpConnectionRequestParams): Promise<Record<string, unknown>>;
87
+ private _requestRaw;
88
+ private _request;
89
+ }
@@ -0,0 +1,154 @@
1
+ import 'dotenv/config';
2
+ import { getBackendUrl } from '../utils/urlUtils.js';
3
+ import { buildOperatorHeaders } from './operatorHeaders.js';
4
+ // ZIG-569 — defense-in-depth mirror of the backend leak-guard
5
+ // (assertProxyResponseDoesNotLeakTokens). The backend strips the *specific*
6
+ // vault token from the response; clients never see that token, so this layer
7
+ // instead pattern-scans the proxied body for high-signal provider credential
8
+ // shapes and refuses to hand a likely-leaked secret to the model.
9
+ const LEAKED_SECRET_PATTERNS = [
10
+ /\bgh[posru]_[A-Za-z0-9]{16,}\b/, // GitHub PAT / OAuth / user / server / refresh
11
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/, // fine-grained GitHub PAT
12
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, // Slack bot/user/app/refresh tokens
13
+ /\bxapp-[A-Za-z0-9-]{10,}\b/, // Slack app-level token
14
+ /"(?:access_token|refresh_token)"\s*:\s*"[^"]{8,}"/, // raw OAuth token JSON keys
15
+ ];
16
+ export function assertNoLeakedConnectionSecret(serialized) {
17
+ for (const re of LEAKED_SECRET_PATTERNS) {
18
+ if (re.test(serialized)) {
19
+ throw new Error('connection proxy response withheld: it appears to contain a credential (token-leak guard)');
20
+ }
21
+ }
22
+ }
23
+ /**
24
+ * ZIG-894 — the one connections client for every surface. Consolidates the
25
+ * former agent-sdk private ZiggsConnectClient and ziggs-mcp's inline fetches
26
+ * (proxyConnection, createMcpConnectionRequest). Proxied provider calls never
27
+ * return the raw OAuth token; every proxy response is additionally scanned by
28
+ * the client-side leak guard before it reaches the caller. Grant *listing*
29
+ * across connections is the unified GET /grants; the per-connection grant
30
+ * mutations (issue / attenuate / revoke) live here.
31
+ */
32
+ export class ConnectionsClient {
33
+ operatorKey;
34
+ agentId;
35
+ baseUrl;
36
+ constructor(operatorKey, agentId, baseUrl) {
37
+ if (!operatorKey)
38
+ throw new Error('ConnectionsClient: operatorKey is required');
39
+ this.operatorKey = operatorKey;
40
+ this.agentId = agentId;
41
+ this.baseUrl = baseUrl || getBackendUrl();
42
+ }
43
+ /**
44
+ * Present a ConnectionGrant to the broker and perform a provider action.
45
+ * Returns the provider result — never the raw OAuth token. Requires an
46
+ * impersonated agent (the grant holder).
47
+ */
48
+ async proxy({ connectionId, grantId, action, payload }) {
49
+ if (!connectionId)
50
+ throw new Error('proxy: connectionId is required');
51
+ if (!grantId)
52
+ throw new Error('proxy: grantId is required');
53
+ if (!action)
54
+ throw new Error('proxy: action is required');
55
+ if (!this.agentId) {
56
+ throw new Error('proxy: agentId is required — connection broker calls must impersonate the grant holder agent');
57
+ }
58
+ const text = await this._requestRaw('POST', `/connections/${encodeURIComponent(connectionId)}/proxy`, { grantId, action, payload: payload ?? {} });
59
+ assertNoLeakedConnectionSecret(text);
60
+ let parsed = text;
61
+ try {
62
+ parsed = text ? JSON.parse(text) : null;
63
+ }
64
+ catch {
65
+ parsed = text;
66
+ }
67
+ const result = parsed?.['result'];
68
+ return result ?? parsed;
69
+ }
70
+ /**
71
+ * List ConnectionGrants on a connection. When impersonating an agent the
72
+ * server already scopes rows to that holder; the client-side filter is kept
73
+ * as defense-in-depth (same behavior as the former ZiggsConnectClient).
74
+ */
75
+ async listGrants({ connectionId }) {
76
+ if (!connectionId)
77
+ throw new Error('listGrants: connectionId is required');
78
+ if (!this.agentId) {
79
+ throw new Error('listGrants: agentId is required — grants are scoped to the impersonated agent');
80
+ }
81
+ const res = (await this._request('GET', `/connections/${encodeURIComponent(connectionId)}/grants`, undefined));
82
+ const grants = res['grants'] || [];
83
+ return grants.filter((g) => g['holderId'] === this.agentId);
84
+ }
85
+ /** Issue a connection grant to an agent holder (connection owner side). */
86
+ async issueGrant({ connectionId, holderId, caveats, }) {
87
+ if (!connectionId)
88
+ throw new Error('issueGrant: connectionId is required');
89
+ if (!holderId)
90
+ throw new Error('issueGrant: holderId is required');
91
+ return this._request('POST', `/connections/${encodeURIComponent(connectionId)}/grants`, {
92
+ holderId,
93
+ caveats,
94
+ });
95
+ }
96
+ /**
97
+ * Attenuate (re-delegate) a connection grant with tighter caveats. Narrowing
98
+ * a grant whose connection you own mints immediately; a grant whose owner is
99
+ * a different party returns `{ status: 'pending_approval', agreementId }`
100
+ * (ZIG-701 consent flow) and no grant is minted yet.
101
+ */
102
+ async attenuateGrant({ connectionId, grantId, holderId, caveats, }) {
103
+ if (!connectionId)
104
+ throw new Error('attenuateGrant: connectionId is required');
105
+ if (!grantId)
106
+ throw new Error('attenuateGrant: grantId is required');
107
+ if (!holderId)
108
+ throw new Error('attenuateGrant: holderId is required');
109
+ return this._request('POST', `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}/attenuate`, { holderId, caveats });
110
+ }
111
+ /** Revoke a single connection grant. */
112
+ async revokeGrant({ connectionId, grantId, }) {
113
+ if (!connectionId)
114
+ throw new Error('revokeGrant: connectionId is required');
115
+ if (!grantId)
116
+ throw new Error('revokeGrant: grantId is required');
117
+ return this._request('DELETE', `/connections/${encodeURIComponent(connectionId)}/grants/${encodeURIComponent(grantId)}`, undefined);
118
+ }
119
+ /**
120
+ * ZIG-686 — agent-initiated MCP connection request: ask the principal to
121
+ * connect a remote MCP server and grant this agent the listed tools. Opens a
122
+ * connection-consent agreement in the working chat (ZIG-798); on approval the
123
+ * server is connected (if needed) and the agent is granted the tools.
124
+ */
125
+ async requestMcpConnection({ serverUrl, tools, reason, chatId, onBehalfOfUserId, }) {
126
+ if (!serverUrl)
127
+ throw new Error('requestMcpConnection: serverUrl is required');
128
+ return (await this._request('POST', '/connections/mcp/requests', { serverUrl, tools, reason, chatId }, onBehalfOfUserId ? { 'X-On-Behalf-Of-User': onBehalfOfUserId } : undefined));
129
+ }
130
+ async _requestRaw(method, path, body, extraHeaders) {
131
+ const init = {
132
+ method,
133
+ headers: buildOperatorHeaders(this.operatorKey, this.agentId, {
134
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
135
+ ...extraHeaders,
136
+ }),
137
+ };
138
+ if (body !== undefined)
139
+ init.body = JSON.stringify(body);
140
+ const response = await fetch(`${this.baseUrl}${path}`, init);
141
+ const text = await response.text().catch(() => '');
142
+ if (!response.ok) {
143
+ const err = new Error(`${method} ${path} ${response.status} ${text.slice(0, 200)}`);
144
+ err.status = response.status;
145
+ err.body = text;
146
+ throw err;
147
+ }
148
+ return text;
149
+ }
150
+ async _request(method, path, body, extraHeaders) {
151
+ const text = await this._requestRaw(method, path, body, extraHeaders);
152
+ return text ? JSON.parse(text) : null;
153
+ }
154
+ }
@@ -39,6 +39,26 @@ export type DelegateContextGrantResult = {
39
39
  agreementId: string;
40
40
  ownerId?: string;
41
41
  };
42
+ /** A single readable entry inside a grant's scope — id + label only (ZIG-870). */
43
+ export interface ReachEntry {
44
+ id: string;
45
+ label: string;
46
+ }
47
+ /**
48
+ * ZIG-870 reach expansion — the chat/agreement ids a grant you hold actually
49
+ * covers, so an org/agreement-scoped grant becomes a concrete list you can
50
+ * `context_read` through (via=chat:<id> / agreement:<id>). Ids + labels only,
51
+ * never content. Org scope is capped; `truncatedChats`/`truncatedAgreements`
52
+ * report how many were left off, never silently dropped.
53
+ */
54
+ export interface GrantReachResult {
55
+ grantId: string;
56
+ scope: ContextGrantScope;
57
+ chats: ReachEntry[];
58
+ agreements: ReachEntry[];
59
+ truncatedChats: number;
60
+ truncatedAgreements: number;
61
+ }
42
62
  /**
43
63
  * ZIG-411 context grant management — list / issue / delegate / revoke.
44
64
  */
@@ -53,6 +73,13 @@ export declare class ContextGrantsClient {
53
73
  constructor(operatorKey: string, agentId?: string, baseUrl?: string);
54
74
  issueGrant(input: IssueContextGrantInput): Promise<GrantView>;
55
75
  delegateGrant(parentGrantId: string, input: DelegateContextGrantInput): Promise<DelegateContextGrantResult>;
76
+ /**
77
+ * ZIG-870 — expand a grant you hold into the chat/agreement ids inside its
78
+ * scope. A grant is a fence, not a listing: discovery says "you hold
79
+ * org:acme", this says which chats/agreements that covers. Holder-only,
80
+ * labels-only, grant-fenced server-side.
81
+ */
82
+ getReach(grantId: string): Promise<GrantReachResult>;
56
83
  revokeGrant(grantId: string): Promise<{
57
84
  status: string;
58
85
  revokedCount?: number;
@@ -88,6 +88,31 @@ export class ContextGrantsClient {
88
88
  }
89
89
  return { status: 'granted', grant: parsed.grant };
90
90
  }
91
+ /**
92
+ * ZIG-870 — expand a grant you hold into the chat/agreement ids inside its
93
+ * scope. A grant is a fence, not a listing: discovery says "you hold
94
+ * org:acme", this says which chats/agreements that covers. Holder-only,
95
+ * labels-only, grant-fenced server-side.
96
+ */
97
+ async getReach(grantId) {
98
+ const res = await fetch(`${this.baseUrl}/context/grants/${encodeURIComponent(grantId)}/reach`, { headers: buildOperatorHeaders(this.operatorKey, this.agentId) });
99
+ const body = await res.text().catch(() => '');
100
+ if (!res.ok) {
101
+ throwApiError(res, body, `getReach failed: ${res.status} ${res.statusText}`);
102
+ }
103
+ const parsed = JSON.parse(body);
104
+ if (!parsed.scope) {
105
+ throw new Error('Invalid response: expected { scope, chats, agreements } from GET /context/grants/:id/reach');
106
+ }
107
+ return {
108
+ grantId: parsed.grantId ?? grantId,
109
+ scope: parsed.scope,
110
+ chats: parsed.chats ?? [],
111
+ agreements: parsed.agreements ?? [],
112
+ truncatedChats: parsed.truncatedChats ?? 0,
113
+ truncatedAgreements: parsed.truncatedAgreements ?? 0,
114
+ };
115
+ }
91
116
  async revokeGrant(grantId) {
92
117
  const res = await fetch(`${this.baseUrl}/context/grants/${encodeURIComponent(grantId)}`, {
93
118
  method: 'DELETE',
@@ -84,6 +84,32 @@ export interface InboxAckResult {
84
84
  ackedUpTo: string;
85
85
  }>;
86
86
  }
87
+ /**
88
+ * Long-poll option shared by the inbox reads. The server holds the request up
89
+ * to this many seconds (server-clamped, ~25s ceiling) and returns as soon as
90
+ * anything actionable exists. Omit for an immediate snapshot — the response
91
+ * shape is identical either way, so `wait` only changes how long an EMPTY
92
+ * answer is withheld.
93
+ */
94
+ export interface InboxReadOptions {
95
+ waitSeconds?: number;
96
+ }
97
+ /** One agent's slice of the operator-level multiplexed read. */
98
+ export interface InboxOperatorAgentEntry {
99
+ agentId: string;
100
+ /** That agent's own inbox — fenced to its grants, floored by its cursors. */
101
+ inbox: InboxEnvelope;
102
+ }
103
+ /** GET /inbox/operator: one poll across every agent the key's owner runs. */
104
+ export interface InboxOperatorEnvelope {
105
+ asOf: string;
106
+ /** Agents with actionable content only. */
107
+ agents: InboxOperatorAgentEntry[];
108
+ /** Agents examined whose inboxes were empty. */
109
+ idleAgents: number;
110
+ /** Owned agents beyond the server's per-pass cap — not examined. */
111
+ truncatedAgents: number;
112
+ }
87
113
  /**
88
114
  * The doorbell, not the door (ZIG-434): references and counts since the
89
115
  * agent's last ack — never content. Flow: inbox → read → act → ack (ZIG-446).
@@ -99,6 +125,14 @@ export declare class InboxClient {
99
125
  */
100
126
  constructor(operatorKey: string, agentId?: string, baseUrl?: string);
101
127
  private headers;
102
- getInbox(): Promise<InboxEnvelope>;
128
+ private inboxUrl;
129
+ getInbox(opts?: InboxReadOptions): Promise<InboxEnvelope>;
130
+ /**
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.
135
+ */
136
+ getOperatorInbox(opts?: InboxReadOptions): Promise<InboxOperatorEnvelope>;
103
137
  ack(scopes: InboxAck[]): Promise<InboxAckResult>;
104
138
  }