@testchimp/cli 0.1.58 → 0.1.59

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);
@@ -1498,6 +1594,15 @@ export async function runChimphands(opts) {
1498
1594
  let sessionActive = true;
1499
1595
  let lastUserActivity = Date.now();
1500
1596
  let exitCode;
1597
+ let cancelTurnRequested = false;
1598
+ let activeOpencodeChild = null;
1599
+ let agentTurnInProgress = false;
1600
+ const turnControl = {
1601
+ getCancelRequested: () => cancelTurnRequested,
1602
+ onActiveChild: (child) => {
1603
+ activeOpencodeChild = child;
1604
+ },
1605
+ };
1501
1606
  const enqueueUserMessage = (msg) => {
1502
1607
  const id = msg.id?.trim();
1503
1608
  if (id) {
@@ -1569,6 +1674,21 @@ export async function runChimphands(opts) {
1569
1674
  onIdle: () => {
1570
1675
  idle = true;
1571
1676
  },
1677
+ onCancelTurn: () => {
1678
+ cancelTurnRequested = true;
1679
+ const ch = activeOpencodeChild;
1680
+ if (ch && !ch.killed) {
1681
+ try {
1682
+ ch.kill("SIGTERM");
1683
+ }
1684
+ catch {
1685
+ /* ignore */
1686
+ }
1687
+ }
1688
+ },
1689
+ onUserFileEdit: (edit) => {
1690
+ void applyUserFileEdit(backend, apiKey, sessionId, edit, () => agentTurnInProgress);
1691
+ },
1572
1692
  shouldRun: () => sessionActive,
1573
1693
  });
1574
1694
  const shutdownRuntime = async () => {
@@ -1641,6 +1761,8 @@ export async function runChimphands(opts) {
1641
1761
  tick();
1642
1762
  });
1643
1763
  while (prompt) {
1764
+ cancelTurnRequested = false;
1765
+ agentTurnInProgress = true;
1644
1766
  let useOpencodeSessionId = opencodeSessionId;
1645
1767
  let isNewOpencodeSession = !useOpencodeSessionId;
1646
1768
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
@@ -1658,6 +1780,7 @@ export async function runChimphands(opts) {
1658
1780
  },
1659
1781
  onWorkingBranch: noteWorkingBranch,
1660
1782
  postEvent: turnPostEvent,
1783
+ ...turnControl,
1661
1784
  }, attachUrl);
1662
1785
  if (result.code !== 0 &&
1663
1786
  useOpencodeSessionId &&
@@ -1676,8 +1799,10 @@ export async function runChimphands(opts) {
1676
1799
  },
1677
1800
  onWorkingBranch: noteWorkingBranch,
1678
1801
  postEvent: turnPostEvent,
1802
+ ...turnControl,
1679
1803
  }, attachUrl);
1680
1804
  }
1805
+ agentTurnInProgress = false;
1681
1806
  await poster.flush();
1682
1807
  if (result.opencodeSessionId) {
1683
1808
  opencodeSessionId = result.opencodeSessionId;
@@ -1700,7 +1825,7 @@ export async function runChimphands(opts) {
1700
1825
  console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1701
1826
  }
1702
1827
  }
1703
- if (result.code !== 0) {
1828
+ if (result.code !== 0 && !result.cancelled) {
1704
1829
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
1705
1830
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
1706
1831
  try {
@@ -1724,7 +1849,12 @@ export async function runChimphands(opts) {
1724
1849
  exitCode = result.code || 1;
1725
1850
  break;
1726
1851
  }
1727
- postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1852
+ if (result.cancelled) {
1853
+ postEvent(ROLE_STATUS, "Turn stopped", { status: STATUS_WAITING_USER });
1854
+ }
1855
+ else {
1856
+ postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
1857
+ }
1728
1858
  lastUserActivity = Date.now();
1729
1859
  idle = false;
1730
1860
  prompt = (await waitForNextPrompt()) || "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
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",