@ziggs-ai/api-client 0.1.30 → 0.3.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,4 @@
1
- import { type ControlSocketOptions } from './websocket/ControlSocket.js';
1
+ import { type InboxOperatorAgentEntry } from './http/InboxClient.js';
2
2
  type OpenFn = () => Promise<unknown>;
3
3
  type CloseFn = (handle: unknown) => Promise<void>;
4
4
  export interface ConnectionManagerMeta {
@@ -10,7 +10,26 @@ export interface ConnectionManagerMeta {
10
10
  export interface ConnectionManagerOptions {
11
11
  maxActive?: number;
12
12
  idleTimeoutMs?: number;
13
- control?: Pick<ControlSocketOptions, 'wsUrl' | 'operatorKey'>;
13
+ /**
14
+ * Fleet control (delivery law, phase 4): the operator key whose owned agents
15
+ * this manager lazily starts. `start()` long-polls `GET /inbox/operator` with
16
+ * this key; when an agent's slice shows actionable content, it starts that
17
+ * agent's host (which then consumes via its own per-agent inbox loop). No
18
+ * socket, no push notification — lazy process management driven by the poll. `wsUrl`
19
+ * is accepted for back-compat but unused (the poll is HTTP).
20
+ */
21
+ control?: {
22
+ wsUrl?: string;
23
+ operatorKey?: string;
24
+ };
25
+ /**
26
+ * Gate on the operator-poll lazy start (ZIG-910). Called for each agent
27
+ * slice with actionable content before its host is started; return false to
28
+ * leave the host down this pass. The slice stays in later envelopes (reading
29
+ * never acks), so the gate is consulted again on every poll. Explicit
30
+ * `startAgent()` calls are not gated. Default: everything actionable starts.
31
+ */
32
+ shouldLazyStart?: (entry: InboxOperatorAgentEntry) => boolean;
14
33
  }
15
34
  export interface QueryFilter {
16
35
  domain?: string;
@@ -20,17 +39,27 @@ export interface QueryFilter {
20
39
  export declare class ConnectionManager {
21
40
  private maxActive;
22
41
  private idleTimeoutMs;
23
- private _controlOpts;
24
- private _controlHandle;
42
+ private _operatorKey;
43
+ private _polling;
44
+ private _pollDone;
25
45
  private _entries;
26
46
  private _active;
27
47
  private _meta;
28
- private _waking;
29
- constructor({ maxActive, idleTimeoutMs, control }?: ConnectionManagerOptions);
48
+ private _starting;
49
+ private _shouldLazyStart;
50
+ constructor({ maxActive, idleTimeoutMs, control, shouldLazyStart }?: ConnectionManagerOptions);
30
51
  register(id: string, openFn: OpenFn, closeFn: CloseFn, meta?: ConnectionManagerMeta): void;
31
- start(): void;
52
+ start(operatorKey?: string): void;
32
53
  stop(): Promise<void>;
33
- wake(id: string): Promise<unknown>;
54
+ /**
55
+ * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
56
+ * owned agent whose slice is actionable and not already running. The started
57
+ * host consumes + acks via its own per-agent inbox loop, so this loop never
58
+ * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
59
+ * the loop never exits until `stop()` (nothing to resynchronize).
60
+ */
61
+ private _runOperatorPoll;
62
+ startAgent(id: string): Promise<unknown>;
34
63
  sleep(id: string): Promise<void>;
35
64
  sleepAll(): Promise<void>;
36
65
  touch(id: string): void;
@@ -1,23 +1,33 @@
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;
11
- _waking;
12
- constructor({ maxActive = 50, idleTimeoutMs = 60_000, control } = {}) {
18
+ _starting;
19
+ _shouldLazyStart;
20
+ constructor({ maxActive = 50, idleTimeoutMs = 60_000, control, shouldLazyStart } = {}) {
13
21
  this.maxActive = maxActive;
14
22
  this.idleTimeoutMs = idleTimeoutMs;
15
- this._controlOpts = control ?? null;
16
- this._controlHandle = null;
23
+ this._shouldLazyStart = shouldLazyStart ?? null;
24
+ this._operatorKey = control?.operatorKey ?? null;
25
+ this._polling = false;
26
+ this._pollDone = null;
17
27
  this._entries = new Map();
18
28
  this._active = new Map();
19
29
  this._meta = new Map();
20
- this._waking = new Map();
30
+ this._starting = new Map();
21
31
  }
22
32
  register(id, openFn, closeFn, meta) {
23
33
  if (!id)
@@ -30,27 +40,65 @@ export class ConnectionManager {
30
40
  if (meta)
31
41
  this._meta.set(id, meta);
32
42
  }
33
- start() {
34
- if (this._controlHandle || !this._controlOpts)
43
+ start(operatorKey) {
44
+ if (operatorKey)
45
+ this._operatorKey = operatorKey;
46
+ if (this._polling || !this._operatorKey)
35
47
  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
- });
48
+ this._polling = true;
49
+ this._pollDone = this._runOperatorPoll(this._operatorKey);
41
50
  }
42
51
  async stop() {
43
- this._controlHandle?.close();
44
- this._controlHandle = null;
52
+ this._polling = false;
53
+ if (this._pollDone)
54
+ await this._pollDone.catch(() => { });
55
+ this._pollDone = null;
45
56
  await this.sleepAll();
46
57
  }
47
- async wake(id) {
58
+ /**
59
+ * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
60
+ * owned agent whose slice is actionable and not already running. The started
61
+ * host consumes + acks via its own per-agent inbox loop, so this loop never
62
+ * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
63
+ * the loop never exits until `stop()` (nothing to resynchronize).
64
+ */
65
+ async _runOperatorPoll(operatorKey) {
66
+ // No agentId → operator-level multiplex read (transport auth only).
67
+ const inbox = new InboxClient(operatorKey);
68
+ let consecutiveErrors = 0;
69
+ while (this._polling) {
70
+ try {
71
+ const env = await inbox.getOperatorInbox({ waitSeconds: OPERATOR_POLL_WAIT_SECONDS });
72
+ consecutiveErrors = 0;
73
+ for (const entry of env.agents) {
74
+ const id = entry.agentId;
75
+ // Only slices with actionable content are listed; start the host if
76
+ // it isn't already running and we know how to open it.
77
+ if (!id || this._active.has(id) || !this._entries.has(id))
78
+ continue;
79
+ if (this._shouldLazyStart && !this._shouldLazyStart(entry))
80
+ continue;
81
+ this.startAgent(id).catch((err) => runtimeLog.warn('ConnectionManager', `lazy-start("${id}") failed: ${err.message}`));
82
+ }
83
+ }
84
+ catch (err) {
85
+ if (!this._polling)
86
+ break;
87
+ consecutiveErrors++;
88
+ const backoff = Math.min(POLL_BACKOFF_MAX_MS, POLL_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
89
+ const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14);
90
+ runtimeLog.warn('ConnectionManager', `operator poll failed (#${consecutiveErrors}): ${err.message} — retrying in ~${Math.round(jitter)}ms`);
91
+ await sleep(jitter);
92
+ }
93
+ }
94
+ }
95
+ async startAgent(id) {
48
96
  const existing = this._active.get(id);
49
97
  if (existing) {
50
98
  this._resetTimer(id);
51
99
  return existing.handle;
52
100
  }
53
- const pending = this._waking.get(id);
101
+ const pending = this._starting.get(id);
54
102
  if (pending)
55
103
  return pending;
56
104
  const entry = this._entries.get(id);
@@ -58,7 +106,7 @@ export class ConnectionManager {
58
106
  throw new Error(`[ConnectionManager] unknown id: "${id}"`);
59
107
  if (this._active.size >= this.maxActive)
60
108
  await this._evictLRU();
61
- const wakePromise = (async () => {
109
+ const startPromise = (async () => {
62
110
  try {
63
111
  const handle = await entry.openFn();
64
112
  this._active.set(id, { handle, timer: null, lastActive: Date.now() });
@@ -66,11 +114,11 @@ export class ConnectionManager {
66
114
  return handle;
67
115
  }
68
116
  finally {
69
- this._waking.delete(id);
117
+ this._starting.delete(id);
70
118
  }
71
119
  })();
72
- this._waking.set(id, wakePromise);
73
- return wakePromise;
120
+ this._starting.set(id, startPromise);
121
+ return startPromise;
74
122
  }
75
123
  async sleep(id) {
76
124
  const entry = this._active.get(id);
@@ -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,9 @@ 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>;
52
61
  /**
53
62
  * ZIG-899 — the deliberate-record variant: a deliverable the model chose to
@@ -49,12 +49,13 @@ 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) {
@@ -94,6 +95,7 @@ export class ArtifactsClient {
94
95
  agreementId: input.agreementId,
95
96
  taskId: input.taskId,
96
97
  service: input.service,
98
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
97
99
  }),
98
100
  });
99
101
  const body = await res.text().catch(() => '');
@@ -29,7 +29,7 @@ export interface McpConnectionRequestParams {
29
29
  /**
30
30
  * ZIG-894 — the one connections client for every surface. Consolidates the
31
31
  * former agent-sdk private ZiggsConnectClient and ziggs-mcp's inline fetches
32
- * (proxyConnection, createMcpConnectionRequest). Proxied provider calls never
32
+ * (proxy, requestMcpConnection). Proxied provider calls never
33
33
  * return the raw OAuth token; every proxy response is additionally scanned by
34
34
  * the client-side leak guard before it reaches the caller. Grant *listing*
35
35
  * across connections is the unified GET /grants; the per-connection grant
@@ -23,7 +23,7 @@ export function assertNoLeakedConnectionSecret(serialized) {
23
23
  /**
24
24
  * ZIG-894 — the one connections client for every surface. Consolidates the
25
25
  * former agent-sdk private ZiggsConnectClient and ziggs-mcp's inline fetches
26
- * (proxyConnection, createMcpConnectionRequest). Proxied provider calls never
26
+ * (proxy, requestMcpConnection). Proxied provider calls never
27
27
  * return the raw OAuth token; every proxy response is additionally scanned by
28
28
  * the client-side leak guard before it reaches the caller. Grant *listing*
29
29
  * across connections is the unified GET /grants; the per-connection grant
@@ -1,10 +1,14 @@
1
1
  import 'dotenv/config';
2
- /** Labels-only pointer to context the agent could request access to (P4). */
2
+ /**
3
+ * Labels-only pointer to context the agent could request access to (P4).
4
+ * ZIG-927: covers chats, agreements, and connections; connection labels are the
5
+ * provider name only (never tokens/account labels).
6
+ */
3
7
  export interface DiscoverableItem {
4
- type: 'chat';
8
+ type: 'chat' | 'agreement' | 'connection';
5
9
  label: string;
6
10
  scopeRef: {
7
- kind: 'chat' | 'agreement' | 'org';
11
+ kind: 'chat' | 'agreement' | 'org' | 'connection';
8
12
  id: string;
9
13
  };
10
14
  orgId: string;
@@ -53,7 +53,7 @@ export declare class ContextReadClient {
53
53
  /**
54
54
  * Aggregated chat snapshot — `GET /context/snapshot?via=chat:<id>`. The
55
55
  * server composes history + agreements + roster through the same grant-fenced
56
- * readers as {@link read}, so hosted agents pull one fenced view per wake.
56
+ * readers as {@link read}, so hosted agents pull one fenced view per poll cycle.
57
57
  * Rejects with an `Error & { status }` on non-OK so callers can branch (404).
58
58
  */
59
59
  snapshot(chatId: string, opts?: {
@@ -56,7 +56,7 @@ export class ContextReadClient {
56
56
  /**
57
57
  * Aggregated chat snapshot — `GET /context/snapshot?via=chat:<id>`. The
58
58
  * server composes history + agreements + roster through the same grant-fenced
59
- * readers as {@link read}, so hosted agents pull one fenced view per wake.
59
+ * readers as {@link read}, so hosted agents pull one fenced view per poll cycle.
60
60
  * Rejects with an `Error & { status }` on non-OK so callers can branch (404).
61
61
  */
62
62
  async snapshot(chatId, opts = {}) {
@@ -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
  }
@@ -29,8 +29,15 @@ export class InboxClient {
29
29
  headers['X-Agent-Id'] = this.agentId;
30
30
  return headers;
31
31
  }
32
- async getInbox() {
33
- const res = await fetch(`${this.baseUrl}/inbox`, {
32
+ inboxUrl(path, opts) {
33
+ const url = new URL(`${this.baseUrl}${path}`);
34
+ if (opts.waitSeconds != null && opts.waitSeconds > 0) {
35
+ url.searchParams.set('wait', String(opts.waitSeconds));
36
+ }
37
+ return url.toString();
38
+ }
39
+ async getInbox(opts = {}) {
40
+ const res = await fetch(this.inboxUrl('/inbox', opts), {
34
41
  headers: this.headers(),
35
42
  });
36
43
  const body = await res.text().catch(() => '');
@@ -39,6 +46,22 @@ export class InboxClient {
39
46
  }
40
47
  return JSON.parse(body);
41
48
  }
49
+ /**
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.
54
+ */
55
+ async getOperatorInbox(opts = {}) {
56
+ const res = await fetch(this.inboxUrl('/inbox/operator', opts), {
57
+ headers: this.headers(),
58
+ });
59
+ const body = await res.text().catch(() => '');
60
+ if (!res.ok) {
61
+ throw new Error(`InboxClient.getOperatorInbox ${res.status} ${body.slice(0, 200)}`);
62
+ }
63
+ return JSON.parse(body);
64
+ }
42
65
  async ack(scopes) {
43
66
  if (!scopes.length)
44
67
  throw new Error('InboxClient.ack: scopes are required');
@@ -16,6 +16,14 @@ export declare function getTask(taskId: string, creds: Creds): Promise<Task>;
16
16
  export interface UpdateTaskStateData {
17
17
  result?: unknown;
18
18
  errorMessage?: string;
19
+ /**
20
+ * Optional idempotency key. A redelivered transition carrying the same key
21
+ * no-ops server-side (returns the current task) instead of erroring on an
22
+ * already-terminal task. Sent in the body — the backend reads
23
+ * `UpdateTaskStateDto.idempotencyKey`. Derive it deterministically so a
24
+ * stateless crash-replay reproduces it.
25
+ */
26
+ idempotencyKey?: string;
19
27
  }
20
28
  export declare function updateTaskState(taskId: string, state: TaskState, data: UpdateTaskStateData | undefined, creds: Creds): Promise<Task>;
21
29
  export declare function getActiveTasksForAgent(agentId: string, creds: Creds): Promise<Task[]>;
@@ -25,4 +25,4 @@ export type { ConnectionsError, ConnectionProxyParams, McpConnectionRequestParam
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, } from './InboxClient.js';
28
+ export type { InboxScopeKind, InboxScopeEntry, InboxProposalRef, InboxConnectionRequestRef, InboxMcpServerRequestRef, InboxHumanAttention, InboxEnvelope, InboxAck, InboxAckResult, InboxOperatorAgentEntry, InboxOperatorEnvelope, } from './InboxClient.js';
package/dist/index.d.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  export * from './http/index.js';
2
2
  export * from './relay/provisionRelayWorkers.js';
3
- export { WebSocketClient } from './websocket/index.js';
4
- export { createControlSocket } from './websocket/ControlSocket.js';
5
3
  export { ConnectionManager } from './ConnectionManager.js';
6
4
  export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, AGREEMENT_ENGAGEMENT_KIND, isValidContentType } from './types.js';
7
5
  export { getBackendUrl, getWebSocketUrl } from './utils/urlUtils.js';
package/dist/index.js CHANGED
@@ -1,7 +1,5 @@
1
1
  export * from './http/index.js';
2
2
  export * from './relay/provisionRelayWorkers.js';
3
- export { WebSocketClient } from './websocket/index.js';
4
- export { createControlSocket } from './websocket/ControlSocket.js';
5
3
  export { ConnectionManager } from './ConnectionManager.js';
6
4
  export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, AGREEMENT_ENGAGEMENT_KIND, isValidContentType } from './types.js';
7
5
  export { getBackendUrl, getWebSocketUrl } from './utils/urlUtils.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.1.30",
3
+ "version": "0.3.0",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,13 +0,0 @@
1
- import { type Socket } from 'socket.io-client';
2
- export interface ControlSocketOptions {
3
- wsUrl: string;
4
- operatorKey: string;
5
- agentIds: () => string[];
6
- onWake: (agentId: string) => void;
7
- }
8
- export interface ControlSocketHandle {
9
- socket: Socket;
10
- close: () => void;
11
- resync: () => void;
12
- }
13
- export declare function createControlSocket({ wsUrl, operatorKey, agentIds, onWake }: ControlSocketOptions): ControlSocketHandle;
@@ -1,37 +0,0 @@
1
- import { io } from 'socket.io-client';
2
- import { runtimeLog } from '../shared/runtimeLog.js';
3
- export function createControlSocket({ wsUrl, operatorKey, agentIds, onWake }) {
4
- if (!wsUrl || !operatorKey)
5
- throw new Error('[createControlSocket] wsUrl and operatorKey are required');
6
- if (typeof agentIds !== 'function')
7
- throw new Error('[createControlSocket] agentIds must be a function returning string[]');
8
- if (typeof onWake !== 'function')
9
- throw new Error('[createControlSocket] onWake must be a function');
10
- const socket = io(wsUrl, {
11
- auth: { token: `Bearer ${operatorKey}` },
12
- query: { token: operatorKey, role: 'launcher' },
13
- extraHeaders: { Authorization: `Bearer ${operatorKey}` },
14
- transports: ['websocket'],
15
- reconnection: true,
16
- reconnectionDelay: 1000,
17
- reconnectionDelayMax: 30000,
18
- randomizationFactor: 0.5,
19
- });
20
- socket.on('connect', () => {
21
- const ids = agentIds();
22
- runtimeLog.info('ControlSocket', `connected (${socket.id}); registering ${ids.length} agent(s)`);
23
- socket.emit('launcher:register', { agentIds: ids });
24
- });
25
- socket.on('launcher:wake', ({ agentId }) => {
26
- if (agentId)
27
- onWake(agentId);
28
- });
29
- socket.on('disconnect', (reason) => runtimeLog.debug('ControlSocket', `disconnected: ${reason}`));
30
- socket.on('connect_error', (err) => runtimeLog.warn('ControlSocket', `connect error: ${err.message}`));
31
- return {
32
- socket,
33
- close: () => socket.disconnect(),
34
- resync: () => { if (socket.connected)
35
- socket.emit('launcher:register', { agentIds: agentIds() }); },
36
- };
37
- }
@@ -1,86 +0,0 @@
1
- import 'dotenv/config';
2
- import { type MessageHandler } from '../types.js';
3
- export interface WebSocketClientOptions {
4
- wsUrl?: string;
5
- operatorKey?: string;
6
- agentId?: string;
7
- /**
8
- * Optional user JWT (from `POST /users/login`). When set, the socket
9
- * authenticates as that user — the gateway populates `socket.userId` and
10
- * routes messages to them as a human user. Mutually exclusive with
11
- * `operatorKey` + `agentId`. Used by the eval harness to seed chats as a
12
- * real user instead of impersonating an agent.
13
- */
14
- userToken?: string;
15
- label?: string;
16
- }
17
- /**
18
- * Mirrors the backend `ResourceEvent` shape. Emitted on the wire as
19
- * `resource_changed` when a resource the agent has access to mutates.
20
- */
21
- export interface ResourceEvent {
22
- kind: 'message' | 'artifact' | 'task-state' | 'agreement';
23
- ts: string;
24
- resourceId: string;
25
- chatId?: string;
26
- agreementId?: string;
27
- taskId?: string;
28
- change?: 'created' | 'updated' | 'state-changed';
29
- reason?: string;
30
- /**
31
- * Principal whose action produced the event, when the backend knows it.
32
- * Hosts drop events the receiving agent itself caused — its own writes
33
- * aren't news to it.
34
- */
35
- actorId?: string;
36
- }
37
- export type ResourceEventHandler = (event: ResourceEvent) => void | Promise<void>;
38
- export interface SendOptions {
39
- entryType?: string;
40
- content_type?: string;
41
- contentType?: string;
42
- taskId?: string;
43
- /**
44
- * When true, emits a 'chat:chunk' event instead of 'chat'.
45
- * Use for streaming partial responses; send a final message
46
- * (partial: false or omitted) to signal end of stream.
47
- * Requires backend streaming support.
48
- */
49
- partial?: boolean;
50
- /** Pre-allocated messageId for streaming continuity — links chunks to the final message. */
51
- messageId?: string;
52
- }
53
- export declare class WebSocketClient {
54
- private wsUrl;
55
- private operatorKey;
56
- private agentId;
57
- private userToken;
58
- private label;
59
- private socket;
60
- private messageHandler;
61
- private resourceEventHandler;
62
- constructor(options?: WebSocketClientOptions);
63
- setMessageHandler(handler: MessageHandler): void;
64
- /**
65
- * Subscribe to `resource_changed` notifications — the unified push channel
66
- * for non-message resource changes (artifact, task-state, agreement). The
67
- * agent decides whether to pull the corresponding primitive based on
68
- * `event.kind` + ids. Replaces cursor-based polling for delta detection.
69
- */
70
- setResourceEventHandler(handler: ResourceEventHandler): void;
71
- private connectHandlers;
72
- /**
73
- * Called on every socket connect, including socket.io reconnects.
74
- * Used for inbox catch-up (ZIG-454) so missed pushes are recovered from MongoDB.
75
- */
76
- onConnect(handler: () => void | Promise<void>): void;
77
- private _emitConnect;
78
- private _connect;
79
- connectAsync(timeout?: number): Promise<this>;
80
- disconnect(): void;
81
- send(chatId: string, receiverId: string, content: string, options?: SendOptions): void;
82
- isConnected(): boolean;
83
- handleIncomingMessage(payload: unknown): Promise<void>;
84
- private buildSocketOptions;
85
- private generateMessageId;
86
- }
@@ -1,268 +0,0 @@
1
- import { io } from 'socket.io-client';
2
- import 'dotenv/config';
3
- import { getWebSocketUrl } from '../utils/urlUtils.js';
4
- import { runtimeLog } from '../shared/runtimeLog.js';
5
- export class WebSocketClient {
6
- wsUrl;
7
- operatorKey;
8
- agentId;
9
- userToken;
10
- label;
11
- socket;
12
- messageHandler;
13
- resourceEventHandler;
14
- constructor(options = {}) {
15
- this.wsUrl = options.wsUrl || getWebSocketUrl();
16
- this.userToken = options.userToken || null;
17
- this.operatorKey = this.userToken ? null : (options.operatorKey || process.env.ZIGGS_OPERATOR_KEY || null);
18
- this.agentId = this.userToken ? null : (options.agentId || null);
19
- this.label = options.label || 'agent';
20
- this.socket = null;
21
- this.messageHandler = null;
22
- this.resourceEventHandler = null;
23
- }
24
- setMessageHandler(handler) {
25
- this.messageHandler = handler;
26
- }
27
- /**
28
- * Subscribe to `resource_changed` notifications — the unified push channel
29
- * for non-message resource changes (artifact, task-state, agreement). The
30
- * agent decides whether to pull the corresponding primitive based on
31
- * `event.kind` + ids. Replaces cursor-based polling for delta detection.
32
- */
33
- setResourceEventHandler(handler) {
34
- this.resourceEventHandler = handler;
35
- }
36
- connectHandlers = [];
37
- /**
38
- * Called on every socket connect, including socket.io reconnects.
39
- * Used for inbox catch-up (ZIG-454) so missed pushes are recovered from MongoDB.
40
- */
41
- onConnect(handler) {
42
- this.connectHandlers.push(handler);
43
- }
44
- async _emitConnect() {
45
- for (const handler of this.connectHandlers) {
46
- try {
47
- await handler();
48
- }
49
- catch (error) {
50
- const msg = error instanceof Error ? error.message : String(error);
51
- runtimeLog.warn(this.label, `connect handler error: ${msg}`);
52
- }
53
- }
54
- }
55
- _connect() {
56
- if (this.socket?.connected)
57
- return;
58
- runtimeLog.debug(this.label, `Connecting to ${this.wsUrl}`);
59
- const socketOptions = this.buildSocketOptions();
60
- this.socket = io(this.wsUrl, socketOptions);
61
- this.socket.on('connect', () => {
62
- runtimeLog.info(this.label, 'Connected');
63
- void this._emitConnect();
64
- });
65
- this.socket.on('connect_error', (error) => {
66
- runtimeLog.error(this.label, `Connection error: ${error.message}`);
67
- });
68
- this.socket.on('disconnect', (reason) => {
69
- const reasonDescriptions = {
70
- 'io server disconnect': 'Server forcefully disconnected the client',
71
- 'io client disconnect': 'Client manually disconnected',
72
- 'ping timeout': 'Server did not respond to ping (connection timeout)',
73
- 'transport close': 'Connection closed by transport layer',
74
- 'transport error': 'Transport error occurred',
75
- 'parse error': 'Error parsing server message',
76
- 'forced close': 'Connection was forcibly closed',
77
- 'forced server close': 'Server forcibly closed the connection',
78
- };
79
- const description = reasonDescriptions[reason] || 'Unknown disconnect reason';
80
- runtimeLog.debug(this.label, `Disconnected: ${reason} — ${description}`);
81
- });
82
- // ZIG-207: subscribe to the canonical event name. Backend dual-emits
83
- // `messages` + `chat:message:new` with byte-identical payloads, so
84
- // listening to ONE of the two is exactly correct — listening to both
85
- // would double-invoke handleIncomingMessage(). We pick the canonical
86
- // namespaced name so once the sunset PR drops the legacy `messages`
87
- // emit, no client change is needed.
88
- this.socket.on('chat:message:new', async (payload) => {
89
- try {
90
- await this.handleIncomingMessage(payload);
91
- }
92
- catch (error) {
93
- const msg = error instanceof Error ? error.message : String(error);
94
- runtimeLog.error(this.label, `Error handling incoming message: ${msg}`);
95
- throw error;
96
- }
97
- });
98
- this.socket.on('resource_changed', async (payload) => {
99
- if (!this.resourceEventHandler)
100
- return;
101
- try {
102
- await this.resourceEventHandler(payload);
103
- }
104
- catch (error) {
105
- const msg = error instanceof Error ? error.message : String(error);
106
- runtimeLog.error(this.label, `Error handling resource_changed: ${msg}`);
107
- }
108
- });
109
- this.socket.on('error', (error) => {
110
- runtimeLog.error(this.label, `Socket.IO error: ${String(error)}`);
111
- });
112
- // ZIG-860: the send path is fire-and-forget (no ack callback), so the
113
- // backend's structured rejection is the ONLY failure signal. Not listening
114
- // made every rejected send silent — an agent's message just vanished (the
115
- // a2a schema bug hid behind exactly this). Log loudly; delivery recovery
116
- // stays with the inbox/catch-up machinery.
117
- this.socket.on('chat:error', (payload) => {
118
- const p = (payload ?? {});
119
- runtimeLog.error(this.label, `chat:error [${p['code'] ?? 'UNKNOWN'}] chatId=${p['chatId'] ?? '-'} messageId=${p['messageId'] ?? '-'}: ${p['message'] ?? ''}`);
120
- });
121
- }
122
- connectAsync(timeout = 10_000) {
123
- if (this.socket?.connected)
124
- return Promise.resolve(this);
125
- return new Promise((resolve, reject) => {
126
- this._connect();
127
- // Only reject on timeout — not on the first connect_error. socket.io
128
- // retries automatically (reconnectionAttempts: Infinity), so a single
129
- // network hiccup or a briefly-down backend shouldn't crash the caller.
130
- const timer = setTimeout(() => reject(new Error(`[${this.label}] Connection timeout after ${timeout}ms`)), timeout);
131
- this.socket.once('connect', () => { clearTimeout(timer); resolve(this); });
132
- });
133
- }
134
- disconnect() {
135
- if (this.socket) {
136
- this.socket.disconnect();
137
- this.socket = null;
138
- }
139
- }
140
- send(chatId, receiverId, content, options = {}) {
141
- if (!chatId || typeof chatId !== 'string')
142
- throw new Error('send: chatId is required');
143
- if (!receiverId || typeof receiverId !== 'string')
144
- throw new Error('send: receiverId is required');
145
- if (typeof content !== 'string' || content.trim().length === 0)
146
- throw new Error('send: content must be a non-empty string');
147
- if (!this.socket)
148
- throw new Error('send: socket is not initialized. Call connect() first.');
149
- if (!this.socket.connected)
150
- throw new Error('send: socket is not connected. Call connect() and wait for connection.');
151
- const messageId = options.messageId ?? this.generateMessageId();
152
- if (!messageId || typeof messageId !== 'string')
153
- throw new Error('send: failed to generate valid messageId');
154
- const message = {
155
- receiverId,
156
- chatId,
157
- messageId,
158
- text: content,
159
- entryType: options.entryType || 'message',
160
- content_type: options.content_type || options.contentType || 'text',
161
- };
162
- const messagePreview = content.length > 100 ? content.substring(0, 100) + '...' : content;
163
- // ZIG-207: emit the canonical namespaced inbound event. Backend
164
- // registers `chat:message:send` as a one-line alias on `handleChat`,
165
- // so this is byte-identical to the legacy `chat` emit. Streaming
166
- // chunks keep their existing `chat:chunk` name (already namespaced).
167
- const event = options.partial ? 'chat:chunk' : 'chat:message:send';
168
- runtimeLog.debug(this.label, `Sending message (${event}) - chatId: ${chatId}, receiverId: ${receiverId}\n Message: ${messagePreview}`);
169
- this.socket.emit(event, message);
170
- }
171
- isConnected() {
172
- return this.socket?.connected || false;
173
- }
174
- async handleIncomingMessage(payload) {
175
- if (!payload || typeof payload !== 'object') {
176
- throw new Error('handleIncomingMessage: payload must be an object');
177
- }
178
- const p = payload;
179
- if (!p['text'] || typeof p['text'] !== 'string' || p['text'].trim().length === 0) {
180
- throw new Error('handleIncomingMessage: payload.text is required and must be a non-empty string');
181
- }
182
- if (!p['chatId'] || typeof p['chatId'] !== 'string') {
183
- throw new Error('handleIncomingMessage: payload.chatId is required and must be a string');
184
- }
185
- const sender = p['sender'];
186
- const senderId = sender?.['id'];
187
- const senderType = sender?.['type'];
188
- if (!senderId || typeof senderId !== 'string') {
189
- throw new Error('handleIncomingMessage: payload.sender.id is required and must be a string');
190
- }
191
- runtimeLog.debug(this.label, `Received message - chatId: ${p['chatId']}, sender: ${senderId} (${senderType})`);
192
- if (this.agentId && senderId === this.agentId)
193
- return;
194
- if (!this.messageHandler) {
195
- throw new Error('handleIncomingMessage: messageHandler is not set. Call setMessageHandler() first.');
196
- }
197
- const chatId = p['chatId'];
198
- const receiver = p['receiver'];
199
- const task = p['task'] ?? null;
200
- const agreement = p['agreement'] ?? null;
201
- const operation = p['operation'] ?? null;
202
- const agreementId = p['agreementId'] ?? null;
203
- runtimeLog.info(this.label, `[wire-debug] incoming chatId=${chatId} sender=${senderId} content_type=${p['content_type'] ?? p['contentType'] ?? '<none>'} entryType=${p['entryType'] ?? '<none>'} ts=${typeof p['timestamp']}/${typeof p['sentTimestamp']} keys=${Object.keys(p).join(',')} task?=${task ? `taskId=${task['taskId']} state=${task['state']}` : 'no'} agreement?=${agreement ? `agreementId=${agreement['agreementId']} status=${agreement['status']}` : 'no'} operation?=${operation ?? 'no'}`);
204
- const metadata = {
205
- chatId,
206
- messageId: typeof p['messageId'] === 'string' ? p['messageId'] : undefined,
207
- userId: senderId,
208
- sender: { id: senderId, type: senderType },
209
- senderId,
210
- senderType,
211
- receiver: receiver || null,
212
- receiverId: receiver?.['id'] || null,
213
- entryType: p['entryType'],
214
- content_type: (p['content_type'] ?? p['contentType']),
215
- taskId: task?.['taskId'] ?? null,
216
- task,
217
- agreement,
218
- operation,
219
- agreementId,
220
- // ZIG-860: carry the send time through — the live-path watermark ack
221
- // (AgentHost, ZIG-832 P4) reads it, and dropping it here meant the ack
222
- // never fired for any live message.
223
- timestamp: typeof p['timestamp'] === 'string' ? p['timestamp'] : undefined,
224
- sentTimestamp: typeof p['sentTimestamp'] === 'string' ? p['sentTimestamp'] : undefined,
225
- };
226
- await this.messageHandler(p['text'], metadata);
227
- }
228
- buildSocketOptions() {
229
- // `forceNew: true` is required when multiple WebSocketClient instances
230
- // hit the same URL with DIFFERENT auth. socket.io's default
231
- // `multiplex: true` shares the engine.io Manager across `io()` calls
232
- // to the same URL — and the Manager's auth is fixed by whoever opened
233
- // it first. So a second client (e.g. an eval's user-mode connection
234
- // alongside agent-host operator-key connections) would silently inherit
235
- // the first client's auth and get rejected. Forcing a fresh Manager
236
- // per client avoids that aliasing.
237
- const options = {
238
- transports: ['websocket'],
239
- reconnection: true,
240
- reconnectionAttempts: Infinity,
241
- reconnectionDelay: 1000,
242
- reconnectionDelayMax: 30000,
243
- randomizationFactor: 0.5,
244
- timeout: 20000,
245
- autoConnect: true,
246
- forceNew: true,
247
- multiplex: false,
248
- };
249
- if (this.userToken) {
250
- const bearer = `Bearer ${this.userToken}`;
251
- options.auth = { token: bearer };
252
- options.extraHeaders = { Authorization: bearer };
253
- // No agentId — the gateway sees the `userId` claim and routes as a human user.
254
- }
255
- else if (this.operatorKey) {
256
- const bearer = `Bearer ${this.operatorKey}`;
257
- options.auth = { token: bearer };
258
- options.extraHeaders = { Authorization: bearer };
259
- // agentId goes in query for fleet keys; agent-scoped keys omit it (ZIG-279).
260
- if (this.agentId)
261
- options.query = { agentId: this.agentId };
262
- }
263
- return options;
264
- }
265
- generateMessageId() {
266
- return crypto.randomUUID();
267
- }
268
- }
@@ -1 +0,0 @@
1
- export { WebSocketClient } from './WebSocketClient.js';
@@ -1 +0,0 @@
1
- export { WebSocketClient } from './WebSocketClient.js';