@testchimp/cli 0.1.58 → 0.1.60

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.
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { execSync, spawn } from "node:child_process";
7
7
  import { mkdirSync, openSync, writeFileSync } from "node:fs";
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
8
10
  import http from "node:http";
9
11
  import https from "node:https";
10
12
  import { URL } from "node:url";
@@ -1059,6 +1061,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1059
1061
  stdio: ["pipe", "pipe", "pipe"],
1060
1062
  env: childEnv,
1061
1063
  });
1064
+ callbacks.onActiveChild?.(child);
1062
1065
  try {
1063
1066
  child.stdin?.end();
1064
1067
  }
@@ -1232,6 +1235,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1232
1235
  }
1233
1236
  });
1234
1237
  child.on("close", (code) => {
1238
+ callbacks.onActiveChild?.(null);
1235
1239
  if (progressTicker) {
1236
1240
  clearInterval(progressTicker);
1237
1241
  progressTicker = null;
@@ -1242,6 +1246,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1242
1246
  const stderrFatal = extractOpencodeFatalError(err);
1243
1247
  if (stderrFatal)
1244
1248
  fatalError = stderrFatal;
1249
+ if (callbacks.getCancelRequested?.()) {
1250
+ resolve({ code: 0, err: "", opencodeSessionId: activeSessionId, cancelled: true });
1251
+ return;
1252
+ }
1245
1253
  if (fatalError) {
1246
1254
  resolve({ code: 1, err: fatalError, opencodeSessionId: activeSessionId });
1247
1255
  return;
@@ -1258,6 +1266,63 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1258
1266
  });
1259
1267
  });
1260
1268
  }
1269
+ function normalizeWorktreeRelativePath(filePath) {
1270
+ let p = String(filePath || "").trim().replace(/\\/g, "/");
1271
+ while (p.startsWith("/"))
1272
+ p = p.slice(1);
1273
+ if (!p || p.includes("\0")) {
1274
+ throw new Error("invalid path");
1275
+ }
1276
+ const segments = [];
1277
+ for (const part of p.split("/")) {
1278
+ if (!part || part === ".")
1279
+ continue;
1280
+ if (part === "..") {
1281
+ if (!segments.length)
1282
+ throw new Error("invalid path");
1283
+ segments.pop();
1284
+ continue;
1285
+ }
1286
+ segments.push(part);
1287
+ }
1288
+ if (!segments.length)
1289
+ throw new Error("invalid path");
1290
+ return segments.join("/");
1291
+ }
1292
+ async function ackWorktreeFileWrite(backend, apiKey, sessionId, requestId, ok, errorMessage) {
1293
+ const body = {
1294
+ sessionId,
1295
+ requestId,
1296
+ ok,
1297
+ };
1298
+ if (errorMessage)
1299
+ body.errorMessage = errorMessage.slice(0, 2000);
1300
+ await postJson(backend, apiKey, "/api/chimphands/ack_worktree_file_write", body).catch((err) => {
1301
+ console.error(`ChimpHands ack_worktree_file_write failed: ${err instanceof Error ? err.message : String(err)}`);
1302
+ });
1303
+ }
1304
+ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1305
+ if (turnActive()) {
1306
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, "agent turn in progress");
1307
+ return;
1308
+ }
1309
+ try {
1310
+ const relative = normalizeWorktreeRelativePath(edit.path);
1311
+ const root = process.cwd();
1312
+ const full = path.resolve(root, relative);
1313
+ const rootResolved = path.resolve(root);
1314
+ if (full !== rootResolved && !full.startsWith(rootResolved + path.sep)) {
1315
+ throw new Error("path outside worktree");
1316
+ }
1317
+ await fs.mkdir(path.dirname(full), { recursive: true });
1318
+ await fs.writeFile(full, edit.content, "utf8");
1319
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
1320
+ }
1321
+ catch (err) {
1322
+ const msg = err instanceof Error ? err.message : String(err);
1323
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, msg);
1324
+ }
1325
+ }
1261
1326
  function connectInboundStream(backend, apiKey, sessionId, handlers) {
1262
1327
  const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
1263
1328
  const lib = url.protocol === "https:" ? https : http;
@@ -1305,6 +1370,37 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
1305
1370
  if (eventName === "idle") {
1306
1371
  handlers.onIdle();
1307
1372
  }
1373
+ else if (eventName === "cancel_turn") {
1374
+ try {
1375
+ const payload = JSON.parse(data);
1376
+ if (payload.sessionId && payload.sessionId !== sessionId) {
1377
+ continue;
1378
+ }
1379
+ }
1380
+ catch {
1381
+ /* ignore malformed payload */
1382
+ }
1383
+ handlers.onCancelTurn?.();
1384
+ }
1385
+ else if (eventName === "user_file_edit") {
1386
+ try {
1387
+ const edit = JSON.parse(data);
1388
+ if (edit.sessionId && edit.sessionId !== sessionId) {
1389
+ continue;
1390
+ }
1391
+ const requestId = edit.requestId || edit.request_id;
1392
+ if (requestId && edit.path) {
1393
+ handlers.onUserFileEdit?.({
1394
+ requestId,
1395
+ path: edit.path,
1396
+ content: edit.content ?? "",
1397
+ });
1398
+ }
1399
+ }
1400
+ catch {
1401
+ /* ignore */
1402
+ }
1403
+ }
1308
1404
  else if (eventName === "user_message" || eventName === "message") {
1309
1405
  try {
1310
1406
  const msg = JSON.parse(data);
@@ -1450,9 +1546,7 @@ export async function runChimphands(opts) {
1450
1546
  syncLiveSse(attached);
1451
1547
  })
1452
1548
  : () => { };
1453
- const stopTunnel = runtimeId && attachUrl
1454
- ? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
1455
- : () => { };
1549
+ let stopTunnel = () => { };
1456
1550
  const userId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
1457
1551
  if (userId) {
1458
1552
  process.env.TESTCHIMP_USER_ID = userId;
@@ -1498,6 +1592,15 @@ export async function runChimphands(opts) {
1498
1592
  let sessionActive = true;
1499
1593
  let lastUserActivity = Date.now();
1500
1594
  let exitCode;
1595
+ let cancelTurnRequested = false;
1596
+ let activeOpencodeChild = null;
1597
+ let agentTurnInProgress = false;
1598
+ const turnControl = {
1599
+ getCancelRequested: () => cancelTurnRequested,
1600
+ onActiveChild: (child) => {
1601
+ activeOpencodeChild = child;
1602
+ },
1603
+ };
1501
1604
  const enqueueUserMessage = (msg) => {
1502
1605
  const id = msg.id?.trim();
1503
1606
  if (id) {
@@ -1529,6 +1632,13 @@ export async function runChimphands(opts) {
1529
1632
  // Polling is best-effort when inbound SSE misses an event.
1530
1633
  }
1531
1634
  };
1635
+ const seenWorktreeWriteRequestIds = new Set();
1636
+ const handleUserFileEdit = (edit) => {
1637
+ if (seenWorktreeWriteRequestIds.has(edit.requestId))
1638
+ return;
1639
+ seenWorktreeWriteRequestIds.add(edit.requestId);
1640
+ void applyUserFileEdit(backend, apiKey, sessionId, edit, () => agentTurnInProgress);
1641
+ };
1532
1642
  const postEvent = (role, content, opts) => {
1533
1643
  const bodyOpts = { ...opts };
1534
1644
  if (opencodeSessionId && !bodyOpts.opencodeSessionId) {
@@ -1569,8 +1679,26 @@ export async function runChimphands(opts) {
1569
1679
  onIdle: () => {
1570
1680
  idle = true;
1571
1681
  },
1682
+ onCancelTurn: () => {
1683
+ cancelTurnRequested = true;
1684
+ const ch = activeOpencodeChild;
1685
+ if (ch && !ch.killed) {
1686
+ try {
1687
+ ch.kill("SIGTERM");
1688
+ }
1689
+ catch {
1690
+ /* ignore */
1691
+ }
1692
+ }
1693
+ },
1694
+ onUserFileEdit: handleUserFileEdit,
1572
1695
  shouldRun: () => sessionActive,
1573
1696
  });
1697
+ if (runtimeId && attachUrl) {
1698
+ stopTunnel = startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, {
1699
+ onUserFileEdit: handleUserFileEdit,
1700
+ });
1701
+ }
1574
1702
  const shutdownRuntime = async () => {
1575
1703
  sessionActive = false;
1576
1704
  stopInbound();
@@ -1641,6 +1769,8 @@ export async function runChimphands(opts) {
1641
1769
  tick();
1642
1770
  });
1643
1771
  while (prompt) {
1772
+ cancelTurnRequested = false;
1773
+ agentTurnInProgress = true;
1644
1774
  let useOpencodeSessionId = opencodeSessionId;
1645
1775
  let isNewOpencodeSession = !useOpencodeSessionId;
1646
1776
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
@@ -1658,6 +1788,7 @@ export async function runChimphands(opts) {
1658
1788
  },
1659
1789
  onWorkingBranch: noteWorkingBranch,
1660
1790
  postEvent: turnPostEvent,
1791
+ ...turnControl,
1661
1792
  }, attachUrl);
1662
1793
  if (result.code !== 0 &&
1663
1794
  useOpencodeSessionId &&
@@ -1676,8 +1807,10 @@ export async function runChimphands(opts) {
1676
1807
  },
1677
1808
  onWorkingBranch: noteWorkingBranch,
1678
1809
  postEvent: turnPostEvent,
1810
+ ...turnControl,
1679
1811
  }, attachUrl);
1680
1812
  }
1813
+ agentTurnInProgress = false;
1681
1814
  await poster.flush();
1682
1815
  if (result.opencodeSessionId) {
1683
1816
  opencodeSessionId = result.opencodeSessionId;
@@ -1700,7 +1833,7 @@ export async function runChimphands(opts) {
1700
1833
  console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1701
1834
  }
1702
1835
  }
1703
- if (result.code !== 0) {
1836
+ if (result.code !== 0 && !result.cancelled) {
1704
1837
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
1705
1838
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
1706
1839
  try {
@@ -1724,7 +1857,12 @@ export async function runChimphands(opts) {
1724
1857
  exitCode = result.code || 1;
1725
1858
  break;
1726
1859
  }
1727
- postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1860
+ if (result.cancelled) {
1861
+ postEvent(ROLE_STATUS, "Turn stopped", { status: STATUS_WAITING_USER });
1862
+ }
1863
+ else {
1864
+ postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1865
+ }
1728
1866
  lastUserActivity = Date.now();
1729
1867
  idle = false;
1730
1868
  prompt = (await waitForNextPrompt()) || "";
@@ -1840,7 +1978,7 @@ async function commitAndPushDirtyWorktree(message) {
1840
1978
  console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
1841
1979
  }
1842
1980
  }
1843
- function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1981
+ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, handlers) {
1844
1982
  let stopped = false;
1845
1983
  const base = attachUrl.replace(/\/$/, "");
1846
1984
  let ws = null;
@@ -1971,6 +2109,21 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
1971
2109
  socket.send(JSON.stringify({ type: "pong" }));
1972
2110
  return;
1973
2111
  }
2112
+ if (frame.type === "inbound_event" && frame.event === "user_file_edit" && frame.data) {
2113
+ const edit = frame.data;
2114
+ if (edit.sessionId && edit.sessionId !== sessionId) {
2115
+ return;
2116
+ }
2117
+ const requestId = edit.requestId || edit.request_id;
2118
+ if (requestId && edit.path) {
2119
+ handlers?.onUserFileEdit?.({
2120
+ requestId,
2121
+ path: edit.path,
2122
+ content: edit.content ?? "",
2123
+ });
2124
+ }
2125
+ return;
2126
+ }
1974
2127
  if (frame.type === "http_request" || frame.requestId) {
1975
2128
  void handleHttpRequest(socket, frame);
1976
2129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
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",