@testchimp/cli 0.1.40 → 0.1.41

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.
@@ -3,6 +3,13 @@
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
+ export type ReportWorkingBranchOptions = {
7
+ sessionId: string;
8
+ branch: string;
9
+ pullRequestUrl?: string;
10
+ };
11
+ /** Agent/CLI hook: persist the conversation working branch (+ optional PR) for UI + later turns. */
12
+ export declare function reportWorkingBranch(opts: ReportWorkingBranchOptions): Promise<void>;
6
13
  type RunOptions = {
7
14
  sessionId: string;
8
15
  prompt?: string;
@@ -3,7 +3,7 @@
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, execFileSync } from "node:child_process";
6
+ import { spawn } from "node:child_process";
7
7
  import { mkdirSync, writeFileSync } from "node:fs";
8
8
  import http from "node:http";
9
9
  import https from "node:https";
@@ -17,9 +17,51 @@ const STATUS_RUNNING = "CHIMPHANDS_SESSION_STATUS_RUNNING";
17
17
  const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
18
18
  const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
19
19
  const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
20
+ const OPENCODE_AGENT_ID = "chimphands";
21
+ const STREAM_POST_MIN_INTERVAL_MS = 60;
22
+ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's cloud coding agent running in GitHub Actions.
23
+
24
+ ## Repo changes (mandatory)
25
+ - NEVER commit or push directly to the default branch (main/master).
26
+ - This conversation uses ONE working branch and ONE pull request. Reuse them for all follow-up work in this chat.
27
+ - If bootstrap lists a working branch, checkout that branch and push additional commits there — update the same PR.
28
+ - 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\`).
29
+ - Branch names MUST start with \`testchimp-\` or \`chimphands-\`.
30
+ - After creating a branch or opening a PR, IMMEDIATELY run:
31
+ \`testchimp chimphands report-branch --branch <name> [--pr-url <url>]\`
32
+ - Tell the user which branch you are on and include the PR URL when available.
33
+
34
+ ## TestChimp workflows (/testchimp …)
35
+ - Load and follow the \`testchimp\` skill under \`.agents/skills/testchimp/SKILL.md\`.
36
+ - For any /testchimp command: use TestChimp MCP tools (preferred) or \`testchimp\` CLI — never invent API results.
37
+ - Follow plan → explicit user approval → execute. Do not skip MCP calls or claim done without tool evidence.
38
+ - Export \`TESTCHIMP_EXECUTION_SOURCE=CLOUD_AGENT\` before Playwright/Mobilewright runs.
39
+
40
+ ## Honesty
41
+ - If MCP/tools fail, report the error. Never narrate success without tool output or a PR link when repo changes were needed.`;
20
42
  function normalizeUserMessage(content) {
21
43
  return content.trim();
22
44
  }
45
+ function sleep(ms) {
46
+ return new Promise((resolve) => setTimeout(resolve, ms));
47
+ }
48
+ /** Agent/CLI hook: persist the conversation working branch (+ optional PR) for UI + later turns. */
49
+ export async function reportWorkingBranch(opts) {
50
+ const apiKey = requireApiKey();
51
+ const backend = getBackendUrl();
52
+ const branch = opts.branch.trim();
53
+ if (!branch) {
54
+ throw new Error("branch is required");
55
+ }
56
+ const body = {
57
+ sessionId: opts.sessionId.trim(),
58
+ workingBranch: branch,
59
+ };
60
+ const pr = opts.pullRequestUrl?.trim();
61
+ if (pr)
62
+ body.pullRequestUrl = pr;
63
+ await postJson(backend, apiKey, "/api/chimphands/post_agent_event", body);
64
+ }
23
65
  function apiHeaders(apiKey) {
24
66
  return {
25
67
  "Content-Type": "application/json",
@@ -38,11 +80,70 @@ async function postJson(backend, apiKey, path, body) {
38
80
  }
39
81
  return text;
40
82
  }
41
- function postJsonFireAndForget(backend, apiKey, path, body) {
42
- void postJson(backend, apiKey, path, body).catch((err) => {
43
- const detail = err instanceof Error ? err.message : String(err);
44
- console.error(`ChimpHands API telemetry failed ${path}: ${detail}`);
45
- });
83
+ /** Serializes post_agent_event calls so streaming chunks commit and fan out in order. */
84
+ class AgentEventPoster {
85
+ backend;
86
+ apiKey;
87
+ sessionId;
88
+ chain = Promise.resolve();
89
+ lastStreamPostAt = 0;
90
+ constructor(backend, apiKey, sessionId) {
91
+ this.backend = backend;
92
+ this.apiKey = apiKey;
93
+ this.sessionId = sessionId;
94
+ }
95
+ enqueue(role, content, opts) {
96
+ const body = {
97
+ sessionId: this.sessionId,
98
+ role,
99
+ content: String(content || "").slice(0, 20000),
100
+ };
101
+ if (opts?.messageId)
102
+ body.messageId = opts.messageId;
103
+ if (opts?.status != null)
104
+ body.status = opts.status;
105
+ if (opts?.opencodeSessionId)
106
+ body.opencodeSessionId = opts.opencodeSessionId;
107
+ if (opts?.workingBranch)
108
+ body.workingBranch = opts.workingBranch;
109
+ if (opts?.pullRequestUrl)
110
+ body.pullRequestUrl = opts.pullRequestUrl;
111
+ this.chain = this.chain.then(async () => {
112
+ if (opts?.throttle) {
113
+ const now = Date.now();
114
+ const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
115
+ if (wait > 0)
116
+ await sleep(wait);
117
+ this.lastStreamPostAt = Date.now();
118
+ }
119
+ await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
120
+ });
121
+ return this.chain;
122
+ }
123
+ fireAndForget(role, content, opts) {
124
+ void this.enqueue(role, content, opts).catch((err) => {
125
+ const detail = err instanceof Error ? err.message : String(err);
126
+ console.error(`ChimpHands API telemetry failed: ${detail}`);
127
+ });
128
+ }
129
+ flush() {
130
+ return this.chain;
131
+ }
132
+ reportWorkingBranch(branch, pullRequestUrl) {
133
+ this.chain = this.chain.then(async () => {
134
+ const body = {
135
+ sessionId: this.sessionId,
136
+ workingBranch: branch,
137
+ };
138
+ if (pullRequestUrl?.trim())
139
+ body.pullRequestUrl = pullRequestUrl.trim();
140
+ await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
141
+ });
142
+ void this.chain.catch((err) => {
143
+ const detail = err instanceof Error ? err.message : String(err);
144
+ console.error(`ChimpHands report-branch failed: ${detail}`);
145
+ });
146
+ }
46
147
  }
47
148
  const TESTCHIMP_PROVIDER_ID = "testchimp";
48
149
  function resolveOpencodeModelId(boot) {
@@ -94,10 +195,18 @@ function parseOpencodeEvent(line) {
94
195
  }
95
196
  function formatToolUseContent(part) {
96
197
  const title = part.state?.title || part.tool || "tool";
198
+ const status = part.state?.status?.trim();
199
+ const input = part.state?.input;
200
+ const inputText = input && Object.keys(input).length
201
+ ? `\nInput: ${JSON.stringify(input).slice(0, 4000)}`
202
+ : "";
97
203
  const output = part.state?.output?.trim();
204
+ const statusLine = status ? `[${title}] (${status})` : `[${title}]`;
98
205
  if (output)
99
- return `[${title}]\n${output}`;
100
- return `[${title}]`;
206
+ return `${statusLine}\n${output}`;
207
+ if (inputText)
208
+ return `${statusLine}${inputText}`;
209
+ return statusLine;
101
210
  }
102
211
  function opencodeMessageId(prefix, part) {
103
212
  const raw = part?.id || part?.messageID;
@@ -125,6 +234,42 @@ function summarizeOpencodeFailure(stderr, stdout, exitCode) {
125
234
  }
126
235
  return exitCode ? `opencode exited with code ${exitCode}` : "opencode failed";
127
236
  }
237
+ function isMissingOpencodeSessionError(message) {
238
+ const m = message.toLowerCase();
239
+ return ((m.includes("session") && m.includes("not found")) ||
240
+ m.includes("unknown session") ||
241
+ m.includes("invalid session"));
242
+ }
243
+ function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
244
+ const parts = [];
245
+ if (workingBranch?.trim()) {
246
+ parts.push("## Conversation working branch (reuse for this thread)", `Branch: \`${workingBranch.trim()}\``, pullRequestUrl?.trim() ? `PR: ${pullRequestUrl.trim()}` : "", "Checkout this branch, commit and push here. Do NOT open a new PR unless the one above was merged/closed.", "");
247
+ }
248
+ const task = normalizeUserMessage(userPrompt);
249
+ if (isNewOpencodeSession && conversationSummary.trim()) {
250
+ parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
251
+ return parts.filter(Boolean).join("\n");
252
+ }
253
+ if (parts.length) {
254
+ parts.push(`Current task:\n${task}`);
255
+ return parts.filter(Boolean).join("\n");
256
+ }
257
+ return task;
258
+ }
259
+ function detectWorkingBranchFromToolOutput(output) {
260
+ const text = output.trim();
261
+ if (!text)
262
+ return {};
263
+ const prMatch = text.match(/https:\/\/github\.com\/[^\s)\]]+\/pull\/\d+/);
264
+ const checkoutMatch = text.match(/checkout\s+-b\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
265
+ const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
266
+ const branchMatch = text.match(/branch['":\s]+((?:testchimp-|chimphands-)[^\s'"]+)/i);
267
+ const branch = (checkoutMatch?.[1] || pushMatch?.[1] || branchMatch?.[1])?.replace(/[`'"]/g, "");
268
+ return {
269
+ branch,
270
+ pullRequestUrl: prMatch?.[0],
271
+ };
272
+ }
128
273
  function writeOpencodeConfig(backend, apiKey, boot) {
129
274
  const llmBase = (boot.llm_base_url || `${backend}/v1`).replace(/\/$/, "");
130
275
  const llmKey = apiKey || boot.llm_api_key || "";
@@ -133,6 +278,7 @@ function writeOpencodeConfig(backend, apiKey, boot) {
133
278
  const mcpEnv = {
134
279
  TESTCHIMP_API_KEY: apiKey,
135
280
  TESTCHIMP_BACKEND_URL: backend,
281
+ TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
136
282
  };
137
283
  const serviceUserId = boot.chimphands_service_account_user_id?.trim();
138
284
  if (serviceUserId) {
@@ -141,6 +287,7 @@ function writeOpencodeConfig(backend, apiKey, boot) {
141
287
  writeFileSync("opencode.json", JSON.stringify({
142
288
  $schema: "https://opencode.ai/config.json",
143
289
  model,
290
+ default_agent: OPENCODE_AGENT_ID,
144
291
  autoupdate: false,
145
292
  provider: {
146
293
  [TESTCHIMP_PROVIDER_ID]: {
@@ -157,6 +304,23 @@ function writeOpencodeConfig(backend, apiKey, boot) {
157
304
  },
158
305
  },
159
306
  },
307
+ agent: {
308
+ [OPENCODE_AGENT_ID]: {
309
+ mode: "primary",
310
+ description: "TestChimp ChimpHands cloud agent (PR-only repo writes)",
311
+ prompt: CHIMPHANDS_AGENT_PROMPT,
312
+ steps: 80,
313
+ permission: {
314
+ skill: "allow",
315
+ bash: "allow",
316
+ edit: "allow",
317
+ read: "allow",
318
+ },
319
+ },
320
+ },
321
+ skills: {
322
+ paths: [".agents/skills/testchimp"],
323
+ },
160
324
  mcp: {
161
325
  testchimp: {
162
326
  type: "local",
@@ -168,140 +332,144 @@ function writeOpencodeConfig(backend, apiKey, boot) {
168
332
  }, null, 2));
169
333
  return model;
170
334
  }
171
- function runOpencode(prompt, model, childEnv, postEvent) {
172
- const help = (() => {
173
- try {
174
- return execFileSync("opencode", ["run", "--help"], { encoding: "utf8", env: childEnv });
175
- }
176
- catch {
177
- return "";
178
- }
179
- })();
180
- const useJson = help.includes("--format");
181
- const baseArgs = ["run", prompt, "--model", model];
182
- if (useJson) {
183
- const child = spawn("opencode", [...baseArgs, "--format", "json"], {
184
- stdio: ["ignore", "pipe", "pipe"],
185
- env: childEnv,
186
- });
187
- let err = "";
188
- child.stderr.on("data", (d) => {
189
- err += d.toString();
190
- });
191
- return new Promise((resolve) => {
192
- let buf = "";
193
- let fatalError = null;
194
- const textByPartId = new Map();
195
- const handleOpencodeLine = (line) => {
196
- if (!line.trim())
197
- return;
198
- const fatal = extractOpencodeFatalError(line);
199
- if (fatal) {
200
- fatalError = fatal;
201
- return;
202
- }
203
- const ev = parseOpencodeEvent(line);
204
- if (!ev?.type)
205
- return;
206
- switch (ev.type) {
207
- case "text": {
208
- const chunk = ev.part?.text;
209
- if (!chunk)
210
- return;
211
- const partId = ev.part?.id || ev.part?.messageID;
212
- if (!partId) {
213
- postEvent(ROLE_ASSISTANT, chunk);
214
- return;
215
- }
216
- const next = (textByPartId.get(partId) || "") + chunk;
217
- textByPartId.set(partId, next);
218
- postEvent(ROLE_ASSISTANT, next, undefined, opencodeMessageId("oc_text_", ev.part));
335
+ function buildOpencodeArgs(prompt, model, opencodeSessionId) {
336
+ const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
337
+ if (opencodeSessionId?.trim()) {
338
+ args.push("--session", opencodeSessionId.trim());
339
+ }
340
+ return args;
341
+ }
342
+ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks) {
343
+ let activeSessionId = opencodeSessionId?.trim() || undefined;
344
+ const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId);
345
+ const child = spawn("opencode", baseArgs, {
346
+ stdio: ["ignore", "pipe", "pipe"],
347
+ env: childEnv,
348
+ });
349
+ let err = "";
350
+ child.stderr.on("data", (d) => {
351
+ err += d.toString();
352
+ });
353
+ return new Promise((resolve) => {
354
+ let buf = "";
355
+ let fatalError = null;
356
+ const textByPartId = new Map();
357
+ const noteSessionId = (sessionId) => {
358
+ const id = sessionId?.trim();
359
+ if (!id || id === activeSessionId)
360
+ return;
361
+ activeSessionId = id;
362
+ callbacks.onSessionId?.(id);
363
+ };
364
+ const handleOpencodeLine = (line) => {
365
+ if (!line.trim())
366
+ return;
367
+ const fatal = extractOpencodeFatalError(line);
368
+ if (fatal) {
369
+ fatalError = fatal;
370
+ return;
371
+ }
372
+ const ev = parseOpencodeEvent(line);
373
+ if (!ev?.type)
374
+ return;
375
+ noteSessionId(ev.sessionID);
376
+ switch (ev.type) {
377
+ case "text": {
378
+ const chunk = ev.part?.text;
379
+ if (!chunk)
219
380
  return;
220
- }
221
- case "reasoning": {
222
- const chunk = ev.part?.text;
223
- if (!chunk)
224
- return;
225
- const partId = ev.part?.id || ev.part?.messageID;
226
- if (!partId) {
227
- postEvent(ROLE_REASONING, chunk);
228
- return;
229
- }
230
- const reasoningKey = `reasoning:${partId}`;
231
- const next = (textByPartId.get(reasoningKey) || "") + chunk;
232
- textByPartId.set(reasoningKey, next);
233
- postEvent(ROLE_REASONING, next, undefined, opencodeMessageId("oc_reasoning_", ev.part));
381
+ const partId = ev.part?.id || ev.part?.messageID;
382
+ if (!partId) {
383
+ callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
234
384
  return;
235
385
  }
236
- case "tool_use": {
237
- if (ev.part?.state?.status !== "completed")
238
- return;
239
- postEvent(ROLE_TOOL, formatToolUseContent(ev.part), undefined, opencodeMessageId("oc_tool_", ev.part));
386
+ const next = (textByPartId.get(partId) || "") + chunk;
387
+ textByPartId.set(partId, next);
388
+ callbacks.postEvent(ROLE_ASSISTANT, next, {
389
+ throttle: true,
390
+ messageId: opencodeMessageId("oc_text_", ev.part),
391
+ });
392
+ return;
393
+ }
394
+ case "reasoning": {
395
+ const chunk = ev.part?.text;
396
+ if (!chunk)
240
397
  return;
241
- }
242
- case "error": {
243
- const msg = ev.error?.data?.message ||
244
- ev.error?.message ||
245
- line.trim();
246
- if (msg)
247
- fatalError = msg;
398
+ const partId = ev.part?.id || ev.part?.messageID;
399
+ if (!partId) {
400
+ callbacks.postEvent(ROLE_REASONING, chunk, { throttle: true });
248
401
  return;
249
402
  }
250
- case "step_start":
251
- case "step_finish":
252
- return;
253
- default:
254
- return;
255
- }
256
- };
257
- child.stdout.on("data", (chunk) => {
258
- buf += chunk.toString();
259
- const lines = buf.split("\n");
260
- buf = lines.pop() || "";
261
- for (const line of lines) {
262
- handleOpencodeLine(line);
263
- }
264
- });
265
- child.on("close", (code) => {
266
- if (buf.trim()) {
267
- handleOpencodeLine(buf.trim());
403
+ const reasoningKey = `reasoning:${partId}`;
404
+ const next = (textByPartId.get(reasoningKey) || "") + chunk;
405
+ textByPartId.set(reasoningKey, next);
406
+ callbacks.postEvent(ROLE_REASONING, next, {
407
+ throttle: true,
408
+ messageId: opencodeMessageId("oc_reasoning_", ev.part),
409
+ });
410
+ return;
268
411
  }
269
- const stderrFatal = extractOpencodeFatalError(err);
270
- if (stderrFatal)
271
- fatalError = stderrFatal;
272
- if (fatalError) {
273
- resolve({ code: 1, err: fatalError });
412
+ case "tool_use": {
413
+ const status = ev.part?.state?.status;
414
+ if (!status || status === "pending")
415
+ return;
416
+ const toolContent = formatToolUseContent(ev.part);
417
+ callbacks.postEvent(ROLE_TOOL, toolContent, {
418
+ messageId: opencodeMessageId("oc_tool_", ev.part),
419
+ });
420
+ if (status === "completed") {
421
+ const detected = detectWorkingBranchFromToolOutput(toolContent);
422
+ if (detected.branch) {
423
+ callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
424
+ }
425
+ }
274
426
  return;
275
427
  }
276
- if (code != null && code !== 0) {
277
- resolve({ code, err: summarizeOpencodeFailure(err, buf, code) });
428
+ case "error": {
429
+ const msg = ev.error?.data?.message ||
430
+ ev.error?.message ||
431
+ line.trim();
432
+ if (msg)
433
+ fatalError = msg;
278
434
  return;
279
435
  }
280
- resolve({ code: code == null ? 1 : code, err });
281
- });
436
+ case "step_start":
437
+ case "step_finish":
438
+ return;
439
+ default:
440
+ return;
441
+ }
442
+ };
443
+ child.stdout.on("data", (chunk) => {
444
+ buf += chunk.toString();
445
+ const lines = buf.split("\n");
446
+ buf = lines.pop() || "";
447
+ for (const line of lines) {
448
+ handleOpencodeLine(line);
449
+ }
282
450
  });
283
- }
284
- try {
285
- const out = execFileSync("opencode", baseArgs, {
286
- encoding: "utf8",
287
- maxBuffer: 20 * 1024 * 1024,
288
- stdio: ["ignore", "pipe", "pipe"],
289
- env: childEnv,
451
+ child.on("close", (code) => {
452
+ if (buf.trim()) {
453
+ handleOpencodeLine(buf.trim());
454
+ }
455
+ const stderrFatal = extractOpencodeFatalError(err);
456
+ if (stderrFatal)
457
+ fatalError = stderrFatal;
458
+ if (fatalError) {
459
+ resolve({ code: 1, err: fatalError, opencodeSessionId: activeSessionId });
460
+ return;
461
+ }
462
+ if (code != null && code !== 0) {
463
+ resolve({
464
+ code,
465
+ err: summarizeOpencodeFailure(err, buf, code),
466
+ opencodeSessionId: activeSessionId,
467
+ });
468
+ return;
469
+ }
470
+ resolve({ code: code == null ? 1 : code, err, opencodeSessionId: activeSessionId });
290
471
  });
291
- const fatal = extractOpencodeFatalError(out);
292
- if (fatal)
293
- return Promise.resolve({ code: 1, err: fatal });
294
- if (out)
295
- postEvent(ROLE_ASSISTANT, out);
296
- return Promise.resolve({ code: 0, err: "" });
297
- }
298
- catch (e) {
299
- const errObj = e;
300
- const stderr = errObj.stderr?.toString() || "";
301
- const stdout = errObj.stdout?.toString() || "";
302
- const fatal = summarizeOpencodeFailure(stderr, stdout, errObj.status ?? 1);
303
- return Promise.resolve({ code: errObj.status || 1, err: fatal });
304
- }
472
+ });
305
473
  }
306
474
  function connectInboundStream(backend, apiKey, sessionId, handlers) {
307
475
  const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
@@ -384,7 +552,6 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
384
552
  export async function runChimphands(opts) {
385
553
  const apiKey = requireApiKey();
386
554
  const backend = getBackendUrl();
387
- // Ensure child processes see the resolved backend (prod default when unset).
388
555
  process.env.TESTCHIMP_BACKEND_URL = backend;
389
556
  const sessionId = (opts.sessionId || process.env.SESSION_ID || "").trim();
390
557
  if (!sessionId) {
@@ -396,10 +563,13 @@ export async function runChimphands(opts) {
396
563
  });
397
564
  const boot = JSON.parse(bootText);
398
565
  const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
566
+ const poster = new AgentEventPoster(backend, apiKey, sessionId);
399
567
  if (githubRunId) {
400
- postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", {
568
+ await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
401
569
  sessionId,
402
570
  githubRunId,
571
+ }).catch((err) => {
572
+ console.error(`ChimpHands link run failed: ${err instanceof Error ? err.message : String(err)}`);
403
573
  });
404
574
  }
405
575
  const userId = boot.chimphands_service_account_user_id || "";
@@ -409,6 +579,26 @@ export async function runChimphands(opts) {
409
579
  mkdirSync(".opencode", { recursive: true });
410
580
  const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
411
581
  console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
582
+ let opencodeSessionId = boot.opencode_session_id?.trim() || undefined;
583
+ const conversationSummary = boot.conversation_summary || "";
584
+ let workingBranch = boot.working_branch?.trim() || undefined;
585
+ let pullRequestUrl = boot.pull_request_url?.trim() || undefined;
586
+ const noteWorkingBranch = (branch, prUrl) => {
587
+ const normalizedBranch = branch.trim();
588
+ if (!normalizedBranch)
589
+ return;
590
+ const branchIsNew = !workingBranch;
591
+ const nextPr = prUrl?.trim() || pullRequestUrl;
592
+ const prIsNew = !!prUrl?.trim() && prUrl.trim() !== pullRequestUrl;
593
+ if (workingBranch === normalizedBranch && !prIsNew)
594
+ return;
595
+ workingBranch = normalizedBranch;
596
+ if (prUrl?.trim())
597
+ pullRequestUrl = prUrl.trim();
598
+ if (branchIsNew || prIsNew) {
599
+ poster.reportWorkingBranch(normalizedBranch, nextPr);
600
+ }
601
+ };
412
602
  const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
413
603
  const queue = [];
414
604
  const seenUserMessageIds = new Set();
@@ -446,17 +636,12 @@ export async function runChimphands(opts) {
446
636
  // Polling is best-effort when inbound SSE misses an event.
447
637
  }
448
638
  };
449
- const postEvent = (role, content, status, messageId) => {
450
- const body = {
451
- sessionId,
452
- role,
453
- content: String(content || "").slice(0, 20000),
454
- };
455
- if (messageId)
456
- body.messageId = messageId;
457
- if (status != null)
458
- body.status = status;
459
- postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", body);
639
+ const postEvent = (role, content, opts) => {
640
+ const bodyOpts = { ...opts };
641
+ if (opencodeSessionId && !bodyOpts.opencodeSessionId) {
642
+ bodyOpts.opencodeSessionId = opencodeSessionId;
643
+ }
644
+ poster.fireAndForget(role, content, bodyOpts);
460
645
  };
461
646
  const complete = (status, errorMessage) => {
462
647
  const body = { sessionId, status };
@@ -464,12 +649,15 @@ export async function runChimphands(opts) {
464
649
  body.errorMessage = String(errorMessage).slice(0, 4000);
465
650
  if (githubRunId)
466
651
  body.githubRunId = githubRunId;
467
- postJsonFireAndForget(backend, apiKey, "/api/chimphands/complete_session", body);
652
+ void postJson(backend, apiKey, "/api/chimphands/complete_session", body).catch((err) => {
653
+ console.error(`ChimpHands complete_session failed: ${err instanceof Error ? err.message : String(err)}`);
654
+ });
468
655
  };
469
656
  const childEnv = {
470
657
  ...process.env,
471
658
  TESTCHIMP_API_KEY: apiKey,
472
659
  TESTCHIMP_BACKEND_URL: backend,
660
+ TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
473
661
  };
474
662
  if (userId)
475
663
  childEnv.TESTCHIMP_USER_ID = userId;
@@ -480,11 +668,8 @@ export async function runChimphands(opts) {
480
668
  },
481
669
  shouldRun: () => sessionActive,
482
670
  });
483
- postEvent(ROLE_STATUS, "Agent ready", STATUS_RUNNING);
671
+ poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
484
672
  let prompt = normalizeUserMessage(promptInput || boot.initial_prompt || "");
485
- if (boot.conversation_summary) {
486
- prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
487
- }
488
673
  for (const m of boot.pending_user_messages || []) {
489
674
  if (m?.content)
490
675
  enqueueUserMessage({ content: m.content });
@@ -521,17 +706,50 @@ export async function runChimphands(opts) {
521
706
  tick();
522
707
  });
523
708
  while (prompt) {
524
- const result = await runOpencode(prompt, opencodeModel, childEnv, postEvent);
709
+ let useOpencodeSessionId = opencodeSessionId;
710
+ let isNewOpencodeSession = !useOpencodeSessionId;
711
+ let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
712
+ let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
713
+ onSessionId: (id) => {
714
+ opencodeSessionId = id;
715
+ void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
716
+ sessionId,
717
+ opencodeSessionId: id,
718
+ }).catch(() => { });
719
+ },
720
+ onWorkingBranch: noteWorkingBranch,
721
+ postEvent,
722
+ });
723
+ if (result.code !== 0 &&
724
+ useOpencodeSessionId &&
725
+ isMissingOpencodeSessionError(result.err || "")) {
726
+ console.error(`ChimpHands OpenCode session ${useOpencodeSessionId} missing on runner; starting fresh thread.`);
727
+ opencodeSessionId = undefined;
728
+ isNewOpencodeSession = true;
729
+ effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
730
+ result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
731
+ onSessionId: (id) => {
732
+ opencodeSessionId = id;
733
+ void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
734
+ sessionId,
735
+ opencodeSessionId: id,
736
+ }).catch(() => { });
737
+ },
738
+ onWorkingBranch: noteWorkingBranch,
739
+ postEvent,
740
+ });
741
+ }
742
+ await poster.flush();
743
+ if (result.opencodeSessionId) {
744
+ opencodeSessionId = result.opencodeSessionId;
745
+ }
525
746
  if (result.code !== 0) {
526
747
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
527
748
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
528
749
  try {
529
- await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
530
- sessionId,
531
- role: ROLE_STATUS,
532
- content: errMsg,
750
+ await poster.enqueue(ROLE_STATUS, errMsg, {
533
751
  status: STATUS_FAILED,
534
- githubRunId: githubRunId || undefined,
752
+ opencodeSessionId,
535
753
  });
536
754
  await postJson(backend, apiKey, "/api/chimphands/complete_session", {
537
755
  sessionId,
@@ -543,19 +761,19 @@ export async function runChimphands(opts) {
543
761
  catch (reportErr) {
544
762
  const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
545
763
  console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
546
- postEvent(ROLE_STATUS, errMsg, STATUS_FAILED);
764
+ postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
547
765
  complete(STATUS_FAILED, errMsg);
548
766
  }
549
767
  process.exit(result.code || 1);
550
768
  }
551
- postEvent(ROLE_STATUS, "Waiting for user input", STATUS_WAITING_USER);
552
- // Idle countdown starts when the agent finishes a turn, not at job bootstrap.
769
+ postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
553
770
  lastUserActivity = Date.now();
554
771
  idle = false;
555
772
  prompt = (await waitForNextPrompt()) || "";
556
773
  }
557
774
  sessionActive = false;
558
775
  stopInbound();
776
+ await poster.flush();
559
777
  console.error("ChimpHands session idle — no user input before timeout; completing.");
560
778
  complete(STATUS_IDLE);
561
779
  }
@@ -1562,6 +1562,28 @@ export function buildCliProgram() {
1562
1562
  console.log(await runTool("list-api-operation-interactions", mergeBodies(body, opts.jsonInput), { postMcp }));
1563
1563
  });
1564
1564
  const chimphands = program.command("chimphands").description("ChimpHands GitHub Actions agent bridge");
1565
+ chimphands
1566
+ .command("report-branch")
1567
+ .description("Report the conversation working branch (and optional PR URL) to TestChimp")
1568
+ .requiredOption("--branch <name>", "Feature branch name (testchimp-* or chimphands-*)")
1569
+ .option("--pr-url <url>", "Open pull request URL")
1570
+ .option("--session-id <id>", "ChimpHands session id (or SESSION_ID env)")
1571
+ .action(async (opts) => {
1572
+ const { reportWorkingBranch } = await import("../chimphands/run.js");
1573
+ try {
1574
+ await reportWorkingBranch({
1575
+ sessionId: String(opts.sessionId || process.env.SESSION_ID || "").trim(),
1576
+ branch: String(opts.branch || "").trim(),
1577
+ pullRequestUrl: opts.prUrl != null ? String(opts.prUrl).trim() : undefined,
1578
+ });
1579
+ console.log(JSON.stringify({ ok: true }));
1580
+ }
1581
+ catch (e) {
1582
+ const msg = e instanceof Error ? e.message : String(e);
1583
+ console.error(`[testchimp chimphands report-branch] ${msg}`);
1584
+ process.exit(1);
1585
+ }
1586
+ });
1565
1587
  chimphands
1566
1588
  .command("run")
1567
1589
  .description("Bootstrap session, configure OpenCode, and run the interactive bridge")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
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",