@forloop-cc/forloop-opencode-plugin-planner 2.2.5 → 2.2.7

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.5",
4
+ "version": "2.2.7",
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",
@@ -24,6 +24,10 @@
24
24
  ".": {
25
25
  "import": "./plugins/forloop-plugin.ts",
26
26
  "types": "./plugins/forloop-plugin.ts"
27
+ },
28
+ "./exports": {
29
+ "import": "./plugins/exports.ts",
30
+ "types": "./plugins/exports.ts"
27
31
  }
28
32
  },
29
33
  "files": [
@@ -519,6 +519,19 @@ export class ForLoopAPIClient {
519
519
  body: JSON.stringify({ sprintId, sessionId }),
520
520
  });
521
521
  }
522
+
523
+ async sendStreamChunk(data: {
524
+ taskId: string;
525
+ trackingId: string;
526
+ sprintId: number;
527
+ chunk: string;
528
+ index: number;
529
+ }): Promise<any> {
530
+ return this.request('/api/opencode/agents/stream', {
531
+ method: 'POST',
532
+ body: JSON.stringify(data),
533
+ });
534
+ }
522
535
  }
523
536
 
524
537
  export interface AgentSuggestionRequest {
@@ -0,0 +1,14 @@
1
+ // Re-exports for @forloop-cc/forloop-cli consumption.
2
+ // These were moved out of forloop-plugin.ts because Bun's TypeScript
3
+ // transpiler in opencode-ai@1.15.6 fails when a file has both
4
+ // `export default async` and additional `export { }` / `export type { }` lines.
5
+ export { ForLoopAPIClient } from './capabilities/api-client';
6
+ export type { APIError, CreateStoryRequest, CreateSprintRequest, ListSprintsParams, SprintOptions } from './capabilities/api-client';
7
+ export { getConfig, isLambdaExecution, PROD_API_URL, DEV_API_URL } from './capabilities/config';
8
+ export type { ForLoopConfig } from './capabilities/config';
9
+ export { getToken, setToken, clearToken } from './capabilities/token-storage';
10
+ export type { TokenProfile } from './capabilities/token-storage';
11
+ export { validateToken, detectTokenType } from './capabilities/auth';
12
+ export type { TokenValidationResult } from './capabilities/auth';
13
+ export { resolveSprintId, getCurrentBranch, resolveSprintFromBranch, getForloopRoot, getManifestPath } from './capabilities/context-resolver';
14
+ export type { ContextResolution } from './capabilities/context-resolver';
@@ -35,6 +35,7 @@ import {
35
35
  createFileListTool,
36
36
  createFileDeleteTool,
37
37
  createFileDownloadUrlTool,
38
+ createFileDownloadTool,
38
39
  } from './tools/file-tools';
39
40
  import {
40
41
  createScheduleTool,
@@ -66,6 +67,18 @@ import { createDeveloperStatusTool } from './tools/developer-status-tool';
66
67
  import { injectEnvironment } from './hooks/shell-env';
67
68
  import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
68
69
 
70
+ // Named re-exports — see plugins/exports.ts for details
71
+ export { ForLoopAPIClient } from './capabilities/api-client';
72
+ export type { APIError, CreateStoryRequest, CreateSprintRequest, ListSprintsParams, SprintOptions } from './capabilities/api-client';
73
+ export { getConfig, isLambdaExecution, PROD_API_URL, DEV_API_URL } from './capabilities/config';
74
+ export type { ForLoopConfig } from './capabilities/config';
75
+ export { getToken, setToken, clearToken } from './capabilities/token-storage';
76
+ export type { TokenProfile } from './capabilities/token-storage';
77
+ export { validateToken, detectTokenType } from './capabilities/auth';
78
+ export type { TokenValidationResult } from './capabilities/auth';
79
+ export { resolveSprintId, getCurrentBranch, resolveSprintFromBranch, getForloopRoot, getManifestPath } from './capabilities/context-resolver';
80
+ export type { ContextResolution } from './capabilities/context-resolver';
81
+
69
82
  export default async (ctx: any) => {
70
83
  const config = getConfig();
71
84
  const token = (await getToken()) || '';
@@ -123,7 +136,8 @@ export default async (ctx: any) => {
123
136
  'forloopFileUpload': createFileUploadTool(client),
124
137
  'forloopFileList': createFileListTool(client),
125
138
  'forloopFileDelete': createFileDeleteTool(client),
126
- 'forloopFileDownload': createFileDownloadUrlTool(client),
139
+ 'forloopFileDownloadUrl': createFileDownloadUrlTool(client),
140
+ 'forloopFileDownload': createFileDownloadTool(client),
127
141
 
128
142
  // Schedule Meetings
129
143
  'forloopScheduleCreate': createScheduleTool(client),
@@ -174,13 +188,3 @@ export default async (ctx: any) => {
174
188
  };
175
189
  };
176
190
 
177
- export { ForLoopAPIClient } from './capabilities/api-client';
178
- export type { APIError, CreateStoryRequest, CreateSprintRequest, ListSprintsParams, SprintOptions } from './capabilities/api-client';
179
- export { getConfig, isLambdaExecution, PROD_API_URL, DEV_API_URL } from './capabilities/config';
180
- export type { ForLoopConfig } from './capabilities/config';
181
- export { getToken, setToken, clearToken } from './capabilities/token-storage';
182
- export type { TokenProfile } from './capabilities/token-storage';
183
- export { validateToken, detectTokenType } from './capabilities/auth';
184
- export type { TokenValidationResult } from './capabilities/auth';
185
- export { resolveSprintId, getCurrentBranch, resolveSprintFromBranch, getForloopRoot, getManifestPath } from './capabilities/context-resolver';
186
- export type { ContextResolution } from './capabilities/context-resolver';
@@ -49,8 +49,13 @@ async function recordAssistantMessage(
49
49
  await new Promise((resolve) => setTimeout(resolve, 600 * i));
50
50
  }
51
51
  const result = await client.recordMessage(data);
52
- if (result?.message !== 'no matching user message to update') return;
53
- console.log(`[ForLoop] Retry ${i + 1}/${attempts}: user row not found yet`);
52
+ // "no matching user message" is expected in Lambda — the server's
53
+ // handleAICallback creates the full Conversation record separately.
54
+ if (result?.message === 'no matching user message to update') {
55
+ console.log(`[ForLoop] No matching user message (expected in Lambda) — callback handles persistence`);
56
+ return;
57
+ }
58
+ return;
54
59
  } catch (err: any) {
55
60
  console.warn(`[ForLoop] Attempt ${i + 1}/${attempts} failed:`, err.message);
56
61
  if (i === attempts - 1) {
@@ -72,19 +77,26 @@ export function createChatMessageHook(client: ForLoopAPIClient) {
72
77
  const sprintId = readActiveSprintId();
73
78
  if (!sprintId) return;
74
79
 
80
+ const { sessionID, agent, model, messageID } = input;
81
+ const { message, parts } = output;
82
+ const content = extractTextFromParts(parts);
83
+
84
+ // Log incoming user message for debug visibility (Lambda)
85
+ if (content && messageID) {
86
+ console.log(`[ForLoop] REQUEST — user message (${content.length} chars)`, {
87
+ sprintId,
88
+ agent: agent || 'unknown',
89
+ sessionId: sessionID,
90
+ preview: content.substring(0, 300),
91
+ });
92
+ }
75
93
  // Only record on user's local machine; Lambda records via its own system
76
94
  if (isLambdaExecution()) return;
77
95
 
78
- const { sessionID, agent, model, messageID } = input;
79
-
80
96
  // Only record messages with a model (indicates actual LLM processing,
81
97
  // not system events like context compaction or summary generation)
82
98
  if (!model || !model.providerID) return;
83
99
 
84
- const { message, parts } = output;
85
- const content = extractTextFromParts(parts);
86
- if (!content) return;
87
-
88
100
  const conversationId = buildConversationId(sprintId, agent || 'unknown', sessionID);
89
101
 
90
102
  // Fire-and-forget: don't await - must not block the LLM from receiving the message
@@ -107,28 +119,23 @@ export function createChatMessageHook(client: ForLoopAPIClient) {
107
119
  export function createEventHook(client: ForLoopAPIClient) {
108
120
  const textBuffer = new Map<string, string>();
109
121
  const readyToStream = new Set<string>(); // messageIDs that have passed the context phase
122
+ const directSentText = new Map<string, string>(); // trackingId → last full text sent via direct HTTP
110
123
 
111
- const writeStreamChunk = (sprintId: number, trackingId: string, taskId: string, text: string, done: boolean) => {
112
- try {
113
- const dir = '/tmp/forloop-stream';
114
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
115
- const filePath = path.join(dir, `sprint_${sprintId}.json`);
116
- let data: Record<string, any> = {};
117
- try { data = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch {}
118
- const existing = data[trackingId] || { index: 0 };
119
- data[trackingId] = {
120
- trackingId,
121
- taskId: taskId || '',
122
- sprintId,
123
- text,
124
- index: existing.index + 1,
125
- done,
126
- lastUpdated: Date.now(),
127
- };
128
- fs.writeFileSync(filePath, JSON.stringify(data));
129
- } catch (err: any) {
130
- console.warn('[ForLoop] Failed to write stream chunk:', err.message);
131
- }
124
+ const sendStreamDelta = (sprintId: number, trackingId: string, taskId: string, fullText: string) => {
125
+ const lastSent = directSentText.get(trackingId) || '';
126
+ if (!fullText || fullText.length <= lastSent.length) return;
127
+ const delta = fullText.startsWith(lastSent) ? fullText.substring(lastSent.length) : fullText;
128
+ if (!delta) return;
129
+ directSentText.set(trackingId, fullText);
130
+ client.sendStreamChunk({
131
+ taskId: taskId || '',
132
+ trackingId,
133
+ sprintId,
134
+ chunk: delta,
135
+ index: 0,
136
+ }).catch((err: Error) => {
137
+ console.warn('[ForLoop] Direct stream chunk failed:', err.message);
138
+ });
132
139
  };
133
140
 
134
141
  return async ({ event }: { event: { type: string; properties: any } }) => {
@@ -138,14 +145,15 @@ export function createEventHook(client: ForLoopAPIClient) {
138
145
  const trackingId = process.env.FORLOOP_TRACKING_ID;
139
146
  const taskId = process.env.FORLOOP_TASK_ID;
140
147
  const isLambda = isLambdaExecution();
148
+ const debugStream = process.env.FORLOOP_DEBUG_STREAM === 'true';
141
149
 
142
- // Lambda with streaming: write chunks to file for parent process to poll
150
+ // Lambda with streaming: send chunks directly via HTTP
143
151
  if (isLambda && trackingId && process.env.FORLOOP_STREAM_ENABLED === 'true') {
144
152
  switch (event.type) {
145
153
  case 'message.part.updated': {
146
154
  const part = event.properties?.part;
147
155
  const messageID = part?.messageID || '';
148
- console.error('[stream-event] message.part.updated', { type: part?.type, trackingId, sprintId, messageID });
156
+ if (debugStream) console.error('[stream-event] message.part.updated', { type: part?.type, trackingId, sprintId, messageID });
149
157
 
150
158
  // Skip non-text parts — but track that this messageID is now in "generation" phase
151
159
  if (!part || part.type !== 'text') {
@@ -160,7 +168,7 @@ export function createEventHook(client: ForLoopAPIClient) {
160
168
  // Also track that we've seen the context and subsequent text is streamable.
161
169
  const textLen = (part.text || '').length;
162
170
  if (!readyToStream.has(messageID) && textLen > 5000) {
163
- console.error('[stream-event] skipping context text', { trackingId, messageID, textLen });
171
+ if (debugStream) console.error('[stream-event] skipping context text', { trackingId, messageID, textLen });
164
172
  readyToStream.add(messageID); // mark as past context
165
173
  break;
166
174
  }
@@ -168,8 +176,10 @@ export function createEventHook(client: ForLoopAPIClient) {
168
176
  readyToStream.add(messageID);
169
177
  const current = textBuffer.get(part.messageID) || '';
170
178
  textBuffer.set(part.messageID, part.text || current);
171
- console.error('[stream-event] writing chunk', { trackingId, textLen });
172
- writeStreamChunk(sprintId, trackingId, taskId || '', part.text || '', false);
179
+ if (debugStream) console.error('[stream-event] writing chunk', { trackingId, textLen });
180
+
181
+ // Send delta directly for real-time streaming (primary path)
182
+ sendStreamDelta(sprintId, trackingId, taskId || '', part.text || '');
173
183
  break;
174
184
  }
175
185
  case 'message.updated': {
@@ -177,7 +187,14 @@ export function createEventHook(client: ForLoopAPIClient) {
177
187
  if (!info || info.role !== 'assistant') break;
178
188
  const bufferedText = textBuffer.get(info.id);
179
189
  if (!bufferedText) break;
180
- writeStreamChunk(sprintId, trackingId, taskId || '', bufferedText, true);
190
+
191
+ console.log(`[ForLoop] RESPONSE — planner message (${bufferedText.length} chars)`, {
192
+ sprintId,
193
+ trackingId,
194
+ sessionId: info.sessionID,
195
+ agent: info.agent || 'unknown',
196
+ preview: bufferedText.substring(0, 500),
197
+ });
181
198
 
182
199
  const conversationId = buildConversationId(
183
200
  sprintId,
@@ -176,10 +176,10 @@ export function createFileDeleteTool(client: ForLoopAPIClient) {
176
176
 
177
177
  export function createFileDownloadUrlTool(client: ForLoopAPIClient) {
178
178
  return tool({
179
- description: 'Get a download URL for a file',
179
+ description: 'Get a presigned download URL for a file (does not download the file itself)',
180
180
  args: {
181
181
  fileId: tool.schema.number()
182
- .describe('File ID to download'),
182
+ .describe('File ID to get download URL for'),
183
183
  },
184
184
  async execute(args, _context) {
185
185
  const tokenResult = await validateToken();
@@ -189,10 +189,9 @@ export function createFileDownloadUrlTool(client: ForLoopAPIClient) {
189
189
  client.setToken?.(tokenResult.token);
190
190
 
191
191
  try {
192
- // Log file read (tracks access)
193
- await client.logFileRead(args.fileId);
192
+ // Fire-and-forget audit log — don't block download on audit failure
193
+ client.logFileRead(args.fileId).catch(() => {});
194
194
 
195
- // Get download URL
196
195
  const download = await client.getFileDownloadUrl(args.fileId);
197
196
 
198
197
  return [
@@ -209,3 +208,59 @@ export function createFileDownloadUrlTool(client: ForLoopAPIClient) {
209
208
  },
210
209
  });
211
210
  }
211
+
212
+ export function createFileDownloadTool(client: ForLoopAPIClient) {
213
+ return tool({
214
+ description: 'Download a sprint file by ID to the local sandbox',
215
+ args: {
216
+ fileId: tool.schema.number()
217
+ .describe('File ID to download (from forloopFileList)'),
218
+ destPath: tool.schema.string()
219
+ .optional()
220
+ .describe('Destination path relative to sandbox downloads dir (default: <filename>)'),
221
+ },
222
+ async execute(args, _context) {
223
+ const tokenResult = await validateToken();
224
+ if (!tokenResult.valid) {
225
+ return tokenResult.error ?? '❌ No API token configured.';
226
+ }
227
+ client.setToken?.(tokenResult.token);
228
+
229
+ try {
230
+ const open = await client.getFileOpenUrl(Number(args.fileId));
231
+ const url = String(open?.openUrl || open?.url || '');
232
+ if (!url) {
233
+ return `❌ Could not get download URL for file #${args.fileId}.`;
234
+ }
235
+
236
+ const response = await fetch(url);
237
+ if (!response.ok) {
238
+ return `❌ Download failed: HTTP ${response.status}`;
239
+ }
240
+
241
+ const buf = Buffer.from(await response.arrayBuffer());
242
+ const fileName = open?.originalName || open?.filename || `file-${args.fileId}`;
243
+ const downloadDir = path.join(process.env.HOME || '/tmp', 'downloads');
244
+ if (!fs.existsSync(downloadDir)) {
245
+ fs.mkdirSync(downloadDir, { recursive: true });
246
+ }
247
+
248
+ const destFile = args.destPath
249
+ ? path.join(downloadDir, args.destPath)
250
+ : path.join(downloadDir, fileName);
251
+ const destDir = path.dirname(destFile);
252
+ if (!fs.existsSync(destDir)) {
253
+ fs.mkdirSync(destDir, { recursive: true });
254
+ }
255
+ fs.writeFileSync(destFile, buf);
256
+
257
+ // Log file read (fire-and-forget audit)
258
+ client.logFileRead(args.fileId).catch(() => {});
259
+
260
+ return `✅ Downloaded file #${args.fileId} → ${destFile} (${buf.length} bytes)`;
261
+ } catch (error: any) {
262
+ return `❌ File download failed: ${error.message}`;
263
+ }
264
+ },
265
+ });
266
+ }