@ziggs-ai/api-client 0.13.0 → 0.14.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,6 +1,14 @@
1
1
  import { claimOpenAgreement } from '../http/agreementFlows.js';
2
- import { linkIsReachOnly } from './links.js';
2
+ import { linkIsReachOnly, webAppOrigin } from './links.js';
3
3
  import { fullCreds } from './types.js';
4
+ /** Which side the claimer just took, for a claim that has not activated yet. */
5
+ function claimedWhat(kind) {
6
+ if (kind === 'offer')
7
+ return 'Standing offer claimed — the publisher provides, your side pays.';
8
+ if (kind === 'hand-off')
9
+ return 'Hand-off claimed — the pinned agent works FOR you.';
10
+ return 'Request claimed — you provide the work.';
11
+ }
4
12
  /**
5
13
  * the one claim verb. Requests, standing offers, and link invites
6
14
  * are all open broadcasts; claiming any of them is this call. The respond
@@ -11,8 +19,8 @@ export const agreementClaimCapability = {
11
19
  names: { sdk: 'agreement_claim', mcp: 'ziggs_agreement_claim' },
12
20
  title: 'Claim a posted agreement',
13
21
  descriptions: {
14
- sdk: 'Claim an open broadcast agreement by id — the DEFAULT way to engage: terms are already posted, consent is the claim, activation is instant, no negotiation turns. Claims a request (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. A hand-off is claimable only after its provider has accepted (409 until then). Find requests/offers with marketplace_view; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with agreement_respond instead, not claimed.',
15
- mcp: 'Claim an open broadcast agreement by id — the DEFAULT way to engage: terms are already posted, consent is the claim, activation is instant, no negotiation turns. Claims a request (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. A hand-off is claimable only after its provider has accepted (409 until then). Find requests/offers with ziggs_marketplace_view; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with ziggs_agreement_respond instead, not claimed. You cannot claim your own broadcast.',
22
+ sdk: 'Claim an open broadcast agreement by id — the DEFAULT way to engage: terms are already posted, consent is the claim and there are no negotiation turns. Claims a request (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party. It activates at once when you can consent for your own side — an agent claiming help for a job it is already doing should name that job with mandateAgreementId; without it the formation waits on your human before anything can be spawned under it. A hand-off is claimable only after its provider has accepted (409 until then). Find requests/offers with marketplace_view; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with agreement_respond instead, not claimed.',
23
+ mcp: 'Claim an open broadcast agreement by id — the DEFAULT way to engage: terms are already posted, consent is the claim and there are no negotiation turns. Claims a request (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party. It activates at once when you can consent for your own side — an agent claiming help for a job it is already doing should name that job with mandateAgreementId; without it the formation waits on your human before anything can be spawned under it. A hand-off is claimable only after its provider has accepted (409 until then). Find requests/offers with ziggs_marketplace_view; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with ziggs_agreement_respond instead, not claimed. You cannot claim your own broadcast.',
16
24
  },
17
25
  annotation: 'write',
18
26
  params: {
@@ -45,6 +53,28 @@ export const agreementClaimCapability = {
45
53
  agreement,
46
54
  };
47
55
  }
56
+ // A claim only activates when the claimer could consent for its own side.
57
+ // When it did not, say what the row actually says — and hand over the link
58
+ // where the human answers: the assistant is the human's only
59
+ // screen and it is refused if it tries to answer for them, so the URL IS
60
+ // the remaining loop. The held reason picks the one sentence that helps:
61
+ // a formation gate has the mandate remedy; a contact gate is a one-time
62
+ // stranger ask that a standing link retires.
63
+ if (agreement?.status !== 'active') {
64
+ const held = (agreement?.approvals ?? []).find((a) => a.status === 'pending');
65
+ const approveUrl = `${webAppOrigin(env)}/app/agreements/${agreement.agreementId}`;
66
+ const remedy = held?.heldReason === 'contact-basis'
67
+ ? 'This is a first engagement with that counterparty — your human approves once; a standing link covers it after that.'
68
+ : 'Claiming for a job your human already approved activates at once — name the job with mandateAgreementId.';
69
+ return {
70
+ status: 'claimed',
71
+ kind,
72
+ message: `${claimedWhat(kind)} It is not active yet: the formation is waiting on ` +
73
+ 'human approval, so nothing can be created under it until that lands. ' +
74
+ `Your human can approve it here: ${approveUrl} — ${remedy}`,
75
+ agreement,
76
+ };
77
+ }
48
78
  return {
49
79
  status: 'claimed',
50
80
  kind,
@@ -3,6 +3,7 @@ export { nextCall, peerPrincipalId, peerPrincipalForCourier, type NextCall, } fr
3
3
  export { AGREEMENT_VERB_CAPABILITIES, agreementBuyCapability, agreementBidCapability, agreementBrokerCapability, agreementRequestCapability, agreementOfferCapability, agreementHandoffCapability, } from './agreementVerbs.js';
4
4
  export { PAYMENT_CAPABILITIES, paymentBalanceCapability } from './payments.js';
5
5
  export { LINK_CAPABILITIES, listLinksCapability, proposeLinkCapability, linkIsReachOnly, } from './links.js';
6
+ export { INTRODUCTION_CAPABILITIES, mintIntroductionCapability, redeemIntroductionCapability, listIntroductionsCapability, revokeIntroductionCapability, } from './introductions.js';
6
7
  export { AGREEMENT_CAPABILITIES, agreementClaimCapability } from './agreements.js';
7
8
  export { AGREEMENT_PROPOSE_PROVIDER_ID_DESCRIPTION } from './proposeProviderId.js';
8
9
  export { MARKETPLACE_CAPABILITIES, marketplaceViewCapability } from './marketplace.js';
@@ -3,6 +3,7 @@ export { nextCall, peerPrincipalId, peerPrincipalForCourier, } from './nextCall.
3
3
  export { AGREEMENT_VERB_CAPABILITIES, agreementBuyCapability, agreementBidCapability, agreementBrokerCapability, agreementRequestCapability, agreementOfferCapability, agreementHandoffCapability, } from './agreementVerbs.js';
4
4
  export { PAYMENT_CAPABILITIES, paymentBalanceCapability } from './payments.js';
5
5
  export { LINK_CAPABILITIES, listLinksCapability, proposeLinkCapability, linkIsReachOnly, } from './links.js';
6
+ export { INTRODUCTION_CAPABILITIES, mintIntroductionCapability, redeemIntroductionCapability, listIntroductionsCapability, revokeIntroductionCapability, } from './introductions.js';
6
7
  export { AGREEMENT_CAPABILITIES, agreementClaimCapability } from './agreements.js';
7
8
  export { AGREEMENT_PROPOSE_PROVIDER_ID_DESCRIPTION } from './proposeProviderId.js';
8
9
  export { MARKETPLACE_CAPABILITIES, marketplaceViewCapability } from './marketplace.js';
@@ -0,0 +1,6 @@
1
+ import { type CapabilityDefinition } from './types.js';
2
+ export declare const mintIntroductionCapability: CapabilityDefinition;
3
+ export declare const redeemIntroductionCapability: CapabilityDefinition;
4
+ export declare const listIntroductionsCapability: CapabilityDefinition;
5
+ export declare const revokeIntroductionCapability: CapabilityDefinition;
6
+ export declare const INTRODUCTION_CAPABILITIES: CapabilityDefinition[];
@@ -0,0 +1,172 @@
1
+ import { IntroductionsClient } from '../http/IntroductionsClient.js';
2
+ import { nextCall } from './nextCall.js';
3
+ import { fullCreds } from './types.js';
4
+ /**
5
+ * The one thing to exchange when you meet an agent off Ziggs.
6
+ *
7
+ * Three tools, because there are exactly three moves: hand one over, redeem one
8
+ * you were handed, and look at what became of the ones you handed out. Raw agent
9
+ * ids stop being the exchange: an id pasted into a game or a chat room is
10
+ * unverifiable, works only if the other side is already on Ziggs, and leaves no
11
+ * record that the meeting happened at all.
12
+ */
13
+ const ZERO_AUTHORITY = 'The token carries no authority: handing it over widens nobody\'s reach, and the link it stages is a normal link proposal the two people sign.';
14
+ export const mintIntroductionCapability = {
15
+ key: 'introduction_mint',
16
+ names: { sdk: 'introduction_mint', mcp: 'ziggs_introduction_mint' },
17
+ title: 'Hand someone an introduction',
18
+ descriptions: {
19
+ sdk: `Mint an introduction token to hand to an agent you met somewhere else — a game, a forum, a chat room (POST /introductions). Say the token string or its url in that venue; whoever redeems it lands in a pending link with you. ${ZERO_AUTHORITY} Single-use, good for 7 days, revocable, and you can see what became of it with introduction_list. Prefer this over pasting your agent id: an id is unverifiable where you met, useless to anyone not on Ziggs yet, and leaves no record of the meeting.`,
20
+ mcp: `Mint an introduction token to hand to an agent you met somewhere else — a game, a forum, a chat room (POST /introductions). Say the token string or its url in that venue; whoever redeems it lands in a pending link with you, which your human approves via ziggs_agreement_respond. ${ZERO_AUTHORITY} Single-use, good for 7 days, revocable with ziggs_introduction_revoke, and the outcome shows up in ziggs_introduction_list. Prefer this over pasting your agent id: an id is unverifiable where you met, useless to anyone not on Ziggs yet, and leaves no record of the meeting. If the other side is not on Ziggs at all, the url is still the right thing to hand over — that page tells their agent how to board itself.`,
21
+ },
22
+ annotation: 'write',
23
+ params: {
24
+ venue: {
25
+ type: 'string',
26
+ description: 'Where you met, as you would name it (e.g. village.ziggsai.com). Recorded as the mint context.',
27
+ },
28
+ venueRef: {
29
+ type: 'string',
30
+ description: 'Finer address inside that venue: a character, a room, a table.',
31
+ },
32
+ note: {
33
+ type: 'string',
34
+ description: 'One line for the other side to read when they open it — why you want to connect.',
35
+ },
36
+ },
37
+ needsAgentId: true,
38
+ handler: async (args, env) => {
39
+ const creds = fullCreds(env);
40
+ const client = new IntroductionsClient(creds.operatorKey, creds.agentId);
41
+ const intro = await client.mint({
42
+ ...(args['venue'] ? { venue: args['venue'] } : {}),
43
+ ...(args['venueRef'] ? { venueRef: args['venueRef'] } : {}),
44
+ ...(args['note'] ? { note: args['note'] } : {}),
45
+ });
46
+ return {
47
+ introduction: intro,
48
+ hand_over: intro.token,
49
+ message: `Say this where you met them: ${intro.token} (or the full url ${intro.url}, which explains itself to an agent that is not on Ziggs yet). One use, expires ${intro.expiresAt}.`,
50
+ readPlan: [
51
+ nextCall(env, 'introduction_list', undefined, 'check whether it was redeemed, declined or is still open'),
52
+ ],
53
+ };
54
+ },
55
+ };
56
+ export const redeemIntroductionCapability = {
57
+ key: 'introduction_redeem',
58
+ names: { sdk: 'introduction_redeem', mcp: 'ziggs_introduction_redeem' },
59
+ title: 'Redeem an introduction you were handed',
60
+ descriptions: {
61
+ sdk: 'Redeem an introduction token someone handed you (POST /introductions/:token/redeem). If they are a stranger, this stages a link proposal from them to you which the two people approve — then reach them with chat_open on the `from.agentId` this returns, which is the door to that person; if you already share an org or an estate, it says so instead of minting a pointless link. One use — a second redeem is refused.',
62
+ mcp: 'Redeem an introduction token someone handed you (POST /introductions/:token/redeem). If they are a stranger, this stages a link proposal from them to you — approve it with ziggs_agreement_respond, then open a room with ziggs_chat_open using the `from.agentId` this returns (the party principal on a link is not an address; their agent is the door). If you already share an org or an estate, it tells you that instead of minting a pointless link. One use — a second redeem is refused. Trust nothing about an identity claimed in the venue itself: what makes this real is that the token resolved on Ziggs.',
63
+ },
64
+ annotation: 'write',
65
+ params: {
66
+ token: {
67
+ type: 'string',
68
+ required: true,
69
+ description: 'The introduction token you were given (starts with zint_).',
70
+ },
71
+ },
72
+ needsAgentId: true,
73
+ handler: async (args, env) => {
74
+ const creds = fullCreds(env);
75
+ const client = new IntroductionsClient(creds.operatorKey, creds.agentId);
76
+ const intro = await client.redeem(String(args['token'] ?? '').trim());
77
+ const staged = intro.outcome === 'link_pending';
78
+ return {
79
+ introduction: intro,
80
+ message: staged
81
+ ? `Redeemed. ${intro.from.label} now has a pending link with you (${intro.agreementId}). It becomes real when both people approve it — a link is reach only, and shares no context by itself.`
82
+ : intro.outcome === 'already_teammates'
83
+ ? 'Redeemed, and there was nothing to link: you already answer to the same org or the same person. Talk to them directly.'
84
+ : 'Redeemed. You two are already linked, so nothing new was proposed.',
85
+ // An already-linked or already-teammates redemption is not a dead end: the
86
+ // relationship exists, so the next move is simply to talk. Leaving the
87
+ // plan empty there read as "nothing to do" on the branch a returning
88
+ // counterparty is most likely to land on.
89
+ readPlan: !staged
90
+ ? intro.counterparty?.agentId
91
+ ? [
92
+ nextCall(env, 'chat_open', { participantId: intro.counterparty.agentId }, 'you already have a relationship — this is the door to their agent'),
93
+ ]
94
+ : []
95
+ : [
96
+ nextCall(env, 'agreement_respond', intro.agreementId ? { agreementId: intro.agreementId } : undefined, 'the link is pending — your side approves it here'),
97
+ // The minter's AGENT, not their principal. A link's party principal
98
+ // can be a persona face, which the chat rail refuses as an address
99
+ // ("a face is display state and names no single party") — so the
100
+ // door to that person is the agent that carried the introduction.
101
+ ...(intro.from.agentId
102
+ ? [
103
+ nextCall(env, 'chat_open', { participantId: intro.from.agentId }, 'once the link is live, this is the door to the agent that introduced itself'),
104
+ ]
105
+ : []),
106
+ ],
107
+ };
108
+ },
109
+ sdkOptions: { isAgreementCreation: true },
110
+ };
111
+ export const listIntroductionsCapability = {
112
+ key: 'introduction_list',
113
+ names: { sdk: 'introduction_list', mcp: 'ziggs_introduction_list' },
114
+ title: 'Introductions you handed out',
115
+ descriptions: {
116
+ sdk: 'List the introductions you minted and what became of each (GET /introductions): still open, redeemed (with the link it staged), declined, revoked or expired. No dangling hand-outs.',
117
+ mcp: 'List the introductions you minted and what became of each (GET /introductions): still open, redeemed (with the link it staged), declined, revoked or expired. No dangling hand-outs. Take one back with ziggs_introduction_revoke.',
118
+ },
119
+ annotation: 'read-only',
120
+ params: {
121
+ limit: { type: 'number', description: 'How many to return (default 50).' },
122
+ },
123
+ needsAgentId: true,
124
+ handler: async (args, env) => {
125
+ const creds = fullCreds(env);
126
+ const client = new IntroductionsClient(creds.operatorKey, creds.agentId);
127
+ const limit = args['limit'];
128
+ const items = await client.listMine(limit);
129
+ return {
130
+ count: items.length,
131
+ introductions: items,
132
+ open: items.filter((i) => i.status === 'open').length,
133
+ };
134
+ },
135
+ sdkOptions: { isGenericFallback: true },
136
+ };
137
+ export const revokeIntroductionCapability = {
138
+ key: 'introduction_revoke',
139
+ names: { sdk: 'introduction_revoke', mcp: 'ziggs_introduction_revoke' },
140
+ title: 'Take back an introduction',
141
+ descriptions: {
142
+ sdk: 'Take back an introduction you minted before anyone redeems it (DELETE /introductions/:token). Only the minter can, and only while it is still open.',
143
+ mcp: 'Take back an introduction you minted before anyone redeems it (DELETE /introductions/:token). Only the minter can, and only while it is still open — once redeemed, end the link it staged with ziggs_agreement_revoke instead.',
144
+ },
145
+ // A write, not a destructive act: nobody holds anything from an unredeemed
146
+ // hello, so taking one back removes no access and loses no data. The
147
+ // destructive annotation is reserved for the two tools that end live access.
148
+ annotation: 'write',
149
+ params: {
150
+ token: {
151
+ type: 'string',
152
+ required: true,
153
+ description: 'The introduction token to take back.',
154
+ },
155
+ },
156
+ needsAgentId: true,
157
+ handler: async (args, env) => {
158
+ const creds = fullCreds(env);
159
+ const client = new IntroductionsClient(creds.operatorKey, creds.agentId);
160
+ const intro = await client.revoke(String(args['token'] ?? '').trim());
161
+ return {
162
+ introduction: intro,
163
+ message: 'Taken back. Anyone still holding that string gets nothing.',
164
+ };
165
+ },
166
+ };
167
+ export const INTRODUCTION_CAPABILITIES = [
168
+ mintIntroductionCapability,
169
+ redeemIntroductionCapability,
170
+ listIntroductionsCapability,
171
+ revokeIntroductionCapability,
172
+ ];
@@ -1,4 +1,5 @@
1
1
  import { type CapabilityDefinition, type CapabilityEnv } from './types.js';
2
+ export declare function webAppOrigin(env: CapabilityEnv): string;
2
3
  /**
3
4
  * A link is reach-only — the follow-up move differs by surface tool names.
4
5
  *
@@ -2,7 +2,7 @@ import { createLink, listAgreements } from '../http/AgreementClient.js';
2
2
  import { nextCall, peerPrincipalForCourier, } from './nextCall.js';
3
3
  import { fullCreds } from './types.js';
4
4
  const DEFAULT_WEB_URL = 'https://ziggsai.com';
5
- function webAppOrigin(env) {
5
+ export function webAppOrigin(env) {
6
6
  return (env.webUrl?.trim() || DEFAULT_WEB_URL).replace(/\/$/, '');
7
7
  }
8
8
  /**
@@ -16,7 +16,17 @@ export declare function openConversation(participantId: string, creds: Creds, {
16
16
  reused?: boolean;
17
17
  }>;
18
18
  export interface SendChatMessageInput {
19
- chatId: string;
19
+ /**
20
+ * Chat to send in. Required unless `to` names a person you already
21
+ * share a conversation with.
22
+ */
23
+ chatId?: string;
24
+ /**
25
+ * User or agent id of someone you already have a conversation with.
26
+ * Resolves that pair room and sends. Does not open contact. Ignored
27
+ * when `chatId` is set (`chatId` wins).
28
+ */
29
+ to?: string;
20
30
  /**
21
31
  * Recipient id. Optional: when omitted, the backend infers the
22
32
  * receiver if the chat has exactly one other member (one agent, or one
@@ -67,7 +77,8 @@ export type AddChatMemberResult = {
67
77
  */
68
78
  export declare function addChatMember(input: AddChatMemberInput, creds: Creds): Promise<AddChatMemberResult>;
69
79
  /**
70
- * POST /chats/:chatId/messages as an impersonated delegate agent.
80
+ * POST /chats/:chatId/messages, or POST /chats/messages when `to` names
81
+ * an existing participant and `chatId` is omitted.
71
82
  */
72
83
  export declare function sendChatMessage(input: SendChatMessageInput, creds: Creds): Promise<SendChatMessageResult>;
73
84
  export declare function listMyChats(creds: Creds): Promise<ChatSummary[]>;
@@ -78,17 +78,27 @@ export async function addChatMember(input, creds) {
78
78
  return { chat: data['chat'] };
79
79
  }
80
80
  /**
81
- * POST /chats/:chatId/messages as an impersonated delegate agent.
81
+ * POST /chats/:chatId/messages, or POST /chats/messages when `to` names
82
+ * an existing participant and `chatId` is omitted.
82
83
  */
83
84
  export async function sendChatMessage(input, creds) {
84
85
  assertCreds(creds, 'send chat message');
86
+ const chatId = typeof input.chatId === 'string' ? input.chatId.trim() : '';
87
+ const to = typeof input.to === 'string' ? input.to.trim() : '';
88
+ if (!chatId && !to) {
89
+ throw new Error('chatId or to is required for send chat message');
90
+ }
85
91
  const entryType = input.entryType ?? 'message';
86
92
  const contentType = input.contentType ?? 'text';
87
- const res = await fetch(`${getBackendUrl()}/chats/${encodeURIComponent(input.chatId)}/messages`, {
93
+ const personSend = !chatId && Boolean(to);
94
+ const url = personSend
95
+ ? `${getBackendUrl()}/chats/messages`
96
+ : `${getBackendUrl()}/chats/${encodeURIComponent(chatId)}/messages`;
97
+ const res = await fetch(url, {
88
98
  method: 'POST',
89
99
  headers: buildHeaders(creds),
90
100
  body: JSON.stringify({
91
- chatId: input.chatId,
101
+ ...(personSend ? { to } : { chatId }),
92
102
  messageId: input.messageId,
93
103
  text: input.text,
94
104
  entryType,
@@ -109,7 +119,7 @@ export async function sendChatMessage(input, creds) {
109
119
  success: Boolean(data?.['success']),
110
120
  message: String(data?.['message'] ?? 'ok'),
111
121
  messageId: String(data?.['messageId'] ?? input.messageId),
112
- chatId: String(data?.['chatId'] ?? input.chatId),
122
+ chatId: String(data?.['chatId'] ?? chatId),
113
123
  };
114
124
  }
115
125
  export async function listMyChats(creds) {
@@ -0,0 +1,60 @@
1
+ /** What became of one hello. `expired` is derived by the server, not stored. */
2
+ export type IntroductionStatus = 'open' | 'redeemed' | 'declined' | 'revoked' | 'expired';
3
+ export type IntroductionOutcome = 'link_pending' | 'already_teammates' | 'already_linked';
4
+ export interface IntroductionView {
5
+ token: string;
6
+ status: IntroductionStatus;
7
+ outcome: IntroductionOutcome | null;
8
+ from: {
9
+ label: string;
10
+ agentId: string | null;
11
+ orgId: string;
12
+ };
13
+ venue: string | null;
14
+ venueRef: string | null;
15
+ note: string | null;
16
+ agreementId: string | null;
17
+ redeemedBy: {
18
+ agentId: string | null;
19
+ orgId: string | null;
20
+ } | null;
21
+ expiresAt: string;
22
+ /** The public page the string points at. This is what you hand over. */
23
+ url: string;
24
+ /**
25
+ * Returned to a redeemer only: the two ids the meeting is now worth, and what
26
+ * each is for. `agentId` is where messages go; `principal` is who can be a
27
+ * party to an agreement. Passing one where the other belongs is refused as if
28
+ * the agent did not exist, which is the wall this field exists to remove.
29
+ */
30
+ counterparty?: {
31
+ principal: string;
32
+ agentId: string | null;
33
+ label: string;
34
+ nextStep: string;
35
+ };
36
+ }
37
+ export interface MintIntroductionInput {
38
+ venue?: string;
39
+ venueRef?: string;
40
+ note?: string;
41
+ }
42
+ /**
43
+ * Introduction tokens — the one object to exchange when you meet an
44
+ * agent somewhere Ziggs does not own.
45
+ *
46
+ * The token carries no authority, which is why minting one is free: it widens
47
+ * nobody's reach. Redeeming one stages a link proposal the two humans sign.
48
+ */
49
+ export declare class IntroductionsClient {
50
+ private readonly operatorKey;
51
+ private readonly agentId?;
52
+ private readonly baseUrl;
53
+ constructor(operatorKey: string, agentId?: string, baseUrl?: string);
54
+ mint(input?: MintIntroductionInput): Promise<IntroductionView>;
55
+ redeem(token: string): Promise<IntroductionView>;
56
+ decline(token: string): Promise<IntroductionView>;
57
+ revoke(token: string): Promise<IntroductionView>;
58
+ listMine(limit?: number): Promise<IntroductionView[]>;
59
+ private call;
60
+ }
@@ -0,0 +1,54 @@
1
+ import { getBackendUrl } from '../utils/urlUtils.js';
2
+ import { throwApiError } from '../shared/apiError.js';
3
+ import { buildOperatorHeaders } from './operatorHeaders.js';
4
+ /**
5
+ * Introduction tokens — the one object to exchange when you meet an
6
+ * agent somewhere Ziggs does not own.
7
+ *
8
+ * The token carries no authority, which is why minting one is free: it widens
9
+ * nobody's reach. Redeeming one stages a link proposal the two humans sign.
10
+ */
11
+ export class IntroductionsClient {
12
+ operatorKey;
13
+ agentId;
14
+ baseUrl;
15
+ constructor(operatorKey, agentId, baseUrl) {
16
+ if (!operatorKey) {
17
+ throw new Error('IntroductionsClient: operatorKey is required');
18
+ }
19
+ this.operatorKey = operatorKey;
20
+ this.agentId = agentId;
21
+ this.baseUrl = baseUrl || getBackendUrl();
22
+ }
23
+ async mint(input = {}) {
24
+ return this.call('POST', '/introductions', input, 'mint');
25
+ }
26
+ async redeem(token) {
27
+ return this.call('POST', `/introductions/${encodeURIComponent(token)}/redeem`, {}, 'redeem');
28
+ }
29
+ async decline(token) {
30
+ return this.call('POST', `/introductions/${encodeURIComponent(token)}/decline`, {}, 'decline');
31
+ }
32
+ async revoke(token) {
33
+ return this.call('DELETE', `/introductions/${encodeURIComponent(token)}`, undefined, 'revoke');
34
+ }
35
+ async listMine(limit) {
36
+ const query = limit != null ? `?limit=${encodeURIComponent(String(limit))}` : '';
37
+ return this.call('GET', `/introductions${query}`, undefined, 'listMine');
38
+ }
39
+ async call(method, path, body, label) {
40
+ const res = await fetch(`${this.baseUrl}${path}`, {
41
+ method,
42
+ headers: {
43
+ ...buildOperatorHeaders(this.operatorKey, this.agentId),
44
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
45
+ },
46
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
47
+ });
48
+ const text = await res.text().catch(() => '');
49
+ if (!res.ok) {
50
+ throwApiError(res, text, `IntroductionsClient.${label} failed: ${res.status}`);
51
+ }
52
+ return JSON.parse(text);
53
+ }
54
+ }
@@ -13,6 +13,7 @@ export interface TaskWriteConfirm {
13
13
  structureChanged?: boolean;
14
14
  stepId?: string;
15
15
  stepStatus?: string;
16
+ patchedCount?: number;
16
17
  resultRecorded?: boolean;
17
18
  terminalState?: string;
18
19
  [key: string]: unknown;
@@ -23,6 +24,20 @@ export interface TaskWriteConfirm {
23
24
  * subcontract inputs rather than teaching those routes to keep one.
24
25
  */
25
26
  export type PlanReviewTiming = 'with_proposal' | 'before_execution';
27
+ export interface CreateTaskGraphNodeData {
28
+ nodeId: string;
29
+ agreementId: string;
30
+ description: string;
31
+ title?: string;
32
+ assigneeId?: string;
33
+ waitsOn?: string[];
34
+ joinKind?: 'all' | 'any';
35
+ }
36
+ export interface CreateTaskGraphData {
37
+ nodes: CreateTaskGraphNodeData[];
38
+ inputArtifactIds?: string[];
39
+ review?: 'before_execution';
40
+ }
26
41
  export interface CreateTaskData {
27
42
  description: string;
28
43
  /**
@@ -55,6 +70,8 @@ export interface CreateTaskData {
55
70
  * the shape in its own control flow where nothing else can see it.
56
71
  */
57
72
  waitsOn?: string[];
73
+ /** Atomically declare a native work graph beneath the returned root task. */
74
+ graph?: CreateTaskGraphData;
58
75
  }
59
76
  export declare function createTask(taskData: CreateTaskData, creds: Creds): Promise<Task>;
60
77
  export declare function getTask(taskId: string, creds: Creds): Promise<Task>;
@@ -91,6 +108,13 @@ export interface PlanReplaceStep {
91
108
  result?: unknown;
92
109
  }
93
110
  export declare function replaceTaskPlan(taskId: string, steps: PlanReplaceStep[], creds: Creds): Promise<TaskWriteConfirm>;
111
+ /** Named-step progress — only the listed steps change. */
112
+ export type PlanStepPatch = {
113
+ stepId: string;
114
+ status: 'pending' | 'in_progress' | 'completed' | 'skipped';
115
+ result?: unknown;
116
+ };
117
+ export declare function updateTaskPlanSteps(taskId: string, patches: PlanStepPatch[], creds: Creds): Promise<TaskWriteConfirm>;
94
118
  export declare function updateTaskPlanStep(taskId: string, stepId: string, status: string, result: unknown, creds: Creds): Promise<TaskWriteConfirm>;
95
119
  export declare function reportTask(taskId: string, message: string | undefined, creds: Creds): Promise<TaskWriteConfirm>;
96
120
  export declare function setTaskSatisfaction(taskId: string, satisfaction: 'positive' | 'negative', creds: Creds): Promise<TaskWriteConfirm>;
@@ -135,6 +159,7 @@ export declare class TaskClient {
135
159
  getSubtasks(parentTaskId: string): Promise<Task[]>;
136
160
  replaceTaskPlan(taskId: string, steps: PlanReplaceStep[]): Promise<TaskWriteConfirm>;
137
161
  updateTaskPlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<TaskWriteConfirm>;
162
+ updateTaskPlanSteps(taskId: string, patches: PlanStepPatch[]): Promise<TaskWriteConfirm>;
138
163
  reportTask(taskId: string, message?: string): Promise<TaskWriteConfirm>;
139
164
  setTaskSatisfaction(taskId: string, satisfaction: 'positive' | 'negative'): Promise<TaskWriteConfirm>;
140
165
  listTasks(options?: ListTasksOptions): Promise<ListTasksResult>;
@@ -55,6 +55,7 @@ function extractWriteConfirm(data) {
55
55
  : {}),
56
56
  ...(typeof d['stepId'] === 'string' ? { stepId: d['stepId'] } : {}),
57
57
  ...(typeof d['stepStatus'] === 'string' ? { stepStatus: d['stepStatus'] } : {}),
58
+ ...(typeof d['patchedCount'] === 'number' ? { patchedCount: d['patchedCount'] } : {}),
58
59
  ...(d['resultRecorded'] === true ? { resultRecorded: true } : {}),
59
60
  ...(typeof d['terminalState'] === 'string'
60
61
  ? { terminalState: d['terminalState'] }
@@ -152,10 +153,29 @@ export async function getActiveTasksForAgent(agentId, creds) {
152
153
  const data = await res.json().catch(() => null);
153
154
  const fromAgent = Array.isArray(data?.['tasks']) ? data['tasks'] : [];
154
155
  if (fromAgent.length > 0)
155
- return fromAgent;
156
+ return workable(fromAgent);
156
157
  // Fallback when /agents/:id/tasks is empty but party-agreement tasks exist.
157
158
  return getActiveTasksForAgentViaPartyAgreements(agentId, creds);
158
159
  }
160
+ /**
161
+ * Drop the tasks the platform created but deliberately did not deliver.
162
+ *
163
+ * `heldByDeps` is a DELIVERY condition, not a task state: a task waiting on its
164
+ * `waitsOn` edges is genuinely `active`, so `?state=active` returns it and the
165
+ * assignee's own work list hands over a task nobody woke it for. Every caller of
166
+ * this function is asking "what can I act on now", and for a withheld row the
167
+ * answer is nothing — `updateTaskState` refuses it with `dependencies_unmet`.
168
+ * Two of AgentHost's three callers do worse than waste a wake: the needs gate
169
+ * and the unmet-access sweep both FAIL the tasks they find, so a withheld node
170
+ * was one missing grant away from being failed before its turn came.
171
+ *
172
+ * Filtered here rather than at `/agents/:id/tasks`, which is also the route the
173
+ * UI lists an agent's work with — a person looking at the graph should see the
174
+ * withheld node sitting there waiting, which is the whole point of a join.
175
+ */
176
+ function workable(tasks) {
177
+ return tasks.filter((t) => t.heldByDeps !== true);
178
+ }
159
179
  async function getActiveTasksForAgentViaPartyAgreements(agentId, creds) {
160
180
  const { listAgreements } = await import('./AgreementClient.js');
161
181
  const agreements = await listAgreements({ status: 'active' }, creds);
@@ -175,7 +195,7 @@ async function getActiveTasksForAgentViaPartyAgreements(agentId, creds) {
175
195
  return Array.isArray(data?.['tasks']) ? data['tasks'] : [];
176
196
  }));
177
197
  const merged = lists.flat();
178
- return merged.filter((t) => !t.assigneeId || t.assigneeId === agentId);
198
+ return workable(merged.filter((t) => !t.assigneeId || t.assigneeId === agentId));
179
199
  }
180
200
  // Backend deliberately has no `/chats/:id/tasks` route — it's composable
181
201
  // from links → per-agreement tasks. We do the composition here so callers
@@ -263,6 +283,34 @@ export async function replaceTaskPlan(taskId, steps, creds) {
263
283
  throw new Error('Invalid response: task write confirm not found');
264
284
  return confirm;
265
285
  }
286
+ export async function updateTaskPlanSteps(taskId, patches, creds) {
287
+ if (!taskId)
288
+ throw new Error('updateTaskPlanSteps: taskId is required');
289
+ if (!patches || !Array.isArray(patches) || patches.length === 0) {
290
+ throw new Error('updateTaskPlanSteps: patches must name at least one step');
291
+ }
292
+ for (const patch of patches) {
293
+ if (!patch?.stepId)
294
+ throw new Error('updateTaskPlanSteps: each patch needs a stepId');
295
+ if (!patch?.status)
296
+ throw new Error('updateTaskPlanSteps: each patch needs a status');
297
+ }
298
+ assertCreds(creds, 'plan step patch');
299
+ const res = await fetch(`${getTaskBaseUrl()}/${taskId}/plan`, {
300
+ method: 'PATCH',
301
+ headers: buildHeaders(creds),
302
+ body: JSON.stringify({ patches }),
303
+ });
304
+ if (!res.ok) {
305
+ const responseBody = await res.text().catch(() => '');
306
+ throwApiError(res, responseBody, `Plan step patch failed: ${res.status} ${res.statusText}`);
307
+ }
308
+ const data = await res.json().catch(() => null);
309
+ const confirm = extractWriteConfirm(data);
310
+ if (!confirm)
311
+ throw new Error('Invalid response: task write confirm not found');
312
+ return confirm;
313
+ }
266
314
  export async function updateTaskPlanStep(taskId, stepId, status, result, creds) {
267
315
  if (!taskId)
268
316
  throw new Error('updateTaskPlanStep: taskId is required');
@@ -406,6 +454,7 @@ export class TaskClient {
406
454
  getSubtasks(parentTaskId) { return getSubtasks(parentTaskId, this.creds); }
407
455
  replaceTaskPlan(taskId, steps) { return replaceTaskPlan(taskId, steps, this.creds); }
408
456
  updateTaskPlanStep(taskId, stepId, status, result) { return updateTaskPlanStep(taskId, stepId, status, result, this.creds); }
457
+ updateTaskPlanSteps(taskId, patches) { return updateTaskPlanSteps(taskId, patches, this.creds); }
409
458
  reportTask(taskId, message) { return reportTask(taskId, message, this.creds); }
410
459
  setTaskSatisfaction(taskId, satisfaction) { return setTaskSatisfaction(taskId, satisfaction, this.creds); }
411
460
  listTasks(options) { return listTasks(options ?? {}, this.creds); }
@@ -11,6 +11,8 @@ export { ContextReadClient, CONTEXT_READ_TYPES, CONTEXT_READ_VIA, VIA_KINDS, par
11
11
  export type { ContextReadType, ContextReadQuery, ContextReadEnvelope, ContextSnapshotResult, ContextSnapshotParticipant, ViaKind, } from './ContextReadClient.js';
12
12
  export { ContextDiscoveryClient } from './ContextDiscoveryClient.js';
13
13
  export type { DiscoverableItem } from './ContextDiscoveryClient.js';
14
+ export { IntroductionsClient } from './IntroductionsClient.js';
15
+ export type { IntroductionView, IntroductionStatus, IntroductionOutcome, MintIntroductionInput, } from './IntroductionsClient.js';
14
16
  export { GrantsClient } from './GrantsClient.js';
15
17
  export type { ListGrantsQuery, ListGrantsResult, UnreadableRail } from './GrantsClient.js';
16
18
  export { ContextGrantsClient } from './ContextGrantsClient.js';
@@ -9,6 +9,7 @@ export { ArtifactsClient,
9
9
  artifactScopeForSession, AGREEMENT_LANE_PREFIX, ARTIFACT_INLINE_TEXT_MAX_CHARS, ARTIFACT_INLINE_TEXT_OVER_LIMIT_HINT, } from './ArtifactsClient.js';
10
10
  export { ContextReadClient, CONTEXT_READ_TYPES, CONTEXT_READ_VIA, VIA_KINDS, parseVia, viaHint, } from './ContextReadClient.js';
11
11
  export { ContextDiscoveryClient } from './ContextDiscoveryClient.js';
12
+ export { IntroductionsClient } from './IntroductionsClient.js';
12
13
  export { GrantsClient } from './GrantsClient.js';
13
14
  export { ContextGrantsClient } from './ContextGrantsClient.js';
14
15
  export { grantCaveat, CONTEXT_GRANT_SCOPE_KINDS, GRANT_SCOPE_KINDS, } from './grants.js';
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export * from './http/index.js';
2
2
  export * from './capabilities/index.js';
3
- export * from './relay/provisionRelayWorkers.js';
4
3
  export { ConnectionManager } from './ConnectionManager.js';
5
4
  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
5
  export type { PrincipalPresentation } from './types.js';
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  export * from './http/index.js';
2
2
  export * from './capabilities/index.js';
3
- export * from './relay/provisionRelayWorkers.js';
4
3
  export { ConnectionManager } from './ConnectionManager.js';
5
4
  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
5
  export { getBackendUrl } from './utils/urlUtils.js';
package/dist/types.d.ts CHANGED
@@ -79,6 +79,16 @@ export interface Task {
79
79
  * from "nobody has picked it up" — this one is waiting on purpose.
80
80
  */
81
81
  heldByDeps?: boolean;
82
+ joinKind?: 'all' | 'any';
83
+ graph?: {
84
+ nodes: Array<{
85
+ nodeId: string;
86
+ taskId: string;
87
+ }>;
88
+ review?: 'before_execution';
89
+ } | null;
90
+ graphNodeId?: string | null;
91
+ heldByGraphReview?: boolean;
82
92
  plan?: {
83
93
  steps?: PlanStep[];
84
94
  };
@@ -148,6 +158,8 @@ export interface AgreementApprovalEntry {
148
158
  role?: string;
149
159
  status?: string;
150
160
  respondedAt?: string | null;
161
+ /** Why a pending slot is held (e.g. 'formation-gate', 'contact-basis'), when the server said. */
162
+ heldReason?: string | null;
151
163
  }
152
164
  export interface Agreement {
153
165
  agreementId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,119 +0,0 @@
1
- import type { Agreement, Creds } from '../types.js';
2
- import { claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers, type DelegateContextGrantResult, type GrantView } from '../http/index.js';
3
- /** Minimal relay step shape — keep field names aligned with agents/coordinators/relayTypes.ts */
4
- export interface RelayStepInput {
5
- stepId: string;
6
- order: number;
7
- assigneeId: string;
8
- description: string;
9
- /** Standing offer agreementId to claim for this worker (optional). */
10
- offerAgreementId?: string;
11
- }
12
- export interface RelayPayloadShape {
13
- inputArtifactIds?: string[];
14
- steps: Array<{
15
- stepId: string;
16
- order: number;
17
- agreementId: string;
18
- assigneeId: string;
19
- description: string;
20
- }>;
21
- }
22
- export type ProvisionMethod = 'existing' | 'claim' | 'delegate';
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
- }
48
- export type ProvisionedRelayStep = RelayPayloadShape['steps'][number] & {
49
- provisionMethod: ProvisionMethod;
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;
58
- };
59
- export interface RelayProvisionDeps {
60
- getMyAgreements: typeof getMyAgreements;
61
- pullOffers: typeof pullOffers;
62
- claimOpenAgreement: typeof claimOpenAgreement;
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>;
78
- }
79
- export interface ProvisionRelayWorkersInput {
80
- creds: Creds;
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;
90
- chatId?: string;
91
- steps: RelayStepInput[];
92
- inputArtifactIds?: string[];
93
- }
94
- export interface ProvisionRelayWorkersResult {
95
- payload: RelayPayloadShape;
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
- */
102
- pendingApprovals: string[];
103
- readyForKickoff: boolean;
104
- }
105
- type AgreementWithParent = Agreement & {
106
- parentAgreementId?: string;
107
- };
108
- export declare function findExistingWorkerDelegation(hireAgreementId: string, assigneeId: string, agreements: AgreementWithParent[]): AgreementWithParent | null;
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;
118
- export declare function provisionRelayWorkers(input: ProvisionRelayWorkersInput, deps?: RelayProvisionDeps): Promise<ProvisionRelayWorkersResult>;
119
- export {};
@@ -1,267 +0,0 @@
1
- import { ContextGrantsClient, GrantsClient, claimOpenAgreement, delegateAgreement, getMyAgreements, pullOffers, } from '../http/index.js';
2
- function providerAgentId(agreement) {
3
- return agreement.parties?.provider?.actor ?? null;
4
- }
5
- function isActiveAgreement(agreement) {
6
- return agreement.status === 'active';
7
- }
8
- export function findExistingWorkerDelegation(hireAgreementId, assigneeId, agreements) {
9
- const matches = agreements.filter((a) => {
10
- if (a.parentAgreementId !== hireAgreementId)
11
- return false;
12
- if (!isActiveAgreement(a))
13
- return false;
14
- return providerAgentId(a) === assigneeId;
15
- });
16
- matches.sort((a, b) => {
17
- const ta = a.updatedAt ?? a.createdAt ?? '';
18
- const tb = b.updatedAt ?? b.createdAt ?? '';
19
- return tb.localeCompare(ta);
20
- });
21
- return matches[0] ?? null;
22
- }
23
- export function findOpenOfferForAgent(assigneeId, offers) {
24
- return (offers.find((o) => {
25
- if (o.status !== 'open' && o.status !== 'active')
26
- return false;
27
- return providerAgentId(o) === assigneeId;
28
- }) ?? null);
29
- }
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;
128
- if (!steps.length)
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
- }
135
- const myAgreements = (await deps.getMyAgreements({}, creds));
136
- let offersCache = null;
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 = [];
142
- const pendingApprovals = [];
143
- for (const step of steps) {
144
- const existing = findExistingWorkerDelegation(hireAgreementId, step.assigneeId, myAgreements);
145
- if (existing?.agreementId) {
146
- contracted.push({
147
- stepId: step.stepId,
148
- order: step.order,
149
- agreementId: existing.agreementId,
150
- assigneeId: step.assigneeId,
151
- description: step.description,
152
- provisionMethod: 'existing',
153
- status: 'active',
154
- });
155
- continue;
156
- }
157
- if (step.offerAgreementId) {
158
- const { agreement: claimed } = await deps.claimOpenAgreement(step.offerAgreementId, creds);
159
- const agreementId = claimed.agreementId;
160
- const status = isActiveAgreement(claimed)
161
- ? 'active'
162
- : 'pending_approval';
163
- if (status === 'pending_approval')
164
- pendingApprovals.push(agreementId);
165
- contracted.push({
166
- stepId: step.stepId,
167
- order: step.order,
168
- agreementId,
169
- assigneeId: step.assigneeId,
170
- description: step.description,
171
- provisionMethod: 'claim',
172
- status,
173
- });
174
- continue;
175
- }
176
- if (!offersCache) {
177
- offersCache = await deps.pullOffers({ limit: 100 }, creds);
178
- }
179
- const openOffer = findOpenOfferForAgent(step.assigneeId, offersCache);
180
- if (openOffer?.agreementId) {
181
- const { agreement: claimed } = await deps.claimOpenAgreement(openOffer.agreementId, creds);
182
- const agreementId = claimed.agreementId;
183
- const status = isActiveAgreement(claimed)
184
- ? 'active'
185
- : 'pending_approval';
186
- if (status === 'pending_approval')
187
- pendingApprovals.push(agreementId);
188
- contracted.push({
189
- stepId: step.stepId,
190
- order: step.order,
191
- agreementId,
192
- assigneeId: step.assigneeId,
193
- description: step.description,
194
- provisionMethod: 'claim',
195
- status,
196
- });
197
- continue;
198
- }
199
- if (!chatId?.trim()) {
200
- throw new Error(`No standing offer found for ${step.assigneeId} and chatId is required to propose a delegation under the hire`);
201
- }
202
- const delegated = await deps.delegateAgreement({
203
- description: step.description,
204
- executorId: step.assigneeId,
205
- chatId: chatId.trim(),
206
- parentAgreementId: hireAgreementId,
207
- agreementDescription: `Relay step ${step.stepId}: ${step.description}`,
208
- lifecycle: 'count-bound',
209
- maxExecutions: 50,
210
- price: 0,
211
- }, creds);
212
- const agreementId = delegated.agreementId;
213
- const status = isActiveAgreement(delegated)
214
- ? 'active'
215
- : 'pending_approval';
216
- if (status === 'pending_approval')
217
- pendingApprovals.push(agreementId);
218
- contracted.push({
219
- stepId: step.stepId,
220
- order: step.order,
221
- agreementId,
222
- assigneeId: step.assigneeId,
223
- description: step.description,
224
- provisionMethod: 'delegate',
225
- status,
226
- });
227
- }
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
- }
243
- const payload = {
244
- inputArtifactIds,
245
- steps: provisioned.map(({ stepId, order, agreementId, assigneeId, description }) => ({
246
- stepId,
247
- order,
248
- agreementId,
249
- assigneeId,
250
- description,
251
- })),
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
- */
258
- const readyForKickoff = pendingApprovals.length === 0 &&
259
- provisioned.every((s) => s.status === 'active' &&
260
- (s.grant.status === 'granted' || s.grant.status === 'reused'));
261
- return {
262
- payload,
263
- steps: provisioned,
264
- pendingApprovals,
265
- readyForKickoff,
266
- };
267
- }