@ziggs-ai/api-client 0.7.1 → 0.9.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.
Files changed (38) hide show
  1. package/README.md +10 -0
  2. package/dist/capabilities/artifacts.d.ts +28 -0
  3. package/dist/capabilities/artifacts.js +200 -17
  4. package/dist/capabilities/context.js +37 -16
  5. package/dist/capabilities/grants.d.ts +8 -1
  6. package/dist/capabilities/grants.js +17 -11
  7. package/dist/capabilities/index.d.ts +1 -1
  8. package/dist/capabilities/index.js +1 -1
  9. package/dist/capabilities/types.d.ts +1 -0
  10. package/dist/capabilities/types.js +4 -2
  11. package/dist/http/AgreementClient.d.ts +35 -16
  12. package/dist/http/AgreementClient.js +87 -33
  13. package/dist/http/ArtifactsClient.d.ts +38 -1
  14. package/dist/http/ArtifactsClient.js +70 -10
  15. package/dist/http/ChatClient.js +4 -14
  16. package/dist/http/ContextGrantsClient.d.ts +21 -1
  17. package/dist/http/ContextGrantsClient.js +36 -18
  18. package/dist/http/ContextReadClient.d.ts +30 -3
  19. package/dist/http/ContextReadClient.js +58 -1
  20. package/dist/http/InboxClient.d.ts +32 -3
  21. package/dist/http/MarketplaceClient.d.ts +0 -1
  22. package/dist/http/MarketplaceClient.js +4 -10
  23. package/dist/http/PaymentsClient.js +2 -12
  24. package/dist/http/TaskClient.d.ts +8 -0
  25. package/dist/http/TaskClient.js +4 -21
  26. package/dist/http/grants.d.ts +12 -1
  27. package/dist/http/grants.js +21 -0
  28. package/dist/http/index.d.ts +4 -4
  29. package/dist/http/index.js +2 -2
  30. package/dist/http/operatorHeaders.d.ts +7 -1
  31. package/dist/http/operatorHeaders.js +8 -1
  32. package/dist/index.d.ts +3 -1
  33. package/dist/index.js +5 -1
  34. package/dist/shared/apiError.d.ts +22 -0
  35. package/dist/shared/apiError.js +57 -0
  36. package/dist/types.d.ts +55 -0
  37. package/dist/types.js +19 -0
  38. package/package.json +1 -1
@@ -1,6 +1,5 @@
1
1
  import 'dotenv/config';
2
2
  import { type Creds, type Agreement, type EngagementKind, type BroadcastAudience } from '../types.js';
3
- export type PlanReviewTiming = 'with_proposal' | 'before_execution';
4
3
  /**
5
4
  * Shared proposal terms. When `engagementKind` is omitted the server defaults to `service`.
6
5
  * For an open buyer-broadcast quest, set `proposedTo` to a broadcast sentinel —
@@ -22,7 +21,6 @@ export interface ProposeTerms {
22
21
  billing?: 'total' | 'per_task';
23
22
  agreementDescription?: string;
24
23
  parentAgreementId?: string;
25
- parentTaskId?: string;
26
24
  /**
27
25
  * Who does the work. Required on direct proposals (your own id = you offer;
28
26
  * the proposedTo id = you commission the recipient); forbidden on
@@ -30,9 +28,6 @@ export interface ProposeTerms {
30
28
  * side — there is no payer input.
31
29
  */
32
30
  providerId?: string;
33
- plan?: unknown;
34
- planReviewTiming?: PlanReviewTiming;
35
- requireMidWorkPlanAck?: boolean;
36
31
  /** Defaults to `service` on the server when omitted. Set `hire` for representation contracts. */
37
32
  engagementKind?: EngagementKind;
38
33
  idempotencyKey?: string;
@@ -67,16 +62,12 @@ export interface DelegateAgreementData {
67
62
  executorId: string;
68
63
  chatId: string;
69
64
  parentAgreementId: string;
70
- parentTaskId?: string;
71
65
  price?: number;
72
66
  lifecycle?: string;
73
67
  expiresAt?: string;
74
68
  maxExecutions?: number;
75
69
  agreementDescription?: string;
76
70
  payerId?: string;
77
- plan?: unknown;
78
- planReviewTiming?: PlanReviewTiming;
79
- requireMidWorkPlanAck?: boolean;
80
71
  idempotencyKey?: string;
81
72
  }
82
73
  export declare function delegateAgreement(proposalData: DelegateAgreementData, creds: Creds): Promise<Agreement>;
@@ -91,7 +82,29 @@ export declare function delegateAgreement(proposalData: DelegateAgreementData, c
91
82
  export declare function respondToAgreement(agreementId: string, action: 'approve' | 'reject', creds: Creds, opts?: {
92
83
  ownerUserId?: string | null;
93
84
  agreement?: Agreement | null;
85
+ /** Where to send the human when the decision is theirs (ZIG-1087). */
86
+ appUrl?: string;
94
87
  }): Promise<Agreement>;
88
+ /**
89
+ * The approval facts a "who decides this?" question is answered from: the party
90
+ * ids still owing a decision, plus the named responder slot.
91
+ *
92
+ * Split out (ZIG-1087) so the inbox card and the respond call answer that
93
+ * question with ONE rule. The card had no rule at all — it offered the respond
94
+ * tool for every pending proposal, including the ones only the human can
95
+ * answer, and the agent found out by 403.
96
+ */
97
+ export interface PendingApprovalFacts {
98
+ /** Party ids with a pending entry on the approvals ledger. */
99
+ pendingPartyIds: readonly string[];
100
+ /** The direct responder slot, when the proposal names one. */
101
+ proposedTo?: string | null;
102
+ }
103
+ /**
104
+ * Which of `candidateIds` holds the pending approval slot, first match wins —
105
+ * so pass them in preference order (agent id before owner principal).
106
+ */
107
+ export declare function resolvePendingApprovalPartyId(facts: PendingApprovalFacts, candidateIds: ReadonlyArray<string | null | undefined>): string | null;
95
108
  /**
96
109
  * ZIG-524 — resolve which approvals.partyId the current operator may submit.
97
110
  * Checks pending ledger entries against impersonated agent id and owner principal.
@@ -116,10 +129,6 @@ export interface CounterAgreementData {
116
129
  lifecycle?: string;
117
130
  maxExecutions?: number;
118
131
  description?: string;
119
- /** Override plan `{ steps: [...] }`; omit to copy from original proposal task */
120
- plan?: unknown;
121
- planReviewTiming?: PlanReviewTiming;
122
- requireMidWorkPlanAck?: boolean;
123
132
  }
124
133
  export declare function counterAgreement(agreementId: string, counter: CounterAgreementData, creds: Creds): Promise<Agreement>;
125
134
  export declare function getAgreementStatus(agreementId: string, creds: Creds): Promise<unknown | null>;
@@ -185,7 +194,15 @@ export declare function claimAgreement(agreementId: string, creds: Creds): Promi
185
194
  ok: boolean;
186
195
  agreement: Agreement;
187
196
  }>;
188
- export type ChatLinkType = 'origin' | 'mention' | 'delegation' | 'join';
197
+ /**
198
+ * Link types a caller may ASK for — the server's `CALLER_LINK_TYPES`, not its
199
+ * full `LINK_TYPES`. `space` is deliberately absent: an agreement space gets
200
+ * exactly one system-minted room, and letting a request name that type would
201
+ * burn the root's single slot on an unrelated chat. Shorter than the server
202
+ * union on purpose, which is why this list carries the reason with it.
203
+ */
204
+ export declare const CHAT_LINK_TYPES: readonly ["origin", "mention", "delegation", "join"];
205
+ export type ChatLinkType = (typeof CHAT_LINK_TYPES)[number];
189
206
  export declare function linkAgreementToChat(agreementId: string, chatId: string, linkType: ChatLinkType | undefined, creds: Creds): Promise<unknown | null>;
190
207
  export declare function getChatsForAgreement(agreementId: string, creds: Creds): Promise<unknown[]>;
191
208
  export declare function joinAgreement(agreementId: string, creds: Creds): Promise<{
@@ -193,10 +210,12 @@ export declare function joinAgreement(agreementId: string, creds: Creds): Promis
193
210
  agentId: string | null;
194
211
  isNew: boolean;
195
212
  }>;
196
- export type ArtifactLinkType = 'produced' | 'referenced';
213
+ export declare const ARTIFACT_LINK_TYPES: readonly ["produced", "referenced"];
214
+ export type ArtifactLinkType = (typeof ARTIFACT_LINK_TYPES)[number];
197
215
  export declare function linkArtifactToAgreement(agreementId: string, artifactId: string, linkType: ArtifactLinkType | undefined, creds: Creds): Promise<unknown | null>;
198
216
  export declare function getArtifactsForAgreement(agreementId: string, creds: Creds): Promise<unknown[]>;
199
- export type UserRole = 'payer' | 'provider' | 'participant' | 'observer';
217
+ export declare const AGREEMENT_USER_ROLES: readonly ["payer", "provider", "participant", "observer"];
218
+ export type UserRole = (typeof AGREEMENT_USER_ROLES)[number];
200
219
  export declare function linkUserToAgreement(agreementId: string, userId: string, role: UserRole, creds: Creds): Promise<unknown | null>;
201
220
  export declare function getUsersForAgreement(agreementId: string, creds: Creds): Promise<unknown[]>;
202
221
  export declare class AgreementClient {
@@ -1,7 +1,8 @@
1
1
  import 'dotenv/config';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
3
  import { getBackendUrl } from '../utils/urlUtils.js';
4
- import { OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, isBroadcastTarget, ApiError } from '../types.js';
4
+ import { OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, isBroadcastTarget } from '../types.js';
5
+ import { throwApiError } from '../shared/apiError.js';
5
6
  // Lazy: read at call time so dotenv loaded after this module is imported
6
7
  // still takes effect. Baking it at module-load time would freeze the URL
7
8
  // before the caller's dotenv.config() runs.
@@ -13,6 +14,9 @@ function buildHeaders(creds) {
13
14
  'content-type': 'application/json',
14
15
  Authorization: `Bearer ${creds.operatorKey}`,
15
16
  'X-Agent-Id': creds.agentId,
17
+ // ZIG-1092 — the wake's lane, so the backend can fence this call to the
18
+ // engagement it belongs to rather than the agent's whole authority.
19
+ ...(creds.laneId ? { 'X-Ziggs-Lane': creds.laneId } : {}),
16
20
  };
17
21
  }
18
22
  function assertCreds(creds, op) {
@@ -21,20 +25,6 @@ function assertCreds(creds, op) {
21
25
  if (!creds?.agentId)
22
26
  throw new Error(`agentId is required for ${op}`);
23
27
  }
24
- function parseErrorMessage(responseBody, defaultMessage) {
25
- if (!responseBody)
26
- return defaultMessage;
27
- try {
28
- const d = JSON.parse(responseBody);
29
- return d['details'] || d['error'] || d['message'] || defaultMessage;
30
- }
31
- catch {
32
- return responseBody || defaultMessage;
33
- }
34
- }
35
- function throwApiError(response, responseBody, defaultMessage) {
36
- throw new ApiError(parseErrorMessage(responseBody, defaultMessage), response.status, responseBody);
37
- }
38
28
  export async function proposeAgreement(proposalData, creds) {
39
29
  if (!proposalData)
40
30
  throw new Error('Proposal data is required for proposal creation');
@@ -124,43 +114,78 @@ export async function respondToAgreement(agreementId, action, creds, opts = {})
124
114
  if (!agreement) {
125
115
  throw new Error(`Agreement ${agreementId} not found`);
126
116
  }
117
+ const partyId = resolveMyPendingApprovalPartyId(agreement, {
118
+ ownerUserId: opts.ownerUserId,
119
+ agentId: creds.agentId,
120
+ });
127
121
  const proposedTo = agreement.parties?.proposedTo;
128
122
  if (isBroadcastTarget(proposedTo) || isBroadcastTarget(agreement.parties?.payer)) {
129
123
  // ZIG-1021: respond is approve/reject on DIRECT proposals only. An open
130
124
  // broadcast (quest, standing offer, link invite) has no per-recipient
131
- // approval slot — one recipient can neither approve nor reject it
132
- // (ZIG-719); the move is to claim it, which has its own verb now.
133
- throw new Error(`Agreement ${agreementId} is an open broadcast and has no personal approval slot — respond cannot ${action} it. Claim it with the claim tool (agreement_claim / ziggs_agreement_claim), or ignore it to pass.`);
125
+ // approval slot — a BYSTANDER can neither approve nor reject it
126
+ // (ZIG-719); their move is to claim it, which has its own verb.
127
+ //
128
+ // ZIG-1077: with one exception — the caller who HOLDS a pending approval
129
+ // on the row. A hand-off pins its provider and that provider's consent
130
+ // is a real ledger slot even on an open broadcast (ZIG-1059); this guard
131
+ // used to reject before looking, so the provider could not consent
132
+ // through ANY surface and the hand-off sat unclaimable forever. The
133
+ // backend keeps a consented broadcast OPEN for claims.
134
+ if (!partyId) {
135
+ throw new Error(`Agreement ${agreementId} is an open broadcast and has no personal approval slot — respond cannot ${action} it. Claim it with the claim tool (agreement_claim / ziggs_agreement_claim), or ignore it to pass.`);
136
+ }
134
137
  }
135
- const partyId = resolveMyPendingApprovalPartyId(agreement, {
136
- ownerUserId: opts.ownerUserId,
137
- agentId: creds.agentId,
138
- });
139
- if (!partyId) {
138
+ else if (!partyId) {
140
139
  throw new Error(`No pending approval entry for this operator on agreement ${agreementId}`);
141
140
  }
141
+ // ZIG-1087 — the slot resolved to the principal, not to us. Every call from
142
+ // this client impersonates `creds.agentId` (X-Agent-Id on every request), and
143
+ // `PUT /approvals/:partyId` takes a decision only from the party itself, so
144
+ // sending this would 403 with "You can only submit your own approval" —
145
+ // a condition, from which the caller cannot tell that no retry will ever
146
+ // work. Withholding consent from delegates is deliberate; being silent about
147
+ // it is not.
148
+ if (partyId && partyId !== creds.agentId) {
149
+ throw new Error(`Agreement ${agreementId} is waiting on ${partyId} — your principal, not you. ` +
150
+ `Consent is the human's to give: a delegate can submit its own approval slot and never its principal's, ` +
151
+ `so there is nothing to retry here and no tool that changes it. ` +
152
+ `Ask your human to approve or reject it${opts.appUrl ? ` at ${opts.appUrl}` : ' in the Ziggs app, under Agreements'}, ` +
153
+ `then read the agreement again to see the outcome.`);
154
+ }
142
155
  return approveAgreementAsParty(agreementId, partyId, action === 'approve' ? 'approved' : 'rejected', creds);
143
156
  }
144
157
  /**
145
- * ZIG-524 — resolve which approvals.partyId the current operator may submit.
146
- * Checks pending ledger entries against impersonated agent id and owner principal.
158
+ * Which of `candidateIds` holds the pending approval slot, first match wins —
159
+ * so pass them in preference order (agent id before owner principal).
147
160
  */
148
- export function resolveMyPendingApprovalPartyId(agreement, opts) {
149
- const actorIds = [opts.agentId, opts.ownerUserId].filter((id) => typeof id === 'string' && id.length > 0);
150
- const pending = (agreement.approvals ?? []).filter((a) => a.status === 'pending' || a.status === 'PENDING');
161
+ export function resolvePendingApprovalPartyId(facts, candidateIds) {
162
+ const actorIds = candidateIds.filter((id) => typeof id === 'string' && id.length > 0);
151
163
  for (const id of actorIds) {
152
- const hit = pending.find((a) => a.partyId === id);
153
- if (hit)
154
- return hit.partyId;
164
+ if (facts.pendingPartyIds.includes(id))
165
+ return id;
155
166
  }
156
- const proposedTo = agreement.parties?.proposedTo;
167
+ // A row with no pending ledger entries is a legacy one (approvals[] predates
168
+ // ZIG-244), and there the named responder slot IS the decision.
169
+ const proposedTo = facts.proposedTo ?? null;
157
170
  if (proposedTo &&
158
171
  actorIds.includes(proposedTo) &&
159
- (pending.length === 0 || pending.some((a) => a.partyId === proposedTo))) {
172
+ facts.pendingPartyIds.length === 0) {
160
173
  return proposedTo;
161
174
  }
162
175
  return null;
163
176
  }
177
+ /**
178
+ * ZIG-524 — resolve which approvals.partyId the current operator may submit.
179
+ * Checks pending ledger entries against impersonated agent id and owner principal.
180
+ */
181
+ export function resolveMyPendingApprovalPartyId(agreement, opts) {
182
+ return resolvePendingApprovalPartyId({
183
+ pendingPartyIds: (agreement.approvals ?? [])
184
+ .filter((a) => a.status === 'pending' || a.status === 'PENDING')
185
+ .map((a) => a.partyId),
186
+ proposedTo: agreement.parties?.proposedTo ?? null,
187
+ }, [opts.agentId, opts.ownerUserId]);
188
+ }
164
189
  /**
165
190
  * Record this party's approval decision on an agreement
166
191
  * (`PUT /agreements/:agreementId/approvals/:partyId`).
@@ -410,6 +435,22 @@ export async function claimAgreement(agreementId, creds) {
410
435
  }
411
436
  return data;
412
437
  }
438
+ // ---------------------------------------------------------------------------
439
+ // Chat links
440
+ // ---------------------------------------------------------------------------
441
+ /**
442
+ * Link types a caller may ASK for — the server's `CALLER_LINK_TYPES`, not its
443
+ * full `LINK_TYPES`. `space` is deliberately absent: an agreement space gets
444
+ * exactly one system-minted room, and letting a request name that type would
445
+ * burn the root's single slot on an unrelated chat. Shorter than the server
446
+ * union on purpose, which is why this list carries the reason with it.
447
+ */
448
+ export const CHAT_LINK_TYPES = [
449
+ 'origin',
450
+ 'mention',
451
+ 'delegation',
452
+ 'join',
453
+ ];
413
454
  export async function linkAgreementToChat(agreementId, chatId, linkType = 'mention', creds) {
414
455
  if (!agreementId || !chatId)
415
456
  return null;
@@ -472,6 +513,10 @@ export async function joinAgreement(agreementId, creds) {
472
513
  }
473
514
  return data;
474
515
  }
516
+ // ---------------------------------------------------------------------------
517
+ // Artifact links
518
+ // ---------------------------------------------------------------------------
519
+ export const ARTIFACT_LINK_TYPES = ['produced', 'referenced'];
475
520
  export async function linkArtifactToAgreement(agreementId, artifactId, linkType = 'produced', creds) {
476
521
  if (!agreementId || !artifactId)
477
522
  return null;
@@ -516,6 +561,15 @@ export async function getArtifactsForAgreement(agreementId, creds) {
516
561
  return [];
517
562
  }
518
563
  }
564
+ // ---------------------------------------------------------------------------
565
+ // User links
566
+ // ---------------------------------------------------------------------------
567
+ export const AGREEMENT_USER_ROLES = [
568
+ 'payer',
569
+ 'provider',
570
+ 'participant',
571
+ 'observer',
572
+ ];
519
573
  export async function linkUserToAgreement(agreementId, userId, role, creds) {
520
574
  if (!agreementId || !userId || !role)
521
575
  return null;
@@ -7,6 +7,13 @@ export interface ListArtifactsOptions {
7
7
  export interface ListArtifactsQuery {
8
8
  chatId?: string;
9
9
  agreementId?: string;
10
+ taskId?: string;
11
+ /**
12
+ * ZIG-1037 — `'me'` lists artifacts YOU authored, in any scope or none. The
13
+ * only listing that surfaces a free-standing artifact, since the others read
14
+ * attach tables. Mutually exclusive with the scope filters.
15
+ */
16
+ authoredBy?: 'me';
10
17
  }
11
18
  export interface ListArtifactsResult {
12
19
  artifacts: unknown[];
@@ -55,11 +62,15 @@ export declare function artifactScopeForSession(sessionId: string): {
55
62
  export declare class ArtifactsClient {
56
63
  private readonly operatorKey;
57
64
  private readonly agentId?;
65
+ private readonly laneId?;
58
66
  /**
59
67
  * @param operatorKey Agent-scoped or fleet operator key.
60
68
  * @param agentId Required for fleet keys (sent as X-Agent-Id). Omit for agent-scoped keys.
69
+ * @param laneId ZIG-1092 — the wake's lane (chat id, or `agrn-<agreementId>`),
70
+ * sent as X-Ziggs-Lane so `authoredBy=me` is fenced to this engagement
71
+ * instead of returning every customer's deliverables in one call.
61
72
  */
62
- constructor(operatorKey: string, agentId?: string);
73
+ constructor(operatorKey: string, agentId?: string, laneId?: string);
63
74
  list(q: ListArtifactsQuery, opts?: ListArtifactsOptions): Promise<ListArtifactsResult>;
64
75
  /**
65
76
  * Record an agent thought as an `agent-private` artifact. Replaces the
@@ -84,6 +95,32 @@ export declare class ArtifactsClient {
84
95
  writeStrict(input: WriteArtifactInput): Promise<{
85
96
  artifactId?: string;
86
97
  }>;
98
+ /**
99
+ * ZIG-1037 — attach an existing artifact to a chat or a task.
100
+ *
101
+ * Attaching confers nothing on its own: it places the artifact inside the
102
+ * container, and that container's audience (chat members / task parties) can
103
+ * read it from then on. This is how a free-standing artifact reaches anyone
104
+ * without granting it to one specific agent.
105
+ *
106
+ * The agreement equivalent already existed as POST /agreements/:id/artifacts.
107
+ */
108
+ attachToChat(artifactId: string, chatId: string): Promise<{
109
+ success: boolean;
110
+ }>;
111
+ attachToTask(artifactId: string, taskId: string, role?: 'input' | 'output'): Promise<{
112
+ success: boolean;
113
+ }>;
114
+ private _attach;
115
+ /**
116
+ * ZIG-1037 — at most one container, and none is fine.
117
+ *
118
+ * This used to demand exactly one, which is what made a deliverable fail at the
119
+ * last step when the model had not worked out which container it was standing
120
+ * in. A write with no scope is now a free-standing artifact; `taskId` alone
121
+ * binds it to a task. Both containers at once is still a mistake worth naming,
122
+ * since only one of them would take effect.
123
+ */
87
124
  private _assertScopeXor;
88
125
  private _headers;
89
126
  }
@@ -1,5 +1,6 @@
1
1
  import 'dotenv/config';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
+ import { throwApiError } from '../shared/apiError.js';
3
4
  import { getBackendUrl } from '../utils/urlUtils.js';
4
5
  import { buildOperatorHeaders } from './operatorHeaders.js';
5
6
  /**
@@ -27,25 +28,35 @@ export function artifactScopeForSession(sessionId) {
27
28
  export class ArtifactsClient {
28
29
  operatorKey;
29
30
  agentId;
31
+ laneId;
30
32
  /**
31
33
  * @param operatorKey Agent-scoped or fleet operator key.
32
34
  * @param agentId Required for fleet keys (sent as X-Agent-Id). Omit for agent-scoped keys.
35
+ * @param laneId ZIG-1092 — the wake's lane (chat id, or `agrn-<agreementId>`),
36
+ * sent as X-Ziggs-Lane so `authoredBy=me` is fenced to this engagement
37
+ * instead of returning every customer's deliverables in one call.
33
38
  */
34
- constructor(operatorKey, agentId) {
39
+ constructor(operatorKey, agentId, laneId) {
35
40
  if (!operatorKey)
36
41
  throw new Error('ArtifactsClient: operatorKey is required');
37
42
  this.operatorKey = operatorKey;
38
43
  this.agentId = agentId;
44
+ this.laneId = laneId;
39
45
  }
40
46
  async list(q, opts = {}) {
41
- if ((q.chatId && q.agreementId) || (!q.chatId && !q.agreementId)) {
42
- throw new Error('ArtifactsClient.list: pass exactly one of chatId or agreementId');
47
+ const selectors = [q.chatId, q.agreementId, q.taskId, q.authoredBy].filter(Boolean);
48
+ if (selectors.length !== 1) {
49
+ throw new Error('ArtifactsClient.list: pass exactly one of chatId, agreementId, taskId, or authoredBy');
43
50
  }
44
51
  const url = new URL(`${getBackendUrl()}/artifacts`);
45
52
  if (q.chatId)
46
53
  url.searchParams.set('chatId', q.chatId);
47
54
  if (q.agreementId)
48
55
  url.searchParams.set('agreementId', q.agreementId);
56
+ if (q.taskId)
57
+ url.searchParams.set('taskId', q.taskId);
58
+ if (q.authoredBy)
59
+ url.searchParams.set('authoredBy', q.authoredBy);
49
60
  if (opts.after)
50
61
  url.searchParams.set('after', opts.after);
51
62
  if (opts.limit != null)
@@ -53,7 +64,7 @@ export class ArtifactsClient {
53
64
  const res = await fetch(url.toString(), { headers: this._headers() });
54
65
  if (!res.ok) {
55
66
  const body = await res.text().catch(() => '');
56
- throw new Error(`ArtifactsClient.list ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
67
+ throwApiError(res, body, `listArtifacts failed: ${res.status} ${res.statusText}`);
57
68
  }
58
69
  return (await res.json());
59
70
  }
@@ -118,7 +129,7 @@ export class ArtifactsClient {
118
129
  });
119
130
  const body = await res.text().catch(() => '');
120
131
  if (!res.ok) {
121
- throw new Error(`POST /artifacts ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
132
+ throwApiError(res, body, `recordArtifact failed: ${res.status} ${res.statusText}`);
122
133
  }
123
134
  try {
124
135
  const parsed = body ? JSON.parse(body) : {};
@@ -128,14 +139,63 @@ export class ArtifactsClient {
128
139
  return {};
129
140
  }
130
141
  }
142
+ /**
143
+ * ZIG-1037 — attach an existing artifact to a chat or a task.
144
+ *
145
+ * Attaching confers nothing on its own: it places the artifact inside the
146
+ * container, and that container's audience (chat members / task parties) can
147
+ * read it from then on. This is how a free-standing artifact reaches anyone
148
+ * without granting it to one specific agent.
149
+ *
150
+ * The agreement equivalent already existed as POST /agreements/:id/artifacts.
151
+ */
152
+ async attachToChat(artifactId, chatId) {
153
+ return this._attach(`/chats/${encodeURIComponent(chatId)}/artifacts`, {
154
+ artifactId,
155
+ });
156
+ }
157
+ async attachToTask(artifactId, taskId, role = 'output') {
158
+ return this._attach(`/tasks/${encodeURIComponent(taskId)}/artifacts`, {
159
+ artifactId,
160
+ role,
161
+ });
162
+ }
163
+ async _attach(path, body) {
164
+ const res = await fetch(`${getBackendUrl()}${path}`, {
165
+ method: 'POST',
166
+ headers: this._headers(),
167
+ body: JSON.stringify(body),
168
+ });
169
+ const text = await res.text().catch(() => '');
170
+ if (!res.ok) {
171
+ throwApiError(res, text, `attachArtifact failed: ${res.status} ${res.statusText}`);
172
+ }
173
+ return { success: true };
174
+ }
175
+ /**
176
+ * ZIG-1037 — at most one container, and none is fine.
177
+ *
178
+ * This used to demand exactly one, which is what made a deliverable fail at the
179
+ * last step when the model had not worked out which container it was standing
180
+ * in. A write with no scope is now a free-standing artifact; `taskId` alone
181
+ * binds it to a task. Both containers at once is still a mistake worth naming,
182
+ * since only one of them would take effect.
183
+ */
131
184
  _assertScopeXor(input) {
132
- if ((input.chatId && input.agreementId) || (!input.chatId && !input.agreementId)) {
133
- throw new Error('ArtifactsClient.write: pass exactly one of chatId or agreementId');
185
+ if (input.chatId && input.agreementId) {
186
+ // ZIG-1075: the refusal names the recovery, because this is the one gate
187
+ // for every caller — the exposed tool surfaces inherit it rather than each
188
+ // wording their own, and a caller that used to have chatId silently
189
+ // dropped here now learns what to do instead. Wording matters: a bare
190
+ // "pick one" at the last step of a finished task is what the drop was
191
+ // added to avoid (ZIG-924 dogfood).
192
+ throw new Error('pass at most one of chatId or agreementId — a deliverable under an ' +
193
+ 'agreement wants agreementId alone (its parties see it); use chatId ' +
194
+ 'only for a chat-scoped note. To put it in both places, record it ' +
195
+ 'with agreementId, then attach it to the chat.');
134
196
  }
135
197
  }
136
198
  _headers() {
137
- return buildOperatorHeaders(this.operatorKey, this.agentId, {
138
- 'content-type': 'application/json',
139
- });
199
+ return buildOperatorHeaders(this.operatorKey, this.agentId, { 'content-type': 'application/json' }, this.laneId);
140
200
  }
141
201
  }
@@ -1,12 +1,15 @@
1
1
  import 'dotenv/config';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
3
  import { getBackendUrl } from '../utils/urlUtils.js';
4
- import { ApiError } from '../types.js';
4
+ import { throwApiError } from '../shared/apiError.js';
5
5
  function buildHeaders(creds) {
6
6
  return {
7
7
  'content-type': 'application/json',
8
8
  Authorization: `Bearer ${creds.operatorKey}`,
9
9
  'X-Agent-Id': creds.agentId,
10
+ // ZIG-1092 — the wake's lane, so the backend can fence this call to the
11
+ // engagement it belongs to rather than the agent's whole authority.
12
+ ...(creds.laneId ? { 'X-Ziggs-Lane': creds.laneId } : {}),
10
13
  };
11
14
  }
12
15
  function assertCreds(creds, op) {
@@ -15,19 +18,6 @@ function assertCreds(creds, op) {
15
18
  if (!creds?.agentId)
16
19
  throw new Error(`agentId is required for ${op}`);
17
20
  }
18
- function throwApiError(response, responseBody, defaultMessage) {
19
- let message = defaultMessage;
20
- if (responseBody) {
21
- try {
22
- const d = JSON.parse(responseBody);
23
- message = d['details'] || d['error'] || d['message'] || defaultMessage;
24
- }
25
- catch {
26
- message = responseBody || defaultMessage;
27
- }
28
- }
29
- throw new ApiError(message, response.status, responseBody);
30
- }
31
21
  export async function openConversation(participantId, creds) {
32
22
  if (!participantId)
33
23
  throw new Error('participantId is required for openConversation');
@@ -1,6 +1,11 @@
1
1
  import 'dotenv/config';
2
2
  import type { GrantView } from './grants.js';
3
- export type ContextGrantScopeKind = 'chat' | 'agreement' | 'org';
3
+ /**
4
+ * ZIG-1037 added `artifact` — the narrowest context scope: one specific artifact,
5
+ * shared without sharing any chat or agreement it sits in. Still the context
6
+ * rail, not a new grant primitive.
7
+ */
8
+ export type ContextGrantScopeKind = 'chat' | 'agreement' | 'org' | 'artifact';
4
9
  export type ContextTemporal = 'from-now' | 'from-start';
5
10
  export interface ContextGrantScope {
6
11
  kind: ContextGrantScopeKind;
@@ -80,6 +85,21 @@ export declare class ContextGrantsClient {
80
85
  * labels-only, grant-fenced server-side.
81
86
  */
82
87
  getReach(grantId: string): Promise<GrantReachResult>;
88
+ /**
89
+ * ZIG-1037 — share an artifact THIS agent authored with another agent.
90
+ *
91
+ * Not a delegation: an agent holds no grant over its own output, so there is
92
+ * no parent to attenuate. Authorship is the authority, held by the agent's
93
+ * owner — which is why the receiver decides the outcome. A receiver already
94
+ * inside the sharer's trust boundary (same owner, same org, or an active link)
95
+ * is granted immediately; anyone else opens a request the owner approves,
96
+ * exactly like `delegateGrant`'s new-party path.
97
+ */
98
+ shareArtifact(artifactId: string, input: {
99
+ holderId: string;
100
+ expiresAt?: string | null;
101
+ chatId?: string;
102
+ }): Promise<DelegateContextGrantResult>;
83
103
  revokeGrant(grantId: string): Promise<{
84
104
  status: string;
85
105
  revokedCount?: number;
@@ -1,24 +1,7 @@
1
1
  import 'dotenv/config';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
- import { ApiError } from '../types.js';
4
3
  import { buildOperatorHeaders } from './operatorHeaders.js';
5
- function throwApiError(response, responseBody, defaultMessage) {
6
- let message = defaultMessage;
7
- if (responseBody) {
8
- try {
9
- const d = JSON.parse(responseBody);
10
- message =
11
- d['details'] ||
12
- d['error'] ||
13
- d['message'] ||
14
- defaultMessage;
15
- }
16
- catch {
17
- message = responseBody || defaultMessage;
18
- }
19
- }
20
- throw new ApiError(message, response.status, responseBody);
21
- }
4
+ import { throwApiError } from '../shared/apiError.js';
22
5
  /**
23
6
  * ZIG-411 context grant management — list / issue / delegate / revoke.
24
7
  */
@@ -113,6 +96,41 @@ export class ContextGrantsClient {
113
96
  truncatedAgreements: parsed.truncatedAgreements ?? 0,
114
97
  };
115
98
  }
99
+ /**
100
+ * ZIG-1037 — share an artifact THIS agent authored with another agent.
101
+ *
102
+ * Not a delegation: an agent holds no grant over its own output, so there is
103
+ * no parent to attenuate. Authorship is the authority, held by the agent's
104
+ * owner — which is why the receiver decides the outcome. A receiver already
105
+ * inside the sharer's trust boundary (same owner, same org, or an active link)
106
+ * is granted immediately; anyone else opens a request the owner approves,
107
+ * exactly like `delegateGrant`'s new-party path.
108
+ */
109
+ async shareArtifact(artifactId, input) {
110
+ const res = await fetch(`${this.baseUrl}/context/artifacts/${encodeURIComponent(artifactId)}/share`, {
111
+ method: 'POST',
112
+ headers: buildOperatorHeaders(this.operatorKey, this.agentId, {
113
+ 'content-type': 'application/json',
114
+ }),
115
+ body: JSON.stringify(input),
116
+ });
117
+ const body = await res.text().catch(() => '');
118
+ if (!res.ok) {
119
+ throwApiError(res, body, `shareArtifact failed: ${res.status} ${res.statusText}`);
120
+ }
121
+ const parsed = JSON.parse(body);
122
+ if (parsed.status === 'pending_approval' && parsed.agreementId) {
123
+ return {
124
+ status: 'pending_approval',
125
+ agreementId: parsed.agreementId,
126
+ ownerId: parsed.ownerId,
127
+ };
128
+ }
129
+ if (!parsed.grant?.grantId) {
130
+ throw new Error('Invalid response: expected { grant } or { status: "pending_approval" } from POST /context/artifacts/:id/share');
131
+ }
132
+ return { status: 'granted', grant: parsed.grant };
133
+ }
116
134
  async revokeGrant(grantId) {
117
135
  const res = await fetch(`${this.baseUrl}/context/grants/${encodeURIComponent(grantId)}`, {
118
136
  method: 'DELETE',