@testchimp/cli 0.1.67 → 0.1.69

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.
@@ -0,0 +1,25 @@
1
+ /** Helpers for mid-turn user steering via OpenCode prompt_async. */
2
+ export type OpencodeSessionMessage = {
3
+ info?: {
4
+ id?: string;
5
+ role?: string;
6
+ };
7
+ parts?: Array<{
8
+ type?: string;
9
+ text?: string;
10
+ state?: {
11
+ status?: string;
12
+ };
13
+ }>;
14
+ };
15
+ export declare function normalizeSteeringContent(content: string): string;
16
+ export declare function midTurnSteeringKey(id: string | undefined, content: string): string;
17
+ export declare function extractUserMessageText(msg: OpencodeSessionMessage): string;
18
+ /** True when an assistant message after the matching user text has substantive output. */
19
+ export declare function userMessageHasAssistantReply(messages: OpencodeSessionMessage[], userContent: string): boolean;
20
+ export declare function fetchOpencodeSessionMessages(attachUrl: string, opencodeSessionId: string, cwd?: string): Promise<OpencodeSessionMessage[]>;
21
+ /**
22
+ * After a turn ends, re-queue mid-turn steering messages that prompt_async accepted
23
+ * but OpenCode never answered (e.g. blocked on long-running bash).
24
+ */
25
+ export declare function releaseUnansweredMidTurnMessages(attachUrl: string, opencodeSessionId: string, midTurnInjected: Map<string, string>, queue: string[], wake: () => void, sidecarAnsweredKeys?: Set<string>): Promise<void>;
@@ -0,0 +1,93 @@
1
+ /** Helpers for mid-turn user steering via OpenCode prompt_async. */
2
+ export function normalizeSteeringContent(content) {
3
+ return content.trim();
4
+ }
5
+ export function midTurnSteeringKey(id, content) {
6
+ const norm = normalizeSteeringContent(content);
7
+ return id?.trim() || `content:${norm}`;
8
+ }
9
+ export function extractUserMessageText(msg) {
10
+ return (msg.parts || [])
11
+ .filter((p) => p.type === "text" && p.text?.trim())
12
+ .map((p) => p.text.trim())
13
+ .join("\n");
14
+ }
15
+ /** True when an assistant message after the matching user text has substantive output. */
16
+ export function userMessageHasAssistantReply(messages, userContent) {
17
+ const normalizedTarget = normalizeSteeringContent(userContent);
18
+ if (!normalizedTarget)
19
+ return true;
20
+ let userIdx = -1;
21
+ for (let i = messages.length - 1; i >= 0; i--) {
22
+ const role = (messages[i]?.info?.role || "").toLowerCase();
23
+ if (role !== "user")
24
+ continue;
25
+ const text = normalizeSteeringContent(extractUserMessageText(messages[i]));
26
+ if (text === normalizedTarget) {
27
+ userIdx = i;
28
+ break;
29
+ }
30
+ }
31
+ if (userIdx < 0)
32
+ return false;
33
+ for (let i = userIdx + 1; i < messages.length; i++) {
34
+ const role = (messages[i]?.info?.role || "").toLowerCase();
35
+ if (role !== "assistant")
36
+ continue;
37
+ const hasText = (messages[i]?.parts || []).some((p) => (p.type === "text" || p.type === "reasoning") && p.text?.trim());
38
+ const hasCompletedTool = (messages[i]?.parts || []).some((p) => p.type === "tool" && p.state?.status === "completed");
39
+ if (hasText || hasCompletedTool)
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ export async function fetchOpencodeSessionMessages(attachUrl, opencodeSessionId, cwd = process.cwd()) {
45
+ const base = attachUrl.replace(/\/$/, "");
46
+ const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
47
+ const res = await fetch(url, {
48
+ headers: { Accept: "application/json", "x-opencode-directory": cwd },
49
+ });
50
+ if (!res.ok) {
51
+ throw new Error(`OpenCode session messages HTTP ${res.status}`);
52
+ }
53
+ const data = (await res.json());
54
+ return Array.isArray(data) ? data : [];
55
+ }
56
+ /**
57
+ * After a turn ends, re-queue mid-turn steering messages that prompt_async accepted
58
+ * but OpenCode never answered (e.g. blocked on long-running bash).
59
+ */
60
+ export async function releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjected, queue, wake, sidecarAnsweredKeys = new Set()) {
61
+ if (!midTurnInjected.size)
62
+ return;
63
+ let messages = [];
64
+ try {
65
+ messages = await fetchOpencodeSessionMessages(attachUrl, opencodeSessionId);
66
+ }
67
+ catch (err) {
68
+ console.error(`ChimpHands mid-turn steering check failed — queueing unanswered injected messages: ${err instanceof Error ? err.message : String(err)}`);
69
+ for (const [key, content] of midTurnInjected.entries()) {
70
+ if (sidecarAnsweredKeys.has(key))
71
+ continue;
72
+ queue.push(content);
73
+ }
74
+ midTurnInjected.clear();
75
+ wake();
76
+ return;
77
+ }
78
+ for (const [key, content] of midTurnInjected.entries()) {
79
+ if (sidecarAnsweredKeys.has(key)) {
80
+ console.error(`ChimpHands mid-turn steering message ${key} answered by sidecar — no re-queue`);
81
+ continue;
82
+ }
83
+ if (userMessageHasAssistantReply(messages, content)) {
84
+ console.error(`ChimpHands mid-turn steering message ${key} was answered in-session — no re-queue`);
85
+ continue;
86
+ }
87
+ console.error(`ChimpHands mid-turn steering message ${key} unanswered — queueing for next turn`);
88
+ queue.push(content);
89
+ }
90
+ midTurnInjected.clear();
91
+ if (queue.length)
92
+ wake();
93
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { userMessageHasAssistantReply, } from "./midTurnSteering.js";
4
+ function userMsg(text) {
5
+ return { info: { role: "user" }, parts: [{ type: "text", text }] };
6
+ }
7
+ function assistantText(text) {
8
+ return { info: { role: "assistant" }, parts: [{ type: "text", text }] };
9
+ }
10
+ function assistantTool(status) {
11
+ return {
12
+ info: { role: "assistant" },
13
+ parts: [{ type: "tool", state: { status } }],
14
+ };
15
+ }
16
+ describe("userMessageHasAssistantReply", () => {
17
+ it("returns false when user message has no assistant reply after it", () => {
18
+ const messages = [userMsg("why gcp_sa_json_path?")];
19
+ assert.equal(userMessageHasAssistantReply(messages, "why gcp_sa_json_path?"), false);
20
+ });
21
+ it("returns true when assistant text follows the matching user message", () => {
22
+ const messages = [
23
+ userMsg("first question"),
24
+ assistantText("first answer"),
25
+ userMsg("why gcp_sa_json_path?"),
26
+ assistantText("We should use WIF in CI."),
27
+ ];
28
+ assert.equal(userMessageHasAssistantReply(messages, "why gcp_sa_json_path?"), true);
29
+ });
30
+ it("matches the latest user message with the same content", () => {
31
+ const messages = [
32
+ userMsg("repeat"),
33
+ assistantText("old answer"),
34
+ userMsg("repeat"),
35
+ ];
36
+ assert.equal(userMessageHasAssistantReply(messages, "repeat"), false);
37
+ });
38
+ it("counts completed tool output as a reply", () => {
39
+ const messages = [userMsg("check auth"), assistantTool("completed")];
40
+ assert.equal(userMessageHasAssistantReply(messages, "check auth"), true);
41
+ });
42
+ it("ignores pending tool parts as a reply", () => {
43
+ const messages = [userMsg("run tests"), assistantTool("running")];
44
+ assert.equal(userMessageHasAssistantReply(messages, "run tests"), false);
45
+ });
46
+ });
@@ -12,6 +12,8 @@ import https from "node:https";
12
12
  import { URL } from "node:url";
13
13
  import { getBackendUrl, requireApiKey } from "../core/client.js";
14
14
  import { isChimphandsLlmRetryEnabled, startLlmRetryProxy } from "./llmRetryProxy.js";
15
+ import { midTurnSteeringKey, releaseUnansweredMidTurnMessages, } from "./midTurnSteering.js";
16
+ import { runSteeringSidecarReply } from "./steeringSidecar.js";
15
17
  const ROLE_ASSISTANT = "CHIMPHANDS_MESSAGE_ROLE_ASSISTANT";
16
18
  const ROLE_TOOL = "CHIMPHANDS_MESSAGE_ROLE_TOOL";
17
19
  const ROLE_REASONING = "CHIMPHANDS_MESSAGE_ROLE_REASONING";
@@ -21,6 +23,7 @@ const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
21
23
  const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
22
24
  const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
23
25
  const OPENCODE_AGENT_ID = "chimphands";
26
+ const MID_TURN_DELIVERED_STATUS = "Answering your question while the current step continues…";
24
27
  /** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
25
28
  const STREAM_POST_MIN_INTERVAL_MS = 150;
26
29
  function isStreamFanoutRole(role) {
@@ -84,6 +87,11 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
84
87
  - Follow plan → explicit user approval → execute (interactive default). Do not skip MCP calls or claim done without tool evidence.
85
88
  - Export \`TESTCHIMP_EXECUTION_SOURCE=CLOUD_AGENT\` before Playwright/Mobilewright runs.
86
89
 
90
+ ## CI runner hygiene (GitHub Actions)
91
+ - On hosted runners: load skill \`references/chimphands-ci-runner.md\` before compile, Docker builds, or large installs.
92
+ - Never \`git fetch origin\` (full fetch) — shallow single-branch fetch only; branch names are in \`.chimphands/remote-branch-names.txt\`.
93
+ - Reclaim disk between heavy phases (\`docker builder prune -af\` after image builds, stop compile daemons, delete temp/test artifacts). One reclaim+retry on ENOSPC — no blind loops.
94
+
87
95
  ## Honesty
88
96
  - If MCP/tools fail, report the error. Never narrate success without tool output or a PR link when repo changes were needed.`;
89
97
  function normalizeUserMessage(content) {
@@ -180,7 +188,7 @@ class AgentEventPoster {
180
188
  if (opts?.pullRequestUrl)
181
189
  body.pullRequestUrl = opts.pullRequestUrl;
182
190
  if (opts?.toolExecution)
183
- body.toolExecution = opts.toolExecution;
191
+ body.toolExecution = protoJsonToolExecution(opts.toolExecution);
184
192
  const streamRole = isStreamFanoutRole(role);
185
193
  this.chain = this.chain.then(async () => {
186
194
  const live = !!opts?.liveStream && !opts?.durable;
@@ -207,7 +215,7 @@ class AgentEventPoster {
207
215
  if (opts?.opencodeSessionId)
208
216
  eph.opencodeSessionId = opts.opencodeSessionId;
209
217
  if (opts?.toolExecution)
210
- eph.toolExecution = opts.toolExecution;
218
+ eph.toolExecution = protoJsonToolExecution(opts.toolExecution);
211
219
  try {
212
220
  await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
213
221
  this.ephemeralFailCount = 0;
@@ -308,14 +316,44 @@ function normalizeEpochMillis(raw) {
308
316
  return raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
309
317
  }
310
318
  function coerceTimeoutMillis(input) {
311
- if (!input)
319
+ if (input == null)
320
+ return undefined;
321
+ let obj;
322
+ if (typeof input === "string") {
323
+ try {
324
+ const parsed = JSON.parse(input);
325
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
326
+ obj = parsed;
327
+ }
328
+ }
329
+ catch {
330
+ return undefined;
331
+ }
332
+ }
333
+ else {
334
+ obj = input;
335
+ }
336
+ if (!obj)
312
337
  return undefined;
313
- const raw = input.timeout ?? input.timeoutMs ?? input.timeout_ms;
338
+ const raw = obj.timeout ?? obj.timeoutMs ?? obj.timeout_ms;
314
339
  const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
315
340
  if (!Number.isFinite(n) || n <= 0)
316
341
  return undefined;
317
342
  return Math.round(n);
318
343
  }
344
+ /** Protobuf JSON encodes int64 as strings; numbers can fail JsonFormat.parse. */
345
+ function protoJsonToolExecution(payload) {
346
+ const out = { status: payload.status };
347
+ if (payload.toolName)
348
+ out.toolName = payload.toolName;
349
+ if (payload.startedAtMillis != null)
350
+ out.startedAtMillis = String(payload.startedAtMillis);
351
+ if (payload.timeoutMillis != null)
352
+ out.timeoutMillis = String(payload.timeoutMillis);
353
+ if (payload.endedAtMillis != null)
354
+ out.endedAtMillis = String(payload.endedAtMillis);
355
+ return out;
356
+ }
319
357
  function mapToolExecutionStatus(status) {
320
358
  const s = String(status || "").trim().toLowerCase();
321
359
  if (!s)
@@ -1071,6 +1109,8 @@ async function injectOpencodeUserMessage(attachUrl, opencodeSessionId, content)
1071
1109
  "x-opencode-directory": process.cwd(),
1072
1110
  },
1073
1111
  body: JSON.stringify({
1112
+ agent: OPENCODE_AGENT_ID,
1113
+ noReply: false,
1074
1114
  parts: [{ type: "text", text: content }],
1075
1115
  }),
1076
1116
  });
@@ -1497,6 +1537,11 @@ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1497
1537
  }
1498
1538
  await fs.mkdir(path.dirname(full), { recursive: true });
1499
1539
  await fs.writeFile(full, edit.content, "utf8");
1540
+ const pushed = await commitAndPushDirtyWorktree("User Update");
1541
+ if (!pushed.ok) {
1542
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, pushed.error || "commit/push failed");
1543
+ return;
1544
+ }
1500
1545
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
1501
1546
  }
1502
1547
  catch (err) {
@@ -1811,6 +1856,10 @@ export async function runChimphands(opts) {
1811
1856
  const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
1812
1857
  const queue = [];
1813
1858
  const seenUserMessageIds = new Set();
1859
+ /** Mid-turn prompt_async successes that may need a follow-up turn if OpenCode never answered. */
1860
+ const midTurnInjectedById = new Map();
1861
+ /** Steering keys answered by the parallel child-session sidecar. */
1862
+ const midTurnSidecarAnswered = new Set();
1814
1863
  let idle = false;
1815
1864
  let sessionActive = true;
1816
1865
  let lastUserActivity = Date.now();
@@ -1859,6 +1908,40 @@ export async function runChimphands(opts) {
1859
1908
  }
1860
1909
  }
1861
1910
  };
1911
+ const noteMidTurnInjectSuccess = (steeringKey, content) => {
1912
+ midTurnInjectedById.set(steeringKey, normalizeUserMessage(content));
1913
+ };
1914
+ const deliverMidTurnSteeringSidecar = (steeringKey, content) => {
1915
+ const parentSessionId = opencodeSessionId?.trim();
1916
+ if (!attachUrl || !parentSessionId)
1917
+ return;
1918
+ void runSteeringSidecarReply({
1919
+ attachUrl,
1920
+ parentSessionId,
1921
+ userQuestion: content,
1922
+ conversationSummary,
1923
+ model: opencodeModel,
1924
+ agentId: OPENCODE_AGENT_ID,
1925
+ })
1926
+ .then((replyParts) => {
1927
+ if (!replyParts.length) {
1928
+ console.error(`ChimpHands steering sidecar returned no text for ${steeringKey}`);
1929
+ return;
1930
+ }
1931
+ midTurnSidecarAnswered.add(steeringKey);
1932
+ for (const text of replyParts) {
1933
+ postEvent(ROLE_ASSISTANT, text, {
1934
+ durable: true,
1935
+ opencodeSessionId: parentSessionId,
1936
+ messageId: `ch_steering_${steeringKey}_${Date.now()}`,
1937
+ });
1938
+ }
1939
+ console.error(`ChimpHands steering sidecar answered mid-turn message ${steeringKey} (${replyParts.length} part(s))`);
1940
+ })
1941
+ .catch((err) => {
1942
+ console.error(`ChimpHands steering sidecar failed for ${steeringKey}: ${err instanceof Error ? err.message : String(err)}`);
1943
+ });
1944
+ };
1862
1945
  const flushPendingMidTurnInject = () => {
1863
1946
  if (!attachUrl || !agentTurnInProgress)
1864
1947
  return;
@@ -1867,11 +1950,15 @@ export async function runChimphands(opts) {
1867
1950
  return;
1868
1951
  while (pendingMidTurnInject.length) {
1869
1952
  const content = pendingMidTurnInject.shift();
1953
+ const steeringKey = midTurnSteeringKey(undefined, content);
1870
1954
  void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
1871
1955
  if (!ok) {
1872
1956
  queue.push(content);
1873
1957
  wakePromptWaiter();
1958
+ return;
1874
1959
  }
1960
+ noteMidTurnInjectSuccess(steeringKey, content);
1961
+ deliverMidTurnSteeringSidecar(steeringKey, content);
1875
1962
  });
1876
1963
  }
1877
1964
  };
@@ -1892,13 +1979,16 @@ export async function runChimphands(opts) {
1892
1979
  const sessionToInject = opencodeSessionId?.trim();
1893
1980
  if (sessionToInject) {
1894
1981
  console.error(`ChimpHands delivering mid-turn user message via OpenCode prompt_async (turnActive=true): ${JSON.stringify(preview)}`);
1982
+ const steeringKey = midTurnSteeringKey(id, content);
1895
1983
  void injectOpencodeUserMessage(attachUrl, sessionToInject, content).then((ok) => {
1896
1984
  if (!ok) {
1897
1985
  queue.push(content);
1898
1986
  wakePromptWaiter();
1899
1987
  return;
1900
1988
  }
1901
- postEvent(ROLE_STATUS, "Message sent to agent…", { status: STATUS_RUNNING });
1989
+ noteMidTurnInjectSuccess(steeringKey, content);
1990
+ postEvent(ROLE_STATUS, MID_TURN_DELIVERED_STATUS, { status: STATUS_RUNNING });
1991
+ deliverMidTurnSteeringSidecar(steeringKey, content);
1902
1992
  });
1903
1993
  return;
1904
1994
  }
@@ -2169,6 +2259,14 @@ export async function runChimphands(opts) {
2169
2259
  if (!poster.uiAttached) {
2170
2260
  await commitAndPushDirtyWorktree("chimphands: commit after turn (async / no UI attached)");
2171
2261
  }
2262
+ if (attachUrl && opencodeSessionId && midTurnInjectedById.size) {
2263
+ try {
2264
+ await releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjectedById, queue, wakePromptWaiter, midTurnSidecarAnswered);
2265
+ }
2266
+ catch (err) {
2267
+ console.error(`ChimpHands mid-turn steering release failed: ${err instanceof Error ? err.message : String(err)}`);
2268
+ }
2269
+ }
2172
2270
  if (result.code !== 0 && !result.cancelled) {
2173
2271
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
2174
2272
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -2272,26 +2370,30 @@ async function commitAndPushDirtyWorktree(message) {
2272
2370
  try {
2273
2371
  const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
2274
2372
  if (branch.code !== 0) {
2275
- console.error(`ChimpHands git rev-parse failed: ${branch.err || branch.out}`);
2276
- return;
2373
+ const error = branch.err || branch.out || "git rev-parse failed";
2374
+ console.error(`ChimpHands git rev-parse failed: ${error}`);
2375
+ return { ok: false, error };
2277
2376
  }
2278
2377
  const current = branch.out.trim();
2279
2378
  if (!current || current === "HEAD" || /^(main|master)$/i.test(current)) {
2280
- console.error(`ChimpHands skip commit-before-idle: refusing branch "${current || "(unknown)"}"`);
2281
- return;
2379
+ const error = `refusing branch "${current || "(unknown)"}"`;
2380
+ console.error(`ChimpHands skip commit-before-idle: ${error}`);
2381
+ return { ok: false, error };
2282
2382
  }
2283
2383
  const status = await run(["status", "--porcelain"]);
2284
2384
  if (status.code !== 0) {
2285
- console.error(`ChimpHands git status failed: ${status.err || status.out}`);
2286
- return;
2385
+ const error = status.err || status.out || "git status failed";
2386
+ console.error(`ChimpHands git status failed: ${error}`);
2387
+ return { ok: false, error };
2287
2388
  }
2288
2389
  if (!status.out.trim()) {
2289
- return;
2390
+ return { ok: true };
2290
2391
  }
2291
2392
  const add = await run(["add", "-A"]);
2292
2393
  if (add.code !== 0) {
2293
- console.error(`ChimpHands git add failed: ${add.err || add.out}`);
2294
- return;
2394
+ const error = add.err || add.out || "git add failed";
2395
+ console.error(`ChimpHands git add failed: ${error}`);
2396
+ return { ok: false, error };
2295
2397
  }
2296
2398
  const commitEnv = {
2297
2399
  GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "ChimpHands",
@@ -2301,8 +2403,9 @@ async function commitAndPushDirtyWorktree(message) {
2301
2403
  };
2302
2404
  const commit = await run(["-c", "user.name=ChimpHands", "-c", "user.email=chimphands@testchimp.io", "commit", "-m", message], commitEnv);
2303
2405
  if (commit.code !== 0) {
2304
- console.error(`ChimpHands git commit: ${commit.err || commit.out}`);
2305
- return;
2406
+ const error = commit.err || commit.out || "git commit failed";
2407
+ console.error(`ChimpHands git commit: ${error}`);
2408
+ return { ok: false, error };
2306
2409
  }
2307
2410
  let push = await run(["push", "-u", "origin", "HEAD"]);
2308
2411
  if (push.code !== 0) {
@@ -2315,23 +2418,28 @@ async function commitAndPushDirtyWorktree(message) {
2315
2418
  await refreshGitAuth();
2316
2419
  push = await run(["push", "-u", "origin", "HEAD"]);
2317
2420
  if (push.code !== 0) {
2318
- console.error(`ChimpHands git push failed after refresh: ${push.err || push.out}`);
2319
- return;
2421
+ const error = push.err || push.out || "git push failed after refresh";
2422
+ console.error(`ChimpHands git push failed after refresh: ${error}`);
2423
+ return { ok: false, error };
2320
2424
  }
2321
2425
  }
2322
2426
  else {
2323
- return;
2427
+ return { ok: false, error: push.err || push.out || "git push failed" };
2324
2428
  }
2325
2429
  }
2326
2430
  catch (refreshErr) {
2327
- console.error(`ChimpHands refresh-git-auth after push failure: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
2328
- return;
2431
+ const error = refreshErr instanceof Error ? refreshErr.message : String(refreshErr);
2432
+ console.error(`ChimpHands refresh-git-auth after push failure: ${error}`);
2433
+ return { ok: false, error };
2329
2434
  }
2330
2435
  }
2331
2436
  console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
2437
+ return { ok: true };
2332
2438
  }
2333
2439
  catch (err) {
2334
- console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
2440
+ const error = err instanceof Error ? err.message : String(err);
2441
+ console.error(`ChimpHands commit-before-idle failed: ${error}`);
2442
+ return { ok: false, error };
2335
2443
  }
2336
2444
  }
2337
2445
  function opencodeProxyNeedsDirectory(path) {
@@ -0,0 +1,40 @@
1
+ /** Parallel OpenCode child-session turn for mid-turn steering Q&A. */
2
+ export type OpencodeModelRef = {
3
+ providerID: string;
4
+ modelID: string;
5
+ };
6
+ export declare function parseOpencodeModelRef(model: string): OpencodeModelRef | undefined;
7
+ export declare function buildSteeringSidecarPrompt(userQuestion: string, conversationSummary: string): string;
8
+ type OpencodeMessagePart = {
9
+ type?: string;
10
+ text?: string;
11
+ state?: {
12
+ status?: string;
13
+ output?: string;
14
+ };
15
+ };
16
+ type OpencodeMessageResponse = {
17
+ info?: {
18
+ role?: string;
19
+ id?: string;
20
+ };
21
+ parts?: OpencodeMessagePart[];
22
+ };
23
+ export declare function extractAssistantReplyParts(response: OpencodeMessageResponse): string[];
24
+ export type SteeringSidecarOptions = {
25
+ attachUrl: string;
26
+ parentSessionId: string;
27
+ userQuestion: string;
28
+ conversationSummary?: string;
29
+ model: string;
30
+ agentId: string;
31
+ cwd?: string;
32
+ /** Max wait for the child-session /message round-trip. */
33
+ timeoutMs?: number;
34
+ };
35
+ /**
36
+ * Run a parallel OpenCode turn on a child session so the user gets an immediate
37
+ * answer while the main session stays blocked on a long-running tool.
38
+ */
39
+ export declare function runSteeringSidecarReply(opts: SteeringSidecarOptions): Promise<string[]>;
40
+ export {};
@@ -0,0 +1,103 @@
1
+ /** Parallel OpenCode child-session turn for mid-turn steering Q&A. */
2
+ export function parseOpencodeModelRef(model) {
3
+ const trimmed = model.trim();
4
+ if (!trimmed)
5
+ return undefined;
6
+ const slash = trimmed.indexOf("/");
7
+ if (slash <= 0 || slash >= trimmed.length - 1)
8
+ return undefined;
9
+ return {
10
+ providerID: trimmed.slice(0, slash),
11
+ modelID: trimmed.slice(slash + 1),
12
+ };
13
+ }
14
+ export function buildSteeringSidecarPrompt(userQuestion, conversationSummary) {
15
+ const summary = conversationSummary.trim();
16
+ const parts = [
17
+ "Mid-turn steering reply — the main ChimpHands session is still running a long command in parallel.",
18
+ "Answer the user's question directly and concisely.",
19
+ "Do NOT run bash/shell, edit files, or kick off long tools. Read-only lookup is OK if needed.",
20
+ "Do not repeat the full plan; focus on the question.",
21
+ ];
22
+ if (summary) {
23
+ parts.push("", "Conversation so far:", summary);
24
+ }
25
+ parts.push("", "User question:", userQuestion.trim());
26
+ return parts.join("\n");
27
+ }
28
+ export function extractAssistantReplyParts(response) {
29
+ const role = (response.info?.role || "").toLowerCase();
30
+ if (role && role !== "assistant")
31
+ return [];
32
+ const out = [];
33
+ for (const part of response.parts || []) {
34
+ if (part.type === "text" && part.text?.trim()) {
35
+ out.push(part.text.trim());
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ async function opencodeFetch(attachUrl, path, init, cwd) {
41
+ const base = attachUrl.replace(/\/$/, "");
42
+ return fetch(`${base}${path}`, {
43
+ ...init,
44
+ headers: {
45
+ Accept: "application/json",
46
+ "Content-Type": "application/json",
47
+ "x-opencode-directory": cwd,
48
+ ...init.headers,
49
+ },
50
+ });
51
+ }
52
+ async function createSteeringChildSession(attachUrl, parentSessionId, cwd) {
53
+ const res = await opencodeFetch(attachUrl, "/session", {
54
+ method: "POST",
55
+ body: JSON.stringify({
56
+ parentID: parentSessionId,
57
+ title: "ChimpHands steering",
58
+ }),
59
+ }, cwd);
60
+ if (!res.ok) {
61
+ throw new Error(`OpenCode create steering session HTTP ${res.status}`);
62
+ }
63
+ const data = (await res.json());
64
+ const childId = data.id?.trim();
65
+ if (!childId)
66
+ throw new Error("OpenCode create steering session returned no id");
67
+ return childId;
68
+ }
69
+ /**
70
+ * Run a parallel OpenCode turn on a child session so the user gets an immediate
71
+ * answer while the main session stays blocked on a long-running tool.
72
+ */
73
+ export async function runSteeringSidecarReply(opts) {
74
+ const cwd = opts.cwd || process.cwd();
75
+ const childSessionId = await createSteeringChildSession(opts.attachUrl, opts.parentSessionId, cwd);
76
+ const modelRef = parseOpencodeModelRef(opts.model);
77
+ const prompt = buildSteeringSidecarPrompt(opts.userQuestion, opts.conversationSummary || "");
78
+ const timeoutMs = opts.timeoutMs ?? 120_000;
79
+ const ac = new AbortController();
80
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
81
+ try {
82
+ const body = {
83
+ agent: opts.agentId,
84
+ noReply: false,
85
+ parts: [{ type: "text", text: prompt }],
86
+ };
87
+ if (modelRef)
88
+ body.model = modelRef;
89
+ const res = await opencodeFetch(opts.attachUrl, `/session/${encodeURIComponent(childSessionId)}/message`, {
90
+ method: "POST",
91
+ body: JSON.stringify(body),
92
+ signal: ac.signal,
93
+ }, cwd);
94
+ if (!res.ok) {
95
+ throw new Error(`OpenCode steering /message HTTP ${res.status}`);
96
+ }
97
+ const data = (await res.json());
98
+ return extractAssistantReplyParts(data);
99
+ }
100
+ finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { buildSteeringSidecarPrompt, extractAssistantReplyParts, parseOpencodeModelRef, } from "./steeringSidecar.js";
4
+ describe("parseOpencodeModelRef", () => {
5
+ it("splits provider/model", () => {
6
+ assert.deepEqual(parseOpencodeModelRef("testchimp/gpt-5.6-luna"), {
7
+ providerID: "testchimp",
8
+ modelID: "gpt-5.6-luna",
9
+ });
10
+ });
11
+ });
12
+ describe("buildSteeringSidecarPrompt", () => {
13
+ it("includes summary and user question", () => {
14
+ const prompt = buildSteeringSidecarPrompt("why WIF?", "Prior: discussed CI auth.");
15
+ assert.match(prompt, /why WIF\?/);
16
+ assert.match(prompt, /Prior: discussed CI auth\./);
17
+ assert.match(prompt, /Do NOT run bash/);
18
+ });
19
+ });
20
+ describe("extractAssistantReplyParts", () => {
21
+ it("returns text parts from assistant message", () => {
22
+ const parts = extractAssistantReplyParts({
23
+ info: { role: "assistant" },
24
+ parts: [{ type: "text", text: "Use WIF in CI." }],
25
+ });
26
+ assert.deepEqual(parts, ["Use WIF in CI."]);
27
+ });
28
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
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",
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "test": "npm run build && node --test dist/chimphands/llmRetryProxy.test.js",
16
+ "test": "npm run build && node --test dist/chimphands/llmRetryProxy.test.js dist/chimphands/midTurnSteering.test.js dist/chimphands/steeringSidecar.test.js",
17
17
  "watch": "tsc --watch",
18
18
  "start": "node dist/bin/testchimp.js",
19
19
  "prepublishOnly": "npm run build"