@ziggs-ai/api-client 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +10 -0
  2. package/dist/ConnectionManager.d.ts +21 -57
  3. package/dist/ConnectionManager.js +34 -163
  4. package/dist/capabilities/agreements.js +2 -2
  5. package/dist/capabilities/artifacts.js +11 -10
  6. package/dist/capabilities/connections.js +1 -1
  7. package/dist/capabilities/context.js +29 -11
  8. package/dist/capabilities/grants.d.ts +7 -0
  9. package/dist/capabilities/grants.js +9 -2
  10. package/dist/capabilities/index.d.ts +1 -1
  11. package/dist/capabilities/index.js +1 -1
  12. package/dist/capabilities/links.d.ts +0 -8
  13. package/dist/capabilities/links.js +3 -25
  14. package/dist/capabilities/types.d.ts +1 -0
  15. package/dist/capabilities/types.js +7 -2
  16. package/dist/http/AgreementClient.d.ts +67 -16
  17. package/dist/http/AgreementClient.js +161 -29
  18. package/dist/http/ArtifactsClient.d.ts +5 -1
  19. package/dist/http/ArtifactsClient.js +17 -5
  20. package/dist/http/ChatClient.js +5 -2
  21. package/dist/http/ConnectionsClient.js +4 -4
  22. package/dist/http/ContextDiscoveryClient.js +2 -1
  23. package/dist/http/ContextReadClient.d.ts +30 -3
  24. package/dist/http/ContextReadClient.js +63 -17
  25. package/dist/http/GrantsClient.js +2 -1
  26. package/dist/http/InboxClient.d.ts +32 -50
  27. package/dist/http/InboxClient.js +0 -39
  28. package/dist/http/MarketplaceClient.d.ts +0 -1
  29. package/dist/http/MarketplaceClient.js +8 -3
  30. package/dist/http/MessagesClient.js +3 -5
  31. package/dist/http/OrgsClient.js +3 -2
  32. package/dist/http/PaymentsClient.js +3 -5
  33. package/dist/http/TaskClient.d.ts +8 -0
  34. package/dist/http/TaskClient.js +3 -0
  35. package/dist/http/agreementFlows.d.ts +13 -5
  36. package/dist/http/agreementFlows.js +18 -24
  37. package/dist/http/index.d.ts +3 -3
  38. package/dist/http/index.js +1 -1
  39. package/dist/http/operatorHeaders.d.ts +7 -1
  40. package/dist/http/operatorHeaders.js +8 -1
  41. package/dist/index.d.ts +5 -4
  42. package/dist/index.js +4 -3
  43. package/dist/shared/apiError.d.ts +22 -1
  44. package/dist/shared/apiError.js +60 -3
  45. package/dist/shared/rateLimit.d.ts +12 -22
  46. package/dist/shared/rateLimit.js +18 -51
  47. package/dist/types.d.ts +70 -1
  48. package/dist/types.js +39 -1
  49. package/package.json +1 -1
package/README.md CHANGED
@@ -97,6 +97,16 @@ await inbox.ack([{ kind: 'chat', id: '<chatId>', upTo: env.asOf }]);
97
97
 
98
98
  Other HTTP clients: `ChatClient`, agreement/marketplace helpers — see `src/http/index.ts`.
99
99
 
100
+ ### Persona wire shapes (ZIG-1137)
101
+
102
+ Cross-org inbox and chat payloads mask counterparties behind presentation faces:
103
+
104
+ - Inbox connection requests expose `requesterRef` (`psn_*`) plus display name/org — not `requesterAgentId`.
105
+ - Message/roster rows may carry `presentation` (`ref`, `persona`, `mode`, and `subject` only when entitled). Masked senders omit `underAgreementId` / `presentedAs`.
106
+ - Opaque `psn_*` / `rpb_*` refs are **not** account ids. Do not use them for agent lookup, wake, or payment parties. Chat sends may echo an `rpb_*` as `receiverId` — the backend resolves it in-room.
107
+
108
+ Helpers: `isPersonaRef`, `isRoomPresentationRef`, `isOpaquePresentationRef`.
109
+
100
110
  #### Agent Search Client
101
111
 
102
112
  Search for agents:
@@ -1,87 +1,51 @@
1
- import { type InboxOperatorAgentEntry } from './http/InboxClient.js';
2
1
  type OpenFn = () => Promise<unknown>;
3
2
  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
- }
14
3
  export interface ConnectionManagerMeta {
15
4
  domain?: string;
16
5
  expertise?: string[];
17
6
  tags?: string[];
18
7
  [key: string]: unknown;
19
8
  }
20
- export interface ConnectionManagerOptions {
21
- maxActive?: number;
22
- idleTimeoutMs?: number;
23
- /**
24
- * Fleet control (delivery law, phase 4): the operator key whose owned agents
25
- * this manager lazily starts. `start()` long-polls `GET /inbox/operator` with
26
- * this key; when an agent's slice shows actionable content, it starts that
27
- * agent's host (which then consumes via its own per-agent inbox loop). No
28
- * socket, no push notification — lazy process management driven by the poll. `wsUrl`
29
- * is accepted for back-compat but unused (the poll is HTTP).
30
- */
31
- control?: {
32
- wsUrl?: string;
33
- operatorKey?: string;
34
- };
35
- /**
36
- * Gate on the operator-poll lazy start (ZIG-910). Called for each agent
37
- * slice with actionable content before its host is started; return false to
38
- * leave the host down this pass. The slice stays in later envelopes (reading
39
- * never acks), so the gate is consulted again on every poll. Explicit
40
- * `startAgent()` calls are not gated. Default: everything actionable starts.
41
- */
42
- shouldLazyStart?: (entry: InboxOperatorAgentEntry) => boolean;
43
- }
44
9
  export interface QueryFilter {
45
10
  domain?: string;
46
11
  expertise?: string[];
47
12
  tags?: string[];
48
13
  }
14
+ /**
15
+ * ConnectionManager — the set of agent hosts this process runs.
16
+ *
17
+ * Every registered agent is connected and stays connected (ZIG-1108). There is
18
+ * no subset, no cap, no idle sweep and no lifecycle policy: an agent is alive
19
+ * because its launcher registered it, and the only thing that takes it down is
20
+ * the process exiting. Whether work has arrived recently is not a fact about
21
+ * whether an agent should exist.
22
+ *
23
+ * What this replaced: an operator long-poll that asked the backend which agents
24
+ * had mail, an LRU that evicted hosts past `maxActive`, an idle timer that slept
25
+ * quiet hosts, and a `pinned` flag to exempt the ones an operator had named.
26
+ * Together they made liveness depend on someone remembering to edit a Terraform
27
+ * string, and cost a fleet-wide sweep per poll to find out nothing had changed.
28
+ */
49
29
  export declare class ConnectionManager {
50
- private maxActive;
51
- private idleTimeoutMs;
52
- private _operatorKey;
53
- private _polling;
54
- private _pollDone;
55
30
  private _entries;
56
31
  private _active;
57
32
  private _meta;
58
33
  private _starting;
59
- private _pinned;
60
- private _shouldLazyStart;
61
- constructor({ maxActive, idleTimeoutMs, control, shouldLazyStart }?: ConnectionManagerOptions);
34
+ constructor();
62
35
  register(id: string, openFn: OpenFn, closeFn: CloseFn, meta?: ConnectionManagerMeta): void;
63
- start(operatorKey?: string): void;
64
- stop(): Promise<void>;
65
36
  /**
66
- * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
67
- * owned agent whose slice is actionable and not already running. The started
68
- * host consumes + acks via its own per-agent inbox loop, so this loop never
69
- * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
70
- * the loop never exits until `stop()` (nothing to resynchronize).
37
+ * Connect every registered agent. One host's failure to come up is reported
38
+ * and does not hold back the rest — its own transport retries, and a fleet
39
+ * where 28 of 29 are live is not a launch worth aborting.
71
40
  */
72
- private _runOperatorPoll;
73
- startAgent(id: string, { pinned }?: StartAgentOptions): Promise<unknown>;
41
+ connectAll(): Promise<void>;
42
+ startAgent(id: string): Promise<unknown>;
74
43
  sleep(id: string): Promise<void>;
75
44
  sleepAll(): Promise<void>;
76
- touch(id: string): void;
77
45
  list(): string[];
78
46
  listActive(): string[];
79
- listPinned(): string[];
80
47
  get size(): number;
81
48
  query({ domain, expertise, tags }?: QueryFilter): string[];
82
49
  getMeta(id: string): ConnectionManagerMeta | undefined;
83
- private _scheduleIdle;
84
- private _resetTimer;
85
- private _evictLRU;
86
50
  }
87
51
  export {};
@@ -1,36 +1,29 @@
1
- import { InboxClient } from './http/InboxClient.js';
2
1
  import { runtimeLog } from './shared/runtimeLog.js';
3
- import { isRateLimited } from './shared/rateLimit.js';
4
- const OPERATOR_POLL_WAIT_SECONDS = 25;
5
- const POLL_BACKOFF_BASE_MS = 1_000;
6
- const POLL_BACKOFF_MAX_MS = 30_000;
7
- function sleep(ms) {
8
- return new Promise((resolve) => setTimeout(resolve, ms));
9
- }
2
+ /**
3
+ * ConnectionManager — the set of agent hosts this process runs.
4
+ *
5
+ * Every registered agent is connected and stays connected (ZIG-1108). There is
6
+ * no subset, no cap, no idle sweep and no lifecycle policy: an agent is alive
7
+ * because its launcher registered it, and the only thing that takes it down is
8
+ * the process exiting. Whether work has arrived recently is not a fact about
9
+ * whether an agent should exist.
10
+ *
11
+ * What this replaced: an operator long-poll that asked the backend which agents
12
+ * had mail, an LRU that evicted hosts past `maxActive`, an idle timer that slept
13
+ * quiet hosts, and a `pinned` flag to exempt the ones an operator had named.
14
+ * Together they made liveness depend on someone remembering to edit a Terraform
15
+ * string, and cost a fleet-wide sweep per poll to find out nothing had changed.
16
+ */
10
17
  export class ConnectionManager {
11
- maxActive;
12
- idleTimeoutMs;
13
- _operatorKey;
14
- _polling;
15
- _pollDone;
16
18
  _entries;
17
19
  _active;
18
20
  _meta;
19
21
  _starting;
20
- _pinned;
21
- _shouldLazyStart;
22
- constructor({ maxActive = 50, idleTimeoutMs = 60_000, control, shouldLazyStart } = {}) {
23
- this.maxActive = maxActive;
24
- this.idleTimeoutMs = idleTimeoutMs;
25
- this._shouldLazyStart = shouldLazyStart ?? null;
26
- this._operatorKey = control?.operatorKey ?? null;
27
- this._polling = false;
28
- this._pollDone = null;
22
+ constructor() {
29
23
  this._entries = new Map();
30
24
  this._active = new Map();
31
25
  this._meta = new Map();
32
26
  this._starting = new Map();
33
- this._pinned = new Set();
34
27
  }
35
28
  register(id, openFn, closeFn, meta) {
36
29
  if (!id)
@@ -43,103 +36,34 @@ export class ConnectionManager {
43
36
  if (meta)
44
37
  this._meta.set(id, meta);
45
38
  }
46
- start(operatorKey) {
47
- if (operatorKey)
48
- this._operatorKey = operatorKey;
49
- if (this._polling || !this._operatorKey)
50
- return;
51
- this._polling = true;
52
- this._pollDone = this._runOperatorPoll(this._operatorKey);
53
- }
54
- async stop() {
55
- this._polling = false;
56
- if (this._pollDone)
57
- await this._pollDone.catch(() => { });
58
- this._pollDone = null;
59
- await this.sleepAll();
60
- }
61
39
  /**
62
- * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
63
- * owned agent whose slice is actionable and not already running. The started
64
- * host consumes + acks via its own per-agent inbox loop, so this loop never
65
- * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
66
- * the loop never exits until `stop()` (nothing to resynchronize).
40
+ * Connect every registered agent. One host's failure to come up is reported
41
+ * and does not hold back the rest — its own transport retries, and a fleet
42
+ * where 28 of 29 are live is not a launch worth aborting.
67
43
  */
68
- async _runOperatorPoll(operatorKey) {
69
- // No agentId → operator-level multiplex read (transport auth only).
70
- const inbox = new InboxClient(operatorKey);
71
- let consecutiveErrors = 0;
72
- while (this._polling) {
73
- try {
74
- // Roster scoping (ZIG-965): only agents this manager can actually
75
- // start are worth sweeping server-side. Without it a partial launcher
76
- // (ONLY_AGENTS) paid for a sweep of the owner's whole seeded fleet.
77
- //
78
- // Already-running agents are excluded: this loop's only job is
79
- // lazy-start, and a running host polls its own inbox. Sending them
80
- // made the server answer for agents whose entry we then discarded
81
- // below — pure duplicate work, growing with how healthy the fleet is.
82
- const startable = [...this._entries.keys()].filter((id) => !this._active.has(id));
83
- const env = await inbox.getOperatorInbox({
84
- waitSeconds: OPERATOR_POLL_WAIT_SECONDS,
85
- agents: startable,
86
- });
87
- consecutiveErrors = 0;
88
- for (const entry of env.agents) {
89
- const id = entry.agentId;
90
- // Only slices with actionable content are listed; start the host if
91
- // it isn't already running and we know how to open it.
92
- if (!id || this._active.has(id) || !this._entries.has(id))
93
- continue;
94
- if (this._shouldLazyStart && !this._shouldLazyStart(entry))
95
- continue;
96
- this.startAgent(id).catch((err) => runtimeLog.warn('ConnectionManager', `lazy-start("${id}") failed: ${err.message}`));
97
- }
98
- }
99
- catch (err) {
100
- if (!this._polling)
101
- break;
102
- consecutiveErrors++;
103
- // ZIG-1019: same rule as the per-agent loop — when the server throttles
104
- // us it also says for how long, and retrying sooner just spends more of
105
- // the budget we were told we are out of.
106
- const throttleMs = isRateLimited(err) ? (err.retryAfterMs ?? 30_000) : null;
107
- if (throttleMs !== null) {
108
- consecutiveErrors = 0;
109
- runtimeLog.warn('ConnectionManager', `operator poll throttled: ${err.message} — waiting ${Math.round(throttleMs)}ms as instructed`);
110
- await sleep(throttleMs);
111
- continue;
112
- }
113
- const backoff = Math.min(POLL_BACKOFF_MAX_MS, POLL_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
114
- const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14);
115
- runtimeLog.warn('ConnectionManager', `operator poll failed (#${consecutiveErrors}): ${err.message} — retrying in ~${Math.round(jitter)}ms`);
116
- await sleep(jitter);
117
- }
118
- }
44
+ async connectAll() {
45
+ const ids = [...this._entries.keys()];
46
+ // The count is the cost: every registered agent is one WS connection plus
47
+ // one inbox long-poll, held for the life of the process. There is no cap,
48
+ // so a registration list that balloons shows up here first — loudly, with
49
+ // the number, before the sockets open.
50
+ runtimeLog.info('ConnectionManager', `connecting all ${ids.length} registered agent(s) — one connection + inbox long-poll each, for the life of the process`);
51
+ await Promise.all(ids.map((id) => this.startAgent(id).catch((err) => runtimeLog.warn('ConnectionManager', `start("${id}") failed: ${err.message}`))));
119
52
  }
120
- async startAgent(id, { pinned = false } = {}) {
121
- // Recorded before any await so a pin is never lost to a concurrent
122
- // unpinned start of the same id.
123
- if (pinned)
124
- this._pinned.add(id);
53
+ async startAgent(id) {
125
54
  const existing = this._active.get(id);
126
- if (existing) {
127
- this._resetTimer(id);
128
- return existing.handle;
129
- }
55
+ if (existing)
56
+ return existing;
130
57
  const pending = this._starting.get(id);
131
58
  if (pending)
132
59
  return pending;
133
60
  const entry = this._entries.get(id);
134
61
  if (!entry)
135
62
  throw new Error(`[ConnectionManager] unknown id: "${id}"`);
136
- if (this._active.size >= this.maxActive)
137
- await this._evictLRU();
138
63
  const startPromise = (async () => {
139
64
  try {
140
65
  const handle = await entry.openFn();
141
- this._active.set(id, { handle, timer: null, lastActive: Date.now() });
142
- this._scheduleIdle(id);
66
+ this._active.set(id, handle);
143
67
  return handle;
144
68
  }
145
69
  finally {
@@ -150,22 +74,19 @@ export class ConnectionManager {
150
74
  return startPromise;
151
75
  }
152
76
  async sleep(id) {
153
- const entry = this._active.get(id);
154
- if (!entry)
77
+ if (!this._active.has(id))
155
78
  return;
156
- clearTimeout(entry.timer);
79
+ const handle = this._active.get(id);
157
80
  this._active.delete(id);
158
81
  const reg = this._entries.get(id);
159
82
  if (reg)
160
- await reg.closeFn(entry.handle);
83
+ await reg.closeFn(handle);
161
84
  }
162
85
  async sleepAll() {
163
86
  await Promise.all([...this._active.keys()].map(id => this.sleep(id)));
164
87
  }
165
- touch(id) { this._resetTimer(id); }
166
88
  list() { return [...this._entries.keys()]; }
167
89
  listActive() { return [...this._active.keys()]; }
168
- listPinned() { return [...this._pinned]; }
169
90
  get size() { return this._entries.size; }
170
91
  query({ domain, expertise, tags } = {}) {
171
92
  const results = [];
@@ -181,54 +102,4 @@ export class ConnectionManager {
181
102
  return results;
182
103
  }
183
104
  getMeta(id) { return this._meta.get(id); }
184
- _scheduleIdle(id) {
185
- const entry = this._active.get(id);
186
- if (!entry)
187
- return;
188
- clearTimeout(entry.timer);
189
- // A pinned host has no idle timer at all. Empty polls don't count as
190
- // activity (only delivered items call touch()), so a pinned agent nobody
191
- // talks to would otherwise be swept — and with lazy start unable to keep
192
- // up at fleet size, nothing brings it back until the process restarts.
193
- if (this._pinned.has(id)) {
194
- entry.timer = null;
195
- return;
196
- }
197
- entry.timer = setTimeout(() => {
198
- this.sleep(id).catch(err => runtimeLog.warn('ConnectionManager', `idle sleep("${id}") failed: ${err.message}`));
199
- }, this.idleTimeoutMs);
200
- }
201
- _resetTimer(id) {
202
- const entry = this._active.get(id);
203
- if (!entry)
204
- return;
205
- entry.lastActive = Date.now();
206
- this._scheduleIdle(id);
207
- }
208
- async _evictLRU() {
209
- // Pinned hosts are the last to go: pick the LRU unpinned one, and only
210
- // fall back to a pinned one when every active host is pinned. maxActive
211
- // stays a hard cap — a pin outranks other agents, not the capacity limit —
212
- // but say so, because it means more agents were pinned than can run.
213
- const oldestOf = (ids) => {
214
- let oldest = null;
215
- for (const id of ids) {
216
- const entry = this._active.get(id);
217
- const oldestEntry = oldest ? this._active.get(oldest) : null;
218
- if (!entry)
219
- continue;
220
- if (!oldest || !oldestEntry || entry.lastActive < oldestEntry.lastActive)
221
- oldest = id;
222
- }
223
- return oldest;
224
- };
225
- const active = [...this._active.keys()];
226
- const victim = oldestOf(active.filter(id => !this._pinned.has(id))) ?? oldestOf(active);
227
- if (!victim)
228
- return;
229
- if (this._pinned.has(victim)) {
230
- 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`);
231
- }
232
- await this.sleep(victim);
233
- }
234
105
  }
@@ -1,5 +1,5 @@
1
1
  import { claimOpenAgreement } from '../http/agreementFlows.js';
2
- import { linkIsReachOnly, linkSummary } from './links.js';
2
+ import { linkIsReachOnly } from './links.js';
3
3
  import { fullCreds } from './types.js';
4
4
  /**
5
5
  * ZIG-1021 — the one claim verb. Quests, standing offers, and link invites
@@ -29,7 +29,7 @@ export const agreementClaimCapability = {
29
29
  status: 'linked',
30
30
  kind,
31
31
  message: `Link invite claimed — you are now linked. ${linkIsReachOnly(env)}`,
32
- agreement: linkSummary(agreement),
32
+ agreement,
33
33
  };
34
34
  }
35
35
  return {
@@ -45,7 +45,7 @@ export const recordArtifactCapability = {
45
45
  chatId: { type: 'string', description: 'Optional chat scope' },
46
46
  agreementId: {
47
47
  type: 'string',
48
- description: 'Optional agreement scope — for a hire deliverable, prefer this. When both chatId and agreementId are passed, agreementId wins.',
48
+ description: 'Optional agreement scope — for a hire deliverable, prefer this. Mutually exclusive with chatId: pass one, not both.',
49
49
  },
50
50
  taskId: {
51
51
  type: 'string',
@@ -63,12 +63,13 @@ export const recordArtifactCapability = {
63
63
  needsAgentId: true,
64
64
  handler: async (args, env) => {
65
65
  const agreementId = args['agreementId'];
66
- // Agreement wins when both scopes arrive. Models naturally pass the chat
67
- // they are standing in alongside the agreement they deliver under; a hard
68
- // xor here failed the deliverable at the last step of a finished task
69
- // (ZIG-924 dogfood). The backend accepts at most one — we resolve the
70
- // ambiguity at the exposed surface instead of erroring.
71
- const chatId = agreementId ? undefined : args['chatId'];
66
+ // Both containers is refused, not resolved. This used to drop chatId and
67
+ // call agreement the winner — two answers to one call, and the silent one
68
+ // was worse: the artifact landed somewhere the caller had just been told it
69
+ // would also appear. The refusal itself lives in ArtifactsClient, the one
70
+ // gate every writer goes through, so this surface cannot drift from the
71
+ // others by wording its own verdict (ZIG-1075).
72
+ const chatId = args['chatId'];
72
73
  // ZIG-1037: no scope is legal. The throw that used to live here ("Pass
73
74
  // chatId or agreementId") is the exact failure this ticket removed — it cost
74
75
  // a live agent a turn mid-delivery for naming no container, when the record
@@ -80,7 +81,7 @@ export const recordArtifactCapability = {
80
81
  const contentType = args['content_type'];
81
82
  const taskId = args['taskId'];
82
83
  const creds = fullCreds(env);
83
- const { artifactId } = await new ArtifactsClient(creds.operatorKey, creds.agentId).writeStrict({
84
+ const { artifactId } = await new ArtifactsClient(creds.operatorKey, creds.agentId, creds.laneId).writeStrict({
84
85
  text: args['text'],
85
86
  visibility,
86
87
  chatId,
@@ -128,7 +129,7 @@ export const listArtifactsCapability = {
128
129
  needsAgentId: true,
129
130
  handler: async (args, env) => {
130
131
  const creds = fullCreds(env);
131
- return new ArtifactsClient(creds.operatorKey, creds.agentId).list({ authoredBy: 'me' }, {
132
+ return new ArtifactsClient(creds.operatorKey, creds.agentId, creds.laneId).list({ authoredBy: 'me' }, {
132
133
  after: args['after'],
133
134
  limit: typeof args['limit'] === 'number' ? args['limit'] : undefined,
134
135
  });
@@ -248,7 +249,7 @@ export const attachArtifactCapability = {
248
249
  throw new Error('role must be input or output');
249
250
  }
250
251
  const creds = fullCreds(env);
251
- const client = new ArtifactsClient(creds.operatorKey, creds.agentId);
252
+ const client = new ArtifactsClient(creds.operatorKey, creds.agentId, creds.laneId);
252
253
  if (chatId) {
253
254
  await client.attachToChat(artifactId, chatId);
254
255
  return {
@@ -97,7 +97,7 @@ export const requestConnectionCapability = {
97
97
  note: env.surface === 'mcp'
98
98
  ? 'A connection-consent card is now in the chat awaiting your principal. Tell the human now (pull-only MCP has no push) — they approve it right in the chat. ' +
99
99
  'Once approved, the connection + grant appear in ziggs_connection_list for ziggs_connection_proxy.'
100
- : 'A connection-consent card is now in the chat awaiting your principal — they approve it right there (ZIG-798 flow). ' +
100
+ : 'A connection-consent card is now in the chat awaiting your principal — they approve it right there. ' +
101
101
  'Once approved, the connection + grant appear in grant_list (scopeKind=connection) / connection_list_grants for connection_proxy.',
102
102
  };
103
103
  }
@@ -1,15 +1,9 @@
1
- import { ContextReadClient } from '../http/ContextReadClient.js';
1
+ import { ContextReadClient, CONTEXT_READ_TYPES, CONTEXT_READ_VIA, parseVia, viaHint, } from '../http/ContextReadClient.js';
2
2
  import { ContextGrantsClient, } from '../http/ContextGrantsClient.js';
3
3
  import { ContextDiscoveryClient } from '../http/ContextDiscoveryClient.js';
4
4
  import { grantCaveat, CONTEXT_GRANT_SCOPE_KINDS, } from '../http/grants.js';
5
5
  import { fetchMyOrgs, resolveOrgSelector } from '../http/OrgsClient.js';
6
6
  import { fullCreds } from './types.js';
7
- const CONTEXT_READ_TYPES = [
8
- 'messages',
9
- 'artifacts',
10
- 'agreements',
11
- 'tasks',
12
- ];
13
7
  // ZIG-1037: `artifact` joined the context rail. Delegating an artifact grant
14
8
  // onward works (same kind, same id — an exact re-grant); narrowing a container
15
9
  // grant DOWN to an artifact inside it is deliberately not supported yet.
@@ -49,12 +43,26 @@ export async function resolveOrgScopeId(env, scopeId) {
49
43
  const lister = env.surface === 'mcp' ? 'ziggs_org_list' : 'your org list';
50
44
  throw new Error(`No org named "${scopeId}" in your memberships — use ${lister} to see them, or pass the org id.`);
51
45
  }
46
+ /**
47
+ * Which `via` each read type accepts, rendered for the tool text. Generated from
48
+ * CONTEXT_READ_VIA so the pairing an agent is told about and the pairing the
49
+ * call actually accepts are one statement — the prose that used to enumerate
50
+ * these by hand had already drifted from the server's list.
51
+ */
52
+ const VIA_BY_TYPE = CONTEXT_READ_TYPES.map((t) => `${t} via ${viaHint(t)}`).join('; ');
53
+ /**
54
+ * The one fact about `artifact:<id>` that both surfaces must state: it is how you
55
+ * reach an artifact no container can return. Shared for the same reason the
56
+ * pairings above are generated — hand-copied prose is what drifted.
57
+ */
58
+ const ARTIFACT_VIA_NOTE = 'via=artifact:<id> is a point read of one named artifact and the ONLY way to ' +
59
+ 'read one attached to no chat, agreement or task';
52
60
  export const contextReadCapability = {
53
61
  key: 'context_read',
54
62
  names: { sdk: 'context_read', mcp: 'ziggs_context_read' },
55
63
  descriptions: {
56
- sdk: 'Read the contents of a scope you already hold a grant for: messages | artifacts | agreements | tasks. Use grant_list first to see which scopes your grants cover, then read through any of them. For artifacts you can also name one directly with via=artifact:<id> — the only way to read an artifact attached to no chat, agreement or task. Cursored; all access is grant-fenced server-side.',
57
- mcp: 'Read the contents of a scope you already hold: messages | artifacts | agreements | tasks (the type param), under via=chat:<id>, agreement:<id>, or task:<id> — plus via=artifact:<id> for artifacts, a point read of one named artifact and the ONLY way to read one that is attached to no container (e.g. an artifact someone shared with you; ziggs_grant_list scopeKind=artifact shows those). Forward-delta with after+direction=forward; cursor pagination; contextGrantId pins a grant. The response carries a `readPlan` with the next page and/or forward-delta call pre-filled (after=this page\'s latestSequence), so you can keep reading without rebuilding args. This is the single read path for all four types — to discover which scopes exist, use the listers: ziggs_chat_list, ziggs_task_list, ziggs_agreement_list, ziggs_grant_list, ziggs_link_list.',
64
+ sdk: `Read the contents of a scope you already hold a grant for: ${CONTEXT_READ_TYPES.join(' | ')}. Each type reads through its own entries — ${VIA_BY_TYPE}. Use grant_list first to see which scopes your grants cover, then read through any of them. ${ARTIFACT_VIA_NOTE}. Cursored; all access is grant-fenced server-side.`,
65
+ mcp: `Read the contents of a scope you already hold: ${CONTEXT_READ_TYPES.join(' | ')} (the type param). Each type accepts its own via entries — ${VIA_BY_TYPE} — and any other pairing is refused. ${ARTIFACT_VIA_NOTE} (e.g. an artifact someone shared with you; ziggs_grant_list scopeKind=artifact shows those). Forward-delta with after+direction=forward; cursor pagination; contextGrantId pins a grant. The response carries a \`readPlan\` with the next page and/or forward-delta call pre-filled (after=this page's latestSequence), so you can keep reading without rebuilding args. This is the single read path for all four types — to discover which scopes exist, use the listers: ziggs_chat_list, ziggs_task_list, ziggs_agreement_list, ziggs_grant_list, ziggs_link_list.`,
58
66
  },
59
67
  annotation: 'read-only',
60
68
  params: {
@@ -67,7 +75,7 @@ export const contextReadCapability = {
67
75
  via: {
68
76
  type: 'string',
69
77
  required: true,
70
- description: 'Scope entry you hold, e.g. chat:<id>, agreement:<id>, task:<id>. For type=artifacts also artifact:<id> to read that one artifact.',
78
+ description: `Scope entry you hold, as <kind>:<id>. Accepted per type — ${VIA_BY_TYPE}.`,
71
79
  },
72
80
  cursor: { type: 'string', description: 'Opaque cursor from a prior nextCursor to page' },
73
81
  after: { type: 'string', description: 'ISO timestamp for forward-delta (messages/artifacts)' },
@@ -93,11 +101,21 @@ export const contextReadCapability = {
93
101
  const via = args['via'];
94
102
  if (!via)
95
103
  throw new Error('via is required');
104
+ // Same verdict the server gives, given at the call site so the reason
105
+ // arrives with the mistake. Never a softer one: a client that "fixes" an
106
+ // argument the server would reject is a second answer to one call.
107
+ const parsed = parseVia(via);
108
+ if (!parsed) {
109
+ throw new Error(`via must be <kind>:<id> — one of ${viaHint(type)} for type=${type}`);
110
+ }
111
+ if (!CONTEXT_READ_VIA[type].includes(parsed.kind)) {
112
+ throw new Error(`${type} reads accept via=${viaHint(type)} — not ${parsed.kind}:<id>`);
113
+ }
96
114
  const direction = args['direction'];
97
115
  if (direction !== undefined && direction !== 'forward') {
98
116
  throw new Error('direction must be "forward"');
99
117
  }
100
- return new ContextReadClient(creds.operatorKey, creds.agentId).read(type, {
118
+ return new ContextReadClient(creds.operatorKey, creds.agentId, undefined, creds.laneId).read(type, {
101
119
  via,
102
120
  cursor: args['cursor'],
103
121
  after: args['after'],
@@ -4,6 +4,13 @@ import { type CapabilityDefinition } from './types.js';
4
4
  * (context chat/agreement/org/artifact, connection, wallet), holder-scoped,
5
5
  * cross-session. `unreadableRails` comes from the backend (ZIG-956) so a short
6
6
  * list is never presented as complete when the key can't read a rail.
7
+ *
8
+ * ZIG-1088 — HOLD, not reach. `GET /grants` queries three row collections and
9
+ * nothing else; the implicit arms in AccessService (authorship, chat
10
+ * membership, agreement party, org membership) leave no row behind, so a reader
11
+ * can be entitled to something this list will never mention. The description
12
+ * says so, because the old "the single answer" wording was read as completeness
13
+ * and an empty list as "no access".
7
14
  */
8
15
  export declare const listGrantsCapability: CapabilityDefinition;
9
16
  export declare const GRANTS_CAPABILITIES: CapabilityDefinition[];
@@ -26,13 +26,20 @@ function parseScopeKinds(raw) {
26
26
  * (context chat/agreement/org/artifact, connection, wallet), holder-scoped,
27
27
  * cross-session. `unreadableRails` comes from the backend (ZIG-956) so a short
28
28
  * list is never presented as complete when the key can't read a rail.
29
+ *
30
+ * ZIG-1088 — HOLD, not reach. `GET /grants` queries three row collections and
31
+ * nothing else; the implicit arms in AccessService (authorship, chat
32
+ * membership, agreement party, org membership) leave no row behind, so a reader
33
+ * can be entitled to something this list will never mention. The description
34
+ * says so, because the old "the single answer" wording was read as completeness
35
+ * and an empty list as "no access".
29
36
  */
30
37
  export const listGrantsCapability = {
31
38
  key: 'grant_list',
32
39
  names: { sdk: 'grant_list', mcp: 'ziggs_grant_list' },
33
40
  descriptions: {
34
- sdk: 'List every grant this agent holds across all rails in one call — context (chat/agreement/org/artifact), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no message/artifact content or credentials). The single answer to "what grants of mine do you hold?", holder-scoped and cross-session. Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor. Pair with context_read to read through a context grant, or context_expand_reach to enumerate a scope.',
35
- mcp: 'List every grant this delegate holds across all rails in one call — context (chat/agreement/org/artifact), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). The single answer to "what grants of mine do you hold?", holder-scoped and cross-session. Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor to page. Pass a grantId to ziggs_context_read to pin a specific grant, or ziggs_context_expand_reach to enumerate a scope.',
41
+ sdk: 'List every grant this agent holds across all rails in one call — context (chat/agreement/org/artifact), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no message/artifact content or credentials). Holder-scoped and cross-session. It answers "what grants do I HOLD?", which is narrower than "what can I reach?": reach you have by authoring something, by sitting in a chat, by being a party to an agreement, or through your org is not a grant row and never appears here — so an empty list means "no grants", never "no access". Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor. To answer "what can I reach?" instead, use context_expand_reach to enumerate a scope, or context_read to read through a grant.',
42
+ mcp: 'List every grant this delegate holds across all rails in one call — context (chat/agreement/org/artifact), connection, and wallet — as canonical grants (grantId, scope, caveats, expiresAt, health; no content or credentials). Holder-scoped and cross-session. It answers "what grants do I HOLD?", which is narrower than "what can I reach?": reach you have by authoring something, by sitting in a chat, by being a party to an agreement, or through your org is not a grant row and never appears here — so an empty list means "no grants", never "no access". Filter by scopeKind (rail) and health (defaults to active). Rails you lack the operator-key read scope for are named in unreadableRails, not silently dropped. Cursor-paginated: pass cursor from a prior nextCursor to page. To answer "what can I reach?" instead, use ziggs_context_expand_reach to enumerate a scope, or pass a grantId to ziggs_context_read to pin a specific grant.',
36
43
  },
37
44
  annotation: 'read-only',
38
45
  params: {
@@ -1,6 +1,6 @@
1
1
  export { type CapabilitySurface, type CapabilityAnnotation, type CapabilityParam, type CapabilityEnv, type CapabilityDefinition, fullCreds, rethrowWithContext, } from './types.js';
2
2
  export { PAYMENT_CAPABILITIES, paymentBalanceCapability, paymentTransferCapability, paymentWaitForApprovalCapability, paymentHoldCapability, paymentReleaseCapability, paymentResolveWalletCapability, paymentIssueGrantCapability, paymentAttenuateGrantCapability, paymentRevokeGrantCapability, } from './payments.js';
3
- export { LINK_CAPABILITIES, createLinkInviteCapability, listLinksCapability, linkSummary, linkIsReachOnly, } from './links.js';
3
+ export { LINK_CAPABILITIES, createLinkInviteCapability, listLinksCapability, linkIsReachOnly, } from './links.js';
4
4
  export { AGREEMENT_CAPABILITIES, agreementClaimCapability } from './agreements.js';
5
5
  export { MARKETPLACE_CAPABILITIES, marketplaceViewCapability } from './marketplace.js';
6
6
  export { GRANTS_CAPABILITIES, listGrantsCapability } from './grants.js';
@@ -1,6 +1,6 @@
1
1
  export { fullCreds, rethrowWithContext, } from './types.js';
2
2
  export { PAYMENT_CAPABILITIES, paymentBalanceCapability, paymentTransferCapability, paymentWaitForApprovalCapability, paymentHoldCapability, paymentReleaseCapability, paymentResolveWalletCapability, paymentIssueGrantCapability, paymentAttenuateGrantCapability, paymentRevokeGrantCapability, } from './payments.js';
3
- export { LINK_CAPABILITIES, createLinkInviteCapability, listLinksCapability, linkSummary, linkIsReachOnly, } from './links.js';
3
+ export { LINK_CAPABILITIES, createLinkInviteCapability, listLinksCapability, linkIsReachOnly, } from './links.js';
4
4
  export { AGREEMENT_CAPABILITIES, agreementClaimCapability } from './agreements.js';
5
5
  export { MARKETPLACE_CAPABILITIES, marketplaceViewCapability } from './marketplace.js';
6
6
  export { GRANTS_CAPABILITIES, listGrantsCapability } from './grants.js';
@@ -1,12 +1,4 @@
1
- import type { Agreement } from '../types.js';
2
1
  import { type CapabilityDefinition, type CapabilityEnv } from './types.js';
3
- /**
4
- * ZIG-670 — link-shaped summaries, not raw agreement documents: the money
5
- * block, approvals array, and Mongo internals are noise on a trust
6
- * relationship. ZIG-956 moved this into the shared layer so the MCP mutations
7
- * return it too (they used to leak the raw agreement doc).
8
- */
9
- export declare function linkSummary(a: Agreement): Record<string, unknown>;
10
2
  /**
11
3
  * A link is reach-only — the follow-up move differs by surface tool names.
12
4
  *