@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 +2 -2
- package/plugins/capabilities/api-client.ts +43 -0
- package/plugins/forloop-plugin.ts +14 -0
- package/plugins/tools/a2a-connection-tools.ts +316 -0
- package/plugins/tools/ai-trigger-tools.ts +2 -2
- package/plugins/tools/core-service-tools.ts +89 -12
- package/plugins/tools/sync-tools.ts +17 -1
- package/plugins/tools/wallet-tools.ts +128 -0
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.
|
|
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": ">=
|
|
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('
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
256
|
-
`
|
|
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
|
|
338
|
+
'### Connection payload (owner handoff)',
|
|
262
339
|
'```json',
|
|
263
340
|
JSON.stringify(connectionPayload, null, 2),
|
|
264
341
|
'```',
|
|
265
342
|
'',
|
|
266
|
-
'
|
|
267
|
-
'If the
|
|
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
|
-
|
|
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
|
+
}
|