@ziggs-ai/api-client 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,15 @@
1
- import { createLink, claimAgreement, proposeBroadcast, proposeDirectTo, } from './AgreementClient.js';
1
+ import { claimAgreement, proposeBroadcast, proposeDirectTo, } from './AgreementClient.js';
2
2
  import { publishOffer } from './MarketplaceClient.js';
3
3
  import { isBroadcastTarget, } from '../types.js';
4
4
  export async function proposeUnified(input, creds) {
5
5
  const { proposedTo, chatId, engagementKind, providerId, ...terms } = input;
6
6
  if (!proposedTo)
7
7
  throw new Error('proposedTo is required (a user/agent id, or "everyone"/"org" to broadcast)');
8
- if (engagementKind === 'link') {
9
- // A link is an agreement, proposed to one agent (providerId) or opened as
10
- // an invite (proposedTo 'everyone'); no chat, no money.
11
- const target = isBroadcastTarget(proposedTo) ? undefined : proposedTo;
12
- const { agreement } = await createLink({
13
- ...(target ? { targetAgentId: target } : {}),
14
- ...(terms.description ? { description: terms.description } : {}),
15
- }, creds);
16
- return { agreement, shape: 'link' };
17
- }
8
+ // The 'link' arm is gone. A link is not a proposal shape: it has no
9
+ // price, no chat and no work, and it is formed by `link_propose`, which takes
10
+ // an email or an agent id and answers the same either way. Keeping a second
11
+ // door here would have meant a caller reaching a link through the propose
12
+ // grammar, where the constant answer and the org-root refusal do not apply.
18
13
  if (isBroadcastTarget(proposedTo)) {
19
14
  const audience = proposedTo;
20
15
  if (providerId && creds.agentId && providerId === creds.agentId) {
@@ -68,10 +63,10 @@ export async function proposeUnified(input, creds) {
68
63
  * `kind` arrives on the claim response — the route that did the routing reports
69
64
  * which broadcast kind this turned out to be.
70
65
  */
71
- export async function claimOpenAgreement(agreementId, creds) {
66
+ export async function claimOpenAgreement(agreementId, creds, opts = {}) {
72
67
  if (!agreementId)
73
68
  throw new Error('agreementId is required');
74
- const { agreement, kind } = await claimAgreement(agreementId, creds);
69
+ const { agreement, kind } = await claimAgreement(agreementId, creds, opts);
75
70
  // A server that has not shipped the `kind` field yet still claims correctly;
76
71
  // 'request' is the shape the route has always handled.
77
72
  return { agreement, kind: kind ?? 'request' };
@@ -36,9 +36,15 @@ export type GrantBasisKind = 'creation' | 'publication' | 'membership' | 'accept
36
36
  * stopped showing a whole scope kind after `artifact` was added; anything that
37
37
  * means "every context scope" should read this.
38
38
  */
39
- export declare const CONTEXT_GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact"];
40
- /** Every scope kind across every rail: context + connection + payment. */
41
- export declare const GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact", "connection", "wallet"];
39
+ export declare const CONTEXT_GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact", "task"];
40
+ /**
41
+ * Every scope kind across every rail: context + connection + payment + consent
42
+ * + carrier. Mirrors the server's `GRANT_SCOPE_KINDS` (grants/grant-view.ts),
43
+ * which is the list `GET /grants?scopeKind=` validates against — a kind missing
44
+ * here cannot be asked for at all, which is the failure the comment above
45
+ * describes.
46
+ */
47
+ export declare const GRANT_SCOPE_KINDS: readonly ["chat", "agreement", "org", "artifact", "task", "connection", "wallet", "consent", "inbox"];
42
48
  export type GrantScopeKind = (typeof GRANT_SCOPE_KINDS)[number];
43
49
  export interface GrantScopeView {
44
50
  kind: GrantScopeKind;
@@ -12,12 +12,25 @@ export const CONTEXT_GRANT_SCOPE_KINDS = [
12
12
  'org',
13
13
  // narrowest context scope
14
14
  'artifact',
15
+ // a branch of a work graph: the named task and everything under it, now and
16
+ // in future. Holding one is what being in a job means.
17
+ 'task',
15
18
  ];
16
- /** Every scope kind across every rail: context + connection + payment. */
19
+ /**
20
+ * Every scope kind across every rail: context + connection + payment + consent
21
+ * + carrier. Mirrors the server's `GRANT_SCOPE_KINDS` (grants/grant-view.ts),
22
+ * which is the list `GET /grants?scopeKind=` validates against — a kind missing
23
+ * here cannot be asked for at all, which is the failure the comment above
24
+ * describes.
25
+ */
17
26
  export const GRANT_SCOPE_KINDS = [
18
27
  ...CONTEXT_GRANT_SCOPE_KINDS,
19
28
  'connection',
20
29
  'wallet',
30
+ // approval authority — who may answer a slot
31
+ 'consent',
32
+ // carrier grants — who reads whose mailbox
33
+ 'inbox',
21
34
  ];
22
35
  /** Value of the first caveat of `type` on a grant, or undefined. */
23
36
  export function grantCaveat(grant, type) {
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export * from './http/index.js';
2
2
  export * from './capabilities/index.js';
3
3
  export * from './relay/provisionRelayWorkers.js';
4
4
  export { ConnectionManager } from './ConnectionManager.js';
5
- export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, isPersonaRef, isRoomPresentationRef, isOpaquePresentationRef, AGREEMENT_ENGAGEMENT_KIND, isValidContentType, } from './types.js';
5
+ export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, isPersonaRef, isRoomPresentationRef, isOpaquePresentationRef, AGREEMENT_ENGAGEMENT_KIND, isValidContentType, partySideIds, partyActorIds, } from './types.js';
6
6
  export type { PrincipalPresentation } from './types.js';
7
7
  export { getBackendUrl } from './utils/urlUtils.js';
8
8
  export { configureApiClient, apiClientConfig } from './config.js';
@@ -11,5 +11,5 @@ export { runtimeLog, resetRuntimeLogLevelCache } from './shared/runtimeLog.js';
11
11
  export { RateLimitedError, isRateLimited, parseRetryAfterMs, pollSurfaceError, } from './shared/rateLimit.js';
12
12
  export { parseErrorMessage, parseErrorCode, throwApiError, } from './shared/apiError.js';
13
13
  export { ApiError } from './types.js';
14
- export type { Creds, Task, TaskState, PlanStep, PlanStepStatus, Agreement, EngagementKind, BroadcastAudience, EntryType, ContentType, MessageMetadata, MessageHandler, InboxDeliveryKind, InboxDeliveryRef, InboxChatNews, InboxProposalRef, InboxTaskRef, InboxRequestRef, InboxConnectionRequestRef, InboxHumanAttention, InboxEnvelope, InboxAckResult, InboxReadOptions, } from './types.js';
15
- export type { ProposeTerms, ProposeDirectInput, ProposeBroadcastInput, ProposeAgreementData, } from './http/AgreementClient.js';
14
+ export type { Creds, Task, TaskState, PlanStep, PlanStepStatus, Agreement, AgreementParties, AgreementPartySide, EngagementKind, BroadcastAudience, EntryType, ContentType, MessageMetadata, MessageHandler, InboxDeliveryKind, InboxDeliveryRef, InboxChatNews, InboxProposalRef, InboxTaskRef, InboxRequestRef, InboxConnectionRequestRef, InboxHumanAttention, InboxEnvelope, InboxAckResult, InboxReadOptions, } from './types.js';
15
+ export type { ProposeTerms, ProposeDirectInput, ProposeBroadcastInput, ProposeAgreementData, ClaimOptions, } from './http/AgreementClient.js';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export * from './http/index.js';
2
2
  export * from './capabilities/index.js';
3
3
  export * from './relay/provisionRelayWorkers.js';
4
4
  export { ConnectionManager } from './ConnectionManager.js';
5
- export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, isPersonaRef, isRoomPresentationRef, isOpaquePresentationRef, AGREEMENT_ENGAGEMENT_KIND, isValidContentType, } from './types.js';
5
+ export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, isPersonaRef, isRoomPresentationRef, isOpaquePresentationRef, AGREEMENT_ENGAGEMENT_KIND, isValidContentType, partySideIds, partyActorIds, } from './types.js';
6
6
  export { getBackendUrl } from './utils/urlUtils.js';
7
7
  // the host injects the environment; this package never reads it.
8
8
  export { configureApiClient, apiClientConfig } from './config.js';
@@ -0,0 +1,4 @@
1
+ export declare const INBOX_HOST_CLAIMANT_HEADER = "X-Ziggs-Instance";
2
+ export declare function instanceIdentity(): string;
3
+ /** Test seam: forget the memoized value so a case can set different env. */
4
+ export declare function resetInstanceIdentityForTests(): void;
@@ -0,0 +1,44 @@
1
+ import { hostname } from 'node:os';
2
+ /**
3
+ * Which process is calling GET /inbox.
4
+ *
5
+ * The exclusive-read claimant must be the *host* (this fleet task, a
6
+ * laptop fleet, an MCP process), not the API process. Two fleets share
7
+ * one backend task, so stamping the API's identity would let both wake.
8
+ *
9
+ * `ecs:<id>` on Fargate, `local:<host>:<pid>` otherwise. Env-derived
10
+ * and memoized.
11
+ */
12
+ let cached = null;
13
+ const MAX_LENGTH = 64;
14
+ export const INBOX_HOST_CLAIMANT_HEADER = 'X-Ziggs-Instance';
15
+ export function instanceIdentity() {
16
+ if (cached)
17
+ return cached;
18
+ cached = resolve().slice(0, MAX_LENGTH);
19
+ return cached;
20
+ }
21
+ /** Test seam: forget the memoized value so a case can set different env. */
22
+ export function resetInstanceIdentityForTests() {
23
+ cached = null;
24
+ }
25
+ function resolve() {
26
+ const metadataUri = process.env.ECS_CONTAINER_METADATA_URI_V4 ??
27
+ process.env.ECS_CONTAINER_METADATA_URI;
28
+ if (metadataUri) {
29
+ const id = metadataUri.split('/').filter(Boolean).pop() ?? '';
30
+ const short = id.replace(/[^A-Za-z0-9]/g, '').slice(0, 12);
31
+ if (short.length >= 8)
32
+ return `ecs:${short}`;
33
+ }
34
+ const host = process.env.HOSTNAME || safeHostname();
35
+ return `local:${host}:${process.pid}`;
36
+ }
37
+ function safeHostname() {
38
+ try {
39
+ return hostname() || 'unknown-host';
40
+ }
41
+ catch {
42
+ return 'unknown-host';
43
+ }
44
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Agreement, Creds } from '../types.js';
2
- import { claimOffer, delegateAgreement, getMyAgreements, pullOffers } from '../http/index.js';
2
+ import { claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers, type DelegateContextGrantResult, type GrantView } from '../http/index.js';
3
3
  /** Minimal relay step shape — keep field names aligned with agents/coordinators/relayTypes.ts */
4
4
  export interface RelayStepInput {
5
5
  stepId: string;
@@ -21,19 +21,72 @@ export interface RelayPayloadShape {
21
21
  }
22
22
  export type ProvisionMethod = 'existing' | 'claim' | 'delegate';
23
23
  export type StepProvisionStatus = 'active' | 'pending_approval';
24
+ /**
25
+ * What happened to the coordinator's pass on this worker contract.
26
+ *
27
+ * - `granted` — the coordinator may create work citing this contract.
28
+ * - `reused` — it already held one; nothing was minted.
29
+ * - `pending_approval` — the pass needs a person. An agent cannot MINT a root
30
+ * agreement grant at all: `POST /context/grants` requires the `context:grant`
31
+ * operator scope, which a delegate never holds ("no re-mint or grant will
32
+ * satisfy this"). So an initiator agent's only route is delegating the
33
+ * participation grant it holds — and handing a slice to a third party in
34
+ * another org opens a card that owner must approve.
35
+ * - `deferred` — the contract itself is not active yet, so there is no
36
+ * participation grant to delegate from. Provision again after it activates.
37
+ * - `failed` — named so the caller stops rather than kicking off a run whose
38
+ * branches will each be refused one at a time.
39
+ */
40
+ export type GrantProvisionStatus = 'granted' | 'reused' | 'pending_approval' | 'deferred' | 'failed';
41
+ export interface StepGrantProvision {
42
+ status: GrantProvisionStatus;
43
+ grantId?: string;
44
+ /** The approval card's agreement id, when a person has to say yes. */
45
+ approvalAgreementId?: string;
46
+ error?: string;
47
+ }
24
48
  export type ProvisionedRelayStep = RelayPayloadShape['steps'][number] & {
25
49
  provisionMethod: ProvisionMethod;
26
50
  status: StepProvisionStatus;
51
+ /**
52
+ * The coordinator's pass on this step's contract. Handing over an agreement
53
+ * id is not enough: membership in work is a grant, and the coordinator is not
54
+ * a party to this contract, so nothing about it reaches the coordinator
55
+ * otherwise.
56
+ */
57
+ grant: StepGrantProvision;
27
58
  };
28
59
  export interface RelayProvisionDeps {
29
60
  getMyAgreements: typeof getMyAgreements;
30
61
  pullOffers: typeof pullOffers;
31
- claimOffer: typeof claimOffer;
62
+ claimOpenAgreement: typeof claimOpenAgreement;
32
63
  delegateAgreement: typeof delegateAgreement;
64
+ /**
65
+ * Grants on one agreement scope. `role` picks which side: `holder` is what
66
+ * this initiator holds (the participation grant to delegate FROM), `issuer` is
67
+ * what it has already caused (so a re-provision reuses rather than re-mints).
68
+ */
69
+ listAgreementGrants: (args: {
70
+ agreementId: string;
71
+ role: 'holder' | 'issuer';
72
+ }, creds: Creds) => Promise<GrantView[]>;
73
+ delegateAgreementGrant: (args: {
74
+ parentGrantId: string;
75
+ holderId: string;
76
+ agreementId: string;
77
+ }, creds: Creds) => Promise<DelegateContextGrantResult>;
33
78
  }
34
79
  export interface ProvisionRelayWorkersInput {
35
80
  creds: Creds;
36
81
  hireAgreementId: string;
82
+ /**
83
+ * The coordinator that will create the work. Required, because provisioning a
84
+ * worker FOR a coordinator is two acts and this names who the second one is
85
+ * for: without an `agreement`-scope grant on each worker contract, the
86
+ * coordinator's very first `task_create` is refused with "holds no grant on
87
+ * agreement …" and every branch of the run fails the same way.
88
+ */
89
+ coordinatorId: string;
37
90
  chatId?: string;
38
91
  steps: RelayStepInput[];
39
92
  inputArtifactIds?: string[];
@@ -41,6 +94,11 @@ export interface ProvisionRelayWorkersInput {
41
94
  export interface ProvisionRelayWorkersResult {
42
95
  payload: RelayPayloadShape;
43
96
  steps: ProvisionedRelayStep[];
97
+ /**
98
+ * Everything a person still has to approve before kickoff — agreement
99
+ * activations AND grant hand-overs, in one list. They gate the run
100
+ * identically, so splitting them would just make a caller check two.
101
+ */
44
102
  pendingApprovals: string[];
45
103
  readyForKickoff: boolean;
46
104
  }
@@ -49,5 +107,13 @@ type AgreementWithParent = Agreement & {
49
107
  };
50
108
  export declare function findExistingWorkerDelegation(hireAgreementId: string, assigneeId: string, agreements: AgreementWithParent[]): AgreementWithParent | null;
51
109
  export declare function findOpenOfferForAgent(assigneeId: string, offers: Agreement[]): Agreement | null;
110
+ /**
111
+ * The default grant plumbing, built on the two clients that own these routes.
112
+ *
113
+ * A thin function seam rather than the clients themselves, so a test can drive
114
+ * provisioning without a backend — the same reason the agreement calls are
115
+ * injected.
116
+ */
117
+ export declare function defaultRelayProvisionDeps(): RelayProvisionDeps;
52
118
  export declare function provisionRelayWorkers(input: ProvisionRelayWorkersInput, deps?: RelayProvisionDeps): Promise<ProvisionRelayWorkersResult>;
53
119
  export {};
@@ -1,6 +1,6 @@
1
- import { claimOffer, delegateAgreement, getMyAgreements, pullOffers, } from '../http/index.js';
1
+ import { ContextGrantsClient, GrantsClient, claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers, } from '../http/index.js';
2
2
  function providerAgentId(agreement) {
3
- return agreement.parties?.providerAgent ?? agreement.parties?.provider ?? null;
3
+ return agreement.parties?.provider?.actor ?? null;
4
4
  }
5
5
  function isActiveAgreement(agreement) {
6
6
  return agreement.status === 'active';
@@ -27,23 +27,123 @@ export function findOpenOfferForAgent(assigneeId, offers) {
27
27
  return providerAgentId(o) === assigneeId;
28
28
  }) ?? null);
29
29
  }
30
- export async function provisionRelayWorkers(input, deps = {
31
- getMyAgreements,
32
- pullOffers,
33
- claimOffer,
34
- delegateAgreement,
35
- }) {
36
- const { creds, hireAgreementId, chatId, steps, inputArtifactIds } = input;
30
+ /**
31
+ * The default grant plumbing, built on the two clients that own these routes.
32
+ *
33
+ * A thin function seam rather than the clients themselves, so a test can drive
34
+ * provisioning without a backend — the same reason the agreement calls are
35
+ * injected.
36
+ */
37
+ export function defaultRelayProvisionDeps() {
38
+ return {
39
+ getMyAgreements,
40
+ pullOffers,
41
+ claimOpenAgreement,
42
+ delegateAgreement,
43
+ listAgreementGrants: async ({ agreementId, role }, creds) => {
44
+ const client = new GrantsClient(creds.operatorKey, creds.agentId);
45
+ return client.listAllGrants({
46
+ scopeKind: 'agreement',
47
+ scopeId: agreementId,
48
+ role,
49
+ health: 'active',
50
+ });
51
+ },
52
+ delegateAgreementGrant: async ({ parentGrantId, holderId, agreementId }, creds) => {
53
+ const client = new ContextGrantsClient(creds.operatorKey, creds.agentId);
54
+ return client.delegateGrant(parentGrantId, {
55
+ holderId,
56
+ holderKind: 'agent',
57
+ // `write` is exactly what `canActUnderAgreement` tests. Deliberately not
58
+ // `admit`: that would let a coordinator widen its own crew.
59
+ kind: 'write',
60
+ scope: { kind: 'agreement', id: agreementId },
61
+ // from-start, not from-now: the contract predates the grant, so a
62
+ // from-now watermark would exclude the very row the grant is about.
63
+ temporal: 'from-start',
64
+ });
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * Hand the coordinator a pass on one worker contract.
70
+ *
71
+ * Membership in work is a grant, which is what lets a coordinator create work
72
+ * under a contract it is not a party to. Issuing that grant is the half that has
73
+ * to happen here: without it the recipe fails at the coordinator's first
74
+ * `task_create` with "Caller <coordinator> holds no grant on agreement <worker>,
75
+ * so it cannot create work citing it."
76
+ *
77
+ * Reused before minted, because provisioning is re-run: a second call after an
78
+ * agreement activates should not stack a second pass on a contract that already
79
+ * has one.
80
+ */
81
+ async function grantCoordinatorAccess(args, deps) {
82
+ const { agreementId, coordinatorId, status, creds } = args;
83
+ // Participation grants are minted at ACTIVATION, so a contract still waiting
84
+ // on a signature has nothing to delegate from yet. Say so rather than
85
+ // reporting a failure the caller cannot act on.
86
+ if (status !== 'active') {
87
+ return {
88
+ status: 'deferred',
89
+ error: `${agreementId} is not active yet, so there is no participation grant to ` +
90
+ 'delegate from — provision again once it activates',
91
+ };
92
+ }
93
+ try {
94
+ const alreadyIssued = await deps.listAgreementGrants({ agreementId, role: 'issuer' }, creds);
95
+ const existing = alreadyIssued.find((g) => g.holderId === coordinatorId);
96
+ if (existing?.grantId)
97
+ return { status: 'reused', grantId: existing.grantId };
98
+ const held = await deps.listAgreementGrants({ agreementId, role: 'holder' }, creds);
99
+ // Only a `write` or `admit` parent can beget the `write` child the act rule
100
+ // tests; a `read` participation grant cannot be widened by delegating it.
101
+ // `access` is optional on GrantView because some rails do not record it —
102
+ // context grants always do, so an absent value here is a malformed row and
103
+ // must not be treated as strong enough.
104
+ const parent = held.find((g) => g.access === 'write' || g.access === 'admit');
105
+ if (!parent?.grantId) {
106
+ return {
107
+ status: 'failed',
108
+ error: `no write-or-stronger grant on ${agreementId} to delegate from (found ` +
109
+ `${held.length ? held.map((g) => g.access ?? 'unstated').join(', ') : 'none'}) — ` +
110
+ 'the initiator must be a party to the worker contract it is provisioning',
111
+ };
112
+ }
113
+ const result = await deps.delegateAgreementGrant({ parentGrantId: parent.grantId, holderId: coordinatorId, agreementId }, creds);
114
+ if (result.status === 'pending_approval') {
115
+ return { status: 'pending_approval', approvalAgreementId: result.agreementId };
116
+ }
117
+ return { status: 'granted', grantId: result.grant.grantId };
118
+ }
119
+ catch (err) {
120
+ return {
121
+ status: 'failed',
122
+ error: err instanceof Error ? err.message : String(err),
123
+ };
124
+ }
125
+ }
126
+ export async function provisionRelayWorkers(input, deps = defaultRelayProvisionDeps()) {
127
+ const { creds, hireAgreementId, coordinatorId, chatId, steps, inputArtifactIds } = input;
37
128
  if (!steps.length)
38
129
  throw new Error('steps must not be empty');
130
+ if (!coordinatorId?.trim()) {
131
+ throw new Error('coordinatorId is required: provisioning a worker for a coordinator means both ' +
132
+ 'the contract and an agreement-scope grant to that coordinator on it, and ' +
133
+ 'without the grant every branch of the run is refused');
134
+ }
39
135
  const myAgreements = (await deps.getMyAgreements({}, creds));
40
136
  let offersCache = null;
41
- const provisioned = [];
137
+ // The contracts first, then the passes. Two passes rather than one because a
138
+ // grant can only be delegated from a participation grant, which exists only
139
+ // once the contract is active — so the grant decision needs the settled
140
+ // status of the row, not the intent that created it.
141
+ const contracted = [];
42
142
  const pendingApprovals = [];
43
143
  for (const step of steps) {
44
144
  const existing = findExistingWorkerDelegation(hireAgreementId, step.assigneeId, myAgreements);
45
145
  if (existing?.agreementId) {
46
- provisioned.push({
146
+ contracted.push({
47
147
  stepId: step.stepId,
48
148
  order: step.order,
49
149
  agreementId: existing.agreementId,
@@ -55,14 +155,14 @@ export async function provisionRelayWorkers(input, deps = {
55
155
  continue;
56
156
  }
57
157
  if (step.offerAgreementId) {
58
- const claimed = await deps.claimOffer(step.offerAgreementId, creds);
158
+ const { agreement: claimed } = await deps.claimOpenAgreement(step.offerAgreementId, creds);
59
159
  const agreementId = claimed.agreementId;
60
160
  const status = isActiveAgreement(claimed)
61
161
  ? 'active'
62
162
  : 'pending_approval';
63
163
  if (status === 'pending_approval')
64
164
  pendingApprovals.push(agreementId);
65
- provisioned.push({
165
+ contracted.push({
66
166
  stepId: step.stepId,
67
167
  order: step.order,
68
168
  agreementId,
@@ -78,14 +178,14 @@ export async function provisionRelayWorkers(input, deps = {
78
178
  }
79
179
  const openOffer = findOpenOfferForAgent(step.assigneeId, offersCache);
80
180
  if (openOffer?.agreementId) {
81
- const claimed = await deps.claimOffer(openOffer.agreementId, creds);
181
+ const { agreement: claimed } = await deps.claimOpenAgreement(openOffer.agreementId, creds);
82
182
  const agreementId = claimed.agreementId;
83
183
  const status = isActiveAgreement(claimed)
84
184
  ? 'active'
85
185
  : 'pending_approval';
86
186
  if (status === 'pending_approval')
87
187
  pendingApprovals.push(agreementId);
88
- provisioned.push({
188
+ contracted.push({
89
189
  stepId: step.stepId,
90
190
  order: step.order,
91
191
  agreementId,
@@ -115,7 +215,7 @@ export async function provisionRelayWorkers(input, deps = {
115
215
  : 'pending_approval';
116
216
  if (status === 'pending_approval')
117
217
  pendingApprovals.push(agreementId);
118
- provisioned.push({
218
+ contracted.push({
119
219
  stepId: step.stepId,
120
220
  order: step.order,
121
221
  agreementId,
@@ -125,7 +225,21 @@ export async function provisionRelayWorkers(input, deps = {
125
225
  status,
126
226
  });
127
227
  }
128
- provisioned.sort((a, b) => a.order - b.order);
228
+ contracted.sort((a, b) => a.order - b.order);
229
+ // ── the second act of provisioning: the coordinator's passes ─────────────
230
+ const provisioned = [];
231
+ for (const step of contracted) {
232
+ const grant = await grantCoordinatorAccess({
233
+ agreementId: step.agreementId,
234
+ coordinatorId,
235
+ status: step.status,
236
+ creds,
237
+ }, deps);
238
+ if (grant.status === 'pending_approval' && grant.approvalAgreementId) {
239
+ pendingApprovals.push(grant.approvalAgreementId);
240
+ }
241
+ provisioned.push({ ...step, grant });
242
+ }
129
243
  const payload = {
130
244
  inputArtifactIds,
131
245
  steps: provisioned.map(({ stepId, order, agreementId, assigneeId, description }) => ({
@@ -136,8 +250,14 @@ export async function provisionRelayWorkers(input, deps = {
136
250
  description,
137
251
  })),
138
252
  };
253
+ /**
254
+ * Ready means BOTH acts are done for every step. A run whose contracts are all
255
+ * active but whose coordinator holds no passes looks provisioned and has every
256
+ * branch refused, so the grant has to count here or this flag goes on lying.
257
+ */
139
258
  const readyForKickoff = pendingApprovals.length === 0 &&
140
- provisioned.every((s) => s.status === 'active');
259
+ provisioned.every((s) => s.status === 'active' &&
260
+ (s.grant.status === 'granted' || s.grant.status === 'reused'));
141
261
  return {
142
262
  payload,
143
263
  steps: provisioned,
package/dist/types.d.ts CHANGED
@@ -71,6 +71,14 @@ export interface Task {
71
71
  updatedAt?: string;
72
72
  parentTaskId?: string;
73
73
  rootTaskId?: string;
74
+ /** Tasks this one waits for before it may start. */
75
+ waitsOn?: string[];
76
+ /**
77
+ * True while {@link waitsOn} is unmet: the task exists but has not been
78
+ * delivered, and its assignee cannot take the processing lock yet. Distinct
79
+ * from "nobody has picked it up" — this one is waiting on purpose.
80
+ */
81
+ heldByDeps?: boolean;
74
82
  plan?: {
75
83
  steps?: PlanStep[];
76
84
  };
@@ -91,19 +99,46 @@ export declare const AGREEMENT_ENGAGEMENT_KIND: {
91
99
  readonly LINK: "link";
92
100
  };
93
101
  export type EngagementKind = (typeof AGREEMENT_ENGAGEMENT_KIND)[keyof typeof AGREEMENT_ENGAGEMENT_KIND];
102
+ /**
103
+ * One side of an agreement.
104
+ *
105
+ * An agent id occupies `actor` and nowhere else, so "is this agent on this
106
+ * side?" is a structural read rather than a lookup against the Agent
107
+ * collection. Asking `principal` about an agent id is always the wrong
108
+ * question — it would match that agent's estate instead.
109
+ */
110
+ export interface AgreementPartySide {
111
+ /**
112
+ * The accountable person or org — never an agent. On `payer` and `proposedTo`
113
+ * this may instead hold a broadcast sentinel ({@link isBroadcastTarget}), in
114
+ * which case nobody has taken the side up yet.
115
+ */
116
+ principal?: string | null;
117
+ /** The agent that performed on this side. Null when the principal acted itself. */
118
+ actor?: string | null;
119
+ }
120
+ /** The four sides of an agreement, each {@link AgreementPartySide}. */
121
+ export interface AgreementParties {
122
+ payer?: AgreementPartySide;
123
+ provider?: AgreementPartySide;
124
+ proposedTo?: AgreementPartySide;
125
+ creator?: AgreementPartySide;
126
+ }
127
+ /** Both ids on one side, principal first, absent ones dropped. */
128
+ export declare function partySideIds(side: AgreementPartySide | null | undefined): string[];
129
+ /**
130
+ * Every agent that acted on this agreement, deduped.
131
+ *
132
+ * The read for "is this agent a party?" — an agent id lives in `actor` alone,
133
+ * so widening the match to `principal` would hit an unrelated estate.
134
+ */
135
+ export declare function partyActorIds(parties: AgreementParties | null | undefined): string[];
94
136
  /** Compact agreement reference carried by task reads. */
95
137
  export interface AgreementSummary {
96
138
  agreementId: string;
97
139
  description: string;
98
140
  status: AgreementStatus;
99
- parties?: {
100
- proposedTo?: string | null;
101
- provider?: string | null;
102
- payer?: string | null;
103
- creator?: string | null;
104
- creatorAgent?: string | null;
105
- providerAgent?: string | null;
106
- };
141
+ parties?: AgreementParties;
107
142
  /** The opposite party relative to the requesting agent; null for observers,
108
143
  * broadcast sentinels, and self-agreements. */
109
144
  counterparty: string | null;
@@ -120,14 +155,7 @@ export interface Agreement {
120
155
  status?: AgreementStatus;
121
156
  engagementKind?: EngagementKind;
122
157
  proposalStatus?: ProposalStatus;
123
- parties?: {
124
- proposedTo?: string;
125
- provider?: string;
126
- payer?: string;
127
- creator?: string;
128
- creatorAgent?: string;
129
- providerAgent?: string;
130
- };
158
+ parties?: AgreementParties;
131
159
  approvals?: AgreementApprovalEntry[];
132
160
  /** Legacy root-level price — always null in practice. Use money.price instead. */
133
161
  price?: number | null;
@@ -341,6 +369,15 @@ export interface InboxProposalRef {
341
369
  agreementId: string;
342
370
  title: string;
343
371
  proposedAt: string | null;
372
+ /**
373
+ * The agent designated to wake for this proposal, or null when it is
374
+ * ambient/human work in this reader's merged inbox.
375
+ *
376
+ * Optional for rolling deploys. When an older server omits it, clients must
377
+ * only use an explicit agent-keyed approval/responder slot as a safe fallback;
378
+ * org visibility alone is never assignment.
379
+ */
380
+ assignedAgentId?: string | null;
344
381
  /**
345
382
  * party ids still owing a decision, and the named responder slot.
346
383
  * The inbox lists proposals awaiting the agent OR its human, and only the
package/dist/types.js CHANGED
@@ -34,6 +34,24 @@ export const AGREEMENT_ENGAGEMENT_KIND = {
34
34
  /** a bilateral reach link between two agents (no money, no work). */
35
35
  LINK: 'link',
36
36
  };
37
+ /** The four sides, in the order every fan-out walks them. */
38
+ const AGREEMENT_PARTY_SIDES = ['payer', 'provider', 'proposedTo', 'creator'];
39
+ /** Both ids on one side, principal first, absent ones dropped. */
40
+ export function partySideIds(side) {
41
+ return [side?.principal, side?.actor].filter((id) => typeof id === 'string' && id.length > 0);
42
+ }
43
+ /**
44
+ * Every agent that acted on this agreement, deduped.
45
+ *
46
+ * The read for "is this agent a party?" — an agent id lives in `actor` alone,
47
+ * so widening the match to `principal` would hit an unrelated estate.
48
+ */
49
+ export function partyActorIds(parties) {
50
+ const p = parties ?? {};
51
+ return [
52
+ ...new Set(AGREEMENT_PARTY_SIDES.map((name) => p[name]?.actor).filter((id) => typeof id === 'string' && id.length > 0)),
53
+ ];
54
+ }
37
55
  /** ⚠️ Mirrors the server's entry-type constant; the server is authoritative. */
38
56
  export const EntryTypes = {
39
57
  MESSAGE: 'message',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",