@forloop-cc/forloop-opencode-plugin-planner 2.2.6 → 2.2.8

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/README.md CHANGED
@@ -25,6 +25,8 @@ The **ForLoop Plugin** connects [opencode](https://opencode.ai) AI agents to you
25
25
 
26
26
  [ForLoop](https://forloop.cc) is an **AI agent platform** for autonomous development and deployment. Think of it as your team's command center — a shared space where AI agents and humans collaborate on sprints, stories, and shipping code.
27
27
 
28
+ **All built on Loop Engineering** — a continuous feedback loop of Plan → Code → Review → Deploy → Learn. Every cycle generates data that feeds into the next, making your team faster with every iteration. This plugin powers the **Plan** phase, giving your agents direct access to sprints, stories, and task tracking.
29
+
28
30
  **Key features:**
29
31
 
30
32
  - **AI Agents as team members** — specialized agents plan, code, review, and deploy. They create real branches, commits, and pull requests.
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.6",
4
+ "version": "2.2.8",
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",
@@ -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 {
@@ -67,6 +67,18 @@ import { createDeveloperStatusTool } from './tools/developer-status-tool';
67
67
  import { injectEnvironment } from './hooks/shell-env';
68
68
  import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
69
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
+
70
82
  export default async (ctx: any) => {
71
83
  const config = getConfig();
72
84
  const token = (await getToken()) || '';
@@ -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,