@forloop-cc/forloop-opencode-plugin-planner 2.2.23 → 2.2.25
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 +71 -0
- package/plugins/forloop-plugin.ts +24 -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 +308 -0
- 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.25",
|
|
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"
|
|
@@ -144,6 +144,8 @@ export class ForLoopAPIClient {
|
|
|
144
144
|
'organization:read',
|
|
145
145
|
'organization:write',
|
|
146
146
|
'organization:delete',
|
|
147
|
+
'a2a:read',
|
|
148
|
+
'a2a:write',
|
|
147
149
|
];
|
|
148
150
|
|
|
149
151
|
const name = `lambda-planner-${Date.now()}`;
|
|
@@ -269,6 +271,23 @@ export class ForLoopAPIClient {
|
|
|
269
271
|
return Array.isArray(response) ? response : (response.data || []);
|
|
270
272
|
}
|
|
271
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
|
+
|
|
272
291
|
async getSprint(id: number, options?: SprintOptions): Promise<any> {
|
|
273
292
|
const query = new URLSearchParams({
|
|
274
293
|
includeStories: options?.includeStories !== false ? 'true' : 'false',
|
|
@@ -369,6 +388,58 @@ export class ForLoopAPIClient {
|
|
|
369
388
|
});
|
|
370
389
|
}
|
|
371
390
|
|
|
391
|
+
async searchCoreServices(params: {
|
|
392
|
+
query?: string;
|
|
393
|
+
tool?: string;
|
|
394
|
+
directOnly?: boolean;
|
|
395
|
+
limit?: number;
|
|
396
|
+
offset?: number;
|
|
397
|
+
sprintId?: number;
|
|
398
|
+
subjectKey?: string;
|
|
399
|
+
} = {}): Promise<{ entries: any[]; total: number }> {
|
|
400
|
+
const search = new URLSearchParams();
|
|
401
|
+
if (params.query) search.set('q', params.query);
|
|
402
|
+
if (params.tool) search.set('tool', params.tool);
|
|
403
|
+
if (params.directOnly) search.set('direct', '1');
|
|
404
|
+
if (params.limit !== undefined) search.set('limit', String(params.limit));
|
|
405
|
+
if (params.offset !== undefined) search.set('offset', String(params.offset));
|
|
406
|
+
if (params.sprintId !== undefined) search.set('sprintId', String(params.sprintId));
|
|
407
|
+
if (params.subjectKey) search.set('subjectKey', params.subjectKey);
|
|
408
|
+
const query = search.toString();
|
|
409
|
+
return this.request(`/api/opencode/a2a/registry${query ? `?${query}` : ''}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async getCoreServiceContract(agentId: string, version: string): Promise<any> {
|
|
413
|
+
return this.request(
|
|
414
|
+
`/api/opencode/a2a/contracts/${encodeURIComponent(agentId)}/${encodeURIComponent(version)}`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
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
|
+
|
|
372
443
|
async summarizeConversation(sprintId: number): Promise<any> {
|
|
373
444
|
return this.request(`/api/opencode/sprints/${sprintId}/summarize`, {
|
|
374
445
|
method: 'POST',
|
|
@@ -68,6 +68,17 @@ import {
|
|
|
68
68
|
} from './tools/agent-sprint-tools';
|
|
69
69
|
import { createAITriggerTools } from './tools/ai-trigger-tools';
|
|
70
70
|
import { createDeveloperStatusTool } from './tools/developer-status-tool';
|
|
71
|
+
import {
|
|
72
|
+
createCoreServicesSearchTool,
|
|
73
|
+
createCoreServiceGetTool,
|
|
74
|
+
createCoreServiceIntegrationRequestTool,
|
|
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';
|
|
71
82
|
import { injectEnvironment } from './hooks/shell-env';
|
|
72
83
|
import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
|
|
73
84
|
|
|
@@ -167,6 +178,19 @@ export default async (ctx: any) => {
|
|
|
167
178
|
// AI Execution Tools
|
|
168
179
|
...aiTriggerTools,
|
|
169
180
|
'forloopDeveloperStatus': createDeveloperStatusTool(client),
|
|
181
|
+
|
|
182
|
+
// Core-service discovery (read-only) + user-approval request
|
|
183
|
+
'core_services_search': createCoreServicesSearchTool(client),
|
|
184
|
+
'core_service_get': createCoreServiceGetTool(client),
|
|
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),
|
|
170
194
|
},
|
|
171
195
|
event: createEventHook(client),
|
|
172
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) {
|
|
@@ -0,0 +1,308 @@
|
|
|
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
|
+
import { getConfig } from '../capabilities/config';
|
|
6
|
+
|
|
7
|
+
type RegistryEntry = {
|
|
8
|
+
agentId: string;
|
|
9
|
+
sprintId: number;
|
|
10
|
+
name: string;
|
|
11
|
+
kind?: 'system' | 'user';
|
|
12
|
+
description: string;
|
|
13
|
+
serviceCode?: string | null;
|
|
14
|
+
baseUrl?: string | null;
|
|
15
|
+
billingPrice?: number | null;
|
|
16
|
+
billingUnit?: string | null;
|
|
17
|
+
billingCurrency?: string | null;
|
|
18
|
+
version?: string | null;
|
|
19
|
+
versionStatus?: string | null;
|
|
20
|
+
sunsetAt?: string | null;
|
|
21
|
+
deprecatedAt?: string | null;
|
|
22
|
+
authMode?: 'none' | 'app' | 'user';
|
|
23
|
+
scopes?: { id: string; tier?: string; description?: string }[];
|
|
24
|
+
tools?: { id: string; name: string; description?: string; whenToUse?: string }[];
|
|
25
|
+
directIntegrable?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const formatPrice = (entry: RegistryEntry): string => {
|
|
29
|
+
if (entry.billingPrice == null) return 'price TBD';
|
|
30
|
+
const currency = entry.billingCurrency || 'FLC';
|
|
31
|
+
const unit = entry.billingUnit ? ` per ${entry.billingUnit}` : ' per success';
|
|
32
|
+
return `${entry.billingPrice} ${currency}${unit}`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const formatEntry = (entry: RegistryEntry, index: number): string => {
|
|
36
|
+
const lines = [
|
|
37
|
+
`${index + 1}. **${entry.name}** — ${entry.kind === 'system' ? 'ForLoop core service' : 'user app'}`,
|
|
38
|
+
` - agentId: \`${entry.agentId}\``,
|
|
39
|
+
` - kind: ${entry.kind ?? 'unknown'}`,
|
|
40
|
+
` - serviceCode: ${entry.serviceCode ?? 'unknown'}`,
|
|
41
|
+
` - baseUrl: ${entry.baseUrl ?? 'not published'}`,
|
|
42
|
+
` - version: ${entry.version ?? 'unspecified'} (${entry.versionStatus ?? 'unknown'})${entry.sunsetAt ? `; sunset ${entry.sunsetAt}` : ''}`,
|
|
43
|
+
` - price: ${formatPrice(entry)}`,
|
|
44
|
+
` - auth: ${entry.authMode ?? 'none'}${entry.directIntegrable ? '; direct-integration enabled' : ''}`,
|
|
45
|
+
` - scopes: ${(entry.scopes ?? []).map((scope) => scope.id).join(', ') || 'none declared'}`,
|
|
46
|
+
` - description: ${entry.description || '(none)'}`,
|
|
47
|
+
];
|
|
48
|
+
if (entry.tools?.length) {
|
|
49
|
+
lines.push(' - tools:');
|
|
50
|
+
for (const serviceTool of entry.tools) {
|
|
51
|
+
const guidance = serviceTool.whenToUse
|
|
52
|
+
? ` — ${serviceTool.whenToUse}`
|
|
53
|
+
: serviceTool.description
|
|
54
|
+
? ` — ${serviceTool.description}`
|
|
55
|
+
: '';
|
|
56
|
+
lines.push(` - \`${serviceTool.id}\` (${serviceTool.name})${guidance}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const clientBaseUrl = (): string => {
|
|
63
|
+
const apiUrl = getConfig().apiUrl.replace(/\/+$/, '');
|
|
64
|
+
if (apiUrl.endsWith('api.dev.forloop.cc')) return 'https://dev.forloop.cc';
|
|
65
|
+
if (apiUrl.endsWith('api.forloop.cc')) return 'https://forloop.cc';
|
|
66
|
+
return apiUrl;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function createCoreServicesSearchTool(client: ForLoopAPIClient) {
|
|
70
|
+
return tool({
|
|
71
|
+
description:
|
|
72
|
+
'Search ForLoop core services and directly-integrable peers by requirement. Use before designing a capability that may already exist (scheduling, notifications, payments). Results include kind, serviceCode, baseUrl, price, scopes, pinned version and tool guidance. Read-only.',
|
|
73
|
+
args: {
|
|
74
|
+
query: tool.schema
|
|
75
|
+
.string()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Requirement sentence or keywords (tokenized; not a whole-phrase match)'),
|
|
78
|
+
tool: tool.schema.string().optional().describe('Tool id to match (e.g. create_schedule)'),
|
|
79
|
+
directOnly: tool.schema
|
|
80
|
+
.boolean()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe('Only return peers that accept direct app integration'),
|
|
83
|
+
limit: tool.schema.number().optional().describe('Maximum results (default 10, max 100)'),
|
|
84
|
+
},
|
|
85
|
+
async execute(args, context) {
|
|
86
|
+
const tokenResult = await validateToken();
|
|
87
|
+
if (!tokenResult.valid) return tokenResult.error;
|
|
88
|
+
client.setToken?.(tokenResult.token);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const resolution = await resolveSprintId(undefined, context?.directory);
|
|
92
|
+
const result = await client.searchCoreServices({
|
|
93
|
+
...(args.query ? { query: args.query } : {}),
|
|
94
|
+
...(args.tool ? { tool: args.tool } : {}),
|
|
95
|
+
...(args.directOnly ? { directOnly: true } : {}),
|
|
96
|
+
limit: Math.min(Math.max(args.limit ?? 10, 1), 100),
|
|
97
|
+
...(resolution.sprintId ? { sprintId: resolution.sprintId } : {}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!result.entries?.length) {
|
|
101
|
+
return 'No matching core service was found. Say so plainly and plan to build the capability instead of inventing a service.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return [
|
|
105
|
+
`Found ${result.entries.length} matching service${result.entries.length === 1 ? '' : 's'} (${result.total} total):`,
|
|
106
|
+
'',
|
|
107
|
+
...result.entries.map((entry, index) => formatEntry(entry as RegistryEntry, index)),
|
|
108
|
+
'',
|
|
109
|
+
'Quote these fields when presenting the option; do not invent endpoints, scopes or versions.',
|
|
110
|
+
].join('\n');
|
|
111
|
+
} catch (error: any) {
|
|
112
|
+
return `❌ Error searching core services: ${error.message}`;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createCoreServiceGetTool(client: ForLoopAPIClient) {
|
|
119
|
+
return tool({
|
|
120
|
+
description:
|
|
121
|
+
'Read a core service contract (OpenAPI + tool index) plus version status, deprecation and sunset. If version is omitted, the current registry version is resolved first. Read-only.',
|
|
122
|
+
args: {
|
|
123
|
+
agentId: tool.schema.string().describe('Service agentId (sprint public id)'),
|
|
124
|
+
version: tool.schema
|
|
125
|
+
.string()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe('Pinned contract version; omit to use the current registry version'),
|
|
128
|
+
},
|
|
129
|
+
async execute(args) {
|
|
130
|
+
const tokenResult = await validateToken();
|
|
131
|
+
if (!tokenResult.valid) return tokenResult.error;
|
|
132
|
+
client.setToken?.(tokenResult.token);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
let version = args.version?.trim();
|
|
136
|
+
let entry: RegistryEntry | undefined;
|
|
137
|
+
if (!version) {
|
|
138
|
+
const found = await client.searchCoreServices({ query: args.agentId, limit: 50 });
|
|
139
|
+
entry = (found.entries ?? []).find(
|
|
140
|
+
(candidate: RegistryEntry) => candidate.agentId === args.agentId
|
|
141
|
+
) as RegistryEntry | undefined;
|
|
142
|
+
if (!entry) {
|
|
143
|
+
return `❌ Core service \`${args.agentId}\` was not found in the registry.`;
|
|
144
|
+
}
|
|
145
|
+
if (!entry.version) {
|
|
146
|
+
return `❌ Core service \`${args.agentId}\` has no published contract version.`;
|
|
147
|
+
}
|
|
148
|
+
version = entry.version;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const contract = await client.getCoreServiceContract(args.agentId, version);
|
|
152
|
+
if (!entry && contract.sunsetAt === undefined) {
|
|
153
|
+
try {
|
|
154
|
+
const found = await client.searchCoreServices({ query: args.agentId, limit: 50 });
|
|
155
|
+
entry = (found.entries ?? []).find(
|
|
156
|
+
(candidate: RegistryEntry) => candidate.agentId === args.agentId
|
|
157
|
+
) as RegistryEntry | undefined;
|
|
158
|
+
} catch {
|
|
159
|
+
entry = undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const sunsetAt = contract.sunsetAt ?? entry?.sunsetAt ?? null;
|
|
163
|
+
const deprecatedAt = contract.deprecatedAt ?? entry?.deprecatedAt ?? null;
|
|
164
|
+
const status = contract.status ?? entry?.versionStatus ?? 'unknown';
|
|
165
|
+
const sunset = sunsetAt ? `\n- sunsetAt: ${sunsetAt}` : '\n- sunsetAt: none';
|
|
166
|
+
const deprecated = deprecatedAt ? `\n- deprecatedAt: ${deprecatedAt}` : '';
|
|
167
|
+
return [
|
|
168
|
+
`# ${args.agentId} — contract ${contract.version}`,
|
|
169
|
+
`- status: ${status}`,
|
|
170
|
+
`${sunset}${deprecated}`,
|
|
171
|
+
`- checksum: ${contract.checksum ?? 'unknown'}`,
|
|
172
|
+
'',
|
|
173
|
+
'## OpenAPI',
|
|
174
|
+
'```json',
|
|
175
|
+
JSON.stringify(contract.openapi, null, 2),
|
|
176
|
+
'```',
|
|
177
|
+
'',
|
|
178
|
+
'## Tool index',
|
|
179
|
+
'```json',
|
|
180
|
+
JSON.stringify(contract.index, null, 2),
|
|
181
|
+
'```',
|
|
182
|
+
].join('\n');
|
|
183
|
+
} catch (error: any) {
|
|
184
|
+
return `❌ Error reading core service contract: ${error.message}`;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient) {
|
|
191
|
+
return tool({
|
|
192
|
+
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.',
|
|
194
|
+
args: {
|
|
195
|
+
agentId: tool.schema.string().describe('Target core-service agentId'),
|
|
196
|
+
scopes: tool.schema
|
|
197
|
+
.array(tool.schema.string())
|
|
198
|
+
.describe('Exact scopes needed (must be declared by the service)'),
|
|
199
|
+
reason: tool.schema
|
|
200
|
+
.string()
|
|
201
|
+
.describe('Why the app needs this integration (shown to the user)'),
|
|
202
|
+
},
|
|
203
|
+
async execute(args, context) {
|
|
204
|
+
const tokenResult = await validateToken();
|
|
205
|
+
if (!tokenResult.valid) return tokenResult.error;
|
|
206
|
+
client.setToken?.(tokenResult.token);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const found = await client.searchCoreServices({ query: args.agentId, limit: 50 });
|
|
210
|
+
const target = (found.entries ?? []).find(
|
|
211
|
+
(entry: RegistryEntry) => entry.agentId === args.agentId
|
|
212
|
+
) as RegistryEntry | undefined;
|
|
213
|
+
if (!target) {
|
|
214
|
+
return `❌ Core service \`${args.agentId}\` was not found in the registry. Do not invent a connection request.`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const declared = (target.scopes ?? []).map((scope) => scope.id);
|
|
218
|
+
const requested = Array.from(
|
|
219
|
+
new Set((args.scopes ?? []).filter((scope) => typeof scope === 'string' && scope.trim()))
|
|
220
|
+
);
|
|
221
|
+
if (!requested.length) {
|
|
222
|
+
return [
|
|
223
|
+
`❌ No scopes requested for \`${args.agentId}\`.`,
|
|
224
|
+
`Declared scopes: ${declared.join(', ') || 'none'}.`,
|
|
225
|
+
].join('\n');
|
|
226
|
+
}
|
|
227
|
+
const undeclared = requested.filter((scope) => !declared.includes(scope));
|
|
228
|
+
if (undeclared.length) {
|
|
229
|
+
return [
|
|
230
|
+
`❌ These scopes are not declared by \`${args.agentId}\`: ${undeclared.join(', ')}`,
|
|
231
|
+
`Declared scopes: ${declared.join(', ') || 'none'}.`,
|
|
232
|
+
].join('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const resolution = await resolveSprintId(undefined, context?.directory);
|
|
236
|
+
const callerSprintId = resolution.sprintId ?? null;
|
|
237
|
+
const connectionPayload = {
|
|
238
|
+
callerSprintId,
|
|
239
|
+
targetAgentId: args.agentId,
|
|
240
|
+
subjectType: 'app' as const,
|
|
241
|
+
scopes: requested,
|
|
242
|
+
};
|
|
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 connectUrl = spaceUrl ? `${spaceUrl}?${linkParams.toString()}` : null;
|
|
266
|
+
|
|
267
|
+
const approvalSteps = connectUrl
|
|
268
|
+
? [
|
|
269
|
+
`1. Open ${connectUrl}`,
|
|
270
|
+
` This opens **${target.name}**'s connection panel in this app, with the scopes below and \`This app\` preselected.`,
|
|
271
|
+
'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.',
|
|
274
|
+
]
|
|
275
|
+
: [
|
|
276
|
+
`1. Open the app's space${spaceUrl ? `: ${spaceUrl}` : ''}.`,
|
|
277
|
+
'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.',
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
return [
|
|
283
|
+
`## Integration approval required — nothing is created yet`,
|
|
284
|
+
`- Service: **${target.name}** (\`${target.agentId}\`)`,
|
|
285
|
+
`- serviceCode: ${target.serviceCode ?? 'unknown'}`,
|
|
286
|
+
`- reason: ${args.reason}`,
|
|
287
|
+
`- scopes: ${requested.join(', ')}`,
|
|
288
|
+
`- cost: ${formatPrice(target)}`,
|
|
289
|
+
'',
|
|
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.',
|
|
291
|
+
'',
|
|
292
|
+
'### Approve now (creates the app-subject grant)',
|
|
293
|
+
...approvalSteps,
|
|
294
|
+
'',
|
|
295
|
+
'### Connection payload (owner handoff)',
|
|
296
|
+
'```json',
|
|
297
|
+
JSON.stringify(connectionPayload, null, 2),
|
|
298
|
+
'```',
|
|
299
|
+
'',
|
|
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.',
|
|
302
|
+
].join('\n');
|
|
303
|
+
} catch (error: any) {
|
|
304
|
+
return `❌ Error creating integration request: ${error.message}`;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
@@ -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
|
+
}
|