@forloop-cc/forloop-opencode-plugin-planner 2.2.24 → 2.2.26

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.24",
4
+ "version": "2.2.26",
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",
@@ -68,7 +68,7 @@
68
68
  "access": "public"
69
69
  },
70
70
  "engines": {
71
- "node": ">=18.0.0"
71
+ "node": ">=22.23.4"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@opencode-ai/plugin": "^1.3.0"
@@ -145,6 +145,7 @@ export class ForLoopAPIClient {
145
145
  'organization:write',
146
146
  'organization:delete',
147
147
  'a2a:read',
148
+ 'a2a:write',
148
149
  ];
149
150
 
150
151
  const name = `lambda-planner-${Date.now()}`;
@@ -270,6 +271,23 @@ export class ForLoopAPIClient {
270
271
  return Array.isArray(response) ? response : (response.data || []);
271
272
  }
272
273
 
274
+ /**
275
+ * Read-only APP wallet snapshot for the sprint (the payer for paid core
276
+ * services). The server scopes it to the sprint publicId and never moves
277
+ * funds.
278
+ */
279
+ async getAppWallet(sprintId: number): Promise<{
280
+ sprintId: number;
281
+ appPublicId: string;
282
+ currency: string;
283
+ available: number;
284
+ reserved: number;
285
+ spent: number;
286
+ spendCap: number | null;
287
+ }> {
288
+ return this.request(`/api/opencode/sprints/${sprintId}/wallet`);
289
+ }
290
+
273
291
  async getSprint(id: number, options?: SprintOptions): Promise<any> {
274
292
  const query = new URLSearchParams({
275
293
  includeStories: options?.includeStories !== false ? 'true' : 'false',
@@ -397,6 +415,31 @@ export class ForLoopAPIClient {
397
415
  );
398
416
  }
399
417
 
418
+ async createConnectionRequest(params: {
419
+ sprintId: number;
420
+ targetAgentId: string;
421
+ scopes: string[];
422
+ subjectType?: 'user' | 'app';
423
+ requestedBy?: 'planner' | 'agent';
424
+ reason?: string;
425
+ }): Promise<any> {
426
+ return this.request('/api/opencode/a2a/connection-requests', {
427
+ method: 'POST',
428
+ body: JSON.stringify(params),
429
+ });
430
+ }
431
+
432
+ async getConnectionState(sprintId: number): Promise<any> {
433
+ return this.request(`/api/opencode/a2a/connection-state?sprintId=${sprintId}`);
434
+ }
435
+
436
+ async openConnectionRequest(requestId: string): Promise<any> {
437
+ return this.request(
438
+ `/api/opencode/a2a/connection-requests/${encodeURIComponent(requestId)}/open`,
439
+ { method: 'POST' }
440
+ );
441
+ }
442
+
400
443
  async summarizeConversation(sprintId: number): Promise<any> {
401
444
  return this.request(`/api/opencode/sprints/${sprintId}/summarize`, {
402
445
  method: 'POST',
@@ -73,6 +73,12 @@ import {
73
73
  createCoreServiceGetTool,
74
74
  createCoreServiceIntegrationRequestTool,
75
75
  } from './tools/core-service-tools';
76
+ import { createA2ACheckWalletTool } from './tools/wallet-tools';
77
+ import {
78
+ createA2ARequestConnectionTool,
79
+ createA2ACheckConnectionsTool,
80
+ createA2AOpenConnectionRequestTool,
81
+ } from './tools/a2a-connection-tools';
76
82
  import { injectEnvironment } from './hooks/shell-env';
77
83
  import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
78
84
 
@@ -177,6 +183,14 @@ export default async (ctx: any) => {
177
183
  'core_services_search': createCoreServicesSearchTool(client),
178
184
  'core_service_get': createCoreServiceGetTool(client),
179
185
  'core_service_integration_request': createCoreServiceIntegrationRequestTool(client),
186
+
187
+ // Read-only paid-integration pre-flight (balance + price + shortfall)
188
+ 'a2a_check_wallet': createA2ACheckWalletTool(client),
189
+
190
+ // Persisted A2A connection requests (planner can request/read, never approve)
191
+ 'a2a_request_connection': createA2ARequestConnectionTool(client),
192
+ 'a2a_check_connections': createA2ACheckConnectionsTool(client),
193
+ 'a2a_open_connection_request': createA2AOpenConnectionRequestTool(client),
180
194
  },
181
195
  event: createEventHook(client),
182
196
  'chat.message': createChatMessageHook(client),
@@ -0,0 +1,316 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { validateToken } from '../capabilities/auth';
4
+ import { resolveSprintId } from '../capabilities/context-resolver';
5
+
6
+ type RegistryEntry = {
7
+ agentId: string;
8
+ name: string;
9
+ serviceCode?: string | null;
10
+ };
11
+
12
+ type RequestWire = {
13
+ id: string;
14
+ targetAgentId: string;
15
+ targetSprintId: number;
16
+ scopes: string[];
17
+ subjectType: 'user' | 'app';
18
+ requestedBy: string;
19
+ reason?: string | null;
20
+ status: 'pending' | 'approved' | 'denied' | 'expired';
21
+ connectionId?: string | null;
22
+ expiresAt?: string | null;
23
+ link?: string | null;
24
+ };
25
+
26
+ type ConnectionState = {
27
+ sprintId: number;
28
+ callerPublicId: string | null;
29
+ connections: Array<{
30
+ connectionId: string;
31
+ targetSprintId: number;
32
+ targetAgentId: string | null;
33
+ subjectType: string;
34
+ scopes: string[];
35
+ status: string;
36
+ autoApprove?: boolean;
37
+ expiresAt?: string | null;
38
+ }>;
39
+ requests: RequestWire[];
40
+ };
41
+
42
+ const findEntry = async (client: ForLoopAPIClient, agentId: string): Promise<RegistryEntry | undefined> => {
43
+ const found = await client.searchCoreServices({ query: agentId, limit: 50 });
44
+ return (found.entries ?? []).find((entry: RegistryEntry) => entry.agentId === agentId);
45
+ };
46
+
47
+ const resolveWorkingSprint = async (context: any): Promise<number | null> => {
48
+ const resolution = await resolveSprintId(undefined, context?.directory);
49
+ return resolution.sprintId ?? null;
50
+ };
51
+
52
+ const requestLines = (request: RequestWire): string[] => {
53
+ const lines = [
54
+ `- requestId: \`${request.id}\``,
55
+ `- target: \`${request.targetAgentId}\``,
56
+ `- scopes: ${request.scopes.join(', ') || 'none'}`,
57
+ `- subject: ${request.subjectType === 'app' ? 'this app' : 'you'}`,
58
+ `- requestedBy: ${request.requestedBy}`,
59
+ `- status: **${request.status}**`,
60
+ ];
61
+ if (request.connectionId) lines.push(`- connectionId: \`${request.connectionId}\``);
62
+ // Expiry is an approval deadline for a PENDING request only. An approved
63
+ // request keeps the row for audit; its old 7-day window must not be read as
64
+ // the connection's lifetime.
65
+ if (request.status === 'pending' && request.expiresAt) {
66
+ lines.push(`- approval window expiresAt: ${request.expiresAt}`);
67
+ }
68
+ if (request.reason) lines.push(`- reason: ${request.reason}`);
69
+ lines.push(`- approval link: ${request.link || '(open App Connections → Connect)'}`);
70
+ return lines;
71
+ };
72
+
73
+ // Planner-side write: records a request for the working sprint's app owner.
74
+ // It never creates an A2AConnection and cannot approve.
75
+ export function createA2ARequestConnectionTool(client: ForLoopAPIClient) {
76
+ return tool({
77
+ 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.',
79
+ args: {
80
+ agentId: tool.schema.string().describe('Target service/peer agentId (public id)'),
81
+ scopes: tool.schema
82
+ .array(tool.schema.string())
83
+ .describe('Exact scopes the integration needs (clamped to the target manifest)'),
84
+ reason: tool.schema
85
+ .string()
86
+ .optional()
87
+ .describe('Why the app needs this connection (shown to the approving user)'),
88
+ },
89
+ async execute(args, context) {
90
+ const tokenResult = await validateToken();
91
+ if (!tokenResult.valid) return tokenResult.error;
92
+ client.setToken?.(tokenResult.token);
93
+
94
+ try {
95
+ const target = await findEntry(client, args.agentId);
96
+ if (!target) {
97
+ return `❌ Target \`${args.agentId}\` was not found in the registry. Do not invent a connection request.`;
98
+ }
99
+ const sprintId = await resolveWorkingSprint(context);
100
+ if (!sprintId) {
101
+ return '❌ Could not resolve the working sprint. Open the planner from an app space, then retry.';
102
+ }
103
+ const requested = Array.from(
104
+ new Set((args.scopes ?? []).map(scope => scope.trim()).filter(Boolean))
105
+ );
106
+ if (!requested.length) {
107
+ return `❌ No scopes requested for \`${args.agentId}\`. Nothing was created.`;
108
+ }
109
+
110
+ const result = await client.createConnectionRequest({
111
+ sprintId,
112
+ targetAgentId: args.agentId,
113
+ scopes: requested,
114
+ subjectType: 'app',
115
+ requestedBy: 'planner',
116
+ ...(args.reason ? { reason: args.reason } : {}),
117
+ });
118
+ const request = result.request as RequestWire;
119
+ const dropped = Array.isArray(result.droppedScopes) ? result.droppedScopes : [];
120
+
121
+ return [
122
+ request.status === 'approved'
123
+ ? '## Connection already approved'
124
+ : '## Connection request created — waiting for the app owner',
125
+ '',
126
+ `- target: **${target.name}** (\`${target.agentId}\`)`,
127
+ ...requestLines({ ...request, link: result.link ?? request.link }),
128
+ ...(dropped.length
129
+ ? ['', `⚠️ Dropped (not declared by the target manifest): ${dropped.join(', ')}`]
130
+ : []),
131
+ '',
132
+ '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
+ '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
+ 'If the user replies that they approved, call `a2a_check_connections` to read the real connection id and scopes.',
135
+ ].join('\n');
136
+ } catch (error: any) {
137
+ return `❌ Error creating the connection request: ${error.message}`;
138
+ }
139
+ },
140
+ });
141
+ }
142
+
143
+ // Planner-side read: the working sprint's real connections + request statuses.
144
+ // This is how the planner learns the connection id after the user approves.
145
+ export function createA2ACheckConnectionsTool(client: ForLoopAPIClient) {
146
+ return tool({
147
+ description:
148
+ 'Read the current sprint’s active A2A connections (real connection ids + granted scopes) and its connection requests with statuses. Use after the user says they approved, and before writing a brief that references a connection id. Read-only.',
149
+ args: {
150
+ sprintId: tool.schema
151
+ .number()
152
+ .optional()
153
+ .describe('Sprint to inspect; defaults to the working sprint'),
154
+ },
155
+ async execute(args, context) {
156
+ const tokenResult = await validateToken();
157
+ if (!tokenResult.valid) return tokenResult.error;
158
+ client.setToken?.(tokenResult.token);
159
+
160
+ try {
161
+ const sprintId = args.sprintId ?? (await resolveWorkingSprint(context));
162
+ if (!sprintId) return '❌ Could not resolve the working sprint.';
163
+
164
+ const state = (await client.getConnectionState(sprintId)) as ConnectionState;
165
+ const connections = state.connections ?? [];
166
+ const requests = state.requests ?? [];
167
+ const lines: string[] = [];
168
+
169
+ if (!connections.length) {
170
+ lines.push('## Active connections', '', 'None. No integration can be called yet.');
171
+ } else {
172
+ lines.push(`## Active connections (${connections.length})`, '');
173
+ for (const connection of connections) {
174
+ lines.push(
175
+ `- Connection ID: \`${connection.connectionId}\``,
176
+ ` - target: \`${connection.targetAgentId ?? connection.targetSprintId}\` (sprint ${connection.targetSprintId})`,
177
+ ` - scopes: ${connection.scopes.join(', ') || 'none'}`,
178
+ ` - subject: ${connection.subjectType === 'app' ? 'this app' : 'the user'}`,
179
+ ` - status: ${connection.status}${connection.autoApprove ? ' (always allow)' : ''}`,
180
+ );
181
+ const expiresAt = connection.expiresAt ? new Date(connection.expiresAt) : null;
182
+ if (!expiresAt || Number.isNaN(expiresAt.getTime())) {
183
+ // Approved grants are permanent by default; only revocation (or a
184
+ // target disconnect/delete) ends them.
185
+ lines.push(' - expires: no expiry (until revoked)');
186
+ } else {
187
+ const daysLeft = Math.ceil((expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000));
188
+ const renewHint = daysLeft <= 14
189
+ ? ' — renews automatically on use; re-request if it lapses'
190
+ : '';
191
+ lines.push(` - expires: ${expiresAt.toISOString()} (${daysLeft} day${daysLeft === 1 ? '' : 's'} left${renewHint})`);
192
+ }
193
+ }
194
+ }
195
+
196
+ lines.push('', `## Connection requests (${requests.length})`, '');
197
+ if (!requests.length) {
198
+ lines.push('None.');
199
+ } else {
200
+ for (const request of requests) {
201
+ lines.push(...requestLines(request), '');
202
+ }
203
+ }
204
+
205
+ const pending = requests.filter(request => request.status === 'pending');
206
+ const approved = requests.filter(request => request.status === 'approved' && request.connectionId);
207
+ if (pending.length) {
208
+ lines.push(
209
+ `${pending.length} request${pending.length === 1 ? ' is' : 's are'} still pending. The user must approve before the integration exists.`,
210
+ );
211
+ } else if (approved.length) {
212
+ lines.push(
213
+ 'Approved request(s) above carry the real connection id — use that id and its granted scopes in the brief. Do not widen the scopes.',
214
+ );
215
+ }
216
+ return lines.join('\n');
217
+ } catch (error: any) {
218
+ return `❌ Error reading connections: ${error.message}`;
219
+ }
220
+ },
221
+ });
222
+ }
223
+
224
+ // Planner-side nudge: ask the user's client to open App Connections focused on
225
+ // the pending request (or create one from target+scopes first). The link is
226
+ // ALWAYS returned so an offline client still gets an actionable fallback.
227
+ export function createA2AOpenConnectionRequestTool(client: ForLoopAPIClient) {
228
+ return tool({
229
+ description:
230
+ 'Open the user’s App Connections panel focused on a pending connection request, so they only need to click Approve. Accepts a request id or target+scopes (creates the request if needed). Emits a platform socket action to the live client; when the client is offline the response still contains the request id and the approval link. This never approves.',
231
+ args: {
232
+ requestId: tool.schema
233
+ .string()
234
+ .optional()
235
+ .describe('Existing connection request id'),
236
+ agentId: tool.schema
237
+ .string()
238
+ .optional()
239
+ .describe('Target agentId when the request id is unknown'),
240
+ scopes: tool.schema
241
+ .array(tool.schema.string())
242
+ .optional()
243
+ .describe('Exact scopes for the request when creating/looking it up'),
244
+ },
245
+ async execute(args, context) {
246
+ const tokenResult = await validateToken();
247
+ if (!tokenResult.valid) return tokenResult.error;
248
+ client.setToken?.(tokenResult.token);
249
+
250
+ try {
251
+ const sprintId = await resolveWorkingSprint(context);
252
+ if (!sprintId) return '❌ Could not resolve the working sprint.';
253
+
254
+ let requestId = args.requestId?.trim() || '';
255
+ let link: string | null = null;
256
+
257
+ if (!requestId) {
258
+ if (!args.agentId) {
259
+ return '❌ Provide a request id, or a target agentId (and scopes) so the request can be created.';
260
+ }
261
+ const state = (await client.getConnectionState(sprintId)) as ConnectionState;
262
+ const wanted = new Set((args.scopes ?? []).map(scope => scope.trim()).filter(Boolean));
263
+ const match = (state.requests ?? []).find(request => {
264
+ if (request.targetAgentId !== args.agentId) return false;
265
+ if (request.status !== 'pending' && request.status !== 'approved') return false;
266
+ if (!wanted.size) return true;
267
+ return (
268
+ request.scopes.length === wanted.size &&
269
+ request.scopes.every(scope => wanted.has(scope))
270
+ );
271
+ });
272
+ if (match) {
273
+ requestId = match.id;
274
+ link = match.link ?? null;
275
+ } else {
276
+ if (!wanted.size) {
277
+ return `❌ No pending request for \`${args.agentId}\`. Call \`a2a_request_connection\` with scopes first.`;
278
+ }
279
+ const created = await client.createConnectionRequest({
280
+ sprintId,
281
+ targetAgentId: args.agentId,
282
+ scopes: [...wanted],
283
+ subjectType: 'app',
284
+ requestedBy: 'planner',
285
+ });
286
+ requestId = created.request?.id ?? '';
287
+ link = created.link ?? created.request?.link ?? null;
288
+ }
289
+ }
290
+
291
+ if (!requestId) return '❌ Could not create or find a connection request.';
292
+
293
+ const opened = await client.openConnectionRequest(requestId);
294
+ const finalLink = opened.link ?? link;
295
+ const delivered = opened.delivered === true;
296
+
297
+ return [
298
+ delivered
299
+ ? '## App Connections opened for the user'
300
+ : '## Link ready — the user’s client was not reachable',
301
+ '',
302
+ `- requestId: \`${opened.requestId ?? requestId}\``,
303
+ `- socket delivered: ${delivered ? 'yes' : 'no'}`,
304
+ `- approval link: ${finalLink || '(open App Connections → Connect)'}`,
305
+ '',
306
+ delivered
307
+ ? 'The panel should now be focused on this request. The user still has to click **Approve** — the planner cannot approve.'
308
+ : 'Send the approval link to the user. It opens App Connections focused on this request so they only need to click **Approve**.',
309
+ 'After they approve, call `a2a_check_connections` to resolve the real connection id and scopes.',
310
+ ].join('\n');
311
+ } catch (error: any) {
312
+ return `❌ Error opening the connection request: ${error.message}`;
313
+ }
314
+ },
315
+ });
316
+ }
@@ -20,13 +20,13 @@ export function createAITriggerTools(client: ForLoopAPIClient) {
20
20
  */
21
21
  function createDeveloperSprintTool(client: ForLoopAPIClient) {
22
22
  return tool({
23
- description: 'Trigger forLoopTaskSupervisor agent to execute space work. This sends stories to the developer agent for implementation via EventBridge → Step Functions → ECS Fargate.',
23
+ description: 'Trigger forLoopTaskSupervisor agent to execute space work. This sends stories to the developer agent for implementation via EventBridge → Step Functions → ECS Fargate. When an approved design preview exists, include `Approved design preview: File #<id> <fileName>` (and its story Design Reference) in the message so the Developer bases the UI on it.',
24
24
  args: {
25
25
  sprintId: tool.schema.number()
26
26
  .describe('Sprint ID to execute'),
27
27
  message: tool.schema.string()
28
28
  .optional()
29
- .describe('Optional instructions for the developer agent')
29
+ .describe('Instructions for the developer agent — include the approved design File #ID/name and the sprint plan file')
30
30
  .default('Start implementing the sprint stories assigned to the developer agent'),
31
31
  },
32
32
  async execute(args, _context) {
@@ -190,7 +190,7 @@ export function createCoreServiceGetTool(client: ForLoopAPIClient) {
190
190
  export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient) {
191
191
  return tool({
192
192
  description:
193
- 'Record an integration request for a core service and return the exact user-approval steps plus the app-subject connection payload. This never creates a connection, never touches credentials, and never constructs URLs by hand.',
193
+ '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
194
  args: {
195
195
  agentId: tool.schema.string().describe('Target core-service agentId'),
196
196
  scopes: tool.schema
@@ -240,31 +240,108 @@ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient
240
240
  subjectType: 'app' as const,
241
241
  scopes: requested,
242
242
  };
243
- const connectUrl = `${clientBaseUrl()}/myspace?connect=${encodeURIComponent(args.agentId)}`;
243
+
244
+ let callerPublicId: string | null = null;
245
+ if (callerSprintId && typeof client.getSprint === 'function') {
246
+ try {
247
+ const callerSprint = await client.getSprint(callerSprintId, {
248
+ includeStories: false,
249
+ includeFiles: false,
250
+ });
251
+ callerPublicId = typeof callerSprint?.publicId === 'string' ? callerSprint.publicId : null;
252
+ } catch {
253
+ // A missing space lookup must not hide the approval instructions.
254
+ callerPublicId = null;
255
+ }
256
+ }
257
+
258
+ const spaceUrl = callerPublicId
259
+ ? `${clientBaseUrl()}/myspace/${encodeURIComponent(callerPublicId)}`
260
+ : null;
261
+ const linkParams = new URLSearchParams();
262
+ linkParams.set('connect', args.agentId);
263
+ linkParams.set('scopes', requested.join(','));
264
+ linkParams.set('subject', 'app');
265
+ const fallbackConnectUrl = spaceUrl ? `${spaceUrl}?${linkParams.toString()}` : null;
266
+
267
+ // Persist the pending request and nudge the live client to open the
268
+ // panel. Both steps are best-effort: an offline client (or a backend
269
+ // without the request routes) must still return an actionable link.
270
+ let requestId: string | null = null;
271
+ let delivered = false;
272
+ let connectUrl: string | null = fallbackConnectUrl;
273
+ let dropped: string[] = [];
274
+ if (callerSprintId) {
275
+ try {
276
+ const created = await client.createConnectionRequest({
277
+ sprintId: callerSprintId,
278
+ targetAgentId: args.agentId,
279
+ scopes: requested,
280
+ subjectType: 'app',
281
+ requestedBy: 'planner',
282
+ reason: args.reason,
283
+ });
284
+ const request = created?.request ?? {};
285
+ requestId = typeof request.id === 'string' ? request.id : null;
286
+ dropped = Array.isArray(created?.droppedScopes) ? created.droppedScopes : [];
287
+ connectUrl = created?.link ?? request.link ?? connectUrl;
288
+
289
+ if (requestId) {
290
+ try {
291
+ const opened = await client.openConnectionRequest(requestId);
292
+ delivered = opened?.delivered === true;
293
+ connectUrl = opened?.link ?? opened?.fallback ?? connectUrl;
294
+ } catch {
295
+ delivered = false;
296
+ }
297
+ }
298
+ } catch {
299
+ // Older backend without request persistence: fall back to the link.
300
+ }
301
+ }
302
+
303
+ const manualSteps = connectUrl
304
+ ? [
305
+ `1. Open ${connectUrl}`,
306
+ ` This opens **${target.name}**'s connection panel in this app, with the scopes below and \`This app\` preselected.`,
307
+ '2. Review the service, scopes and price shown above.',
308
+ '3. Click **Approve**.',
309
+ ]
310
+ : [
311
+ `1. Open the app's space${spaceUrl ? `: ${spaceUrl}` : ''}.`,
312
+ '2. Open **Aivy Actions → App Connections (Beta)** and choose the **Connect** tab.',
313
+ `3. Find **${target.name}**, tick the scopes below, choose **This app**, then click **Approve**.`,
314
+ ];
244
315
 
245
316
  return [
246
- `## Integration request — pending user approval`,
317
+ delivered
318
+ ? '## App Connections opened — waiting for the app owner to approve'
319
+ : '## Approval needed — the app owner must approve this request',
247
320
  `- Service: **${target.name}** (\`${target.agentId}\`)`,
248
321
  `- serviceCode: ${target.serviceCode ?? 'unknown'}`,
249
322
  `- reason: ${args.reason}`,
250
323
  `- scopes: ${requested.join(', ')}`,
251
324
  `- cost: ${formatPrice(target)}`,
325
+ ...(requestId ? [`- requestId: \`${requestId}\``] : []),
326
+ ...(dropped.length
327
+ ? [`⚠️ Dropped (not declared by the target manifest): ${dropped.join(', ')}`]
328
+ : []),
252
329
  '',
253
- 'The planner does not create connections or handle credentials. Ask the user to approve this request in the UI.',
330
+ delivered
331
+ ? '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.'
332
+ : 'The user’s client was not reachable. Send them this link — it opens the same focused panel so they only need to click **Approve**:',
333
+ ...(delivered ? [] : ['', ...manualSteps]),
254
334
  '',
255
- '### Approve in the UI',
256
- `1. Open ${connectUrl}`,
257
- '2. Review the service, scopes and price shown above.',
258
- '3. Approve the app-subject connection.',
259
- '4. Copy the resulting connection id into the integration brief.',
335
+ '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.',
336
+ 'After the user approves, call `a2a_check_connections` to read the real connection id and scopes.',
260
337
  '',
261
- '### Connection payload (owner/developer handoff)',
338
+ '### Connection payload (owner handoff)',
262
339
  '```json',
263
340
  JSON.stringify(connectionPayload, null, 2),
264
341
  '```',
265
342
  '',
266
- 'Only the owner/developer may submit this payload to `POST /api/a2a/connections` (Clerk session auth). Never send credentials or create the connection from the planner.',
267
- 'If the UI cannot set `subjectType: "app"`, hand this payload to the user and wait for the connection id before writing the integration brief.',
343
+ 'The planner can never approve. Never send credentials or create the connection from the planner.',
344
+ 'If the user cannot or does not complete the approval now, write `Connection: pending owner approval` in the integration brief and continue — do not block the plan and do not ask again.',
268
345
  ].join('\n');
269
346
  } catch (error: any) {
270
347
  return `❌ Error creating integration request: ${error.message}`;
@@ -134,6 +134,9 @@ export function createS3ToLocalSyncTool(client: ForLoopAPIClient) {
134
134
  syncTasks: tool.schema.boolean()
135
135
  .default(true)
136
136
  .describe('Sync task files'),
137
+ syncDesign: tool.schema.boolean()
138
+ .default(true)
139
+ .describe('Sync approved design previews/artifacts (project/design)'),
137
140
  overwrite: tool.schema.boolean()
138
141
  .default(false)
139
142
  .describe('Overwrite local files even if size matches'),
@@ -168,6 +171,9 @@ export function createS3ToLocalSyncTool(client: ForLoopAPIClient) {
168
171
  if (args.syncKnowledge) selectedKinds.add('knowledge');
169
172
  if (args.syncPlans) selectedKinds.add('plan');
170
173
  if (args.syncTasks) selectedKinds.add('task');
174
+ // Design sync is on unless explicitly disabled, so a caller that only
175
+ // asks for plans/knowledge still gets the approved preview.
176
+ if (args.syncDesign !== false) selectedKinds.add('design');
171
177
 
172
178
  const byRelPath = new Map<string, any>();
173
179
  for (const item of candidates) {
@@ -384,6 +390,7 @@ function ensureForloopStructure(forloopRoot: string, sprintId?: number) {
384
390
  fs.mkdirSync(path.join(forloopRoot, 'knowledge'), { recursive: true });
385
391
  fs.mkdirSync(path.join(forloopRoot, 'plan'), { recursive: true });
386
392
  fs.mkdirSync(path.join(forloopRoot, 'task'), { recursive: true });
393
+ fs.mkdirSync(path.join(forloopRoot, 'design'), { recursive: true });
387
394
  }
388
395
 
389
396
  fs.mkdirSync(path.join(getForloopRootPath(), 'sync'), { recursive: true });
@@ -438,10 +445,13 @@ function inferRemoteFolderFromRelPath(relPath: string): string {
438
445
  if (first === 'knowledge') return 'project/knowledge';
439
446
  if (first === 'plan') return 'project/plans';
440
447
  if (first === 'task') return 'project/tasks';
448
+ if (first === 'design') return 'project/design';
441
449
  return 'project';
442
450
  }
443
451
 
444
- function mapRemoteFileToLocalRelPath(file: any): { kind: 'knowledge' | 'plan' | 'task'; folder: string; relPath: string } | null {
452
+ type SyncKind = 'knowledge' | 'plan' | 'task' | 'design';
453
+
454
+ function mapRemoteFileToLocalRelPath(file: any): { kind: SyncKind; folder: string; relPath: string } | null {
445
455
  const storageKey = String(file?.storageKey || file?.s3Url || file?.url || '');
446
456
  const name = String(file?.originalName || file?.filename || '').trim();
447
457
  const lowerName = name.toLowerCase();
@@ -456,6 +466,9 @@ function mapRemoteFileToLocalRelPath(file: any): { kind: 'knowledge' | 'plan' |
456
466
  if (keyLower.includes('/project/tasks/')) {
457
467
  return { kind: 'task', folder: 'project/tasks', relPath: path.join('task', path.basename(name || 'remote')) };
458
468
  }
469
+ if (keyLower.includes('/project/design/')) {
470
+ return { kind: 'design', folder: 'project/design', relPath: path.join('design', path.basename(name || 'remote')) };
471
+ }
459
472
 
460
473
  if (lowerName.startsWith('knowledge-')) {
461
474
  return { kind: 'knowledge', folder: 'project/knowledge', relPath: path.join('knowledge', path.basename(name)) };
@@ -466,6 +479,9 @@ function mapRemoteFileToLocalRelPath(file: any): { kind: 'knowledge' | 'plan' |
466
479
  if (lowerName.startsWith('task-')) {
467
480
  return { kind: 'task', folder: 'project/tasks', relPath: path.join('task', path.basename(name)) };
468
481
  }
482
+ if (lowerName.startsWith('design-') || lowerName.endsWith('-preview.html') || lowerName === 'preview.html') {
483
+ return { kind: 'design', folder: 'project/design', relPath: path.join('design', path.basename(name)) };
484
+ }
469
485
 
470
486
  return null;
471
487
  }
@@ -0,0 +1,128 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { validateToken } from '../capabilities/auth';
4
+ import { resolveSprintId } from '../capabilities/context-resolver';
5
+
6
+ type RegistryEntry = {
7
+ agentId: string;
8
+ name: string;
9
+ serviceCode?: string | null;
10
+ kind?: 'system' | 'user';
11
+ billingPrice?: number | null;
12
+ billingUnit?: string | null;
13
+ billingCurrency?: string | null;
14
+ };
15
+
16
+ const formatAmount = (value: number, currency: string): string => `${value} ${currency}`;
17
+
18
+ /**
19
+ * Read-only pre-flight for paid core-service integrations. A paid service
20
+ * debits the app wallet (APP:<sprint publicId>); discovering a shortfall here
21
+ * turns a runtime 402 into an explicit "top up" ask before the Developer starts.
22
+ */
23
+ export function createA2ACheckWalletTool(client: ForLoopAPIClient) {
24
+ return tool({
25
+ description:
26
+ 'Read the app wallet balance and, for a target core service, its advertised price and the estimated cost/shortfall. Use after choosing a paid core service and before writing the integration brief or starting the Developer. Read-only: it never moves funds.',
27
+ args: {
28
+ agentId: tool.schema
29
+ .string()
30
+ .optional()
31
+ .describe('Target core-service agentId (from core_services_search); omit to read only the balance'),
32
+ quantity: tool.schema
33
+ .number()
34
+ .optional()
35
+ .describe('Expected billable units for the estimate (default 1; e.g. emails or deliveries)'),
36
+ },
37
+ async execute(args, context) {
38
+ const tokenResult = await validateToken();
39
+ if (!tokenResult.valid) {
40
+ return tokenResult.error;
41
+ }
42
+ client.setToken?.(tokenResult.token);
43
+
44
+ try {
45
+ const resolution = await resolveSprintId(undefined, context?.directory);
46
+ if (!resolution.sprintId) {
47
+ return '❌ Could not resolve the app space, so the app wallet cannot be read. Ask the user which app this is for (or set the sprint context) and try again.';
48
+ }
49
+
50
+ const wallet = await client.getAppWallet(resolution.sprintId);
51
+ const currency = wallet.currency || 'FLC';
52
+ const quantity =
53
+ typeof args.quantity === 'number' && Number.isFinite(args.quantity) && args.quantity > 0
54
+ ? Math.floor(args.quantity)
55
+ : 1;
56
+
57
+ const lines = [
58
+ '# App wallet',
59
+ `- appPublicId: \`${wallet.appPublicId}\``,
60
+ `- available: ${formatAmount(wallet.available, currency)}`,
61
+ `- reserved: ${formatAmount(wallet.reserved, currency)}`,
62
+ `- spent: ${formatAmount(wallet.spent, currency)}`,
63
+ `- spend cap: ${wallet.spendCap === null ? 'none' : formatAmount(wallet.spendCap, currency)}`,
64
+ ];
65
+
66
+ if (!args.agentId?.trim()) {
67
+ lines.push(
68
+ '',
69
+ 'No target service was supplied; only the wallet balance is shown. Pass `agentId` from `core_services_search` to include the price and shortfall.'
70
+ );
71
+ return lines.join('\n');
72
+ }
73
+
74
+ const found = await client.searchCoreServices({ query: args.agentId, limit: 50 });
75
+ const target = (found.entries ?? []).find(
76
+ (entry: RegistryEntry) => entry.agentId === args.agentId
77
+ ) as RegistryEntry | undefined;
78
+
79
+ if (!target) {
80
+ lines.push(
81
+ '',
82
+ `⚠️ Target \`${args.agentId}\` was not found in the registry. Do not invent a price; re-run \`core_services_search\`.`
83
+ );
84
+ return lines.join('\n');
85
+ }
86
+
87
+ const unit = target.billingUnit ? ` per ${target.billingUnit}` : ' per success';
88
+ lines.push('', `## Target: ${target.name} (\`${target.serviceCode ?? target.agentId}\`)`);
89
+
90
+ if (target.billingPrice === null || target.billingPrice === undefined) {
91
+ lines.push(
92
+ '- price TBD (not published in the registry yet)',
93
+ '',
94
+ 'Price is unknown, so no shortfall can be computed. Check again after the service publishes a price, or ask the user to confirm the expected cost. This check is read-only.'
95
+ );
96
+ return lines.join('\n');
97
+ }
98
+
99
+ const price = target.billingPrice;
100
+ const priceCurrency = target.billingCurrency || currency;
101
+ const estimate = price * quantity;
102
+ const shortfall = Math.max(0, estimate - wallet.available);
103
+
104
+ lines.push(
105
+ `- unit price: ${formatAmount(price, priceCurrency)}${unit}`,
106
+ `- estimate (${quantity}): ${formatAmount(estimate, priceCurrency)}`,
107
+ `- shortfall: ${formatAmount(shortfall, priceCurrency)}`
108
+ );
109
+
110
+ if (shortfall > 0) {
111
+ lines.push(
112
+ '',
113
+ '⚠️ The app wallet is short. Tell the user to top up the app wallet before the Developer starts (App → Credits, or the owner-side `POST /api/ledger/app/:appId/fund`). Starting the Developer now would fail with `402 insufficient_balance`. This check is read-only and does not move funds.'
114
+ );
115
+ } else {
116
+ lines.push(
117
+ '',
118
+ 'The wallet covers this estimate. Re-run this check before a large batch; prices and balances change. This check is read-only and does not move funds.'
119
+ );
120
+ }
121
+
122
+ return lines.join('\n');
123
+ } catch (error: any) {
124
+ return `❌ Error checking the app wallet: ${error.message}`;
125
+ }
126
+ },
127
+ });
128
+ }