@testchimp/cli 0.1.45 → 0.1.47

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";
@@ -33,6 +33,7 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
33
33
  - This conversation uses ONE working branch and ONE pull request. Reuse them for all follow-up work in this chat.
34
34
  - If bootstrap lists a working branch, checkout that branch and push additional commits there — update the same PR.
35
35
  - Only create a NEW branch/PR when (a) no working branch exists yet for this conversation, or (b) the prior PR was merged/closed (verify with \`gh pr view\`).
36
+ - Commit and push on the session working branch after meaningful edit batches. The host also commits any dirty worktree before idle teardown — keep the branch pushed so the UI can show diffs from GitHub.
36
37
  - Branch names MUST start with \`testchimp-\` or \`chimphands-\`.
37
38
  - When creating a NEW working branch: create it, then IMMEDIATELY publish it with
38
39
  \`git push -u origin <branch>\` BEFORE calling report-branch. Users open the branch URL in the UI —
@@ -371,7 +372,16 @@ function writeOpencodeConfig(backend, apiKey, boot) {
371
372
  return model;
372
373
  }
373
374
  function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
374
- const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
375
+ const args = [
376
+ "run",
377
+ prompt,
378
+ "--model",
379
+ model,
380
+ "--format",
381
+ "json",
382
+ "--agent",
383
+ OPENCODE_AGENT_ID,
384
+ ];
375
385
  if (opencodeSessionId?.trim()) {
376
386
  args.push("--session", opencodeSessionId.trim());
377
387
  }
@@ -380,6 +390,74 @@ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
380
390
  }
381
391
  return args;
382
392
  }
393
+ function killListenersOnPort(port) {
394
+ try {
395
+ const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, {
396
+ encoding: "utf8",
397
+ stdio: ["ignore", "pipe", "ignore"],
398
+ }).trim();
399
+ for (const pid of out.split(/\s+/).filter(Boolean)) {
400
+ const n = Number(pid);
401
+ if (!Number.isFinite(n) || n <= 0)
402
+ continue;
403
+ try {
404
+ process.kill(n, "SIGTERM");
405
+ }
406
+ catch {
407
+ /* already gone */
408
+ }
409
+ }
410
+ }
411
+ catch {
412
+ /* nothing listening */
413
+ }
414
+ }
415
+ async function waitForOpencodeHttp(attachUrl, timeoutMs) {
416
+ const base = attachUrl.replace(/\/$/, "");
417
+ const deadline = Date.now() + timeoutMs;
418
+ while (Date.now() < deadline) {
419
+ for (const path of ["/global/health", "/"]) {
420
+ try {
421
+ const res = await fetch(`${base}${path}`);
422
+ if (res.ok || res.status === 401 || res.status === 404)
423
+ return;
424
+ }
425
+ catch {
426
+ /* retry */
427
+ }
428
+ }
429
+ await sleep(400);
430
+ }
431
+ throw new Error(`OpenCode server not ready at ${attachUrl} within ${timeoutMs}ms`);
432
+ }
433
+ /**
434
+ * Restart local `opencode serve` after writing opencode.json so the server loads
435
+ * TestChimp provider + default_agent. Workflow may have started serve earlier without config.
436
+ */
437
+ async function restartLocalOpencodeServer(attachUrl) {
438
+ const u = new URL(attachUrl);
439
+ const hostname = u.hostname || "127.0.0.1";
440
+ const port = u.port || (u.protocol === "https:" ? "443" : "80");
441
+ killListenersOnPort(port);
442
+ await sleep(300);
443
+ const logFd = openSync("opencode-server.log", "a");
444
+ const child = spawn("opencode", ["serve", "--port", port, "--hostname", hostname], {
445
+ detached: true,
446
+ stdio: ["ignore", logFd, logFd],
447
+ env: process.env,
448
+ });
449
+ child.unref();
450
+ if (child.pid) {
451
+ try {
452
+ writeFileSync("/tmp/opencode-server.pid", String(child.pid));
453
+ }
454
+ catch {
455
+ /* best effort */
456
+ }
457
+ }
458
+ console.error(`ChimpHands restarted OpenCode serve on ${hostname}:${port} (pid ${child.pid ?? "?"})`);
459
+ await waitForOpencodeHttp(attachUrl, 60_000);
460
+ }
383
461
  function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
384
462
  let activeSessionId = opencodeSessionId?.trim() || undefined;
385
463
  const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
@@ -648,6 +726,9 @@ export async function runChimphands(opts) {
648
726
  console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
649
727
  if (attachUrl) {
650
728
  console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
729
+ // Serve must load opencode.json (provider + default_agent). Workflow often starts
730
+ // serve before this file exists; restart so attach mode can omit --agent safely.
731
+ await restartLocalOpencodeServer(attachUrl);
651
732
  }
652
733
  let opencodeSessionId = bootStr(boot, "opencode_session_id", "opencodeSessionId") || undefined;
653
734
  const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
@@ -768,6 +849,7 @@ export async function runChimphands(opts) {
768
849
  stopInbound();
769
850
  stopTunnel();
770
851
  stopHeartbeat();
852
+ await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
771
853
  await poster.flush();
772
854
  await snapshotExport();
773
855
  if (runtimeId) {
@@ -918,6 +1000,69 @@ function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
918
1000
  stopped = true;
919
1001
  };
920
1002
  }
1003
+ /** Commit+push dirty worktree on the session branch before idle/teardown (no default-branch writes). */
1004
+ async function commitAndPushDirtyWorktree(message) {
1005
+ const run = (args, env) => new Promise((resolve) => {
1006
+ const child = spawn("git", args, {
1007
+ stdio: ["ignore", "pipe", "pipe"],
1008
+ env: env ? { ...process.env, ...env } : process.env,
1009
+ });
1010
+ let out = "";
1011
+ let err = "";
1012
+ child.stdout.on("data", (d) => {
1013
+ out += d.toString();
1014
+ });
1015
+ child.stderr.on("data", (d) => {
1016
+ err += d.toString();
1017
+ });
1018
+ child.on("close", (code) => resolve({ code: code ?? 1, out, err }));
1019
+ });
1020
+ try {
1021
+ const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
1022
+ if (branch.code !== 0) {
1023
+ console.error(`ChimpHands git rev-parse failed: ${branch.err || branch.out}`);
1024
+ return;
1025
+ }
1026
+ const current = branch.out.trim();
1027
+ if (!current || current === "HEAD" || /^(main|master)$/i.test(current)) {
1028
+ console.error(`ChimpHands skip commit-before-idle: refusing branch "${current || "(unknown)"}"`);
1029
+ return;
1030
+ }
1031
+ const status = await run(["status", "--porcelain"]);
1032
+ if (status.code !== 0) {
1033
+ console.error(`ChimpHands git status failed: ${status.err || status.out}`);
1034
+ return;
1035
+ }
1036
+ if (!status.out.trim()) {
1037
+ return;
1038
+ }
1039
+ const add = await run(["add", "-A"]);
1040
+ if (add.code !== 0) {
1041
+ console.error(`ChimpHands git add failed: ${add.err || add.out}`);
1042
+ return;
1043
+ }
1044
+ const commitEnv = {
1045
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "ChimpHands",
1046
+ GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || "chimphands@testchimp.io",
1047
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "ChimpHands",
1048
+ GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || "chimphands@testchimp.io",
1049
+ };
1050
+ const commit = await run(["-c", "user.name=ChimpHands", "-c", "user.email=chimphands@testchimp.io", "commit", "-m", message], commitEnv);
1051
+ if (commit.code !== 0) {
1052
+ console.error(`ChimpHands git commit: ${commit.err || commit.out}`);
1053
+ return;
1054
+ }
1055
+ const push = await run(["push", "-u", "origin", "HEAD"]);
1056
+ if (push.code !== 0) {
1057
+ console.error(`ChimpHands git push failed: ${push.err || push.out}`);
1058
+ return;
1059
+ }
1060
+ console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
1061
+ }
1062
+ catch (err) {
1063
+ console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
1064
+ }
1065
+ }
921
1066
  async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
922
1067
  const exported = await new Promise((resolve, reject) => {
923
1068
  const child = spawn("opencode", ["export", opencodeSessionId, "--sanitize"], {
@@ -1006,6 +1151,18 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1006
1151
  return;
1007
1152
  socket.send(JSON.stringify(obj));
1008
1153
  };
1154
+ /** Keep each WS text frame small — Tomcat default max is 8KiB; GCLB is happier with modest frames. */
1155
+ const sendBodyChunk = (bytes) => {
1156
+ const MAX = 24 * 1024;
1157
+ for (let offset = 0; offset < bytes.length; offset += MAX) {
1158
+ const slice = bytes.subarray(offset, Math.min(offset + MAX, bytes.length));
1159
+ send({
1160
+ type: "http_response_chunk",
1161
+ requestId,
1162
+ bodyBase64: Buffer.from(slice).toString("base64"),
1163
+ });
1164
+ }
1165
+ };
1009
1166
  try {
1010
1167
  const upstream = await fetch(target, init);
1011
1168
  const respHeaders = {};
@@ -1026,22 +1183,14 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1026
1183
  if (done)
1027
1184
  break;
1028
1185
  if (value && value.length) {
1029
- send({
1030
- type: "http_response_chunk",
1031
- requestId,
1032
- bodyBase64: Buffer.from(value).toString("base64"),
1033
- });
1186
+ sendBodyChunk(Buffer.from(value));
1034
1187
  }
1035
1188
  }
1036
1189
  }
1037
1190
  else {
1038
1191
  const buf = Buffer.from(await upstream.arrayBuffer());
1039
1192
  if (buf.length) {
1040
- send({
1041
- type: "http_response_chunk",
1042
- requestId,
1043
- bodyBase64: buf.toString("base64"),
1044
- });
1193
+ sendBodyChunk(buf);
1045
1194
  }
1046
1195
  }
1047
1196
  send({ type: "http_response_end", requestId });
@@ -1073,6 +1222,20 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1073
1222
  socket.on("open", () => {
1074
1223
  backoffMs = 1000;
1075
1224
  console.error(`ChimpHands agent tunnel WS connected: ${tunnelUrl}`);
1225
+ // Application ping keeps LBs from idling out the tunnel (and proves liveness).
1226
+ const ping = () => {
1227
+ if (stopped || socket.readyState !== 1)
1228
+ return;
1229
+ try {
1230
+ socket.send(JSON.stringify({ type: "ping" }));
1231
+ }
1232
+ catch {
1233
+ /* ignore */
1234
+ }
1235
+ };
1236
+ ping();
1237
+ const pingTimer = setInterval(ping, 20_000);
1238
+ socket.once("close", () => clearInterval(pingTimer));
1076
1239
  });
1077
1240
  socket.on("message", (data) => {
1078
1241
  if (stopped)
@@ -1094,11 +1257,12 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1094
1257
  console.error(`ChimpHands tunnel frame error: ${err instanceof Error ? err.message : String(err)}`);
1095
1258
  }
1096
1259
  });
1097
- socket.on("close", () => {
1260
+ socket.on("close", (code, reason) => {
1098
1261
  ws = null;
1099
1262
  if (stopped)
1100
1263
  return;
1101
- console.error(`ChimpHands agent tunnel WS closed; reconnecting in ${backoffMs}ms`);
1264
+ const why = reason?.toString?.() || "";
1265
+ console.error(`ChimpHands agent tunnel WS closed; code=${code} reason=${why || "(none)"} reconnecting in ${backoffMs}ms`);
1102
1266
  reconnectTimer = setTimeout(() => {
1103
1267
  void connect();
1104
1268
  }, backoffMs);
@@ -1129,18 +1293,6 @@ export async function serveChimphands(opts) {
1129
1293
  if (!attachUrl) {
1130
1294
  throw new Error("--attach URL is required for chimphands serve");
1131
1295
  }
1132
- // Wait for OpenCode server readiness.
1133
- const deadline = Date.now() + 60_000;
1134
- while (Date.now() < deadline) {
1135
- try {
1136
- const res = await fetch(attachUrl.replace(/\/$/, "") + "/");
1137
- if (res.ok || res.status === 401 || res.status === 404)
1138
- break;
1139
- }
1140
- catch {
1141
- /* retry */
1142
- }
1143
- await sleep(500);
1144
- }
1296
+ // Server is (re)started inside runChimphands after opencode.json is written.
1145
1297
  await runChimphands({ ...opts, attachUrl });
1146
1298
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
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",