@testchimp/cli 0.1.50 → 0.1.52

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 (2) hide show
  1. package/dist/chimphands/run.js +223 -123
  2. package/package.json +1 -1
@@ -137,11 +137,12 @@ class AgentEventPoster {
137
137
  if (opts?.pullRequestUrl)
138
138
  body.pullRequestUrl = opts.pullRequestUrl;
139
139
  const streamRole = isStreamFanoutRole(role);
140
- // Live token fanout only while UI watching; completed json events always go durable.
141
- if (streamRole && opts?.liveStream && !this.uiAttached) {
142
- return this.chain;
143
- }
144
140
  this.chain = this.chain.then(async () => {
141
+ // Mid-turn live tokens only while UI is attached — async runs skip FS fanout
142
+ // and rely on turn-end reconcile for durable PG.
143
+ if (opts?.liveStream && !this.uiAttached) {
144
+ return;
145
+ }
145
146
  if (opts?.throttle || (streamRole && opts?.liveStream)) {
146
147
  const now = Date.now();
147
148
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
@@ -172,11 +173,21 @@ class AgentEventPoster {
172
173
  }
173
174
  if (delivered)
174
175
  return;
175
- // Cross-replica: UI SSE not on this FS podfall through to durable.
176
+ // Mid-turn liveStream must not fall through to durablethat would write
177
+ // every token to PG. Turn-end reconcile persists the final transcript.
178
+ if (opts?.liveStream) {
179
+ console.error("ChimpHands ephemeral not delivered (replica miss?) — skipping durable for liveStream");
180
+ return;
181
+ }
182
+ // Completed/non-live: durable fallback for cross-replica UI.
176
183
  console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
177
184
  }
178
185
  catch (err) {
179
186
  const detail = err instanceof Error ? err.message : String(err);
187
+ if (opts?.liveStream) {
188
+ console.error(`ChimpHands ephemeral post failed — skipping durable for liveStream: ${detail}`);
189
+ return;
190
+ }
180
191
  console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
181
192
  }
182
193
  }
@@ -276,7 +287,43 @@ function normalizeStdoutOpencodeEvent(raw) {
276
287
  const props = (inner.properties && typeof inner.properties === "object"
277
288
  ? inner.properties
278
289
  : {});
279
- if (type === "message.part.updated" || type === "message.part.delta") {
290
+ if (type === "message.part.delta") {
291
+ // OpenCode streams tokens as { partID, field, delta } with no `part`.
292
+ const deltaProps = props;
293
+ const part = deltaProps.part;
294
+ if (part) {
295
+ const partType = part.type || deltaProps.field || "";
296
+ let mapped;
297
+ if (partType === "text")
298
+ mapped = "text";
299
+ else if (partType === "reasoning")
300
+ mapped = "reasoning";
301
+ else if (partType === "tool")
302
+ mapped = "tool_use";
303
+ else
304
+ return null;
305
+ if (deltaProps.delta && !part.text)
306
+ part.text = deltaProps.delta;
307
+ return {
308
+ type: mapped,
309
+ sessionID: part.sessionID || deltaProps.sessionID,
310
+ part,
311
+ };
312
+ }
313
+ const partID = deltaProps.partID;
314
+ const field = deltaProps.field || "text";
315
+ const delta = deltaProps.delta;
316
+ if (!partID || delta == null || delta === "")
317
+ return null;
318
+ if (field !== "text" && field !== "reasoning")
319
+ return null;
320
+ return {
321
+ type: field === "reasoning" ? "reasoning" : "text",
322
+ sessionID: deltaProps.sessionID,
323
+ part: { id: partID, type: field, text: delta },
324
+ };
325
+ }
326
+ if (type === "message.part.updated") {
280
327
  const part = props.part;
281
328
  if (!part)
282
329
  return null;
@@ -290,7 +337,6 @@ function normalizeStdoutOpencodeEvent(raw) {
290
337
  mapped = "tool_use";
291
338
  else
292
339
  return null;
293
- // Prefer cumulative text; append delta when that's all we got.
294
340
  if (props.delta && !part.text) {
295
341
  part.text = props.delta;
296
342
  }
@@ -313,7 +359,7 @@ function normalizeStdoutOpencodeEvent(raw) {
313
359
  }
314
360
  return null;
315
361
  }
316
- /** Pull assistant/tool parts from OpenCode HTTP after attach exits early. */
362
+ /** Pull assistant/tool parts from OpenCode HTTP for durable PG (turn-end). */
317
363
  async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, postEvent, onWorkingBranch) {
318
364
  const base = attachUrl.replace(/\/$/, "");
319
365
  const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
@@ -334,8 +380,18 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
334
380
  const data = (await res.json());
335
381
  if (!Array.isArray(data))
336
382
  return 0;
383
+ // Only parts after the latest user message (this turn). Full-history reconcile
384
+ // every turn would O(n) upsert the entire transcript for no benefit.
385
+ let lastUserIdx = -1;
386
+ for (let i = data.length - 1; i >= 0; i--) {
387
+ if ((data[i]?.info?.role || "").toLowerCase() === "user") {
388
+ lastUserIdx = i;
389
+ break;
390
+ }
391
+ }
392
+ const turnSlice = lastUserIdx >= 0 ? data.slice(lastUserIdx + 1) : data;
337
393
  let posted = 0;
338
- for (const msg of data) {
394
+ for (const msg of turnSlice) {
339
395
  if ((msg.info?.role || "").toLowerCase() !== "assistant")
340
396
  continue;
341
397
  for (const part of msg.parts || []) {
@@ -369,7 +425,7 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
369
425
  }
370
426
  }
371
427
  if (posted) {
372
- console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API`);
428
+ console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API (this turn)`);
373
429
  }
374
430
  return posted;
375
431
  }
@@ -436,7 +492,36 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
436
492
  const ev = unwrapped;
437
493
  const type = ev.type || "";
438
494
  const props = ev.properties || {};
439
- if (type === "message.part.updated" || type === "message.part.delta") {
495
+ // Token stream: { partID, field, delta } — often no `part` object.
496
+ if (type === "message.part.delta") {
497
+ const part = props.part;
498
+ const partId = props.partID || part?.id || part?.messageID;
499
+ const field = props.field || part?.type || "text";
500
+ const sessionId = part?.sessionID || props.sessionID;
501
+ if (!sessionMatches(sessionId))
502
+ return;
503
+ callbacks.noteSessionId(sessionId);
504
+ if (!partId || props.delta == null || props.delta === "")
505
+ return;
506
+ if (field === "text") {
507
+ const next = (textByPartId.get(partId) || "") + props.delta;
508
+ textByPartId.set(partId, next);
509
+ callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
510
+ messageId: `oc_text_${partId}`,
511
+ }));
512
+ return;
513
+ }
514
+ if (field === "reasoning") {
515
+ const key = `reasoning:${partId}`;
516
+ const next = (textByPartId.get(key) || "") + props.delta;
517
+ textByPartId.set(key, next);
518
+ callbacks.postEvent(ROLE_REASONING, next, liveOpts({
519
+ messageId: `oc_reasoning_${partId}`,
520
+ }));
521
+ }
522
+ return;
523
+ }
524
+ if (type === "message.part.updated") {
440
525
  const part = props.part;
441
526
  if (!part)
442
527
  return;
@@ -449,13 +534,12 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
449
534
  if (!partId)
450
535
  return;
451
536
  let next = part.text || "";
452
- if (props.delta) {
537
+ if (props.delta && !part.text) {
453
538
  next = (textByPartId.get(partId) || "") + props.delta;
454
539
  }
455
540
  else if (!next && props.delta === undefined) {
456
541
  return;
457
542
  }
458
- // Prefer cumulative part.text when present (idempotent); else delta accumulation.
459
543
  if (part.text)
460
544
  next = part.text;
461
545
  textByPartId.set(partId, next);
@@ -486,8 +570,17 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
486
570
  }
487
571
  if (part.type === "tool") {
488
572
  const status = part.state?.status;
489
- if (!status || status === "pending" || status === "running")
573
+ if (!status || status === "pending")
490
574
  return;
575
+ // Ephemeral "(running)" bubbles while UI attached (liveStream gated in poster).
576
+ if (status === "running") {
577
+ const toolContent = formatToolUseContent(part);
578
+ callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
579
+ messageId: opencodeMessageId("oc_tool_", part),
580
+ throttle: false,
581
+ }));
582
+ return;
583
+ }
491
584
  const toolContent = formatToolUseContent(part);
492
585
  callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
493
586
  messageId: opencodeMessageId("oc_tool_", part),
@@ -863,7 +956,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
863
956
  callbacks.postEvent(ROLE_STATUS, "Agent is still working… (waiting for OpenCode output)", {
864
957
  status: STATUS_RUNNING,
865
958
  });
866
- }, 45_000);
959
+ }, 15_000);
867
960
  const noteSessionId = (sessionId) => {
868
961
  const id = sessionId?.trim();
869
962
  if (!id || id === activeSessionId)
@@ -887,10 +980,31 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
887
980
  fatalError = fatal;
888
981
  return;
889
982
  }
890
- const ev = parseOpencodeEvent(line);
983
+ let parsed;
984
+ try {
985
+ parsed = JSON.parse(line);
986
+ }
987
+ catch {
988
+ return;
989
+ }
990
+ // Newer OpenCode --format json uses bus shape (message.part.updated); normalize first.
991
+ const ev = normalizeStdoutOpencodeEvent(parsed) || parseOpencodeEvent(line);
891
992
  if (!ev?.type)
892
993
  return;
893
994
  noteSessionId(ev.sessionID);
995
+ switch (ev.type) {
996
+ case "text":
997
+ case "reasoning":
998
+ case "tool_use": {
999
+ // Attach mode: live tokens come from OpenCode SSE; durable from turn-end
1000
+ // reconcile. Avoid double-fanout / double-accumulation with stdout.
1001
+ if (attachUrl)
1002
+ return;
1003
+ break;
1004
+ }
1005
+ default:
1006
+ break;
1007
+ }
894
1008
  switch (ev.type) {
895
1009
  case "text": {
896
1010
  const chunk = ev.part?.text;
@@ -901,7 +1015,14 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
901
1015
  callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
902
1016
  return;
903
1017
  }
904
- const next = (textByPartId.get(partId) || "") + chunk;
1018
+ // Without attach, --format json may emit completed cumulative or deltas.
1019
+ // Prefer replace when we already have longer text (cumulative); else append.
1020
+ const prev = textByPartId.get(partId) || "";
1021
+ const next = !prev
1022
+ ? chunk
1023
+ : chunk.startsWith(prev)
1024
+ ? chunk
1025
+ : prev + chunk;
905
1026
  textByPartId.set(partId, next);
906
1027
  callbacks.postEvent(ROLE_ASSISTANT, next, {
907
1028
  throttle: true,
@@ -919,7 +1040,12 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
919
1040
  return;
920
1041
  }
921
1042
  const reasoningKey = `reasoning:${partId}`;
922
- const next = (textByPartId.get(reasoningKey) || "") + chunk;
1043
+ const prev = textByPartId.get(reasoningKey) || "";
1044
+ const next = !prev
1045
+ ? chunk
1046
+ : chunk.startsWith(prev)
1047
+ ? chunk
1048
+ : prev + chunk;
923
1049
  textByPartId.set(reasoningKey, next);
924
1050
  callbacks.postEvent(ROLE_REASONING, next, {
925
1051
  throttle: true,
@@ -928,8 +1054,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
928
1054
  return;
929
1055
  }
930
1056
  case "tool_use": {
1057
+ // Durable-only path (no attach). Skip in-progress.
931
1058
  const status = ev.part?.state?.status;
932
- if (!status || status === "pending")
1059
+ if (!status || status === "pending" || status === "running")
933
1060
  return;
934
1061
  const toolContent = formatToolUseContent(ev.part);
935
1062
  callbacks.postEvent(ROLE_TOOL, toolContent, {
@@ -1093,36 +1220,58 @@ export async function runChimphands(opts) {
1093
1220
  /** Do not open localhost OpenCode /event until serve has been (re)started with config. */
1094
1221
  let opencodeHttpReady = !attachUrl;
1095
1222
  let pendingUiAttached = !!(boot.uiAttached ?? boot.ui_attached);
1223
+ /** Consecutive uiAttached=false heartbeats before stopping OpenCode SSE (multi-replica poison). */
1224
+ let consecutiveUiDetached = 0;
1225
+ const UI_DETACHED_STOP_AFTER = 3;
1226
+ const startLiveSseIfNeeded = (reason) => {
1227
+ if (!attachUrl || !opencodeHttpReady || stopLiveSse)
1228
+ return;
1229
+ console.error(`ChimpHands ${reason}`);
1230
+ stopLiveSse = startOpencodeSseRelay(attachUrl, {
1231
+ getActiveSessionId: () => opencodeSessionId,
1232
+ noteSessionId: (id) => {
1233
+ if (id?.trim())
1234
+ opencodeSessionId = id.trim();
1235
+ },
1236
+ postEvent: (role, content, opts) => {
1237
+ poster.fireAndForget(role, content, {
1238
+ ...opts,
1239
+ opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
1240
+ });
1241
+ },
1242
+ onWorkingBranch: noteWorkingBranchPlaceholder,
1243
+ });
1244
+ };
1096
1245
  const syncLiveSse = (attached) => {
1097
- poster.uiAttached = attached;
1098
1246
  pendingUiAttached = attached;
1099
- if (!attachUrl)
1247
+ if (!attachUrl) {
1248
+ poster.uiAttached = attached;
1100
1249
  return;
1250
+ }
1101
1251
  if (!opencodeHttpReady) {
1252
+ // Defer SSE; still track intent so first sync after ready is correct.
1253
+ poster.uiAttached = attached;
1102
1254
  if (attached) {
1103
1255
  console.error("ChimpHands UI attached — deferring OpenCode SSE until serve is ready");
1104
1256
  }
1105
1257
  return;
1106
1258
  }
1107
- if (attached && !stopLiveSse) {
1108
- console.error("ChimpHands UI attached — starting OpenCode SSE fanout");
1109
- stopLiveSse = startOpencodeSseRelay(attachUrl, {
1110
- getActiveSessionId: () => opencodeSessionId,
1111
- noteSessionId: (id) => {
1112
- if (id?.trim())
1113
- opencodeSessionId = id.trim();
1114
- },
1115
- postEvent: (role, content, opts) => {
1116
- poster.fireAndForget(role, content, {
1117
- ...opts,
1118
- opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
1119
- });
1120
- },
1121
- onWorkingBranch: noteWorkingBranchPlaceholder,
1122
- });
1259
+ if (attached) {
1260
+ consecutiveUiDetached = 0;
1261
+ // Allow liveStream immediately when heartbeat says attached.
1262
+ poster.uiAttached = true;
1263
+ startLiveSseIfNeeded("UI attached — starting OpenCode SSE fanout");
1264
+ return;
1123
1265
  }
1124
- else if (!attached && stopLiveSse) {
1125
- console.error("ChimpHands UI detached stopping OpenCode SSE fanout");
1266
+ // Detached / async: hysteresis avoids flap from cross-replica heartbeats.
1267
+ // Keep poster.uiAttached true + SSE up during the window so we don't drop
1268
+ // live tokens while the UI is still actually listening on another replica.
1269
+ consecutiveUiDetached += 1;
1270
+ if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER)
1271
+ return;
1272
+ poster.uiAttached = false;
1273
+ if (stopLiveSse) {
1274
+ console.error(`ChimpHands UI detached for ${consecutiveUiDetached} heartbeats — stopping OpenCode SSE fanout`);
1126
1275
  stopLiveSse();
1127
1276
  stopLiveSse = null;
1128
1277
  }
@@ -1186,30 +1335,6 @@ export async function runChimphands(opts) {
1186
1335
  const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
1187
1336
  let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
1188
1337
  let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
1189
- const exportSignedUrl = bootStr(boot, "opencode_export_signed_url", "opencodeExportSignedUrl");
1190
- if (exportSignedUrl && attachUrl) {
1191
- try {
1192
- const importedId = await importOpencodeExportFromUrl(exportSignedUrl);
1193
- if (importedId) {
1194
- opencodeSessionId = importedId;
1195
- console.error(`ChimpHands rehydrated OpenCode session from export: ${importedId}`);
1196
- }
1197
- }
1198
- catch (err) {
1199
- console.error(`ChimpHands export import failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
1200
- }
1201
- }
1202
- const snapshotExport = async () => {
1203
- const sid = opencodeSessionId?.trim();
1204
- if (!sid)
1205
- return;
1206
- try {
1207
- await putOpencodeExport(backend, apiKey, sessionId, sid);
1208
- }
1209
- catch (err) {
1210
- console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1211
- }
1212
- };
1213
1338
  noteWorkingBranch = (branch, prUrl) => {
1214
1339
  const normalizedBranch = branch.trim();
1215
1340
  if (!normalizedBranch)
@@ -1309,7 +1434,6 @@ export async function runChimphands(opts) {
1309
1434
  }
1310
1435
  await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
1311
1436
  await poster.flush();
1312
- await snapshotExport();
1313
1437
  if (runtimeId) {
1314
1438
  await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
1315
1439
  runtimeId,
@@ -1320,10 +1444,22 @@ export async function runChimphands(opts) {
1320
1444
  try {
1321
1445
  poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
1322
1446
  let prompt = normalizeUserMessage(promptInput || bootStr(boot, "initial_prompt", "initialPrompt"));
1447
+ // Bootstrap sets initialPrompt from the last pending user message AND returns
1448
+ // pendingUserMessages — enqueue only messages that are not the initial prompt
1449
+ // (otherwise the same turn runs twice: session=new then session=ses_…).
1323
1450
  const pending = boot.pending_user_messages || boot.pendingUserMessages || [];
1451
+ const initialNorm = normalizeUserMessage(prompt);
1324
1452
  for (const m of pending) {
1325
- if (m?.content)
1326
- enqueueUserMessage({ content: m.content });
1453
+ const content = normalizeUserMessage(m?.content || "");
1454
+ if (!content)
1455
+ continue;
1456
+ if (initialNorm && content === initialNorm)
1457
+ continue;
1458
+ const id = ("id" in m && m.id) ||
1459
+ ("message_id" in m && m.message_id) ||
1460
+ ("messageId" in m && m.messageId) ||
1461
+ undefined;
1462
+ enqueueUserMessage({ id: id || undefined, content });
1327
1463
  }
1328
1464
  const waitForNextPrompt = () => new Promise((resolve) => {
1329
1465
  let lastPollAt = 0;
@@ -1397,6 +1533,24 @@ export async function runChimphands(opts) {
1397
1533
  if (result.opencodeSessionId) {
1398
1534
  opencodeSessionId = result.opencodeSessionId;
1399
1535
  }
1536
+ // Turn-end durable reconcile into PG (idempotent messageIds). Mid-turn was
1537
+ // ephemeral-only when UI attached; async runs get their transcript here.
1538
+ // Also reconcile on failure so partial assistant/tool output is not lost.
1539
+ if (attachUrl && opencodeSessionId) {
1540
+ try {
1541
+ await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
1542
+ void poster.enqueue(role, content, {
1543
+ ...opts,
1544
+ opencodeSessionId,
1545
+ // Explicitly not liveStream → always durable post_agent_event.
1546
+ });
1547
+ }, noteWorkingBranch);
1548
+ await poster.flush();
1549
+ }
1550
+ catch (err) {
1551
+ console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1552
+ }
1553
+ }
1400
1554
  if (result.code !== 0) {
1401
1555
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
1402
1556
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -1422,7 +1576,6 @@ export async function runChimphands(opts) {
1422
1576
  break;
1423
1577
  }
1424
1578
  postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1425
- await snapshotExport();
1426
1579
  lastUserActivity = Date.now();
1427
1580
  idle = false;
1428
1581
  prompt = (await waitForNextPrompt()) || "";
@@ -1453,7 +1606,9 @@ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
1453
1606
  try {
1454
1607
  const data = JSON.parse(text);
1455
1608
  const attached = !!(data.uiAttached ?? data.ui_attached);
1456
- if (attached !== lastAttached) {
1609
+ // Always notify on false so syncLiveSse can accumulate detach hysteresis;
1610
+ // still skip duplicate true→true noise.
1611
+ if (attached !== lastAttached || !attached) {
1457
1612
  lastAttached = attached;
1458
1613
  onUiAttached?.(attached);
1459
1614
  }
@@ -1536,61 +1691,6 @@ async function commitAndPushDirtyWorktree(message) {
1536
1691
  console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
1537
1692
  }
1538
1693
  }
1539
- async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
1540
- const exported = await new Promise((resolve, reject) => {
1541
- const child = spawn("opencode", ["export", opencodeSessionId], {
1542
- stdio: ["ignore", "pipe", "pipe"],
1543
- });
1544
- let out = "";
1545
- let err = "";
1546
- child.stdout.on("data", (d) => {
1547
- out += d.toString();
1548
- });
1549
- child.stderr.on("data", (d) => {
1550
- err += d.toString();
1551
- });
1552
- child.on("close", (code) => {
1553
- if (code === 0 && out.trim())
1554
- resolve(out);
1555
- else
1556
- reject(new Error(err.trim() || `opencode export exited ${code}`));
1557
- });
1558
- });
1559
- const exportBase64 = Buffer.from(exported, "utf8").toString("base64");
1560
- await postJson(backend, apiKey, "/api/chimphands/put_opencode_export", {
1561
- sessionId,
1562
- exportBase64,
1563
- });
1564
- }
1565
- async function importOpencodeExportFromUrl(signedUrl) {
1566
- const res = await fetch(signedUrl);
1567
- if (!res.ok) {
1568
- throw new Error(`download export failed: ${res.status}`);
1569
- }
1570
- const text = await res.text();
1571
- writeFileSync("/tmp/chimphands-opencode-export.json", text, "utf8");
1572
- return await new Promise((resolve, reject) => {
1573
- const child = spawn("opencode", ["import", "/tmp/chimphands-opencode-export.json"], {
1574
- stdio: ["ignore", "pipe", "pipe"],
1575
- });
1576
- let out = "";
1577
- let err = "";
1578
- child.stdout.on("data", (d) => {
1579
- out += d.toString();
1580
- });
1581
- child.stderr.on("data", (d) => {
1582
- err += d.toString();
1583
- });
1584
- child.on("close", (code) => {
1585
- if (code !== 0) {
1586
- reject(new Error(err.trim() || `opencode import exited ${code}`));
1587
- return;
1588
- }
1589
- const match = (out + "\n" + err).match(/ses_[A-Za-z0-9]+/);
1590
- resolve(match?.[0]);
1591
- });
1592
- });
1593
- }
1594
1694
  function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1595
1695
  let stopped = false;
1596
1696
  const base = attachUrl.replace(/\/$/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "description": "TestChimp CLI and MCP server — coverage, plans, EaaS, TrueCoverage, API operations (calls /api/mcp/*)",
5
5
  "type": "module",
6
6
  "main": "dist/bin/testchimp.js",