@testchimp/cli 0.1.60 → 0.1.62

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.
@@ -168,6 +168,8 @@ class AgentEventPoster {
168
168
  body.workingBranch = opts.workingBranch;
169
169
  if (opts?.pullRequestUrl)
170
170
  body.pullRequestUrl = opts.pullRequestUrl;
171
+ if (opts?.toolExecution)
172
+ body.toolExecution = opts.toolExecution;
171
173
  const streamRole = isStreamFanoutRole(role);
172
174
  this.chain = this.chain.then(async () => {
173
175
  const live = !!opts?.liveStream && !opts?.durable;
@@ -193,6 +195,8 @@ class AgentEventPoster {
193
195
  eph.messageId = opts.messageId;
194
196
  if (opts?.opencodeSessionId)
195
197
  eph.opencodeSessionId = opts.opencodeSessionId;
198
+ if (opts?.toolExecution)
199
+ eph.toolExecution = opts.toolExecution;
196
200
  try {
197
201
  await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
198
202
  this.ephemeralFailCount = 0;
@@ -282,6 +286,62 @@ function extractOpencodeFatalError(raw) {
282
286
  }
283
287
  return null;
284
288
  }
289
+ const TOOL_STATUS_RUNNING = "CHIMPHANDS_TOOL_EXECUTION_STATUS_RUNNING";
290
+ const TOOL_STATUS_PENDING = "CHIMPHANDS_TOOL_EXECUTION_STATUS_PENDING";
291
+ const TOOL_STATUS_COMPLETED = "CHIMPHANDS_TOOL_EXECUTION_STATUS_COMPLETED";
292
+ const TOOL_STATUS_ERROR = "CHIMPHANDS_TOOL_EXECUTION_STATUS_ERROR";
293
+ function normalizeEpochMillis(raw) {
294
+ if (raw == null || !Number.isFinite(raw) || raw <= 0)
295
+ return undefined;
296
+ // OpenCode uses epoch millis; guard seconds.
297
+ return raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
298
+ }
299
+ function coerceTimeoutMillis(input) {
300
+ if (!input)
301
+ return undefined;
302
+ const raw = input.timeout ?? input.timeoutMs ?? input.timeout_ms;
303
+ const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
304
+ if (!Number.isFinite(n) || n <= 0)
305
+ return undefined;
306
+ return Math.round(n);
307
+ }
308
+ function mapToolExecutionStatus(status) {
309
+ const s = String(status || "").trim().toLowerCase();
310
+ if (!s)
311
+ return undefined;
312
+ if (s === "pending")
313
+ return TOOL_STATUS_PENDING;
314
+ if (s === "running")
315
+ return TOOL_STATUS_RUNNING;
316
+ if (s === "completed")
317
+ return TOOL_STATUS_COMPLETED;
318
+ if (s === "error" || s === "failed" || s === "cancelled")
319
+ return TOOL_STATUS_ERROR;
320
+ return undefined;
321
+ }
322
+ function toolExecutionFromOpencodePart(part) {
323
+ const status = mapToolExecutionStatus(part.state?.status);
324
+ if (!status)
325
+ return undefined;
326
+ const startedAtMillis = normalizeEpochMillis(part.state?.time?.start) ??
327
+ (status === TOOL_STATUS_RUNNING ? Date.now() : undefined);
328
+ const endedAtMillis = normalizeEpochMillis(part.state?.time?.end);
329
+ const toolName = String(part.state?.title || part.tool || "").trim() || undefined;
330
+ const timeoutMillis = coerceTimeoutMillis(part.state?.input);
331
+ return {
332
+ status,
333
+ toolName,
334
+ startedAtMillis,
335
+ timeoutMillis,
336
+ endedAtMillis,
337
+ };
338
+ }
339
+ function toolEventOptions(part, extra) {
340
+ const toolExecution = toolExecutionFromOpencodePart(part);
341
+ if (!toolExecution)
342
+ return extra ?? {};
343
+ return { ...extra, toolExecution };
344
+ }
285
345
  /**
286
346
  * OpenCode `message.part.delta` always sends `field: "text"` for both reasoning and
287
347
  * answer parts. Resolve kind from partID → type learned via `message.part.updated`.
@@ -482,9 +542,9 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
482
542
  if (!status || status === "pending" || status === "running")
483
543
  continue;
484
544
  const toolContent = formatToolUseContent(part);
485
- postEvent(ROLE_TOOL, toolContent, {
545
+ postEvent(ROLE_TOOL, toolContent, toolEventOptions(part, {
486
546
  messageId: opencodeMessageId("oc_tool_", part),
487
- });
547
+ }));
488
548
  posted += 1;
489
549
  if (status === "completed") {
490
550
  const detected = detectWorkingBranchFromToolOutput(toolContent);
@@ -663,17 +723,17 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
663
723
  // Ephemeral "(running)" bubbles while UI attached (liveStream gated in poster).
664
724
  if (status === "running") {
665
725
  const toolContent = formatToolUseContent(part);
666
- callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
726
+ callbacks.postEvent(ROLE_TOOL, toolContent, toolEventOptions(part, liveOpts({
667
727
  messageId: opencodeMessageId("oc_tool_", part),
668
728
  throttle: false,
669
- }));
729
+ })));
670
730
  return;
671
731
  }
672
732
  const toolContent = formatToolUseContent(part);
673
- callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
733
+ callbacks.postEvent(ROLE_TOOL, toolContent, toolEventOptions(part, liveOpts({
674
734
  messageId: opencodeMessageId("oc_tool_", part),
675
735
  throttle: false,
676
- }));
736
+ })));
677
737
  if (status === "completed") {
678
738
  const detected = detectWorkingBranchFromToolOutput(toolContent);
679
739
  if (detected.branch) {
@@ -946,6 +1006,35 @@ function writeOpencodeConfig(backend, apiKey, boot) {
946
1006
  }, null, 2));
947
1007
  return model;
948
1008
  }
1009
+ /** Abort in-flight tool/LLM work on the local OpenCode server (attach mode). */
1010
+ async function abortOpencodeSession(attachUrl, opencodeSessionId) {
1011
+ const base = attachUrl.replace(/\/$/, "");
1012
+ const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/abort`;
1013
+ try {
1014
+ const res = await fetch(url, {
1015
+ method: "POST",
1016
+ headers: {
1017
+ Accept: "application/json",
1018
+ "x-opencode-directory": process.cwd(),
1019
+ },
1020
+ });
1021
+ console.error(`ChimpHands OpenCode session abort: session=${opencodeSessionId} http=${res.status}`);
1022
+ }
1023
+ catch (err) {
1024
+ console.error(`ChimpHands OpenCode session abort failed: ${err instanceof Error ? err.message : String(err)}`);
1025
+ }
1026
+ }
1027
+ function parseInboundUserMessage(data) {
1028
+ if (!data || typeof data !== "object")
1029
+ return null;
1030
+ const msg = data;
1031
+ const content = typeof msg.content === "string" ? msg.content : "";
1032
+ const idRaw = (typeof msg.id === "string" && msg.id) ||
1033
+ (typeof msg.message_id === "string" && msg.message_id) ||
1034
+ (typeof msg.messageId === "string" && msg.messageId) ||
1035
+ "";
1036
+ return { id: idRaw || undefined, content };
1037
+ }
949
1038
  function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
950
1039
  // --auto: headless CI must approve tool permissions (1.18+ otherwise auto-rejects).
951
1040
  // --print-logs: surface server/client progress on stderr while waiting for first token.
@@ -1081,6 +1170,34 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1081
1170
  const textByPartId = new Map();
1082
1171
  const partTypeById = new Map();
1083
1172
  let sawStdout = false;
1173
+ let cancelEscalationTimer = null;
1174
+ let cancelWatcher = null;
1175
+ const clearCancelWatcher = () => {
1176
+ if (cancelWatcher) {
1177
+ clearInterval(cancelWatcher);
1178
+ cancelWatcher = null;
1179
+ }
1180
+ if (cancelEscalationTimer) {
1181
+ clearTimeout(cancelEscalationTimer);
1182
+ cancelEscalationTimer = null;
1183
+ }
1184
+ };
1185
+ const killActiveChild = (signal) => {
1186
+ try {
1187
+ if (!child.killed)
1188
+ child.kill(signal);
1189
+ }
1190
+ catch {
1191
+ /* ignore */
1192
+ }
1193
+ };
1194
+ cancelWatcher = setInterval(() => {
1195
+ if (!callbacks.getCancelRequested?.())
1196
+ return;
1197
+ clearCancelWatcher();
1198
+ killActiveChild("SIGTERM");
1199
+ cancelEscalationTimer = setTimeout(() => killActiveChild("SIGKILL"), 2000);
1200
+ }, 500);
1084
1201
  let progressTicker = setInterval(() => {
1085
1202
  if (sawStdout) {
1086
1203
  if (progressTicker) {
@@ -1200,9 +1317,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1200
1317
  if (!status || status === "pending" || status === "running")
1201
1318
  return;
1202
1319
  const toolContent = formatToolUseContent(ev.part);
1203
- callbacks.postEvent(ROLE_TOOL, toolContent, {
1320
+ callbacks.postEvent(ROLE_TOOL, toolContent, toolEventOptions(ev.part, {
1204
1321
  messageId: opencodeMessageId("oc_tool_", ev.part),
1205
- });
1322
+ }));
1206
1323
  if (status === "completed") {
1207
1324
  const detected = detectWorkingBranchFromToolOutput(toolContent);
1208
1325
  if (detected.branch) {
@@ -1236,6 +1353,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1236
1353
  });
1237
1354
  child.on("close", (code) => {
1238
1355
  callbacks.onActiveChild?.(null);
1356
+ clearCancelWatcher();
1239
1357
  if (progressTicker) {
1240
1358
  clearInterval(progressTicker);
1241
1359
  progressTicker = null;
@@ -1380,6 +1498,7 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
1380
1498
  catch {
1381
1499
  /* ignore malformed payload */
1382
1500
  }
1501
+ console.error(`ChimpHands inbound SSE cancel_turn for session ${sessionId}`);
1383
1502
  handlers.onCancelTurn?.();
1384
1503
  }
1385
1504
  else if (eventName === "user_file_edit") {
@@ -1403,11 +1522,11 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
1403
1522
  }
1404
1523
  else if (eventName === "user_message" || eventName === "message") {
1405
1524
  try {
1406
- const msg = JSON.parse(data);
1407
- handlers.onUserMessage({
1408
- id: msg.id || msg.message_id,
1409
- content: msg.content || "",
1410
- });
1525
+ const msg = parseInboundUserMessage(JSON.parse(data));
1526
+ if (!msg)
1527
+ continue;
1528
+ console.error(`ChimpHands inbound SSE user_message for session ${sessionId}`);
1529
+ handlers.onUserMessage(msg);
1411
1530
  }
1412
1531
  catch {
1413
1532
  /* ignore */
@@ -1614,6 +1733,8 @@ export async function runChimphands(opts) {
1614
1733
  queue.push(content);
1615
1734
  lastUserActivity = Date.now();
1616
1735
  idle = false;
1736
+ const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
1737
+ console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
1617
1738
  };
1618
1739
  const pollPendingUserMessages = async () => {
1619
1740
  try {
@@ -1674,33 +1795,56 @@ export async function runChimphands(opts) {
1674
1795
  };
1675
1796
  if (userId)
1676
1797
  childEnv.TESTCHIMP_USER_ID = userId;
1798
+ const handleCancelTurn = () => {
1799
+ console.error(`ChimpHands cancel_turn received for session ${sessionId}`);
1800
+ cancelTurnRequested = true;
1801
+ const sessionToAbort = opencodeSessionId?.trim();
1802
+ if (attachUrl && sessionToAbort) {
1803
+ void abortOpencodeSession(attachUrl, sessionToAbort);
1804
+ }
1805
+ const ch = activeOpencodeChild;
1806
+ if (ch && !ch.killed) {
1807
+ try {
1808
+ ch.kill("SIGTERM");
1809
+ setTimeout(() => {
1810
+ try {
1811
+ if (!ch.killed)
1812
+ ch.kill("SIGKILL");
1813
+ }
1814
+ catch {
1815
+ /* ignore */
1816
+ }
1817
+ }, 2000);
1818
+ }
1819
+ catch {
1820
+ /* ignore */
1821
+ }
1822
+ }
1823
+ };
1677
1824
  const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
1678
1825
  onUserMessage: enqueueUserMessage,
1679
1826
  onIdle: () => {
1680
1827
  idle = true;
1681
1828
  },
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
- },
1829
+ onCancelTurn: handleCancelTurn,
1694
1830
  onUserFileEdit: handleUserFileEdit,
1695
1831
  shouldRun: () => sessionActive,
1696
1832
  });
1833
+ const inboundPollTimer = setInterval(() => {
1834
+ if (!sessionActive)
1835
+ return;
1836
+ void pollPendingUserMessages();
1837
+ }, 1500);
1697
1838
  if (runtimeId && attachUrl) {
1698
1839
  stopTunnel = startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, {
1699
1840
  onUserFileEdit: handleUserFileEdit,
1841
+ onCancelTurn: handleCancelTurn,
1842
+ onUserMessage: enqueueUserMessage,
1700
1843
  });
1701
1844
  }
1702
1845
  const shutdownRuntime = async () => {
1703
1846
  sessionActive = false;
1847
+ clearInterval(inboundPollTimer);
1704
1848
  stopInbound();
1705
1849
  stopTunnel();
1706
1850
  stopHeartbeat();
@@ -2109,6 +2253,27 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, han
2109
2253
  socket.send(JSON.stringify({ type: "pong" }));
2110
2254
  return;
2111
2255
  }
2256
+ if (frame.type === "inbound_event" && frame.event === "cancel_turn") {
2257
+ const payload = frame.data;
2258
+ if (payload?.sessionId && payload.sessionId !== sessionId) {
2259
+ return;
2260
+ }
2261
+ console.error(`ChimpHands tunnel cancel_turn for session ${sessionId}`);
2262
+ handlers?.onCancelTurn?.();
2263
+ return;
2264
+ }
2265
+ if (frame.type === "inbound_event" && frame.event === "user_message" && frame.data) {
2266
+ const payload = frame.data;
2267
+ if (payload?.sessionId && payload.sessionId !== sessionId) {
2268
+ return;
2269
+ }
2270
+ const msg = parseInboundUserMessage(frame.data);
2271
+ if (msg) {
2272
+ console.error(`ChimpHands tunnel user_message for session ${sessionId}`);
2273
+ handlers?.onUserMessage?.(msg);
2274
+ }
2275
+ return;
2276
+ }
2112
2277
  if (frame.type === "inbound_event" && frame.event === "user_file_edit" && frame.data) {
2113
2278
  const edit = frame.data;
2114
2279
  if (edit.sessionId && edit.sessionId !== sessionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
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",