@testchimp/cli 0.1.51 → 0.1.53

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 +198 -110
  2. package/package.json +1 -1
@@ -115,6 +115,8 @@ class AgentEventPoster {
115
115
  lastStreamPostAt = 0;
116
116
  /** When false, liveStream tokens are dropped; completed events still persist. */
117
117
  uiAttached = false;
118
+ ephemeralFailLogAt = 0;
119
+ ephemeralFailCount = 0;
118
120
  constructor(backend, apiKey, sessionId) {
119
121
  this.backend = backend;
120
122
  this.apiKey = apiKey;
@@ -137,9 +139,12 @@ class AgentEventPoster {
137
139
  if (opts?.pullRequestUrl)
138
140
  body.pullRequestUrl = opts.pullRequestUrl;
139
141
  const streamRole = isStreamFanoutRole(role);
140
- // Never drop liveStream tokens: ephemeral when UI is on this replica path,
141
- // otherwise durable post_agent_event (cross-replica / detached safe).
142
142
  this.chain = this.chain.then(async () => {
143
+ // Mid-turn live tokens only while UI is attached — async runs skip FS fanout
144
+ // and rely on turn-end reconcile for durable PG.
145
+ if (opts?.liveStream && !this.uiAttached) {
146
+ return;
147
+ }
143
148
  if (opts?.throttle || (streamRole && opts?.liveStream)) {
144
149
  const now = Date.now();
145
150
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
@@ -160,6 +165,7 @@ class AgentEventPoster {
160
165
  eph.opencodeSessionId = opts.opencodeSessionId;
161
166
  try {
162
167
  const text = await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
168
+ this.ephemeralFailCount = 0;
163
169
  let delivered = false;
164
170
  try {
165
171
  const parsed = JSON.parse(text);
@@ -170,11 +176,21 @@ class AgentEventPoster {
170
176
  }
171
177
  if (delivered)
172
178
  return;
173
- // Cross-replica: UI SSE not on this FS podfall through to durable.
179
+ // Mid-turn liveStream must not fall through to durablethat would write
180
+ // every token to PG. Turn-end reconcile persists the final transcript.
181
+ if (opts?.liveStream) {
182
+ this.logEphemeralIssue("ephemeral not delivered (replica miss?) — skipping durable for liveStream");
183
+ return;
184
+ }
185
+ // Completed/non-live: durable fallback for cross-replica UI.
174
186
  console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
175
187
  }
176
188
  catch (err) {
177
189
  const detail = err instanceof Error ? err.message : String(err);
190
+ if (opts?.liveStream) {
191
+ this.logEphemeralIssue(`ephemeral post failed — skipping durable for liveStream: ${detail}`);
192
+ return;
193
+ }
178
194
  console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
179
195
  }
180
196
  }
@@ -182,6 +198,16 @@ class AgentEventPoster {
182
198
  });
183
199
  return this.chain;
184
200
  }
201
+ /** Avoid flooding GHA logs when ephemeral 500s every ~150ms. */
202
+ logEphemeralIssue(message) {
203
+ this.ephemeralFailCount += 1;
204
+ const now = Date.now();
205
+ if (this.ephemeralFailCount <= 2 || now - this.ephemeralFailLogAt >= 10_000) {
206
+ this.ephemeralFailLogAt = now;
207
+ const suffix = this.ephemeralFailCount > 2 ? ` (x${this.ephemeralFailCount} since last log)` : "";
208
+ console.error(`ChimpHands ${message}${suffix}`);
209
+ }
210
+ }
185
211
  fireAndForget(role, content, opts) {
186
212
  void this.enqueue(role, content, opts).catch((err) => {
187
213
  const detail = err instanceof Error ? err.message : String(err);
@@ -274,7 +300,43 @@ function normalizeStdoutOpencodeEvent(raw) {
274
300
  const props = (inner.properties && typeof inner.properties === "object"
275
301
  ? inner.properties
276
302
  : {});
277
- if (type === "message.part.updated" || type === "message.part.delta") {
303
+ if (type === "message.part.delta") {
304
+ // OpenCode streams tokens as { partID, field, delta } with no `part`.
305
+ const deltaProps = props;
306
+ const part = deltaProps.part;
307
+ if (part) {
308
+ const partType = part.type || deltaProps.field || "";
309
+ let mapped;
310
+ if (partType === "text")
311
+ mapped = "text";
312
+ else if (partType === "reasoning")
313
+ mapped = "reasoning";
314
+ else if (partType === "tool")
315
+ mapped = "tool_use";
316
+ else
317
+ return null;
318
+ if (deltaProps.delta && !part.text)
319
+ part.text = deltaProps.delta;
320
+ return {
321
+ type: mapped,
322
+ sessionID: part.sessionID || deltaProps.sessionID,
323
+ part,
324
+ };
325
+ }
326
+ const partID = deltaProps.partID;
327
+ const field = deltaProps.field || "text";
328
+ const delta = deltaProps.delta;
329
+ if (!partID || delta == null || delta === "")
330
+ return null;
331
+ if (field !== "text" && field !== "reasoning")
332
+ return null;
333
+ return {
334
+ type: field === "reasoning" ? "reasoning" : "text",
335
+ sessionID: deltaProps.sessionID,
336
+ part: { id: partID, type: field, text: delta },
337
+ };
338
+ }
339
+ if (type === "message.part.updated") {
278
340
  const part = props.part;
279
341
  if (!part)
280
342
  return null;
@@ -288,7 +350,6 @@ function normalizeStdoutOpencodeEvent(raw) {
288
350
  mapped = "tool_use";
289
351
  else
290
352
  return null;
291
- // Prefer cumulative text; append delta when that's all we got.
292
353
  if (props.delta && !part.text) {
293
354
  part.text = props.delta;
294
355
  }
@@ -311,7 +372,7 @@ function normalizeStdoutOpencodeEvent(raw) {
311
372
  }
312
373
  return null;
313
374
  }
314
- /** Pull assistant/tool parts from OpenCode HTTP after attach exits early. */
375
+ /** Pull assistant/tool parts from OpenCode HTTP for durable PG (turn-end). */
315
376
  async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, postEvent, onWorkingBranch) {
316
377
  const base = attachUrl.replace(/\/$/, "");
317
378
  const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
@@ -332,8 +393,18 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
332
393
  const data = (await res.json());
333
394
  if (!Array.isArray(data))
334
395
  return 0;
396
+ // Only parts after the latest user message (this turn). Full-history reconcile
397
+ // every turn would O(n) upsert the entire transcript for no benefit.
398
+ let lastUserIdx = -1;
399
+ for (let i = data.length - 1; i >= 0; i--) {
400
+ if ((data[i]?.info?.role || "").toLowerCase() === "user") {
401
+ lastUserIdx = i;
402
+ break;
403
+ }
404
+ }
405
+ const turnSlice = lastUserIdx >= 0 ? data.slice(lastUserIdx + 1) : data;
335
406
  let posted = 0;
336
- for (const msg of data) {
407
+ for (const msg of turnSlice) {
337
408
  if ((msg.info?.role || "").toLowerCase() !== "assistant")
338
409
  continue;
339
410
  for (const part of msg.parts || []) {
@@ -367,7 +438,7 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
367
438
  }
368
439
  }
369
440
  if (posted) {
370
- console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API`);
441
+ console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API (this turn)`);
371
442
  }
372
443
  return posted;
373
444
  }
@@ -434,7 +505,36 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
434
505
  const ev = unwrapped;
435
506
  const type = ev.type || "";
436
507
  const props = ev.properties || {};
437
- if (type === "message.part.updated" || type === "message.part.delta") {
508
+ // Token stream: { partID, field, delta } — often no `part` object.
509
+ if (type === "message.part.delta") {
510
+ const part = props.part;
511
+ const partId = props.partID || part?.id || part?.messageID;
512
+ const field = props.field || part?.type || "text";
513
+ const sessionId = part?.sessionID || props.sessionID;
514
+ if (!sessionMatches(sessionId))
515
+ return;
516
+ callbacks.noteSessionId(sessionId);
517
+ if (!partId || props.delta == null || props.delta === "")
518
+ return;
519
+ if (field === "text") {
520
+ const next = (textByPartId.get(partId) || "") + props.delta;
521
+ textByPartId.set(partId, next);
522
+ callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
523
+ messageId: `oc_text_${partId}`,
524
+ }));
525
+ return;
526
+ }
527
+ if (field === "reasoning") {
528
+ const key = `reasoning:${partId}`;
529
+ const next = (textByPartId.get(key) || "") + props.delta;
530
+ textByPartId.set(key, next);
531
+ callbacks.postEvent(ROLE_REASONING, next, liveOpts({
532
+ messageId: `oc_reasoning_${partId}`,
533
+ }));
534
+ }
535
+ return;
536
+ }
537
+ if (type === "message.part.updated") {
438
538
  const part = props.part;
439
539
  if (!part)
440
540
  return;
@@ -447,13 +547,12 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
447
547
  if (!partId)
448
548
  return;
449
549
  let next = part.text || "";
450
- if (props.delta) {
550
+ if (props.delta && !part.text) {
451
551
  next = (textByPartId.get(partId) || "") + props.delta;
452
552
  }
453
553
  else if (!next && props.delta === undefined) {
454
554
  return;
455
555
  }
456
- // Prefer cumulative part.text when present (idempotent); else delta accumulation.
457
556
  if (part.text)
458
557
  next = part.text;
459
558
  textByPartId.set(partId, next);
@@ -484,8 +583,17 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
484
583
  }
485
584
  if (part.type === "tool") {
486
585
  const status = part.state?.status;
487
- if (!status || status === "pending" || status === "running")
586
+ if (!status || status === "pending")
587
+ return;
588
+ // Ephemeral "(running)" bubbles while UI attached (liveStream gated in poster).
589
+ if (status === "running") {
590
+ const toolContent = formatToolUseContent(part);
591
+ callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
592
+ messageId: opencodeMessageId("oc_tool_", part),
593
+ throttle: false,
594
+ }));
488
595
  return;
596
+ }
489
597
  const toolContent = formatToolUseContent(part);
490
598
  callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
491
599
  messageId: opencodeMessageId("oc_tool_", part),
@@ -779,20 +887,34 @@ function killListenersOnPort(port) {
779
887
  async function waitForOpencodeHttp(attachUrl, timeoutMs) {
780
888
  const base = attachUrl.replace(/\/$/, "");
781
889
  const deadline = Date.now() + timeoutMs;
890
+ const perTryMs = 2_000;
891
+ let attempt = 0;
892
+ let lastErr = "";
782
893
  while (Date.now() < deadline) {
894
+ attempt += 1;
783
895
  for (const path of ["/global/health", "/"]) {
784
896
  try {
785
- const res = await fetch(`${base}${path}`);
786
- if (res.ok || res.status === 401 || res.status === 404)
897
+ const res = await fetch(`${base}${path}`, {
898
+ signal: AbortSignal.timeout(perTryMs),
899
+ });
900
+ if (res.ok || res.status === 401 || res.status === 404) {
901
+ console.error(`ChimpHands OpenCode HTTP ready at ${attachUrl}${path} (http ${res.status}, attempt ${attempt})`);
787
902
  return;
903
+ }
904
+ lastErr = `HTTP ${res.status}`;
788
905
  }
789
- catch {
790
- /* retry */
906
+ catch (err) {
907
+ lastErr = err instanceof Error ? err.message : String(err);
791
908
  }
792
909
  }
910
+ if (attempt === 1 || attempt % 5 === 0) {
911
+ const left = Math.max(0, deadline - Date.now());
912
+ console.error(`ChimpHands waiting for OpenCode HTTP at ${attachUrl} (attempt ${attempt}, ${Math.ceil(left / 1000)}s left): ${lastErr || "not ready"}`);
913
+ }
793
914
  await sleep(400);
794
915
  }
795
- throw new Error(`OpenCode server not ready at ${attachUrl} within ${timeoutMs}ms`);
916
+ throw new Error(`OpenCode server not ready at ${attachUrl} within ${timeoutMs}ms` +
917
+ (lastErr ? ` (last: ${lastErr})` : ""));
796
918
  }
797
919
  /**
798
920
  * Restart local `opencode serve` after writing opencode.json so the server loads
@@ -897,6 +1019,19 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
897
1019
  if (!ev?.type)
898
1020
  return;
899
1021
  noteSessionId(ev.sessionID);
1022
+ switch (ev.type) {
1023
+ case "text":
1024
+ case "reasoning":
1025
+ case "tool_use": {
1026
+ // Attach mode: live tokens come from OpenCode SSE; durable from turn-end
1027
+ // reconcile. Avoid double-fanout / double-accumulation with stdout.
1028
+ if (attachUrl)
1029
+ return;
1030
+ break;
1031
+ }
1032
+ default:
1033
+ break;
1034
+ }
900
1035
  switch (ev.type) {
901
1036
  case "text": {
902
1037
  const chunk = ev.part?.text;
@@ -907,7 +1042,14 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
907
1042
  callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
908
1043
  return;
909
1044
  }
910
- const next = (textByPartId.get(partId) || "") + chunk;
1045
+ // Without attach, --format json may emit completed cumulative or deltas.
1046
+ // Prefer replace when we already have longer text (cumulative); else append.
1047
+ const prev = textByPartId.get(partId) || "";
1048
+ const next = !prev
1049
+ ? chunk
1050
+ : chunk.startsWith(prev)
1051
+ ? chunk
1052
+ : prev + chunk;
911
1053
  textByPartId.set(partId, next);
912
1054
  callbacks.postEvent(ROLE_ASSISTANT, next, {
913
1055
  throttle: true,
@@ -925,7 +1067,12 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
925
1067
  return;
926
1068
  }
927
1069
  const reasoningKey = `reasoning:${partId}`;
928
- const next = (textByPartId.get(reasoningKey) || "") + chunk;
1070
+ const prev = textByPartId.get(reasoningKey) || "";
1071
+ const next = !prev
1072
+ ? chunk
1073
+ : chunk.startsWith(prev)
1074
+ ? chunk
1075
+ : prev + chunk;
929
1076
  textByPartId.set(reasoningKey, next);
930
1077
  callbacks.postEvent(ROLE_REASONING, next, {
931
1078
  throttle: true,
@@ -934,8 +1081,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
934
1081
  return;
935
1082
  }
936
1083
  case "tool_use": {
1084
+ // Durable-only path (no attach). Skip in-progress.
937
1085
  const status = ev.part?.state?.status;
938
- if (!status || status === "pending")
1086
+ if (!status || status === "pending" || status === "running")
939
1087
  return;
940
1088
  const toolContent = formatToolUseContent(ev.part);
941
1089
  callbacks.postEvent(ROLE_TOOL, toolContent, {
@@ -1122,11 +1270,14 @@ export async function runChimphands(opts) {
1122
1270
  });
1123
1271
  };
1124
1272
  const syncLiveSse = (attached) => {
1125
- poster.uiAttached = attached;
1126
1273
  pendingUiAttached = attached;
1127
- if (!attachUrl)
1274
+ if (!attachUrl) {
1275
+ poster.uiAttached = attached;
1128
1276
  return;
1277
+ }
1129
1278
  if (!opencodeHttpReady) {
1279
+ // Defer SSE; still track intent so first sync after ready is correct.
1280
+ poster.uiAttached = attached;
1130
1281
  if (attached) {
1131
1282
  console.error("ChimpHands UI attached — deferring OpenCode SSE until serve is ready");
1132
1283
  }
@@ -1134,18 +1285,18 @@ export async function runChimphands(opts) {
1134
1285
  }
1135
1286
  if (attached) {
1136
1287
  consecutiveUiDetached = 0;
1288
+ // Allow liveStream immediately when heartbeat says attached.
1289
+ poster.uiAttached = true;
1137
1290
  startLiveSseIfNeeded("UI attached — starting OpenCode SSE fanout");
1138
1291
  return;
1139
1292
  }
1140
- // Keep SSE running so live tokens can durable-post even when heartbeat
1141
- // briefly reports false (UI EventSource on another FS replica).
1293
+ // Detached / async: hysteresis avoids flap from cross-replica heartbeats.
1294
+ // Keep poster.uiAttached true + SSE up during the window so we don't drop
1295
+ // live tokens while the UI is still actually listening on another replica.
1142
1296
  consecutiveUiDetached += 1;
1143
- if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER) {
1144
- if (!stopLiveSse) {
1145
- startLiveSseIfNeeded("starting OpenCode SSE fanout (durable until UI attaches)");
1146
- }
1297
+ if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER)
1147
1298
  return;
1148
- }
1299
+ poster.uiAttached = false;
1149
1300
  if (stopLiveSse) {
1150
1301
  console.error(`ChimpHands UI detached for ${consecutiveUiDetached} heartbeats — stopping OpenCode SSE fanout`);
1151
1302
  stopLiveSse();
@@ -1211,30 +1362,6 @@ export async function runChimphands(opts) {
1211
1362
  const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
1212
1363
  let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
1213
1364
  let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
1214
- const exportSignedUrl = bootStr(boot, "opencode_export_signed_url", "opencodeExportSignedUrl");
1215
- if (exportSignedUrl && attachUrl) {
1216
- try {
1217
- const importedId = await importOpencodeExportFromUrl(exportSignedUrl);
1218
- if (importedId) {
1219
- opencodeSessionId = importedId;
1220
- console.error(`ChimpHands rehydrated OpenCode session from export: ${importedId}`);
1221
- }
1222
- }
1223
- catch (err) {
1224
- console.error(`ChimpHands export import failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
1225
- }
1226
- }
1227
- const snapshotExport = async () => {
1228
- const sid = opencodeSessionId?.trim();
1229
- if (!sid)
1230
- return;
1231
- try {
1232
- await putOpencodeExport(backend, apiKey, sessionId, sid);
1233
- }
1234
- catch (err) {
1235
- console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
1236
- }
1237
- };
1238
1365
  noteWorkingBranch = (branch, prUrl) => {
1239
1366
  const normalizedBranch = branch.trim();
1240
1367
  if (!normalizedBranch)
@@ -1334,7 +1461,6 @@ export async function runChimphands(opts) {
1334
1461
  }
1335
1462
  await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
1336
1463
  await poster.flush();
1337
- await snapshotExport();
1338
1464
  if (runtimeId) {
1339
1465
  await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
1340
1466
  runtimeId,
@@ -1434,6 +1560,24 @@ export async function runChimphands(opts) {
1434
1560
  if (result.opencodeSessionId) {
1435
1561
  opencodeSessionId = result.opencodeSessionId;
1436
1562
  }
1563
+ // Turn-end durable reconcile into PG (idempotent messageIds). Mid-turn was
1564
+ // ephemeral-only when UI attached; async runs get their transcript here.
1565
+ // Also reconcile on failure so partial assistant/tool output is not lost.
1566
+ if (attachUrl && opencodeSessionId) {
1567
+ try {
1568
+ await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
1569
+ void poster.enqueue(role, content, {
1570
+ ...opts,
1571
+ opencodeSessionId,
1572
+ // Explicitly not liveStream → always durable post_agent_event.
1573
+ });
1574
+ }, noteWorkingBranch);
1575
+ await poster.flush();
1576
+ }
1577
+ catch (err) {
1578
+ console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1579
+ }
1580
+ }
1437
1581
  if (result.code !== 0) {
1438
1582
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
1439
1583
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -1459,7 +1603,6 @@ export async function runChimphands(opts) {
1459
1603
  break;
1460
1604
  }
1461
1605
  postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1462
- await snapshotExport();
1463
1606
  lastUserActivity = Date.now();
1464
1607
  idle = false;
1465
1608
  prompt = (await waitForNextPrompt()) || "";
@@ -1575,61 +1718,6 @@ async function commitAndPushDirtyWorktree(message) {
1575
1718
  console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
1576
1719
  }
1577
1720
  }
1578
- async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
1579
- const exported = await new Promise((resolve, reject) => {
1580
- const child = spawn("opencode", ["export", opencodeSessionId], {
1581
- stdio: ["ignore", "pipe", "pipe"],
1582
- });
1583
- let out = "";
1584
- let err = "";
1585
- child.stdout.on("data", (d) => {
1586
- out += d.toString();
1587
- });
1588
- child.stderr.on("data", (d) => {
1589
- err += d.toString();
1590
- });
1591
- child.on("close", (code) => {
1592
- if (code === 0 && out.trim())
1593
- resolve(out);
1594
- else
1595
- reject(new Error(err.trim() || `opencode export exited ${code}`));
1596
- });
1597
- });
1598
- const exportBase64 = Buffer.from(exported, "utf8").toString("base64");
1599
- await postJson(backend, apiKey, "/api/chimphands/put_opencode_export", {
1600
- sessionId,
1601
- exportBase64,
1602
- });
1603
- }
1604
- async function importOpencodeExportFromUrl(signedUrl) {
1605
- const res = await fetch(signedUrl);
1606
- if (!res.ok) {
1607
- throw new Error(`download export failed: ${res.status}`);
1608
- }
1609
- const text = await res.text();
1610
- writeFileSync("/tmp/chimphands-opencode-export.json", text, "utf8");
1611
- return await new Promise((resolve, reject) => {
1612
- const child = spawn("opencode", ["import", "/tmp/chimphands-opencode-export.json"], {
1613
- stdio: ["ignore", "pipe", "pipe"],
1614
- });
1615
- let out = "";
1616
- let err = "";
1617
- child.stdout.on("data", (d) => {
1618
- out += d.toString();
1619
- });
1620
- child.stderr.on("data", (d) => {
1621
- err += d.toString();
1622
- });
1623
- child.on("close", (code) => {
1624
- if (code !== 0) {
1625
- reject(new Error(err.trim() || `opencode import exited ${code}`));
1626
- return;
1627
- }
1628
- const match = (out + "\n" + err).match(/ses_[A-Za-z0-9]+/);
1629
- resolve(match?.[0]);
1630
- });
1631
- });
1632
- }
1633
1721
  function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1634
1722
  let stopped = false;
1635
1723
  const base = attachUrl.replace(/\/$/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
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",