pi-studio-opencode 0.1.0

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 (60) hide show
  1. package/ARCHITECTURE.md +122 -0
  2. package/LICENSE +21 -0
  3. package/README.md +108 -0
  4. package/dist/demo-host-pi.d.ts +1 -0
  5. package/dist/demo-host-pi.js +71 -0
  6. package/dist/demo-host-pi.js.map +1 -0
  7. package/dist/demo-host.d.ts +1 -0
  8. package/dist/demo-host.js +154 -0
  9. package/dist/demo-host.js.map +1 -0
  10. package/dist/host-opencode-plugin.d.ts +52 -0
  11. package/dist/host-opencode-plugin.js +396 -0
  12. package/dist/host-opencode-plugin.js.map +1 -0
  13. package/dist/host-opencode.d.ts +154 -0
  14. package/dist/host-opencode.js +627 -0
  15. package/dist/host-opencode.js.map +1 -0
  16. package/dist/host-pi.d.ts +45 -0
  17. package/dist/host-pi.js +258 -0
  18. package/dist/host-pi.js.map +1 -0
  19. package/dist/install-config.d.ts +36 -0
  20. package/dist/install-config.js +136 -0
  21. package/dist/install-config.js.map +1 -0
  22. package/dist/install.d.ts +16 -0
  23. package/dist/install.js +168 -0
  24. package/dist/install.js.map +1 -0
  25. package/dist/launcher.d.ts +2 -0
  26. package/dist/launcher.js +124 -0
  27. package/dist/launcher.js.map +1 -0
  28. package/dist/main.d.ts +1 -0
  29. package/dist/main.js +732 -0
  30. package/dist/main.js.map +1 -0
  31. package/dist/mock-pi-session.d.ts +27 -0
  32. package/dist/mock-pi-session.js +138 -0
  33. package/dist/mock-pi-session.js.map +1 -0
  34. package/dist/open-browser.d.ts +1 -0
  35. package/dist/open-browser.js +29 -0
  36. package/dist/open-browser.js.map +1 -0
  37. package/dist/opencode-plugin.d.ts +3 -0
  38. package/dist/opencode-plugin.js +326 -0
  39. package/dist/opencode-plugin.js.map +1 -0
  40. package/dist/prototype-pdf.d.ts +12 -0
  41. package/dist/prototype-pdf.js +991 -0
  42. package/dist/prototype-pdf.js.map +1 -0
  43. package/dist/prototype-server.d.ts +88 -0
  44. package/dist/prototype-server.js +1002 -0
  45. package/dist/prototype-server.js.map +1 -0
  46. package/dist/prototype-theme.d.ts +36 -0
  47. package/dist/prototype-theme.js +1471 -0
  48. package/dist/prototype-theme.js.map +1 -0
  49. package/dist/studio-core.d.ts +63 -0
  50. package/dist/studio-core.js +251 -0
  51. package/dist/studio-core.js.map +1 -0
  52. package/dist/studio-host-types.d.ts +50 -0
  53. package/dist/studio-host-types.js +14 -0
  54. package/dist/studio-host-types.js.map +1 -0
  55. package/examples/opencode/INSTALL.md +67 -0
  56. package/examples/opencode/opencode.local-path.jsonc +16 -0
  57. package/package.json +68 -0
  58. package/static/prototype.css +1277 -0
  59. package/static/prototype.html +173 -0
  60. package/static/prototype.js +3198 -0
package/dist/main.js ADDED
@@ -0,0 +1,732 @@
1
+ import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk";
2
+ import { randomUUID } from "node:crypto";
3
+ import { createWriteStream } from "node:fs";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ const DEFAULT_RUN_PROMPT = [
8
+ "Please reply in two short paragraphs.",
9
+ "In the first paragraph, say that the initial run started.",
10
+ "In the second paragraph, say you are waiting for any queued follow-up instructions.",
11
+ ].join("\n");
12
+ const DEFAULT_QUEUE_PROMPT = [
13
+ "Queued instruction:",
14
+ "append one extra final paragraph that says exactly QUEUED_PROMPT_WORKED.",
15
+ ].join("\n");
16
+ const DEFAULT_SECOND_QUEUE_PROMPT = [
17
+ "Queued instruction 2:",
18
+ "append one more final paragraph that says exactly SECOND_QUEUED_PROMPT_WORKED.",
19
+ ].join("\n");
20
+ const DEFAULT_SECOND_RUN_PROMPT = [
21
+ "This is a fresh run after the earlier chain has already gone idle.",
22
+ "Reply with exactly this one line and nothing else:",
23
+ "SECOND_RUN_STARTED",
24
+ ].join("\n");
25
+ const DEFAULT_ABORT_PROMPT = [
26
+ "Write 150 numbered bullet points.",
27
+ "Each point should be a complete sentence about why deterministic event logs are useful.",
28
+ "Do not summarize.",
29
+ ].join("\n");
30
+ function parseArgs(argv) {
31
+ const options = {
32
+ directory: process.cwd(),
33
+ title: `Studio spike ${new Date().toISOString()}`,
34
+ runPrompt: DEFAULT_RUN_PROMPT,
35
+ queuePrompt: DEFAULT_QUEUE_PROMPT,
36
+ secondQueuePrompt: DEFAULT_SECOND_QUEUE_PROMPT,
37
+ secondRunPrompt: DEFAULT_SECOND_RUN_PROMPT,
38
+ queueDelayMs: 1200,
39
+ secondQueueDelayMs: 300,
40
+ settleTimeoutMs: 120_000,
41
+ pollIntervalMs: 1000,
42
+ artifactsDir: resolve(process.cwd(), "artifacts", `pi-studio-opencode-${Date.now()}`),
43
+ multiSteerTest: false,
44
+ newRunAfterIdleTest: false,
45
+ abortTest: false,
46
+ abortDelayMs: 1500,
47
+ abortPrompt: DEFAULT_ABORT_PROMPT,
48
+ };
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const arg = argv[i];
51
+ const next = argv[i + 1];
52
+ if (arg === "--base-url" && next) {
53
+ options.baseUrl = next;
54
+ i += 1;
55
+ continue;
56
+ }
57
+ if (arg === "--directory" && next) {
58
+ options.directory = resolve(next);
59
+ i += 1;
60
+ continue;
61
+ }
62
+ if (arg === "--session" && next) {
63
+ options.sessionId = next;
64
+ i += 1;
65
+ continue;
66
+ }
67
+ if (arg === "--title" && next) {
68
+ options.title = next;
69
+ i += 1;
70
+ continue;
71
+ }
72
+ if (arg === "--run-prompt" && next) {
73
+ options.runPrompt = next;
74
+ i += 1;
75
+ continue;
76
+ }
77
+ if (arg === "--queue-prompt" && next) {
78
+ options.queuePrompt = next;
79
+ i += 1;
80
+ continue;
81
+ }
82
+ if (arg === "--second-queue-prompt" && next) {
83
+ options.secondQueuePrompt = next;
84
+ i += 1;
85
+ continue;
86
+ }
87
+ if (arg === "--second-run-prompt" && next) {
88
+ options.secondRunPrompt = next;
89
+ i += 1;
90
+ continue;
91
+ }
92
+ if (arg === "--queue-delay-ms" && next) {
93
+ options.queueDelayMs = parseIntegerFlag("--queue-delay-ms", next);
94
+ i += 1;
95
+ continue;
96
+ }
97
+ if (arg === "--second-queue-delay-ms" && next) {
98
+ options.secondQueueDelayMs = parseIntegerFlag("--second-queue-delay-ms", next);
99
+ i += 1;
100
+ continue;
101
+ }
102
+ if (arg === "--settle-timeout-ms" && next) {
103
+ options.settleTimeoutMs = parseIntegerFlag("--settle-timeout-ms", next);
104
+ i += 1;
105
+ continue;
106
+ }
107
+ if (arg === "--poll-interval-ms" && next) {
108
+ options.pollIntervalMs = parseIntegerFlag("--poll-interval-ms", next);
109
+ i += 1;
110
+ continue;
111
+ }
112
+ if (arg === "--artifacts-dir" && next) {
113
+ options.artifactsDir = resolve(next);
114
+ i += 1;
115
+ continue;
116
+ }
117
+ if (arg === "--multi-steer-test") {
118
+ options.multiSteerTest = true;
119
+ continue;
120
+ }
121
+ if (arg === "--new-run-after-idle-test") {
122
+ options.newRunAfterIdleTest = true;
123
+ continue;
124
+ }
125
+ if (arg === "--abort-test") {
126
+ options.abortTest = true;
127
+ continue;
128
+ }
129
+ if (arg === "--abort-delay-ms" && next) {
130
+ options.abortDelayMs = parseIntegerFlag("--abort-delay-ms", next);
131
+ i += 1;
132
+ continue;
133
+ }
134
+ if (arg === "--abort-prompt" && next) {
135
+ options.abortPrompt = next;
136
+ i += 1;
137
+ continue;
138
+ }
139
+ if (arg === "--help" || arg === "-h") {
140
+ printUsageAndExit();
141
+ }
142
+ throw new Error(`Unknown argument: ${arg}`);
143
+ }
144
+ return options;
145
+ }
146
+ function parseIntegerFlag(flag, value) {
147
+ const parsed = Number.parseInt(value, 10);
148
+ if (!Number.isFinite(parsed) || parsed < 0) {
149
+ throw new Error(`Invalid value for ${flag}: ${value}`);
150
+ }
151
+ return parsed;
152
+ }
153
+ function printUsageAndExit() {
154
+ console.log(`Usage: npm start -- [options]
155
+
156
+ Options:
157
+ --base-url <url> Connect to an existing opencode server instead of starting one
158
+ --directory <path> Working directory / project directory (default: current directory)
159
+ --session <id> Reuse an existing session instead of creating one
160
+ --title <title> Session title for a new session
161
+ --run-prompt <text> Initial run prompt text
162
+ --queue-prompt <text> First queued steering prompt
163
+ --second-queue-prompt <text> Second queued steering prompt for --multi-steer-test
164
+ --second-run-prompt <text> Fresh run prompt for --new-run-after-idle-test
165
+ --queue-delay-ms <n> Delay before queueing the first steer (default: 1200)
166
+ --second-queue-delay-ms <n> Delay before queueing the second steer (default: 300)
167
+ --settle-timeout-ms <n> Timeout waiting for idle / replies (default: 120000)
168
+ --poll-interval-ms <n> Poll interval for status/messages (default: 1000)
169
+ --artifacts-dir <path> Output directory for logs/artifacts
170
+ --multi-steer-test Queue a second steer while the first chain is still busy
171
+ --new-run-after-idle-test Start a fresh run in the same session after the first chain settles
172
+ --abort-test Start another fresh run and abort it after a short delay
173
+ --abort-delay-ms <n> Delay before aborting the extra run (default: 1500)
174
+ --abort-prompt <text> Prompt used for the optional abort test
175
+ `);
176
+ process.exit(0);
177
+ }
178
+ function summarizeEvent(event) {
179
+ const props = event.properties;
180
+ if (event.type === "session.status") {
181
+ const status = props?.status;
182
+ const sessionID = typeof props?.sessionID === "string" ? props.sessionID : "?";
183
+ return `${event.type} session=${sessionID} status=${status?.type ?? "unknown"}`;
184
+ }
185
+ if (event.type === "session.idle") {
186
+ return `${event.type} session=${String(props?.sessionID ?? "?")}`;
187
+ }
188
+ if (event.type === "message.updated") {
189
+ const info = props?.info;
190
+ return `${event.type} role=${info?.role ?? "?"} message=${info?.id ?? "?"} session=${info?.sessionID ?? "?"}`;
191
+ }
192
+ if (event.type === "message.part.updated") {
193
+ const part = props?.part;
194
+ return `${event.type} partType=${part?.type ?? "?"} session=${part?.sessionID ?? "?"} message=${part?.messageID ?? "?"} part=${part?.id ?? "?"}`;
195
+ }
196
+ if (event.type === "permission.updated") {
197
+ return `${event.type} session=${String(props?.sessionID ?? "?")}`;
198
+ }
199
+ return event.type;
200
+ }
201
+ function normalizeMessage(record) {
202
+ const created = record.info.time.created;
203
+ const completed = record.info.role === "assistant" ? record.info.time.completed : undefined;
204
+ const error = record.info.role === "assistant" && record.info.error
205
+ ? `${record.info.error.name}: ${record.info.error.data.message ?? "unknown error"}`
206
+ : undefined;
207
+ const text = record.parts
208
+ .filter((part) => part.type === "text")
209
+ .map((part) => part.text)
210
+ .join("\n\n")
211
+ .trim();
212
+ const reasoning = record.parts
213
+ .filter((part) => part.type === "reasoning")
214
+ .map((part) => part.text)
215
+ .join("\n\n")
216
+ .trim();
217
+ return {
218
+ id: record.info.id,
219
+ role: record.info.role,
220
+ created,
221
+ completed,
222
+ error,
223
+ text,
224
+ reasoning,
225
+ partTypes: record.parts.map((part) => part.type),
226
+ partCount: record.parts.length,
227
+ };
228
+ }
229
+ function buildEffectivePrompt(basePrompt, steeringPrompts) {
230
+ if (steeringPrompts.length === 0)
231
+ return basePrompt;
232
+ const blocks = [`## Original run prompt\n\n${basePrompt}`];
233
+ for (let i = 0; i < steeringPrompts.length; i++) {
234
+ blocks.push(`## Steering ${i + 1}\n\n${steeringPrompts[i]}`);
235
+ }
236
+ return blocks.join("\n\n");
237
+ }
238
+ function countAssistantMessages(messages) {
239
+ return messages.filter((entry) => entry.info.role === "assistant").length;
240
+ }
241
+ async function fetchSessionMessages(client, sessionID, directory) {
242
+ const response = await client.session.messages({
243
+ path: { id: sessionID },
244
+ query: { directory, limit: 200 },
245
+ throwOnError: true,
246
+ });
247
+ return response.data ?? [];
248
+ }
249
+ async function fetchSessionStatus(client, sessionID, directory) {
250
+ const response = await client.session.status({
251
+ query: { directory },
252
+ throwOnError: true,
253
+ });
254
+ const statusMap = response.data ?? {};
255
+ const status = statusMap[sessionID];
256
+ return status?.type ?? null;
257
+ }
258
+ async function waitForSessionToSettle(client, sessionID, directory, expectedAssistantCount, timeoutMs, pollIntervalMs) {
259
+ const started = Date.now();
260
+ while (Date.now() - started < timeoutMs) {
261
+ const [messages, status] = await Promise.all([
262
+ fetchSessionMessages(client, sessionID, directory),
263
+ fetchSessionStatus(client, sessionID, directory),
264
+ ]);
265
+ const assistantCount = countAssistantMessages(messages);
266
+ const completedAssistantCount = messages.filter((entry) => entry.info.role === "assistant" && Boolean(entry.info.time.completed)).length;
267
+ if ((status === "idle" || status === null) && assistantCount >= expectedAssistantCount && completedAssistantCount >= expectedAssistantCount) {
268
+ return messages;
269
+ }
270
+ await sleep(pollIntervalMs);
271
+ }
272
+ throw new Error(`Timed out waiting for session ${sessionID} to settle.`);
273
+ }
274
+ async function waitForSessionToBecomeIdle(client, sessionID, directory, timeoutMs, pollIntervalMs) {
275
+ const started = Date.now();
276
+ while (Date.now() - started < timeoutMs) {
277
+ const [messages, status] = await Promise.all([
278
+ fetchSessionMessages(client, sessionID, directory),
279
+ fetchSessionStatus(client, sessionID, directory),
280
+ ]);
281
+ if (status === "idle" || status === null) {
282
+ return messages;
283
+ }
284
+ await sleep(pollIntervalMs);
285
+ }
286
+ throw new Error(`Timed out waiting for session ${sessionID} to become idle.`);
287
+ }
288
+ async function createOrReuseSession(client, options) {
289
+ if (options.sessionId) {
290
+ const response = await client.session.get({
291
+ path: { id: options.sessionId },
292
+ query: { directory: options.directory },
293
+ throwOnError: true,
294
+ });
295
+ if (!response.data)
296
+ throw new Error(`Session not found: ${options.sessionId}`);
297
+ return response.data;
298
+ }
299
+ const response = await client.session.create({
300
+ query: { directory: options.directory },
301
+ body: { title: options.title },
302
+ throwOnError: true,
303
+ });
304
+ if (!response.data) {
305
+ throw new Error("Session creation returned no data.");
306
+ }
307
+ return response.data;
308
+ }
309
+ async function submitPrompt(context, input) {
310
+ const sessionStatusAtSubmit = await fetchSessionStatus(context.client, context.session.id, context.options.directory);
311
+ const queuedWhileBusy = sessionStatusAtSubmit === "busy";
312
+ const submittedAt = Date.now();
313
+ let pendingChain = null;
314
+ let activeChain = context.currentChain;
315
+ let steeringPromptsForEffective = [];
316
+ if (input.promptMode === "run") {
317
+ pendingChain = {
318
+ chainId: `chain_${randomUUID()}`,
319
+ chainIndex: context.chains.length + 1,
320
+ sessionId: context.session.id,
321
+ startedAt: submittedAt,
322
+ basePromptText: input.promptText,
323
+ steeringPrompts: [],
324
+ submissionIds: [],
325
+ };
326
+ activeChain = pendingChain;
327
+ }
328
+ else {
329
+ if (!activeChain) {
330
+ throw new Error(`Cannot submit steer prompt without an active chain: ${input.stepLabel}`);
331
+ }
332
+ steeringPromptsForEffective = [...activeChain.steeringPrompts, input.promptText];
333
+ }
334
+ await context.client.session.promptAsync({
335
+ path: { id: context.session.id },
336
+ query: { directory: context.options.directory },
337
+ body: {
338
+ parts: [{ type: "text", text: input.promptText }],
339
+ },
340
+ throwOnError: true,
341
+ });
342
+ if (pendingChain) {
343
+ context.chains.push(pendingChain);
344
+ context.currentChain = pendingChain;
345
+ activeChain = pendingChain;
346
+ }
347
+ if (!activeChain) {
348
+ throw new Error(`Active chain missing after prompt submission: ${input.stepLabel}`);
349
+ }
350
+ const submission = {
351
+ localPromptId: `prompt_${randomUUID()}`,
352
+ submissionIndex: context.submissions.length + 1,
353
+ sessionId: context.session.id,
354
+ chainId: activeChain.chainId,
355
+ chainIndex: activeChain.chainIndex,
356
+ scenarioName: input.scenarioName,
357
+ stepLabel: input.stepLabel,
358
+ promptMode: input.promptMode,
359
+ triggerKind: input.promptMode,
360
+ submittedAt,
361
+ sessionStatusAtSubmit,
362
+ queuedWhileBusy,
363
+ promptText: input.promptText,
364
+ promptSteeringCount: input.promptMode === "run" ? 0 : steeringPromptsForEffective.length,
365
+ promptTriggerText: input.promptText,
366
+ effectivePrompt: input.promptMode === "run"
367
+ ? buildEffectivePrompt(activeChain.basePromptText, [])
368
+ : buildEffectivePrompt(activeChain.basePromptText, steeringPromptsForEffective),
369
+ expectedReply: true,
370
+ };
371
+ context.submissions.push(submission);
372
+ activeChain.submissionIds.push(submission.localPromptId);
373
+ if (input.promptMode === "steer") {
374
+ activeChain.steeringPrompts.push(input.promptText);
375
+ }
376
+ return submission;
377
+ }
378
+ async function waitForIdleAndRefreshAssistantCount(context) {
379
+ const before = context.expectedAssistantCount;
380
+ const messages = await waitForSessionToBecomeIdle(context.client, context.session.id, context.options.directory, context.options.settleTimeoutMs, context.options.pollIntervalMs);
381
+ const after = countAssistantMessages(messages);
382
+ context.expectedAssistantCount = after;
383
+ return {
384
+ messages,
385
+ observedAssistantReplies: Math.max(0, after - before),
386
+ };
387
+ }
388
+ function closeActiveChain(context, observedAssistantReplies) {
389
+ if (!context.currentChain)
390
+ return;
391
+ context.currentChain.completedAt = Date.now();
392
+ if (observedAssistantReplies !== undefined) {
393
+ context.currentChain.observedAssistantReplies = observedAssistantReplies;
394
+ }
395
+ context.currentChain = null;
396
+ }
397
+ function attachMatchesToSubmissions(chains, submissions, normalizedMessages, initialMessageIds) {
398
+ const newMessages = normalizedMessages.filter((message) => !initialMessageIds.has(message.id));
399
+ const userMessages = newMessages.filter((message) => message.role === "user");
400
+ const assistantMessages = newMessages.filter((message) => message.role === "assistant");
401
+ const usedUserIds = new Set();
402
+ for (const submission of submissions) {
403
+ const exactMatch = userMessages.find((message) => (!usedUserIds.has(message.id)
404
+ && message.text === submission.promptText
405
+ && message.created >= submission.submittedAt - 10_000));
406
+ const fallbackMatch = exactMatch
407
+ ? null
408
+ : userMessages.find((message) => !usedUserIds.has(message.id) && message.text === submission.promptText);
409
+ const matchedUser = exactMatch ?? fallbackMatch;
410
+ if (matchedUser) {
411
+ usedUserIds.add(matchedUser.id);
412
+ submission.userMessageId = matchedUser.id;
413
+ submission.userMessageCreated = matchedUser.created;
414
+ }
415
+ }
416
+ const usedAssistantIds = new Set();
417
+ let assistantCursor = 0;
418
+ for (const chain of chains.slice().sort((a, b) => a.chainIndex - b.chainIndex)) {
419
+ const chainSubmissions = submissions
420
+ .filter((submission) => submission.chainId === chain.chainId && submission.expectedReply)
421
+ .sort((a, b) => a.submissionIndex - b.submissionIndex);
422
+ const observedAssistantReplies = chain.observedAssistantReplies ?? chainSubmissions.length;
423
+ for (let i = 0; i < observedAssistantReplies && i < chainSubmissions.length; i++) {
424
+ while (assistantCursor < assistantMessages.length && usedAssistantIds.has(assistantMessages[assistantCursor].id)) {
425
+ assistantCursor += 1;
426
+ }
427
+ const matchedAssistant = assistantMessages[assistantCursor];
428
+ if (!matchedAssistant)
429
+ break;
430
+ assistantCursor += 1;
431
+ usedAssistantIds.add(matchedAssistant.id);
432
+ const submission = chainSubmissions[i];
433
+ submission.responseMessageId = matchedAssistant.id;
434
+ submission.responseCreated = matchedAssistant.created;
435
+ submission.responseCompleted = matchedAssistant.completed;
436
+ submission.responseText = matchedAssistant.text;
437
+ submission.responseError = matchedAssistant.error;
438
+ }
439
+ }
440
+ return {
441
+ initialMessageCount: initialMessageIds.size,
442
+ newMessageCount: newMessages.length,
443
+ newUserMessageCount: userMessages.length,
444
+ newAssistantMessageCount: assistantMessages.length,
445
+ missingUserMatches: submissions.filter((submission) => !submission.userMessageId).map((submission) => submission.localPromptId),
446
+ missingResponseMatches: submissions.filter((submission) => submission.expectedReply && !submission.responseMessageId).map((submission) => submission.localPromptId),
447
+ unassignedUserMessageIds: userMessages.filter((message) => !usedUserIds.has(message.id)).map((message) => message.id),
448
+ unassignedAssistantMessageIds: assistantMessages.filter((message) => !usedAssistantIds.has(message.id)).map((message) => message.id),
449
+ };
450
+ }
451
+ function buildResponseHistory(submissions) {
452
+ return submissions
453
+ .filter((submission) => submission.expectedReply)
454
+ .sort((a, b) => a.submissionIndex - b.submissionIndex)
455
+ .map((submission, index) => ({
456
+ responseIndex: index + 1,
457
+ localPromptId: submission.localPromptId,
458
+ chainId: submission.chainId,
459
+ chainIndex: submission.chainIndex,
460
+ scenarioName: submission.scenarioName,
461
+ stepLabel: submission.stepLabel,
462
+ responseMessageId: submission.responseMessageId ?? null,
463
+ responseText: submission.responseText ?? null,
464
+ responseError: submission.responseError,
465
+ promptMode: submission.promptMode === "run" ? "run" : "effective",
466
+ triggerKind: submission.triggerKind,
467
+ promptSteeringCount: submission.promptSteeringCount,
468
+ promptTriggerText: submission.promptTriggerText,
469
+ effectivePrompt: submission.effectivePrompt,
470
+ queuedWhileBusy: submission.queuedWhileBusy,
471
+ sessionStatusAtSubmit: submission.sessionStatusAtSubmit,
472
+ userMessageId: submission.userMessageId ?? null,
473
+ }));
474
+ }
475
+ function buildChainSummaries(chains, submissions) {
476
+ return chains
477
+ .slice()
478
+ .sort((a, b) => a.chainIndex - b.chainIndex)
479
+ .map((chain) => {
480
+ const chainSubmissions = submissions.filter((submission) => submission.chainId === chain.chainId);
481
+ return {
482
+ chainId: chain.chainId,
483
+ chainIndex: chain.chainIndex,
484
+ sessionId: chain.sessionId,
485
+ startedAt: chain.startedAt,
486
+ completedAt: chain.completedAt,
487
+ observedAssistantReplies: chain.observedAssistantReplies,
488
+ basePromptText: chain.basePromptText,
489
+ steeringPrompts: [...chain.steeringPrompts],
490
+ submissionIds: [...chain.submissionIds],
491
+ responseMessageIds: chainSubmissions
492
+ .map((submission) => submission.responseMessageId)
493
+ .filter((value) => Boolean(value)),
494
+ responseCount: chainSubmissions.filter((submission) => Boolean(submission.responseMessageId)).length,
495
+ };
496
+ });
497
+ }
498
+ async function main() {
499
+ const options = parseArgs(process.argv.slice(2));
500
+ await mkdir(options.artifactsDir, { recursive: true });
501
+ const eventsPath = resolve(options.artifactsDir, "events.jsonl");
502
+ const messagesPath = resolve(options.artifactsDir, "messages-final.json");
503
+ const promptSubmissionsPath = resolve(options.artifactsDir, "prompt-submissions.json");
504
+ const historyPath = resolve(options.artifactsDir, "response-history.json");
505
+ const chainsPath = resolve(options.artifactsDir, "chains.json");
506
+ const matchingDiagnosticsPath = resolve(options.artifactsDir, "matching-diagnostics.json");
507
+ const summaryPath = resolve(options.artifactsDir, "summary.json");
508
+ const eventLogStream = createWriteStream(eventsPath, { flags: "a" });
509
+ const sseController = new AbortController();
510
+ let startedServer = null;
511
+ let client;
512
+ if (options.baseUrl) {
513
+ client = createOpencodeClient({
514
+ baseUrl: options.baseUrl,
515
+ directory: options.directory,
516
+ });
517
+ console.log(`Connected to existing opencode server at ${options.baseUrl}`);
518
+ }
519
+ else {
520
+ const runtime = await createOpencode({});
521
+ client = runtime.client;
522
+ startedServer = runtime.server;
523
+ console.log(`Started local opencode server at ${runtime.server.url}`);
524
+ }
525
+ let eventLoop = null;
526
+ try {
527
+ const events = await client.event.subscribe({
528
+ query: { directory: options.directory },
529
+ signal: sseController.signal,
530
+ onSseError: (error) => {
531
+ if (sseController.signal.aborted)
532
+ return;
533
+ console.error("[sse-error]", error);
534
+ },
535
+ });
536
+ eventLoop = (async () => {
537
+ for await (const event of events.stream) {
538
+ const line = JSON.stringify({ ts: Date.now(), event }) + "\n";
539
+ eventLogStream.write(line);
540
+ console.log(`[event] ${summarizeEvent(event)}`);
541
+ }
542
+ })().catch((error) => {
543
+ if (sseController.signal.aborted)
544
+ return;
545
+ console.error("Event stream failed:", error);
546
+ });
547
+ const session = await createOrReuseSession(client, options);
548
+ console.log(`Using session ${session.id} (${session.title})`);
549
+ const initialMessages = await fetchSessionMessages(client, session.id, options.directory);
550
+ const initialMessageIds = new Set(initialMessages.map((entry) => entry.info.id));
551
+ const context = {
552
+ client,
553
+ session,
554
+ options,
555
+ chains: [],
556
+ submissions: [],
557
+ currentChain: null,
558
+ expectedAssistantCount: countAssistantMessages(initialMessages),
559
+ };
560
+ const scenarioResults = [];
561
+ console.log(`Initial session message count: ${initialMessages.length}`);
562
+ console.log("Submitting initial run...");
563
+ await submitPrompt(context, {
564
+ scenarioName: "initial-queue",
565
+ stepLabel: "run-1",
566
+ promptMode: "run",
567
+ promptText: options.runPrompt,
568
+ });
569
+ await sleep(options.queueDelayMs);
570
+ console.log("Submitting first queued steer...");
571
+ await submitPrompt(context, {
572
+ scenarioName: "initial-queue",
573
+ stepLabel: "steer-1",
574
+ promptMode: "steer",
575
+ promptText: options.queuePrompt,
576
+ });
577
+ let initialScenarioPromptCount = 2;
578
+ if (options.multiSteerTest) {
579
+ await sleep(options.secondQueueDelayMs);
580
+ console.log("Submitting second queued steer...");
581
+ await submitPrompt(context, {
582
+ scenarioName: "multi-steer",
583
+ stepLabel: "steer-2",
584
+ promptMode: "steer",
585
+ promptText: options.secondQueuePrompt,
586
+ });
587
+ initialScenarioPromptCount += 1;
588
+ }
589
+ const initialScenarioSettle = await waitForIdleAndRefreshAssistantCount(context);
590
+ closeActiveChain(context, initialScenarioSettle.observedAssistantReplies);
591
+ scenarioResults.push({
592
+ scenarioName: "initial-queue",
593
+ description: options.multiSteerTest
594
+ ? "Started a run and queued two steering prompts before the session went idle."
595
+ : "Started a run and queued one steering prompt before the session went idle.",
596
+ submittedPromptCount: initialScenarioPromptCount,
597
+ observedAssistantReplies: initialScenarioSettle.observedAssistantReplies,
598
+ });
599
+ if (options.newRunAfterIdleTest) {
600
+ console.log("Submitting fresh run after idle...");
601
+ await submitPrompt(context, {
602
+ scenarioName: "fresh-run-after-idle",
603
+ stepLabel: "run-2",
604
+ promptMode: "run",
605
+ promptText: options.secondRunPrompt,
606
+ });
607
+ const freshRunSettle = await waitForIdleAndRefreshAssistantCount(context);
608
+ closeActiveChain(context, freshRunSettle.observedAssistantReplies);
609
+ scenarioResults.push({
610
+ scenarioName: "fresh-run-after-idle",
611
+ description: "Started a fresh run in the same session after the previous chain had already settled.",
612
+ submittedPromptCount: 1,
613
+ observedAssistantReplies: freshRunSettle.observedAssistantReplies,
614
+ });
615
+ }
616
+ if (options.abortTest) {
617
+ console.log("Submitting fresh run for abort test...");
618
+ const abortSubmission = await submitPrompt(context, {
619
+ scenarioName: "abort-run",
620
+ stepLabel: "run-abort",
621
+ promptMode: "run",
622
+ promptText: options.abortPrompt,
623
+ });
624
+ await sleep(options.abortDelayMs);
625
+ abortSubmission.abortRequestedAt = Date.now();
626
+ console.log("Aborting session...");
627
+ const assistantCountBeforeAbortWait = context.expectedAssistantCount;
628
+ await client.session.abort({
629
+ path: { id: session.id },
630
+ query: { directory: options.directory },
631
+ throwOnError: true,
632
+ });
633
+ const abortMessages = await waitForSessionToBecomeIdle(client, session.id, options.directory, options.settleTimeoutMs, options.pollIntervalMs);
634
+ const assistantCountAfterAbort = countAssistantMessages(abortMessages);
635
+ context.expectedAssistantCount = assistantCountAfterAbort;
636
+ closeActiveChain(context, Math.max(0, assistantCountAfterAbort - assistantCountBeforeAbortWait));
637
+ scenarioResults.push({
638
+ scenarioName: "abort-run",
639
+ description: "Started a fresh run after idle and then aborted it before completion.",
640
+ submittedPromptCount: 1,
641
+ observedAssistantReplies: Math.max(0, assistantCountAfterAbort - assistantCountBeforeAbortWait),
642
+ });
643
+ }
644
+ const finalMessages = await fetchSessionMessages(client, session.id, options.directory);
645
+ const normalizedMessages = finalMessages
646
+ .map(normalizeMessage)
647
+ .sort((a, b) => a.created - b.created);
648
+ const matchingDiagnostics = attachMatchesToSubmissions(context.chains, context.submissions, normalizedMessages, initialMessageIds);
649
+ const responseHistory = buildResponseHistory(context.submissions);
650
+ const chainSummaries = buildChainSummaries(context.chains, context.submissions);
651
+ await writeFile(messagesPath, JSON.stringify(normalizedMessages, null, 2));
652
+ await writeFile(promptSubmissionsPath, JSON.stringify(context.submissions, null, 2));
653
+ await writeFile(historyPath, JSON.stringify(responseHistory, null, 2));
654
+ await writeFile(chainsPath, JSON.stringify(chainSummaries, null, 2));
655
+ await writeFile(matchingDiagnosticsPath, JSON.stringify(matchingDiagnostics, null, 2));
656
+ await writeFile(summaryPath, JSON.stringify({
657
+ directory: options.directory,
658
+ artifactsDir: options.artifactsDir,
659
+ usedExistingServer: Boolean(options.baseUrl),
660
+ baseUrl: options.baseUrl ?? startedServer?.url ?? null,
661
+ session: {
662
+ id: session.id,
663
+ title: session.title,
664
+ },
665
+ counts: {
666
+ initialMessages: initialMessages.length,
667
+ finalMessages: finalMessages.length,
668
+ finalAssistantMessages: countAssistantMessages(finalMessages),
669
+ submittedPrompts: context.submissions.length,
670
+ matchedUserMessages: context.submissions.filter((submission) => Boolean(submission.userMessageId)).length,
671
+ matchedResponses: context.submissions.filter((submission) => Boolean(submission.responseMessageId)).length,
672
+ chains: context.chains.length,
673
+ },
674
+ tests: {
675
+ multiSteerTest: options.multiSteerTest,
676
+ newRunAfterIdleTest: options.newRunAfterIdleTest,
677
+ abortTest: options.abortTest,
678
+ },
679
+ scenarioResults,
680
+ matchingDiagnostics,
681
+ options,
682
+ }, null, 2));
683
+ console.log(`Wrote event log to ${eventsPath}`);
684
+ console.log(`Wrote normalized messages to ${messagesPath}`);
685
+ console.log(`Wrote prompt submissions to ${promptSubmissionsPath}`);
686
+ console.log(`Wrote reconstructed response history to ${historyPath}`);
687
+ console.log(`Wrote chain summaries to ${chainsPath}`);
688
+ console.log(`Wrote matching diagnostics to ${matchingDiagnosticsPath}`);
689
+ console.log(`Wrote summary to ${summaryPath}`);
690
+ console.log("\nExplicitly reconstructed response history:\n");
691
+ for (const item of responseHistory) {
692
+ console.log(`Response ${item.responseIndex}: chain=${item.chainIndex}, scenario=${item.scenarioName}, trigger=${item.triggerKind}, mode=${item.promptMode}, steeringCount=${item.promptSteeringCount}, queuedWhileBusy=${item.queuedWhileBusy}`);
693
+ console.log(` local prompt id: ${item.localPromptId}`);
694
+ console.log(` user message id: ${item.userMessageId ?? "(missing)"}`);
695
+ console.log(` response message id: ${item.responseMessageId ?? "(missing)"}`);
696
+ console.log(` trigger text: ${item.promptTriggerText.replace(/\s+/g, " ").slice(0, 140)}`);
697
+ if (item.responseError) {
698
+ console.log(` response error: ${item.responseError}`);
699
+ }
700
+ console.log(` response preview: ${(item.responseText || "").replace(/\s+/g, " ").slice(0, 140)}`);
701
+ console.log("");
702
+ }
703
+ if (matchingDiagnostics.missingUserMatches.length > 0
704
+ || matchingDiagnostics.missingResponseMatches.length > 0
705
+ || matchingDiagnostics.unassignedUserMessageIds.length > 0
706
+ || matchingDiagnostics.unassignedAssistantMessageIds.length > 0) {
707
+ console.log("Matching diagnostics detected some gaps:");
708
+ console.log(JSON.stringify(matchingDiagnostics, null, 2));
709
+ }
710
+ else {
711
+ console.log("Matching diagnostics: all submitted prompts matched cleanly to user and assistant messages.");
712
+ }
713
+ }
714
+ finally {
715
+ sseController.abort();
716
+ eventLogStream.end();
717
+ try {
718
+ await eventLoop;
719
+ }
720
+ catch {
721
+ // ignore shutdown race
722
+ }
723
+ if (startedServer) {
724
+ startedServer.close();
725
+ }
726
+ }
727
+ }
728
+ void main().catch((error) => {
729
+ console.error(error instanceof Error ? error.stack ?? error.message : error);
730
+ process.exitCode = 1;
731
+ });
732
+ //# sourceMappingURL=main.js.map