agent-relay 3.2.8 → 3.2.10

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.
Files changed (108) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +45361 -23429
  6. package/dist/src/cli/commands/setup.d.ts +8 -0
  7. package/dist/src/cli/commands/setup.d.ts.map +1 -1
  8. package/dist/src/cli/commands/setup.js +42 -0
  9. package/dist/src/cli/commands/setup.js.map +1 -1
  10. package/dist/src/cli/relaycast-mcp.d.ts.map +1 -1
  11. package/dist/src/cli/relaycast-mcp.js +8 -1
  12. package/dist/src/cli/relaycast-mcp.js.map +1 -1
  13. package/package.json +13 -11
  14. package/packages/acp-bridge/package.json +2 -2
  15. package/packages/config/package.json +1 -1
  16. package/packages/hooks/package.json +4 -4
  17. package/packages/memory/package.json +2 -2
  18. package/packages/openclaw/package.json +3 -3
  19. package/packages/policy/package.json +2 -2
  20. package/packages/sdk/dist/workflows/__tests__/cli-session-collector.test.d.ts +2 -0
  21. package/packages/sdk/dist/workflows/__tests__/cli-session-collector.test.d.ts.map +1 -0
  22. package/packages/sdk/dist/workflows/__tests__/cli-session-collector.test.js +54 -0
  23. package/packages/sdk/dist/workflows/__tests__/cli-session-collector.test.js.map +1 -0
  24. package/packages/sdk/dist/workflows/__tests__/collectors/claude.test.d.ts +2 -0
  25. package/packages/sdk/dist/workflows/__tests__/collectors/claude.test.d.ts.map +1 -0
  26. package/packages/sdk/dist/workflows/__tests__/collectors/claude.test.js +85 -0
  27. package/packages/sdk/dist/workflows/__tests__/collectors/claude.test.js.map +1 -0
  28. package/packages/sdk/dist/workflows/__tests__/collectors/codex.test.d.ts +2 -0
  29. package/packages/sdk/dist/workflows/__tests__/collectors/codex.test.d.ts.map +1 -0
  30. package/packages/sdk/dist/workflows/__tests__/collectors/codex.test.js +67 -0
  31. package/packages/sdk/dist/workflows/__tests__/collectors/codex.test.js.map +1 -0
  32. package/packages/sdk/dist/workflows/__tests__/collectors/opencode.test.d.ts +2 -0
  33. package/packages/sdk/dist/workflows/__tests__/collectors/opencode.test.d.ts.map +1 -0
  34. package/packages/sdk/dist/workflows/__tests__/collectors/opencode.test.js +119 -0
  35. package/packages/sdk/dist/workflows/__tests__/collectors/opencode.test.js.map +1 -0
  36. package/packages/sdk/dist/workflows/__tests__/run-summary-table.test.d.ts +2 -0
  37. package/packages/sdk/dist/workflows/__tests__/run-summary-table.test.d.ts.map +1 -0
  38. package/packages/sdk/dist/workflows/__tests__/run-summary-table.test.js +130 -0
  39. package/packages/sdk/dist/workflows/__tests__/run-summary-table.test.js.map +1 -0
  40. package/packages/sdk/dist/workflows/__tests__/step-cwd.test.d.ts +2 -0
  41. package/packages/sdk/dist/workflows/__tests__/step-cwd.test.d.ts.map +1 -0
  42. package/packages/sdk/dist/workflows/__tests__/step-cwd.test.js +42 -0
  43. package/packages/sdk/dist/workflows/__tests__/step-cwd.test.js.map +1 -0
  44. package/packages/sdk/dist/workflows/builder.d.ts +2 -0
  45. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  46. package/packages/sdk/dist/workflows/builder.js +4 -0
  47. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  48. package/packages/sdk/dist/workflows/cli-session-collector.d.ts +39 -0
  49. package/packages/sdk/dist/workflows/cli-session-collector.d.ts.map +1 -0
  50. package/packages/sdk/dist/workflows/cli-session-collector.js +23 -0
  51. package/packages/sdk/dist/workflows/cli-session-collector.js.map +1 -0
  52. package/packages/sdk/dist/workflows/cli.js +228 -48
  53. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  54. package/packages/sdk/dist/workflows/collectors/claude.d.ts +6 -0
  55. package/packages/sdk/dist/workflows/collectors/claude.d.ts.map +1 -0
  56. package/packages/sdk/dist/workflows/collectors/claude.js +330 -0
  57. package/packages/sdk/dist/workflows/collectors/claude.js.map +1 -0
  58. package/packages/sdk/dist/workflows/collectors/codex.d.ts +18 -0
  59. package/packages/sdk/dist/workflows/collectors/codex.d.ts.map +1 -0
  60. package/packages/sdk/dist/workflows/collectors/codex.js +265 -0
  61. package/packages/sdk/dist/workflows/collectors/codex.js.map +1 -0
  62. package/packages/sdk/dist/workflows/collectors/opencode.d.ts +6 -0
  63. package/packages/sdk/dist/workflows/collectors/opencode.d.ts.map +1 -0
  64. package/packages/sdk/dist/workflows/collectors/opencode.js +178 -0
  65. package/packages/sdk/dist/workflows/collectors/opencode.js.map +1 -0
  66. package/packages/sdk/dist/workflows/index.d.ts +3 -0
  67. package/packages/sdk/dist/workflows/index.d.ts.map +1 -1
  68. package/packages/sdk/dist/workflows/index.js +3 -0
  69. package/packages/sdk/dist/workflows/index.js.map +1 -1
  70. package/packages/sdk/dist/workflows/listr-renderer.d.ts +26 -0
  71. package/packages/sdk/dist/workflows/listr-renderer.d.ts.map +1 -0
  72. package/packages/sdk/dist/workflows/listr-renderer.js +232 -0
  73. package/packages/sdk/dist/workflows/listr-renderer.js.map +1 -0
  74. package/packages/sdk/dist/workflows/run-summary-table.d.ts +4 -0
  75. package/packages/sdk/dist/workflows/run-summary-table.d.ts.map +1 -0
  76. package/packages/sdk/dist/workflows/run-summary-table.js +98 -0
  77. package/packages/sdk/dist/workflows/run-summary-table.js.map +1 -0
  78. package/packages/sdk/dist/workflows/runner.d.ts +11 -0
  79. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  80. package/packages/sdk/dist/workflows/runner.js +91 -26
  81. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  82. package/packages/sdk/dist/workflows/types.d.ts +2 -0
  83. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  84. package/packages/sdk/dist/workflows/types.js.map +1 -1
  85. package/packages/sdk/package.json +5 -3
  86. package/packages/sdk/src/workflows/__tests__/cli-session-collector.test.ts +64 -0
  87. package/packages/sdk/src/workflows/__tests__/collectors/claude.test.ts +104 -0
  88. package/packages/sdk/src/workflows/__tests__/collectors/codex.test.ts +82 -0
  89. package/packages/sdk/src/workflows/__tests__/collectors/opencode.test.ts +178 -0
  90. package/packages/sdk/src/workflows/__tests__/run-summary-table.test.ts +160 -0
  91. package/packages/sdk/src/workflows/__tests__/step-cwd.test.ts +72 -0
  92. package/packages/sdk/src/workflows/builder.ts +4 -0
  93. package/packages/sdk/src/workflows/cli-session-collector.ts +58 -0
  94. package/packages/sdk/src/workflows/cli.ts +289 -50
  95. package/packages/sdk/src/workflows/collectors/claude.ts +415 -0
  96. package/packages/sdk/src/workflows/collectors/codex.ts +351 -0
  97. package/packages/sdk/src/workflows/collectors/opencode.ts +279 -0
  98. package/packages/sdk/src/workflows/index.ts +3 -0
  99. package/packages/sdk/src/workflows/listr-renderer.ts +278 -0
  100. package/packages/sdk/src/workflows/run-summary-table.ts +110 -0
  101. package/packages/sdk/src/workflows/runner.ts +122 -28
  102. package/packages/sdk/src/workflows/types.ts +2 -0
  103. package/packages/sdk/vitest.config.ts +1 -1
  104. package/packages/sdk-py/pyproject.toml +1 -1
  105. package/packages/telemetry/package.json +1 -1
  106. package/packages/trajectory/package.json +2 -2
  107. package/packages/user-directory/package.json +2 -2
  108. package/packages/utils/package.json +2 -2
@@ -0,0 +1,351 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ import type {
7
+ CliSessionCollector,
8
+ CliSessionQuery,
9
+ CliSessionReport,
10
+ } from '../cli-session-collector.js';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const CODEX_HOME = path.join(os.homedir(), '.codex');
14
+ const DEFAULT_HISTORY_PATH = path.join(CODEX_HOME, 'history.jsonl');
15
+ const DEFAULT_STATE_PATH = path.join(CODEX_HOME, 'state_5.sqlite');
16
+
17
+ type DatabaseInstance = {
18
+ prepare(sql: string): {
19
+ all<T>(params?: unknown): T[];
20
+ };
21
+ close?: () => void;
22
+ };
23
+
24
+ type DatabaseConstructor = new (
25
+ filename: string,
26
+ options?: { readonly?: boolean; fileMustExist?: boolean },
27
+ ) => DatabaseInstance;
28
+
29
+ interface DatabaseSyncModule {
30
+ DatabaseSync: new (
31
+ filename: string,
32
+ options?: { readOnly?: boolean; open?: boolean },
33
+ ) => DatabaseInstance;
34
+ }
35
+
36
+ interface CodexCollectorOptions {
37
+ historyPath?: string;
38
+ statePath?: string;
39
+ }
40
+
41
+ interface HistoryEntry {
42
+ session_id?: string;
43
+ ts?: number;
44
+ text?: string;
45
+ }
46
+
47
+ interface ThreadRow {
48
+ id: string;
49
+ cwd: string;
50
+ model_provider: string;
51
+ tokens_used: number;
52
+ created_at: number;
53
+ updated_at: number;
54
+ [key: string]: unknown;
55
+ }
56
+
57
+ interface LogRow {
58
+ ts?: number;
59
+ level?: string;
60
+ message?: string | null;
61
+ line?: number | null;
62
+ }
63
+
64
+ function loadBetterSqlite3(): DatabaseConstructor | null {
65
+ try {
66
+ return require('better-sqlite3') as DatabaseConstructor;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ async function openDatabase(dbPath: string): Promise<DatabaseInstance | null> {
73
+ const BetterSqlite = loadBetterSqlite3();
74
+ if (BetterSqlite) {
75
+ try {
76
+ return new BetterSqlite(dbPath, { readonly: true, fileMustExist: true });
77
+ } catch {
78
+ // Fall through to node:sqlite.
79
+ }
80
+ }
81
+
82
+ try {
83
+ const sqlite = (await import('node:sqlite')) as DatabaseSyncModule;
84
+ return new sqlite.DatabaseSync(dbPath, { readOnly: true, open: true });
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function normalizeTimestamp(value: unknown): number | null {
91
+ const numeric = typeof value === 'number' && Number.isFinite(value)
92
+ ? value
93
+ : typeof value === 'string' && value.trim()
94
+ ? Number(value)
95
+ : null;
96
+ if (numeric === null || !Number.isFinite(numeric)) {
97
+ return null;
98
+ }
99
+
100
+ return numeric < 10_000_000_000 ? numeric * 1000 : numeric;
101
+ }
102
+
103
+ function parseJsonLine<T>(line: string): T | null {
104
+ try {
105
+ return JSON.parse(line) as T;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function parseModelProvider(value: string | null | undefined): { provider: string | null; model: string | null } {
112
+ if (!value) {
113
+ return { provider: null, model: null };
114
+ }
115
+
116
+ if (value.includes('/')) {
117
+ const [provider, ...rest] = value.split('/');
118
+ return {
119
+ provider: provider || null,
120
+ model: rest.join('/') || null,
121
+ };
122
+ }
123
+
124
+ if (value.includes(':')) {
125
+ const [provider, ...rest] = value.split(':');
126
+ return {
127
+ provider: provider || null,
128
+ model: rest.join(':') || null,
129
+ };
130
+ }
131
+
132
+ return {
133
+ provider: value,
134
+ model: null,
135
+ };
136
+ }
137
+
138
+ function getNumericField(row: ThreadRow, fieldNames: string[]): number | null {
139
+ for (const fieldName of fieldNames) {
140
+ const value = row[fieldName];
141
+ if (typeof value === 'number' && Number.isFinite(value)) {
142
+ return value;
143
+ }
144
+ }
145
+
146
+ return null;
147
+ }
148
+
149
+ function extractTokens(row: ThreadRow): CliSessionReport['tokens'] {
150
+ const input = getNumericField(row, ['input_tokens', 'prompt_tokens', 'tokens_input']);
151
+ const output = getNumericField(row, ['output_tokens', 'completion_tokens', 'tokens_output']);
152
+ const cacheRead = getNumericField(row, ['cache_read_tokens', 'tokens_cache_read', 'cached_input_tokens']);
153
+
154
+ if (input !== null || output !== null || cacheRead !== null) {
155
+ return {
156
+ input: input ?? 0,
157
+ output: output ?? 0,
158
+ cacheRead: cacheRead ?? 0,
159
+ };
160
+ }
161
+
162
+ return typeof row.tokens_used === 'number'
163
+ ? {
164
+ input: row.tokens_used,
165
+ output: 0,
166
+ cacheRead: 0,
167
+ }
168
+ : null;
169
+ }
170
+
171
+ export class CodexCollector implements CliSessionCollector {
172
+ private readonly historyPath: string;
173
+ private readonly statePath: string;
174
+
175
+ constructor(options: CodexCollectorOptions = {}) {
176
+ this.historyPath = options.historyPath ?? DEFAULT_HISTORY_PATH;
177
+ this.statePath = options.statePath ?? DEFAULT_STATE_PATH;
178
+ }
179
+
180
+ canCollect(): boolean {
181
+ return fs.existsSync(this.statePath) || fs.existsSync(this.historyPath);
182
+ }
183
+
184
+ async collect(query: CliSessionQuery): Promise<CliSessionReport | null> {
185
+ const historyEntries = this.readHistoryEntries();
186
+ const matchedThread = await this.findMatchingThread(query);
187
+
188
+ if (matchedThread) {
189
+ const errors = await this.readThreadErrors(matchedThread.id);
190
+ const { provider, model } = parseModelProvider(matchedThread.model_provider);
191
+ const createdAtMs = normalizeTimestamp(matchedThread.created_at);
192
+ const updatedAtMs = normalizeTimestamp(matchedThread.updated_at);
193
+
194
+ return {
195
+ cli: 'codex',
196
+ sessionId: matchedThread.id,
197
+ model,
198
+ provider,
199
+ durationMs:
200
+ createdAtMs !== null && updatedAtMs !== null && updatedAtMs >= createdAtMs
201
+ ? updatedAtMs - createdAtMs
202
+ : Math.max(query.completedAt - query.startedAt, 0),
203
+ cost: null,
204
+ tokens: extractTokens(matchedThread),
205
+ turns: historyEntries.filter((entry) => entry.session_id === matchedThread.id).length,
206
+ toolCalls: [],
207
+ errors,
208
+ finalStatus: errors.length > 0 ? 'failed' : 'unknown',
209
+ summary: null,
210
+ raw: {
211
+ matchedVia: 'threads',
212
+ thread: matchedThread,
213
+ },
214
+ };
215
+ }
216
+
217
+ const historyMatch = this.findMatchingHistoryEntry(query, historyEntries);
218
+ if (!historyMatch) {
219
+ return null;
220
+ }
221
+
222
+ return {
223
+ cli: 'codex',
224
+ sessionId: historyMatch.session_id ?? null,
225
+ model: null,
226
+ provider: null,
227
+ durationMs: Math.max(query.completedAt - query.startedAt, 0),
228
+ cost: null,
229
+ tokens: null,
230
+ turns: historyMatch.session_id
231
+ ? historyEntries.filter((entry) => entry.session_id === historyMatch.session_id).length
232
+ : 0,
233
+ toolCalls: [],
234
+ errors: [],
235
+ finalStatus: 'unknown',
236
+ summary: null,
237
+ raw: {
238
+ matchedVia: 'history',
239
+ entry: historyMatch,
240
+ },
241
+ };
242
+ }
243
+
244
+ private readHistoryEntries(): HistoryEntry[] {
245
+ if (!fs.existsSync(this.historyPath)) {
246
+ return [];
247
+ }
248
+
249
+ try {
250
+ return fs.readFileSync(this.historyPath, 'utf8')
251
+ .split(/\r?\n/)
252
+ .map((line) => line.trim())
253
+ .filter(Boolean)
254
+ .flatMap((line) => {
255
+ const parsed = parseJsonLine<HistoryEntry>(line);
256
+ return parsed ? [parsed] : [];
257
+ });
258
+ } catch {
259
+ return [];
260
+ }
261
+ }
262
+
263
+ private findMatchingHistoryEntry(query: CliSessionQuery, entries: HistoryEntry[]): HistoryEntry | null {
264
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
265
+ const entry = entries[index];
266
+ const timestamp = normalizeTimestamp(entry.ts);
267
+ if (timestamp === null) {
268
+ continue;
269
+ }
270
+
271
+ if (timestamp >= query.startedAt && timestamp <= query.completedAt) {
272
+ return entry;
273
+ }
274
+ }
275
+
276
+ return null;
277
+ }
278
+
279
+ private async findMatchingThread(query: CliSessionQuery): Promise<ThreadRow | null> {
280
+ if (!fs.existsSync(this.statePath)) {
281
+ return null;
282
+ }
283
+
284
+ const db = await openDatabase(this.statePath);
285
+ if (!db) {
286
+ return null;
287
+ }
288
+
289
+ try {
290
+ const threads = db.prepare(
291
+ `
292
+ SELECT *
293
+ FROM threads
294
+ WHERE cwd = ?
295
+ ORDER BY created_at DESC
296
+ LIMIT 100
297
+ `,
298
+ ).all<ThreadRow>(query.cwd);
299
+
300
+ return threads.find((thread) => {
301
+ const createdAt = normalizeTimestamp(thread.created_at);
302
+ return createdAt !== null && createdAt >= query.startedAt && createdAt <= query.completedAt;
303
+ }) ?? null;
304
+ } catch {
305
+ return null;
306
+ } finally {
307
+ db.close?.();
308
+ }
309
+ }
310
+
311
+ private async readThreadErrors(threadId: string): Promise<CliSessionReport['errors']> {
312
+ if (!fs.existsSync(this.statePath)) {
313
+ return [];
314
+ }
315
+
316
+ const db = await openDatabase(this.statePath);
317
+ if (!db) {
318
+ return [];
319
+ }
320
+
321
+ try {
322
+ const rows = db.prepare(
323
+ `
324
+ SELECT ts, level, message, line
325
+ FROM logs
326
+ WHERE thread_id = ?
327
+ AND lower(level) = 'error'
328
+ ORDER BY ts ASC
329
+ `,
330
+ ).all<LogRow>(threadId);
331
+
332
+ return rows
333
+ .map((row, index) => {
334
+ const message = typeof row.message === 'string' ? row.message.trim() : '';
335
+ if (!message) {
336
+ return null;
337
+ }
338
+
339
+ return {
340
+ turn: index + 1,
341
+ text: message,
342
+ };
343
+ })
344
+ .filter((row): row is { turn: number; text: string } => row !== null);
345
+ } catch {
346
+ return [];
347
+ } finally {
348
+ db.close?.();
349
+ }
350
+ }
351
+ }
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ import type {
7
+ CliSessionCollector,
8
+ CliSessionQuery,
9
+ CliSessionReport,
10
+ } from '../cli-session-collector.js';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const OPENCODE_DB_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
14
+ const MATCH_WINDOW_GRACE_MS = 5_000;
15
+ const ERROR_LINE_PATTERN = /^(Error|error:|Command failed|FAIL)\b/;
16
+
17
+ type DatabaseInstance = {
18
+ prepare(sql: string): {
19
+ get<T>(params?: unknown): T | undefined;
20
+ all<T>(params?: unknown): T[];
21
+ };
22
+ pragma(source: string): unknown;
23
+ close(): void;
24
+ };
25
+
26
+ type DatabaseConstructor = new (
27
+ filename: string,
28
+ options?: { readonly?: boolean; fileMustExist?: boolean },
29
+ ) => DatabaseInstance;
30
+
31
+ interface SessionRow {
32
+ id: string;
33
+ directory: string;
34
+ time_created: number;
35
+ }
36
+
37
+ interface MessageRow {
38
+ id: string;
39
+ session_id: string;
40
+ time_created: number;
41
+ data: string;
42
+ }
43
+
44
+ interface PartRow {
45
+ id: string;
46
+ message_id: string;
47
+ session_id: string;
48
+ time_created: number;
49
+ data: string;
50
+ }
51
+
52
+ interface OpenCodeMessageData {
53
+ role?: string;
54
+ modelID?: string;
55
+ providerID?: string;
56
+ cost?: number;
57
+ finish?: string;
58
+ tokens?: {
59
+ input?: number;
60
+ output?: number;
61
+ cache?: {
62
+ read?: number;
63
+ };
64
+ };
65
+ }
66
+
67
+ interface OpenCodePartData {
68
+ type?: string;
69
+ text?: string;
70
+ name?: string;
71
+ }
72
+
73
+ function loadDatabaseConstructor(): DatabaseConstructor | null {
74
+ try {
75
+ return require('better-sqlite3') as DatabaseConstructor;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function parseJson<T>(value: string): T | null {
82
+ try {
83
+ return JSON.parse(value) as T;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function toNumber(value: unknown): number {
90
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
91
+ }
92
+
93
+ function normalizeStatus(finish: string | undefined, hasErrors: boolean): CliSessionReport['finalStatus'] {
94
+ if (finish === 'stop' || finish === 'completed') {
95
+ return 'completed';
96
+ }
97
+
98
+ if (finish === 'error' || finish === 'failed' || hasErrors) {
99
+ return 'failed';
100
+ }
101
+
102
+ return 'unknown';
103
+ }
104
+
105
+ function isToolPart(part: OpenCodePartData | null): part is OpenCodePartData {
106
+ return !!part?.type && part.type.toLowerCase().includes('tool');
107
+ }
108
+
109
+ export class OpenCodeCollector implements CliSessionCollector {
110
+ canCollect(): boolean {
111
+ if (!fs.existsSync(OPENCODE_DB_PATH)) {
112
+ return false;
113
+ }
114
+
115
+ const Database = loadDatabaseConstructor();
116
+ if (!Database) {
117
+ return false;
118
+ }
119
+
120
+ let db: DatabaseInstance | null = null;
121
+
122
+ try {
123
+ db = new Database(OPENCODE_DB_PATH, { readonly: true, fileMustExist: true });
124
+ db.pragma('query_only = ON');
125
+ db.prepare('SELECT 1').get();
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ } finally {
130
+ db?.close();
131
+ }
132
+ }
133
+
134
+ async collect(query: CliSessionQuery): Promise<CliSessionReport | null> {
135
+ const Database = loadDatabaseConstructor();
136
+ if (!Database) {
137
+ return null;
138
+ }
139
+
140
+ let db: DatabaseInstance | null = null;
141
+
142
+ try {
143
+ db = new Database(OPENCODE_DB_PATH, { readonly: true, fileMustExist: true });
144
+ db.pragma('query_only = ON');
145
+
146
+ const session = db.prepare(
147
+ `
148
+ SELECT id, directory, time_created
149
+ FROM session
150
+ WHERE directory = @cwd
151
+ AND time_created BETWEEN @startedAt AND @completedAt
152
+ ORDER BY time_created DESC
153
+ LIMIT 1
154
+ `,
155
+ ).get<SessionRow>({
156
+ cwd: query.cwd,
157
+ startedAt: query.startedAt - MATCH_WINDOW_GRACE_MS,
158
+ completedAt: query.completedAt,
159
+ });
160
+
161
+ if (!session) {
162
+ return null;
163
+ }
164
+
165
+ const messages = db.prepare(
166
+ `
167
+ SELECT id, session_id, time_created, data
168
+ FROM message
169
+ WHERE session_id = ?
170
+ ORDER BY time_created ASC
171
+ `,
172
+ ).all<MessageRow>(session.id);
173
+
174
+ const parts = db.prepare(
175
+ `
176
+ SELECT id, message_id, session_id, time_created, data
177
+ FROM part
178
+ WHERE session_id = ?
179
+ ORDER BY time_created ASC
180
+ `,
181
+ ).all<PartRow>(session.id);
182
+
183
+ const parsedMessages = messages.map((message) => ({
184
+ ...message,
185
+ parsed: parseJson<OpenCodeMessageData>(message.data),
186
+ }));
187
+ const parsedParts = parts.map((part) => ({
188
+ ...part,
189
+ parsed: parseJson<OpenCodePartData>(part.data),
190
+ }));
191
+
192
+ const lastMessageWithMetadata = [...parsedMessages]
193
+ .reverse()
194
+ .find((message) => message.parsed?.modelID || message.parsed?.providerID || message.parsed?.finish);
195
+
196
+ const tokenTotals = parsedMessages.reduce(
197
+ (totals, message) => {
198
+ const tokens = message.parsed?.tokens;
199
+ totals.input += toNumber(tokens?.input);
200
+ totals.output += toNumber(tokens?.output);
201
+ totals.cacheRead += toNumber(tokens?.cache?.read);
202
+ return totals;
203
+ },
204
+ { input: 0, output: 0, cacheRead: 0 },
205
+ );
206
+
207
+ const hasCostData = parsedMessages.some(
208
+ (message) => typeof message.parsed?.cost === 'number' && Number.isFinite(message.parsed.cost),
209
+ );
210
+ const totalCost = parsedMessages.reduce((sum, message) => sum + toNumber(message.parsed?.cost), 0);
211
+
212
+ const toolCallCounts = new Map<string, number>();
213
+ for (const part of parsedParts) {
214
+ if (!isToolPart(part.parsed)) {
215
+ continue;
216
+ }
217
+
218
+ const name = part.parsed.name?.trim();
219
+ if (!name) {
220
+ continue;
221
+ }
222
+
223
+ toolCallCounts.set(name, (toolCallCounts.get(name) ?? 0) + 1);
224
+ }
225
+
226
+ const errors: CliSessionReport['errors'] = [];
227
+ for (const [index, part] of parsedParts.entries()) {
228
+ const text = part.parsed?.type === 'text' ? part.parsed.text : undefined;
229
+ if (!text) {
230
+ continue;
231
+ }
232
+
233
+ for (const line of text.split(/\r?\n/)) {
234
+ const trimmed = line.trim();
235
+ if (!trimmed || !ERROR_LINE_PATTERN.test(trimmed)) {
236
+ continue;
237
+ }
238
+
239
+ errors.push({ turn: index + 1, text: trimmed });
240
+ }
241
+ }
242
+
243
+ const summary = [...parsedParts]
244
+ .reverse()
245
+ .find((part) => part.parsed?.type === 'text' && part.parsed.text?.trim())?.parsed?.text?.trim() ?? null;
246
+
247
+ const turns = parsedMessages.filter(
248
+ (message) => message.parsed?.role === 'assistant' || message.parsed?.role === 'user',
249
+ ).length || parsedMessages.length;
250
+
251
+ return {
252
+ cli: 'opencode',
253
+ sessionId: session.id,
254
+ model: lastMessageWithMetadata?.parsed?.modelID ?? null,
255
+ provider: lastMessageWithMetadata?.parsed?.providerID ?? null,
256
+ durationMs:
257
+ parsedMessages.length > 0
258
+ ? Math.max(0, parsedMessages[parsedMessages.length - 1].time_created - session.time_created)
259
+ : null,
260
+ cost: hasCostData ? totalCost : null,
261
+ tokens: tokenTotals,
262
+ turns,
263
+ toolCalls: [...toolCallCounts.entries()].map(([name, count]) => ({ name, count })),
264
+ errors,
265
+ finalStatus: normalizeStatus(lastMessageWithMetadata?.parsed?.finish, errors.length > 0),
266
+ summary,
267
+ raw: {
268
+ session,
269
+ messages: parsedMessages.map(({ parsed, ...message }) => ({ ...message, data: parsed ?? message.data })),
270
+ parts: parsedParts.map(({ parsed, ...part }) => ({ ...part, data: parsed ?? part.data })),
271
+ },
272
+ };
273
+ } catch {
274
+ return null;
275
+ } finally {
276
+ db?.close();
277
+ }
278
+ }
279
+ }
@@ -1,6 +1,8 @@
1
1
  export * from './types.js';
2
2
  export * from './runner.js';
3
3
  export * from './custom-steps.js';
4
+ export * from './cli-session-collector.js';
5
+ export * from './run-summary-table.js';
4
6
  export {
5
7
  Models,
6
8
  ClaudeModels,
@@ -22,3 +24,4 @@ export * from './state.js';
22
24
  export * from './templates.js';
23
25
  export { WorkflowTrajectory, type StepOutcome } from './trajectory.js';
24
26
  export { formatDryRunReport } from './dry-run-format.js';
27
+ export { createWorkflowRenderer, type WorkflowRenderer } from './listr-renderer.js';