@flue/client 0.0.4 → 0.0.6

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/dist/index.d.mts CHANGED
@@ -15,6 +15,63 @@ declare class SkillOutputError extends Error {
15
15
  });
16
16
  }
17
17
  //#endregion
18
+ //#region src/events.d.ts
19
+ /**
20
+ * Flue event types and transform utilities.
21
+ *
22
+ * Converts raw OpenCode SSE events into a stable, simplified Flue event
23
+ * format suitable for logging and streaming to external consumers.
24
+ */
25
+ type FlueEvent = {
26
+ timestamp: number;
27
+ sessionId: string;
28
+ } & ({
29
+ type: 'tool.pending';
30
+ tool: string;
31
+ input: string;
32
+ } | {
33
+ type: 'tool.running';
34
+ tool: string;
35
+ input: string;
36
+ } | {
37
+ type: 'tool.complete';
38
+ tool: string;
39
+ input: string;
40
+ output: string;
41
+ duration: number;
42
+ } | {
43
+ type: 'tool.error';
44
+ tool: string;
45
+ input: string;
46
+ error: string;
47
+ duration: number;
48
+ } | {
49
+ type: 'text';
50
+ text: string;
51
+ } | {
52
+ type: 'status';
53
+ status: 'busy' | 'idle' | 'compacted' | 'retry';
54
+ message?: string;
55
+ } | {
56
+ type: 'step.start';
57
+ } | {
58
+ type: 'step.finish';
59
+ reason: string;
60
+ tokens: {
61
+ input: number;
62
+ output: number;
63
+ };
64
+ cost: number;
65
+ } | {
66
+ type: 'error';
67
+ message: string;
68
+ });
69
+ /**
70
+ * Attempt to parse a raw OpenCode SSE event and convert it to a FlueEvent.
71
+ * Returns null if the event should be filtered out (not relevant for logs).
72
+ */
73
+ declare function transformEvent(raw: any): FlueEvent | null;
74
+ //#endregion
18
75
  //#region src/types.d.ts
19
76
  interface FlueOptions {
20
77
  /** OpenCode server URL (default: 'http://localhost:48765'). */
@@ -101,4 +158,4 @@ declare class Flue {
101
158
  close(): Promise<void>;
102
159
  }
103
160
  //#endregion
104
- export { Flue, type FlueOptions, type PromptOptions, type ShellOptions, type ShellResult, type SkillOptions, SkillOutputError };
161
+ export { Flue, type FlueEvent, type FlueOptions, type PromptOptions, type ShellOptions, type ShellResult, type SkillOptions, SkillOutputError, transformEvent };
package/dist/index.mjs CHANGED
@@ -20,6 +20,148 @@ var SkillOutputError = class extends Error {
20
20
  }
21
21
  };
22
22
 
23
+ //#endregion
24
+ //#region src/events.ts
25
+ /**
26
+ * Summarize tool input into a short human-readable string.
27
+ */
28
+ function summarizeInput(tool, input) {
29
+ if (input.command) return String(input.command).slice(0, 500);
30
+ if (input.filePath) return String(input.filePath);
31
+ if (input.pattern) return String(input.pattern);
32
+ if (input.url) return String(input.url);
33
+ if (input.name) return String(input.name);
34
+ return tool;
35
+ }
36
+ /**
37
+ * Attempt to parse a raw OpenCode SSE event and convert it to a FlueEvent.
38
+ * Returns null if the event should be filtered out (not relevant for logs).
39
+ */
40
+ function transformEvent(raw) {
41
+ const type = raw?.type;
42
+ if (!type) return null;
43
+ const now = Date.now();
44
+ if (type === "message.part.updated") {
45
+ const part = raw.properties?.part;
46
+ if (!part) return null;
47
+ const sessionId = part.sessionID ?? "";
48
+ if (part.type === "tool") {
49
+ const tool = part.tool ?? "?";
50
+ const state = part.state;
51
+ if (!state) return null;
52
+ const input = summarizeInput(tool, state.input ?? {});
53
+ switch (state.status) {
54
+ case "pending": return {
55
+ timestamp: now,
56
+ sessionId,
57
+ type: "tool.pending",
58
+ tool,
59
+ input
60
+ };
61
+ case "running": return {
62
+ timestamp: now,
63
+ sessionId,
64
+ type: "tool.running",
65
+ tool,
66
+ input
67
+ };
68
+ case "completed": return {
69
+ timestamp: now,
70
+ sessionId,
71
+ type: "tool.complete",
72
+ tool,
73
+ input,
74
+ output: (state.output ?? "").slice(0, 1e3),
75
+ duration: state.time?.end && state.time?.start ? state.time.end - state.time.start : 0
76
+ };
77
+ case "error": {
78
+ const duration = state.time?.end && state.time?.start ? state.time.end - state.time.start : 0;
79
+ return {
80
+ timestamp: now,
81
+ sessionId,
82
+ type: "tool.error",
83
+ tool,
84
+ input,
85
+ error: state.error ?? "unknown error",
86
+ duration
87
+ };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ if (part.type === "text") {
93
+ const delta = raw.properties?.delta;
94
+ if (delta) return {
95
+ timestamp: now,
96
+ sessionId,
97
+ type: "text",
98
+ text: delta
99
+ };
100
+ return null;
101
+ }
102
+ if (part.type === "step-start") return {
103
+ timestamp: now,
104
+ sessionId: part.sessionID ?? "",
105
+ type: "step.start"
106
+ };
107
+ if (part.type === "step-finish") return {
108
+ timestamp: now,
109
+ sessionId: part.sessionID ?? "",
110
+ type: "step.finish",
111
+ reason: part.reason ?? "",
112
+ tokens: {
113
+ input: part.tokens?.input ?? 0,
114
+ output: part.tokens?.output ?? 0
115
+ },
116
+ cost: part.cost ?? 0
117
+ };
118
+ return null;
119
+ }
120
+ if (type === "session.status") {
121
+ const sessionId = raw.properties?.sessionID ?? "";
122
+ const status = raw.properties?.status;
123
+ if (status?.type === "busy") return {
124
+ timestamp: now,
125
+ sessionId,
126
+ type: "status",
127
+ status: "busy"
128
+ };
129
+ if (status?.type === "idle") return {
130
+ timestamp: now,
131
+ sessionId,
132
+ type: "status",
133
+ status: "idle"
134
+ };
135
+ if (status?.type === "retry") return {
136
+ timestamp: now,
137
+ sessionId,
138
+ type: "status",
139
+ status: "retry",
140
+ message: status.message
141
+ };
142
+ return null;
143
+ }
144
+ if (type === "session.idle") return {
145
+ timestamp: now,
146
+ sessionId: raw.properties?.sessionID ?? "",
147
+ type: "status",
148
+ status: "idle"
149
+ };
150
+ if (type === "session.compacted") return {
151
+ timestamp: now,
152
+ sessionId: raw.properties?.sessionID ?? "",
153
+ type: "status",
154
+ status: "compacted"
155
+ };
156
+ if (type === "session.error") return {
157
+ timestamp: now,
158
+ sessionId: raw.properties?.sessionID ?? "",
159
+ type: "error",
160
+ message: raw.properties?.error ?? "unknown error"
161
+ };
162
+ return null;
163
+ }
164
+
23
165
  //#endregion
24
166
  //#region src/shell.ts
25
167
  async function runShell(command, options) {
@@ -204,9 +346,11 @@ async function runSkill(client, workdir, name, options) {
204
346
  });
205
347
  console.log(`[flue] skill("${name}"): prompt sent`, {
206
348
  hasError: !!asyncResult.error,
207
- error: asyncResult.error
349
+ error: asyncResult.error,
350
+ data: asyncResult.data
208
351
  });
209
352
  if (asyncResult.error) throw new Error(`Failed to send prompt for skill "${name}" (session ${sessionId}): ${JSON.stringify(asyncResult.error)}`);
353
+ await confirmSessionStarted(client, sessionId, workdir, name);
210
354
  console.log(`[flue] skill("${name}"): starting polling`);
211
355
  const parts = await pollUntilIdle(client, sessionId, workdir, name, promptStart);
212
356
  const promptElapsed = ((Date.now() - promptStart) / 1e3).toFixed(1);
@@ -214,23 +358,70 @@ async function runSkill(client, workdir, name, options) {
214
358
  if (!schema) return;
215
359
  return extractResult(parts, schema, sessionId);
216
360
  }
361
+ /**
362
+ * After promptAsync, confirm that OpenCode actually started processing the session.
363
+ * Polls quickly (1s) to detect the session appearing as "busy" or a user message being recorded.
364
+ * Fails fast (~15s) instead of letting the poll loop run for 5 minutes.
365
+ */
366
+ async function confirmSessionStarted(client, sessionId, workdir, skillName) {
367
+ const maxAttempts = 15;
368
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
369
+ await sleep(1e3);
370
+ if (((await client.session.status({ query: { directory: workdir } })).data?.[sessionId])?.type === "busy") {
371
+ console.log(`[flue] skill("${skillName}"): session confirmed running`);
372
+ return;
373
+ }
374
+ const messages = (await client.session.messages({
375
+ path: { id: sessionId },
376
+ query: { directory: workdir }
377
+ })).data;
378
+ if (messages && messages.length > 0) {
379
+ console.log(`[flue] skill("${skillName}"): session confirmed (${messages.length} messages)`);
380
+ return;
381
+ }
382
+ }
383
+ throw new Error(`Skill "${skillName}" failed to start: session ${sessionId} has no messages after 15s.\nThe prompt was accepted but OpenCode never began processing it.\nThis usually means no model is configured. Pass --model to the flue CLI or set "model" in opencode.json.`);
384
+ }
217
385
  async function pollUntilIdle(client, sessionId, workdir, skillName, startTime) {
218
386
  let emptyPolls = 0;
387
+ let pollCount = 0;
219
388
  for (;;) {
220
389
  await sleep(POLL_INTERVAL);
390
+ pollCount++;
221
391
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(0);
222
392
  if (Date.now() - startTime > MAX_POLL_TIME) throw new Error(`Skill "${skillName}" timed out after ${elapsed}s. Session never went idle. This may indicate a stuck session or OpenCode bug.`);
223
- const sessionStatus = (await client.session.status({ query: { directory: workdir } })).data?.[sessionId];
393
+ const statusResult = await client.session.status({ query: { directory: workdir } });
394
+ const sessionStatus = statusResult.data?.[sessionId];
224
395
  if (!sessionStatus || sessionStatus.type === "idle") {
225
396
  const parts = await fetchAllAssistantParts(client, sessionId, workdir);
226
397
  if (parts.length === 0) {
227
398
  emptyPolls++;
228
- if (emptyPolls >= MAX_EMPTY_POLLS) throw new Error(`Skill "${skillName}" produced no output after ${elapsed}s and ${emptyPolls} empty polls. The agent may have failed to start — check model ID and API key.`);
399
+ if (emptyPolls % 12 === 0) {
400
+ console.log(`[flue] skill("${skillName}"): status result: ${JSON.stringify({
401
+ hasData: !!statusResult.data,
402
+ sessionIds: statusResult.data ? Object.keys(statusResult.data) : [],
403
+ error: statusResult.error
404
+ })}`);
405
+ console.log(`[flue] skill("${skillName}"): sessionStatus for ${sessionId}: ${JSON.stringify(sessionStatus)}`);
406
+ }
407
+ if (emptyPolls >= MAX_EMPTY_POLLS) {
408
+ const allMessages = await client.session.messages({
409
+ path: { id: sessionId },
410
+ query: { directory: workdir }
411
+ });
412
+ console.error(`[flue] skill("${skillName}"): TIMEOUT DIAGNOSTICS`, JSON.stringify({
413
+ sessionId,
414
+ statusData: statusResult.data,
415
+ messageCount: Array.isArray(allMessages.data) ? allMessages.data.length : 0,
416
+ messages: allMessages.data
417
+ }, null, 2));
418
+ throw new Error(`Skill "${skillName}" produced no output after ${elapsed}s and ${emptyPolls} empty polls. The agent may have failed to start — check model ID and API key.`);
419
+ }
229
420
  continue;
230
421
  }
231
422
  return parts;
232
423
  }
233
- console.log(`[flue] skill("${skillName}"): running (${elapsed}s)`);
424
+ if (pollCount % 12 === 0) console.log(`[flue] skill("${skillName}"): running (${elapsed}s)`);
234
425
  }
235
426
  }
236
427
  /**
@@ -305,4 +496,4 @@ var Flue = class {
305
496
  };
306
497
 
307
498
  //#endregion
308
- export { Flue, SkillOutputError };
499
+ export { Flue, SkillOutputError, transformEvent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flue/client",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {