@testchimp/cli 0.1.46 → 0.1.48

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.
@@ -3,8 +3,8 @@
3
3
  * Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
4
4
  * Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
5
5
  */
6
- import { spawn } from "node:child_process";
7
- import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { execSync, spawn } from "node:child_process";
7
+ import { mkdirSync, openSync, writeFileSync } from "node:fs";
8
8
  import http from "node:http";
9
9
  import https from "node:https";
10
10
  import { URL } from "node:url";
@@ -18,7 +18,11 @@ const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
18
18
  const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
19
19
  const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
20
20
  const OPENCODE_AGENT_ID = "chimphands";
21
- const STREAM_POST_MIN_INTERVAL_MS = 60;
21
+ /** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
22
+ const STREAM_POST_MIN_INTERVAL_MS = 150;
23
+ function isStreamFanoutRole(role) {
24
+ return role === ROLE_ASSISTANT || role === ROLE_TOOL || role === ROLE_REASONING;
25
+ }
22
26
  const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. You run on GitHub Actions, but this chat is an **interactive** conversation with the user in the TestChimp UI — same expectations as Cursor/Claude Code locally.
23
27
 
24
28
  ## Interactive session (mandatory — default)
@@ -102,13 +106,15 @@ async function postJson(backend, apiKey, path, body) {
102
106
  }
103
107
  return text;
104
108
  }
105
- /** Serializes post_agent_event calls so streaming chunks commit and fan out in order. */
109
+ /** Serializes agent event posts so streaming chunks fan out in order. */
106
110
  class AgentEventPoster {
107
111
  backend;
108
112
  apiKey;
109
113
  sessionId;
110
114
  chain = Promise.resolve();
111
115
  lastStreamPostAt = 0;
116
+ /** When false, assistant/tool/reasoning tokens are dropped (async / no UI). */
117
+ uiAttached = false;
112
118
  constructor(backend, apiKey, sessionId) {
113
119
  this.backend = backend;
114
120
  this.apiKey = apiKey;
@@ -130,15 +136,36 @@ class AgentEventPoster {
130
136
  body.workingBranch = opts.workingBranch;
131
137
  if (opts?.pullRequestUrl)
132
138
  body.pullRequestUrl = opts.pullRequestUrl;
139
+ const ephemeral = isStreamFanoutRole(role);
140
+ if (ephemeral && !this.uiAttached) {
141
+ return this.chain;
142
+ }
133
143
  this.chain = this.chain.then(async () => {
134
- if (opts?.throttle) {
144
+ if (opts?.throttle || ephemeral) {
135
145
  const now = Date.now();
136
146
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
137
147
  if (wait > 0)
138
148
  await sleep(wait);
139
149
  this.lastStreamPostAt = Date.now();
140
150
  }
141
- await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
151
+ const path = ephemeral
152
+ ? "/api/chimphands/post_ephemeral_agent_event"
153
+ : "/api/chimphands/post_agent_event";
154
+ // Ephemeral API only accepts session/role/content/messageId/opencodeSessionId.
155
+ if (ephemeral) {
156
+ const eph = {
157
+ sessionId: this.sessionId,
158
+ role,
159
+ content: body.content,
160
+ };
161
+ if (opts?.messageId)
162
+ eph.messageId = opts.messageId;
163
+ if (opts?.opencodeSessionId)
164
+ eph.opencodeSessionId = opts.opencodeSessionId;
165
+ await postJson(this.backend, this.apiKey, path, eph);
166
+ return;
167
+ }
168
+ await postJson(this.backend, this.apiKey, path, body);
142
169
  });
143
170
  return this.chain;
144
171
  }
@@ -236,6 +263,173 @@ function opencodeMessageId(prefix, part) {
236
263
  return undefined;
237
264
  return `${prefix}${raw}`;
238
265
  }
266
+ /**
267
+ * OpenCode `run --format json` only emits completed text (`part.time.end`).
268
+ * Live tokens come from the server SSE bus (`message.part.updated` + optional `delta`).
269
+ * Subscribe directly when attaching so the platform chat streams.
270
+ */
271
+ function startOpencodeSseRelay(attachUrl, callbacks) {
272
+ const base = attachUrl.replace(/\/$/, "");
273
+ const ac = new AbortController();
274
+ let stopped = false;
275
+ const textByPartId = new Map();
276
+ const sessionMatches = (sessionId) => {
277
+ const active = callbacks.getActiveSessionId();
278
+ if (!sessionId)
279
+ return !active;
280
+ if (!active)
281
+ return true;
282
+ return sessionId === active;
283
+ };
284
+ const handleBusEvent = (raw) => {
285
+ if (!raw || typeof raw !== "object")
286
+ return;
287
+ const ev = raw;
288
+ const type = ev.type || "";
289
+ const props = ev.properties || {};
290
+ if (type === "message.part.updated" || type === "message.part.delta") {
291
+ const part = props.part;
292
+ if (!part)
293
+ return;
294
+ const sessionId = part.sessionID || props.sessionID;
295
+ if (!sessionMatches(sessionId))
296
+ return;
297
+ callbacks.noteSessionId(sessionId);
298
+ if (part.type === "text") {
299
+ const partId = part.id || part.messageID;
300
+ if (!partId)
301
+ return;
302
+ let next = part.text || "";
303
+ if (props.delta) {
304
+ next = (textByPartId.get(partId) || "") + props.delta;
305
+ }
306
+ else if (!next && props.delta === undefined) {
307
+ return;
308
+ }
309
+ // Prefer cumulative part.text when present (idempotent); else delta accumulation.
310
+ if (part.text)
311
+ next = part.text;
312
+ textByPartId.set(partId, next);
313
+ if (!next)
314
+ return;
315
+ callbacks.postEvent(ROLE_ASSISTANT, next, {
316
+ throttle: true,
317
+ messageId: opencodeMessageId("oc_text_", part),
318
+ });
319
+ return;
320
+ }
321
+ if (part.type === "reasoning") {
322
+ const partId = part.id || part.messageID;
323
+ if (!partId)
324
+ return;
325
+ let next = part.text || "";
326
+ if (props.delta && !part.text) {
327
+ next = (textByPartId.get(`reasoning:${partId}`) || "") + props.delta;
328
+ }
329
+ if (part.text)
330
+ next = part.text;
331
+ textByPartId.set(`reasoning:${partId}`, next);
332
+ if (!next)
333
+ return;
334
+ callbacks.postEvent(ROLE_REASONING, next, {
335
+ throttle: true,
336
+ messageId: opencodeMessageId("oc_reasoning_", part),
337
+ });
338
+ return;
339
+ }
340
+ if (part.type === "tool") {
341
+ const status = part.state?.status;
342
+ if (!status || status === "pending" || status === "running")
343
+ return;
344
+ const toolContent = formatToolUseContent(part);
345
+ callbacks.postEvent(ROLE_TOOL, toolContent, {
346
+ messageId: opencodeMessageId("oc_tool_", part),
347
+ });
348
+ if (status === "completed") {
349
+ const detected = detectWorkingBranchFromToolOutput(toolContent);
350
+ if (detected.branch) {
351
+ callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
352
+ }
353
+ }
354
+ }
355
+ return;
356
+ }
357
+ if (type === "session.error") {
358
+ const sessionId = props.sessionID;
359
+ if (!sessionMatches(sessionId))
360
+ return;
361
+ const msg = props.error?.data?.message || props.error?.message || props.error?.name || "OpenCode session error";
362
+ callbacks.postEvent(ROLE_STATUS, String(msg), { status: STATUS_RUNNING });
363
+ }
364
+ };
365
+ const consume = async (body) => {
366
+ const reader = body.getReader();
367
+ const decoder = new TextDecoder();
368
+ let buf = "";
369
+ while (!stopped) {
370
+ const { done, value } = await reader.read();
371
+ if (done)
372
+ break;
373
+ buf += decoder.decode(value, { stream: true });
374
+ const chunks = buf.split("\n\n");
375
+ buf = chunks.pop() || "";
376
+ for (const chunk of chunks) {
377
+ const dataLines = chunk
378
+ .split("\n")
379
+ .filter((l) => l.startsWith("data:"))
380
+ .map((l) => l.slice(5).trimStart());
381
+ if (!dataLines.length)
382
+ continue;
383
+ const data = dataLines.join("\n");
384
+ if (!data || data === "[DONE]")
385
+ continue;
386
+ try {
387
+ handleBusEvent(JSON.parse(data));
388
+ }
389
+ catch {
390
+ /* ignore malformed */
391
+ }
392
+ }
393
+ }
394
+ };
395
+ void (async () => {
396
+ for (const path of ["/event", "/global/event"]) {
397
+ if (stopped)
398
+ return;
399
+ try {
400
+ const res = await fetch(`${base}${path}`, {
401
+ headers: { Accept: "text/event-stream" },
402
+ signal: ac.signal,
403
+ });
404
+ if (!res.ok || !res.body) {
405
+ console.error(`ChimpHands OpenCode SSE ${path} HTTP ${res.status}`);
406
+ continue;
407
+ }
408
+ console.error(`ChimpHands OpenCode SSE streaming via ${path}`);
409
+ await consume(res.body);
410
+ return;
411
+ }
412
+ catch (err) {
413
+ if (stopped || ac.signal.aborted)
414
+ return;
415
+ const detail = err instanceof Error ? err.message : String(err);
416
+ console.error(`ChimpHands OpenCode SSE ${path} failed: ${detail}`);
417
+ }
418
+ }
419
+ if (!stopped) {
420
+ console.error("ChimpHands OpenCode SSE unavailable — falling back to completed-only --format json events");
421
+ }
422
+ })();
423
+ return () => {
424
+ stopped = true;
425
+ try {
426
+ ac.abort();
427
+ }
428
+ catch {
429
+ /* ignore */
430
+ }
431
+ };
432
+ }
239
433
  function summarizeOpencodeFailure(stderr, stdout, exitCode) {
240
434
  for (const chunk of [stderr, stdout]) {
241
435
  for (const line of chunk.split("\n")) {
@@ -350,10 +544,12 @@ function writeOpencodeConfig(backend, apiKey, boot) {
350
544
  prompt: CHIMPHANDS_AGENT_PROMPT,
351
545
  steps: 80,
352
546
  permission: {
547
+ "*": "allow",
353
548
  skill: "allow",
354
549
  bash: "allow",
355
550
  edit: "allow",
356
551
  read: "allow",
552
+ question: "allow",
357
553
  },
358
554
  },
359
555
  },
@@ -364,7 +560,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
364
560
  testchimp: {
365
561
  type: "local",
366
562
  enabled: true,
367
- command: ["npx", "-y", "@testchimp/cli@latest", "mcp"],
563
+ // Prefer the already-installed global binary — `npx -y @latest` can hang in GHA.
564
+ command: ["testchimp", "mcp"],
368
565
  environment: mcpEnv,
369
566
  },
370
567
  },
@@ -372,7 +569,20 @@ function writeOpencodeConfig(backend, apiKey, boot) {
372
569
  return model;
373
570
  }
374
571
  function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
375
- const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
572
+ // --auto: headless CI must approve tool permissions (1.18+ otherwise auto-rejects).
573
+ // --print-logs: surface server/client progress on stderr while waiting for first token.
574
+ const args = [
575
+ "run",
576
+ prompt,
577
+ "--model",
578
+ model,
579
+ "--format",
580
+ "json",
581
+ "--agent",
582
+ OPENCODE_AGENT_ID,
583
+ "--auto",
584
+ "--print-logs",
585
+ ];
376
586
  if (opencodeSessionId?.trim()) {
377
587
  args.push("--session", opencodeSessionId.trim());
378
588
  }
@@ -381,21 +591,102 @@ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
381
591
  }
382
592
  return args;
383
593
  }
594
+ function killListenersOnPort(port) {
595
+ try {
596
+ const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, {
597
+ encoding: "utf8",
598
+ stdio: ["ignore", "pipe", "ignore"],
599
+ }).trim();
600
+ for (const pid of out.split(/\s+/).filter(Boolean)) {
601
+ const n = Number(pid);
602
+ if (!Number.isFinite(n) || n <= 0)
603
+ continue;
604
+ try {
605
+ process.kill(n, "SIGTERM");
606
+ }
607
+ catch {
608
+ /* already gone */
609
+ }
610
+ }
611
+ }
612
+ catch {
613
+ /* nothing listening */
614
+ }
615
+ }
616
+ async function waitForOpencodeHttp(attachUrl, timeoutMs) {
617
+ const base = attachUrl.replace(/\/$/, "");
618
+ const deadline = Date.now() + timeoutMs;
619
+ while (Date.now() < deadline) {
620
+ for (const path of ["/global/health", "/"]) {
621
+ try {
622
+ const res = await fetch(`${base}${path}`);
623
+ if (res.ok || res.status === 401 || res.status === 404)
624
+ return;
625
+ }
626
+ catch {
627
+ /* retry */
628
+ }
629
+ }
630
+ await sleep(400);
631
+ }
632
+ throw new Error(`OpenCode server not ready at ${attachUrl} within ${timeoutMs}ms`);
633
+ }
634
+ /**
635
+ * Restart local `opencode serve` after writing opencode.json so the server loads
636
+ * TestChimp provider + default_agent. Workflow may have started serve earlier without config.
637
+ */
638
+ async function restartLocalOpencodeServer(attachUrl) {
639
+ const u = new URL(attachUrl);
640
+ const hostname = u.hostname || "127.0.0.1";
641
+ const port = u.port || (u.protocol === "https:" ? "443" : "80");
642
+ killListenersOnPort(port);
643
+ await sleep(300);
644
+ const logFd = openSync("opencode-server.log", "a");
645
+ const child = spawn("opencode", ["serve", "--port", port, "--hostname", hostname], {
646
+ detached: true,
647
+ stdio: ["ignore", logFd, logFd],
648
+ env: process.env,
649
+ });
650
+ child.unref();
651
+ if (child.pid) {
652
+ try {
653
+ writeFileSync("/tmp/opencode-server.pid", String(child.pid));
654
+ }
655
+ catch {
656
+ /* best effort */
657
+ }
658
+ }
659
+ console.error(`ChimpHands restarted OpenCode serve on ${hostname}:${port} (pid ${child.pid ?? "?"})`);
660
+ await waitForOpencodeHttp(attachUrl, 60_000);
661
+ }
384
662
  function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
385
663
  let activeSessionId = opencodeSessionId?.trim() || undefined;
386
664
  const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
665
+ const preview = prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt;
666
+ console.error(`ChimpHands invoking OpenCode: model=${model} attach=${attachUrl || "(local)"} session=${activeSessionId || "(new)"} prompt=${JSON.stringify(preview)}`);
667
+ // pipe+end stdin so OpenCode does not wait on Bun.stdin.text() (non-TTY).
387
668
  const child = spawn("opencode", baseArgs, {
388
- stdio: ["ignore", "pipe", "pipe"],
669
+ stdio: ["pipe", "pipe", "pipe"],
389
670
  env: childEnv,
390
671
  });
672
+ try {
673
+ child.stdin?.end();
674
+ }
675
+ catch {
676
+ /* ignore */
677
+ }
391
678
  let err = "";
392
679
  child.stderr.on("data", (d) => {
393
- err += d.toString();
680
+ const chunk = d.toString();
681
+ err += chunk;
682
+ // Live-forward so GHA shows progress while waiting for first JSON event.
683
+ process.stderr.write(chunk);
394
684
  });
395
685
  return new Promise((resolve) => {
396
686
  let buf = "";
397
687
  let fatalError = null;
398
688
  const textByPartId = new Map();
689
+ let sawStdout = false;
399
690
  const noteSessionId = (sessionId) => {
400
691
  const id = sessionId?.trim();
401
692
  if (!id || id === activeSessionId)
@@ -406,6 +697,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
406
697
  const handleOpencodeLine = (line) => {
407
698
  if (!line.trim())
408
699
  return;
700
+ if (!sawStdout) {
701
+ sawStdout = true;
702
+ console.error("ChimpHands OpenCode first stdout event received");
703
+ }
409
704
  const fatal = extractOpencodeFatalError(line);
410
705
  if (fatal) {
411
706
  fatalError = fatal;
@@ -608,6 +903,40 @@ export async function runChimphands(opts) {
608
903
  const boot = JSON.parse(bootText);
609
904
  const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
610
905
  const poster = new AgentEventPoster(backend, apiKey, sessionId);
906
+ poster.uiAttached = !!(boot.uiAttached ?? boot.ui_attached);
907
+ let stopLiveSse = null;
908
+ const syncLiveSse = (attached) => {
909
+ poster.uiAttached = attached;
910
+ if (!attachUrl)
911
+ return;
912
+ if (attached && !stopLiveSse) {
913
+ console.error("ChimpHands UI attached — starting OpenCode SSE fanout");
914
+ stopLiveSse = startOpencodeSseRelay(attachUrl, {
915
+ getActiveSessionId: () => opencodeSessionId,
916
+ noteSessionId: (id) => {
917
+ if (id?.trim())
918
+ opencodeSessionId = id.trim();
919
+ },
920
+ postEvent: (role, content, opts) => {
921
+ poster.fireAndForget(role, content, {
922
+ ...opts,
923
+ opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
924
+ });
925
+ },
926
+ onWorkingBranch: noteWorkingBranchPlaceholder,
927
+ });
928
+ }
929
+ else if (!attached && stopLiveSse) {
930
+ console.error("ChimpHands UI detached — stopping OpenCode SSE fanout");
931
+ stopLiveSse();
932
+ stopLiveSse = null;
933
+ }
934
+ };
935
+ // noteWorkingBranch is defined later; bind via mutable holder until then.
936
+ let noteWorkingBranch = () => { };
937
+ const noteWorkingBranchPlaceholder = (branch, prUrl) => {
938
+ noteWorkingBranch(branch, prUrl);
939
+ };
611
940
  if (githubRunId) {
612
941
  await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
613
942
  sessionId,
@@ -635,7 +964,9 @@ export async function runChimphands(opts) {
635
964
  }
636
965
  }
637
966
  const stopHeartbeat = runtimeId
638
- ? startRuntimeHeartbeat(backend, apiKey, runtimeId)
967
+ ? startRuntimeHeartbeat(backend, apiKey, runtimeId, (attached) => {
968
+ syncLiveSse(attached);
969
+ })
639
970
  : () => { };
640
971
  const stopTunnel = runtimeId && attachUrl
641
972
  ? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
@@ -649,6 +980,9 @@ export async function runChimphands(opts) {
649
980
  console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
650
981
  if (attachUrl) {
651
982
  console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
983
+ // Serve must load opencode.json (provider + default_agent). Workflow often starts
984
+ // serve before this file exists; restart so attach mode can omit --agent safely.
985
+ await restartLocalOpencodeServer(attachUrl);
652
986
  }
653
987
  let opencodeSessionId = bootStr(boot, "opencode_session_id", "opencodeSessionId") || undefined;
654
988
  const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
@@ -678,7 +1012,7 @@ export async function runChimphands(opts) {
678
1012
  console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
679
1013
  }
680
1014
  };
681
- const noteWorkingBranch = (branch, prUrl) => {
1015
+ noteWorkingBranch = (branch, prUrl) => {
682
1016
  const normalizedBranch = branch.trim();
683
1017
  if (!normalizedBranch)
684
1018
  return;
@@ -694,6 +1028,8 @@ export async function runChimphands(opts) {
694
1028
  poster.reportWorkingBranch(normalizedBranch, nextPr);
695
1029
  }
696
1030
  };
1031
+ // Apply bootstrap ui_attached now that session id + branch hooks exist.
1032
+ syncLiveSse(poster.uiAttached);
697
1033
  const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
698
1034
  const queue = [];
699
1035
  const seenUserMessageIds = new Set();
@@ -769,6 +1105,10 @@ export async function runChimphands(opts) {
769
1105
  stopInbound();
770
1106
  stopTunnel();
771
1107
  stopHeartbeat();
1108
+ if (stopLiveSse) {
1109
+ stopLiveSse();
1110
+ stopLiveSse = null;
1111
+ }
772
1112
  await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
773
1113
  await poster.flush();
774
1114
  await snapshotExport();
@@ -822,6 +1162,9 @@ export async function runChimphands(opts) {
822
1162
  let useOpencodeSessionId = opencodeSessionId;
823
1163
  let isNewOpencodeSession = !useOpencodeSessionId;
824
1164
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
1165
+ // Visible in chat (not filtered as routine). OpenCode may not emit text until a
1166
+ // part completes — without this the UI looks empty while the turn is running.
1167
+ postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
825
1168
  let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
826
1169
  onSessionId: (id) => {
827
1170
  opencodeSessionId = id;
@@ -898,22 +1241,34 @@ export async function runChimphands(opts) {
898
1241
  process.exit(exitCode);
899
1242
  }
900
1243
  }
901
- function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
1244
+ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
902
1245
  let stopped = false;
1246
+ let lastAttached;
903
1247
  const tick = async () => {
904
1248
  if (stopped)
905
1249
  return;
906
1250
  try {
907
1251
  // Do not claim tunnel_connected here — only the tunnel poll loop should.
908
- await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
1252
+ const text = await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
909
1253
  runtimeId,
910
1254
  });
1255
+ try {
1256
+ const data = JSON.parse(text);
1257
+ const attached = !!(data.uiAttached ?? data.ui_attached);
1258
+ if (attached !== lastAttached) {
1259
+ lastAttached = attached;
1260
+ onUiAttached?.(attached);
1261
+ }
1262
+ }
1263
+ catch {
1264
+ /* ignore parse */
1265
+ }
911
1266
  }
912
1267
  catch (err) {
913
1268
  console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
914
1269
  }
915
1270
  if (!stopped)
916
- setTimeout(tick, 15_000);
1271
+ setTimeout(tick, 5_000);
917
1272
  };
918
1273
  void tick();
919
1274
  return () => {
@@ -985,7 +1340,7 @@ async function commitAndPushDirtyWorktree(message) {
985
1340
  }
986
1341
  async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
987
1342
  const exported = await new Promise((resolve, reject) => {
988
- const child = spawn("opencode", ["export", opencodeSessionId, "--sanitize"], {
1343
+ const child = spawn("opencode", ["export", opencodeSessionId], {
989
1344
  stdio: ["ignore", "pipe", "pipe"],
990
1345
  });
991
1346
  let out = "";
@@ -1213,18 +1568,6 @@ export async function serveChimphands(opts) {
1213
1568
  if (!attachUrl) {
1214
1569
  throw new Error("--attach URL is required for chimphands serve");
1215
1570
  }
1216
- // Wait for OpenCode server readiness.
1217
- const deadline = Date.now() + 60_000;
1218
- while (Date.now() < deadline) {
1219
- try {
1220
- const res = await fetch(attachUrl.replace(/\/$/, "") + "/");
1221
- if (res.ok || res.status === 401 || res.status === 404)
1222
- break;
1223
- }
1224
- catch {
1225
- /* retry */
1226
- }
1227
- await sleep(500);
1228
- }
1571
+ // Server is (re)started inside runChimphands after opencode.json is written.
1229
1572
  await runChimphands({ ...opts, attachUrl });
1230
1573
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
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",