@forloop-cc/forloop-opencode-plugin-planner 2.2.25 → 2.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@forloop-cc/forloop-opencode-plugin-planner",
4
- "version": "2.2.25",
4
+ "version": "2.2.27",
5
5
  "description": "ForLoop integration plugin for opencode — Sprint, story, and agent tools for the ForLoop platform",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -7,8 +7,15 @@ type RegistryEntry = {
7
7
  agentId: string;
8
8
  name: string;
9
9
  serviceCode?: string | null;
10
+ authMode?: 'none' | 'app' | 'user';
10
11
  };
11
12
 
13
+ // The target manifest is the source of truth for consent: a user-auth service
14
+ // only accepts a user-subject connection, so the planner must never create an
15
+ // app-subject request against it as a workaround.
16
+ const subjectTypeFor = (target: RegistryEntry): 'user' | 'app' =>
17
+ target.authMode === 'user' ? 'user' : 'app';
18
+
12
19
  type RequestWire = {
13
20
  id: string;
14
21
  targetAgentId: string;
@@ -75,7 +82,7 @@ const requestLines = (request: RequestWire): string[] => {
75
82
  export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
76
83
  return tool({
77
84
  description:
78
- 'Create (or idempotently reuse) a connection request for the current sprint: the app asks a service/peer for an exact scope set. The request is persisted and visible in App Connections, but the planner can never approve it — the signed-in owner must click Approve. Returns the request id and a deep link. Use before writing an integration brief that depends on an external service.',
85
+ 'Create (or idempotently reuse) a connection request for the current sprint: the app asks a service/peer for an exact scope set. The request is persisted and visible in App Connections, but the planner can never approve it — the signed-in owner (or, for user-subject grants, the end user themselves) must click Approve. Returns the request id and a deep link. Use before writing an integration brief that depends on an external service. subjectType defaults to the target manifest authMode: "user" for user-owned data (the user consents), otherwise "app" for app-owned/standing integrations. Never force app for a user-auth target.',
79
86
  args: {
80
87
  agentId: tool.schema.string().describe('Target service/peer agentId (public id)'),
81
88
  scopes: tool.schema
@@ -85,6 +92,10 @@ export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
85
92
  .string()
86
93
  .optional()
87
94
  .describe('Why the app needs this connection (shown to the approving user)'),
95
+ subjectType: tool.schema
96
+ .enum(['user', 'app'])
97
+ .optional()
98
+ .describe('Connection subject: "user" for user-owned data (delegated user consent), "app" for an app-owned/standing integration. Defaults to the target authMode; an app subject is refused for a user-auth target.'),
88
99
  },
89
100
  async execute(args, context) {
90
101
  const tokenResult = await validateToken();
@@ -107,23 +118,42 @@ export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
107
118
  return `❌ No scopes requested for \`${args.agentId}\`. Nothing was created.`;
108
119
  }
109
120
 
121
+ const targetSubject = subjectTypeFor(target)
122
+ if (targetSubject === 'user' && args.subjectType === 'app') {
123
+ return [
124
+ `❌ Target \`${args.agentId}\` requires user-auth (authMode: "user"); refusing to create an app-subject request.`,
125
+ 'The end user must consent for their own data. Retry without subjectType (it defaults to "user" for this target) or pass subjectType: "user".',
126
+ ].join('\n');
127
+ }
128
+ const subjectType = args.subjectType ?? targetSubject
110
129
  const result = await client.createConnectionRequest({
111
130
  sprintId,
112
131
  targetAgentId: args.agentId,
113
132
  scopes: requested,
114
- subjectType: 'app',
133
+ subjectType,
115
134
  requestedBy: 'planner',
116
135
  ...(args.reason ? { reason: args.reason } : {}),
117
136
  });
118
137
  const request = result.request as RequestWire;
119
138
  const dropped = Array.isArray(result.droppedScopes) ? result.droppedScopes : [];
120
139
 
140
+ if (subjectType === 'user' && request.subjectType !== 'user') {
141
+ return [
142
+ '❌ The platform did not create a user-subject connection request; nothing usable was created.',
143
+ `- returned subject: ${request.subjectType ?? 'unknown'}`,
144
+ 'Do not use an app-subject request for user-owned data. Record `Connection: pending user consent (UI-only)` and stop until the platform delegated-user exchange and user-subject requests are available.',
145
+ ].join('\n');
146
+ }
147
+
121
148
  return [
122
149
  request.status === 'approved'
123
150
  ? '## Connection already approved'
124
- : '## Connection request created — waiting for the app owner',
151
+ : request.subjectType === 'user'
152
+ ? "## Connection request created — waiting for the user's consent"
153
+ : '## Connection request created — waiting for the app owner',
125
154
  '',
126
155
  `- target: **${target.name}** (\`${target.agentId}\`)`,
156
+ `- request subject: ${request.subjectType === 'user' ? 'user-subject (user consent)' : 'app-subject (app owner)'}`,
127
157
  ...requestLines({ ...request, link: result.link ?? request.link }),
128
158
  ...(dropped.length
129
159
  ? ['', `⚠️ Dropped (not declared by the target manifest): ${dropped.join(', ')}`]
@@ -132,6 +162,7 @@ export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
132
162
  'The planner cannot approve this. Ask the user to click **Approve** in App Connections, or call `a2a_open_connection_request` to bring the panel forward.',
133
163
  'Do **not** write the connection id into a brief until `a2a_check_connections` reports an active connection — a pending request is not a grant.',
134
164
  'If the user replies that they approved, call `a2a_check_connections` to read the real connection id and scopes.',
165
+ 'For user-owned data the user consents in the platform panel; the app owner cannot consent for them.',
135
166
  ].join('\n');
136
167
  } catch (error: any) {
137
168
  return `❌ Error creating the connection request: ${error.message}`;
@@ -258,10 +289,16 @@ export function createA2AOpenConnectionRequestTool(client: ForLoopAPIClient) {
258
289
  if (!args.agentId) {
259
290
  return '❌ Provide a request id, or a target agentId (and scopes) so the request can be created.';
260
291
  }
292
+ const target = await findEntry(client, args.agentId);
293
+ if (!target) {
294
+ return `❌ Target \`${args.agentId}\` was not found in the registry. Do not invent a connection request.`;
295
+ }
296
+ const desiredSubject = subjectTypeFor(target);
261
297
  const state = (await client.getConnectionState(sprintId)) as ConnectionState;
262
298
  const wanted = new Set((args.scopes ?? []).map(scope => scope.trim()).filter(Boolean));
263
299
  const match = (state.requests ?? []).find(request => {
264
300
  if (request.targetAgentId !== args.agentId) return false;
301
+ if (request.subjectType !== desiredSubject) return false;
265
302
  if (request.status !== 'pending' && request.status !== 'approved') return false;
266
303
  if (!wanted.size) return true;
267
304
  return (
@@ -280,9 +317,16 @@ export function createA2AOpenConnectionRequestTool(client: ForLoopAPIClient) {
280
317
  sprintId,
281
318
  targetAgentId: args.agentId,
282
319
  scopes: [...wanted],
283
- subjectType: 'app',
320
+ subjectType: desiredSubject,
284
321
  requestedBy: 'planner',
285
322
  });
323
+ if (desiredSubject === 'user' && created.request?.subjectType !== 'user') {
324
+ return [
325
+ '❌ The platform did not create a user-subject connection request; nothing usable was created.',
326
+ `- returned subject: ${created.request?.subjectType ?? 'unknown'}`,
327
+ 'Do not use an app-subject request for user-owned data. Record `Connection: pending user consent (UI-only)` and stop.',
328
+ ].join('\n');
329
+ }
286
330
  requestId = created.request?.id ?? '';
287
331
  link = created.link ?? created.request?.link ?? null;
288
332
  }
@@ -25,6 +25,12 @@ type RegistryEntry = {
25
25
  directIntegrable?: boolean;
26
26
  };
27
27
 
28
+ // The target manifest is the source of truth for consent: a user-auth service
29
+ // only accepts a user-subject connection, so the planner must never create an
30
+ // app-subject request against it as a workaround.
31
+ const subjectTypeFor = (target: RegistryEntry): 'user' | 'app' =>
32
+ target.authMode === 'user' ? 'user' : 'app';
33
+
28
34
  const formatPrice = (entry: RegistryEntry): string => {
29
35
  if (entry.billingPrice == null) return 'price TBD';
30
36
  const currency = entry.billingCurrency || 'FLC';
@@ -168,7 +174,8 @@ export function createCoreServiceGetTool(client: ForLoopAPIClient) {
168
174
  `# ${args.agentId} — contract ${contract.version}`,
169
175
  `- status: ${status}`,
170
176
  `${sunset}${deprecated}`,
171
- `- checksum: ${contract.checksum ?? 'unknown'}`,
177
+ `- checksum: ${contract.checksum ?? 'unknown'} (stored raw manifest)`,
178
+ `- generatedChecksum: ${contract.generatedChecksum ?? 'unknown'} (normalized contract; matches contract-artifact.json)`,
172
179
  '',
173
180
  '## OpenAPI',
174
181
  '```json',
@@ -190,7 +197,7 @@ export function createCoreServiceGetTool(client: ForLoopAPIClient) {
190
197
  export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient) {
191
198
  return tool({
192
199
  description:
193
- 'Prepare the user-approval step for a core-service integration and return a deep link to the app’s App Connections panel plus the app-subject connection payload. Nothing is created or stored until the user clicks Connect. This never creates a connection, never touches credentials, and never constructs API URLs by hand.',
200
+ 'Start the user-approval step for a core-service integration: persist (or idempotently reuse) the pending connection request, then ask the signed-in user’s live client to open App Connections focused on it so they only need to click Approve. Falls back to the approval link when the client is offline. This never approves, never touches credentials, and never constructs API URLs by hand.',
194
201
  args: {
195
202
  agentId: tool.schema.string().describe('Target core-service agentId'),
196
203
  scopes: tool.schema
@@ -232,12 +239,13 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
232
239
  ].join('\n');
233
240
  }
234
241
 
242
+ const subjectType = subjectTypeFor(target)
235
243
  const resolution = await resolveSprintId(undefined, context?.directory);
236
244
  const callerSprintId = resolution.sprintId ?? null;
237
245
  const connectionPayload = {
238
246
  callerSprintId,
239
247
  targetAgentId: args.agentId,
240
- subjectType: 'app' as const,
248
+ subjectType,
241
249
  scopes: requested,
242
250
  };
243
251
 
@@ -261,44 +269,100 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
261
269
  const linkParams = new URLSearchParams();
262
270
  linkParams.set('connect', args.agentId);
263
271
  linkParams.set('scopes', requested.join(','));
264
- linkParams.set('subject', 'app');
265
- const connectUrl = spaceUrl ? `${spaceUrl}?${linkParams.toString()}` : null;
272
+ linkParams.set('subject', subjectType);
273
+ const fallbackConnectUrl = spaceUrl ? `${spaceUrl}?${linkParams.toString()}` : null;
274
+
275
+ // Persist the pending request and nudge the live client to open the
276
+ // panel. Both steps are best-effort: an offline client (or a backend
277
+ // without the request routes) must still return an actionable link.
278
+ let requestId: string | null = null;
279
+ let delivered = false;
280
+ let connectUrl: string | null = fallbackConnectUrl;
281
+ let dropped: string[] = [];
282
+ if (callerSprintId) {
283
+ try {
284
+ const created = await client.createConnectionRequest({
285
+ sprintId: callerSprintId,
286
+ targetAgentId: args.agentId,
287
+ scopes: requested,
288
+ subjectType,
289
+ requestedBy: 'planner',
290
+ reason: args.reason,
291
+ });
292
+ const request = created?.request ?? {};
293
+ if (subjectType === 'user' && request.subjectType !== 'user') {
294
+ return [
295
+ '❌ The platform did not create a user-subject connection request; nothing usable was created.',
296
+ `- returned subject: ${request.subjectType ?? 'unknown'}`,
297
+ 'Do not use an app-subject request for user-owned data. Record `Connection: pending user consent (UI-only)` and stop until the delegated-user exchange is available.',
298
+ ].join('\n');
299
+ }
300
+ requestId = typeof request.id === 'string' ? request.id : null;
301
+ dropped = Array.isArray(created?.droppedScopes) ? created.droppedScopes : [];
302
+ connectUrl = created?.link ?? request.link ?? connectUrl;
303
+
304
+ if (requestId) {
305
+ try {
306
+ const opened = await client.openConnectionRequest(requestId);
307
+ delivered = opened?.delivered === true;
308
+ connectUrl = opened?.link ?? opened?.fallback ?? connectUrl;
309
+ } catch {
310
+ delivered = false;
311
+ }
312
+ }
313
+ } catch {
314
+ // Older backend without request persistence: fall back to the link.
315
+ }
316
+ }
266
317
 
267
- const approvalSteps = connectUrl
318
+ const userSubject = subjectType === 'user';
319
+ const approver = userSubject ? 'user' : 'app owner';
320
+ const manualSteps = connectUrl
268
321
  ? [
269
322
  `1. Open ${connectUrl}`,
270
- ` This opens **${target.name}**'s connection panel in this app, with the scopes below and \`This app\` preselected.`,
323
+ ` This opens **${target.name}**'s connection panel in this app, with the scopes below and ${userSubject ? '`You`' : '`This app`'} preselected.`,
271
324
  '2. Review the service, scopes and price shown above.',
272
- '3. Click **Connect**.',
273
- '4. Copy the **Connection ID** shown under the new connected app into the integration brief.',
325
+ '3. Click **Approve**.',
274
326
  ]
275
327
  : [
276
328
  `1. Open the app's space${spaceUrl ? `: ${spaceUrl}` : ''}.`,
277
329
  '2. Open **Aivy Actions → App Connections (Beta)** and choose the **Connect** tab.',
278
- `3. Find **${target.name}**, tick the scopes below, choose **This app**, then click **Connect**.`,
279
- '4. Copy the **Connection ID** shown under the new connected app into the integration brief.',
330
+ `3. Find **${target.name}**, tick the scopes below, choose ${userSubject ? '**You**' : '**This app**'}, then click **Approve**.`,
280
331
  ];
332
+ const pendingLabel = userSubject ? 'pending user consent' : 'pending owner approval';
281
333
 
282
334
  return [
283
- `## Integration approval required — nothing is created yet`,
335
+ delivered
336
+ ? `## App Connections opened — waiting for the ${approver} to approve`
337
+ : `## Approval needed — the ${approver} must approve this request`,
284
338
  `- Service: **${target.name}** (\`${target.agentId}\`)`,
285
339
  `- serviceCode: ${target.serviceCode ?? 'unknown'}`,
340
+ `- subject: ${userSubject ? 'user-subject (user consent)' : 'app-subject (app owner)'}`,
286
341
  `- reason: ${args.reason}`,
287
342
  `- scopes: ${requested.join(', ')}`,
288
343
  `- cost: ${formatPrice(target)}`,
344
+ ...(requestId ? [`- requestId: \`${requestId}\``] : []),
345
+ ...(dropped.length
346
+ ? [`⚠️ Dropped (not declared by the target manifest): ${dropped.join(', ')}`]
347
+ : []),
289
348
  '',
290
- 'The planner cannot create or record connections. The grant exists only after the app owner clicks **Connect**; until then nothing is pending and the Permissions page stays empty.',
349
+ delivered
350
+ ? 'The App Connections panel is now open on the user’s screen, focused on this request. They only need to click **Approve** — do not paste a link or repeat manual steps.'
351
+ : 'The user’s client was not reachable. Send them this link — it opens the same focused panel so they only need to click **Approve**:',
352
+ ...(delivered ? [] : ['', ...manualSteps]),
291
353
  '',
292
- '### Approve now (creates the app-subject grant)',
293
- ...approvalSteps,
354
+ 'A pending request is not a grant. Do not write a connection id into the integration brief until `a2a_check_connections` reports an active connection.',
355
+ `After the ${approver} approves, call \`a2a_check_connections\` to read the real connection id and scopes.`,
294
356
  '',
295
- '### Connection payload (owner handoff)',
357
+ `### Connection payload (${approver} handoff)`,
296
358
  '```json',
297
359
  JSON.stringify(connectionPayload, null, 2),
298
360
  '```',
299
361
  '',
300
- 'Only the signed-in app owner may submit this payload to `POST /api/a2a/connections` with a Clerk session. Never send credentials or create the connection from the planner.',
301
- 'If the user cannot or does not complete the approval now, write `Connection: to be created during implementation` in the integration brief and continue — do not block the plan and do not ask again.',
362
+ 'The planner can never approve. Never send credentials or create the connection from the planner.',
363
+ userSubject
364
+ ? `If the user cannot or does not consent now, write \`Connection: ${pendingLabel}\` in the integration brief and continue — do not block the plan and do not ask again. The app owner cannot consent for the user.`
365
+ : `If the user cannot or does not complete the approval now, write \`Connection: ${pendingLabel}\` in the integration brief and continue — do not block the plan and do not ask again.`,
302
366
  ].join('\n');
303
367
  } catch (error: any) {
304
368
  return `❌ Error creating integration request: ${error.message}`;