@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.
- package/dist/chimphands/run.js +180 -28
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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