@testchimp/cli 0.1.47 → 0.1.48

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.
@@ -18,7 +18,11 @@ 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
20
  const OPENCODE_AGENT_ID = "chimphands";
21
- const STREAM_POST_MIN_INTERVAL_MS = 60;
21
+ /** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
22
+ const STREAM_POST_MIN_INTERVAL_MS = 150;
23
+ function isStreamFanoutRole(role) {
24
+ return role === ROLE_ASSISTANT || role === ROLE_TOOL || role === ROLE_REASONING;
25
+ }
22
26
  const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. You run on GitHub Actions, but this chat is an **interactive** conversation with the user in the TestChimp UI — same expectations as Cursor/Claude Code locally.
23
27
 
24
28
  ## Interactive session (mandatory — default)
@@ -102,13 +106,15 @@ async function postJson(backend, apiKey, path, body) {
102
106
  }
103
107
  return text;
104
108
  }
105
- /** Serializes post_agent_event calls so streaming chunks commit and fan out in order. */
109
+ /** Serializes agent event posts so streaming chunks fan out in order. */
106
110
  class AgentEventPoster {
107
111
  backend;
108
112
  apiKey;
109
113
  sessionId;
110
114
  chain = Promise.resolve();
111
115
  lastStreamPostAt = 0;
116
+ /** When false, assistant/tool/reasoning tokens are dropped (async / no UI). */
117
+ uiAttached = false;
112
118
  constructor(backend, apiKey, sessionId) {
113
119
  this.backend = backend;
114
120
  this.apiKey = apiKey;
@@ -130,15 +136,36 @@ class AgentEventPoster {
130
136
  body.workingBranch = opts.workingBranch;
131
137
  if (opts?.pullRequestUrl)
132
138
  body.pullRequestUrl = opts.pullRequestUrl;
139
+ const ephemeral = isStreamFanoutRole(role);
140
+ if (ephemeral && !this.uiAttached) {
141
+ return this.chain;
142
+ }
133
143
  this.chain = this.chain.then(async () => {
134
- if (opts?.throttle) {
144
+ if (opts?.throttle || ephemeral) {
135
145
  const now = Date.now();
136
146
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
137
147
  if (wait > 0)
138
148
  await sleep(wait);
139
149
  this.lastStreamPostAt = Date.now();
140
150
  }
141
- await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
151
+ const path = ephemeral
152
+ ? "/api/chimphands/post_ephemeral_agent_event"
153
+ : "/api/chimphands/post_agent_event";
154
+ // Ephemeral API only accepts session/role/content/messageId/opencodeSessionId.
155
+ if (ephemeral) {
156
+ const eph = {
157
+ sessionId: this.sessionId,
158
+ role,
159
+ content: body.content,
160
+ };
161
+ if (opts?.messageId)
162
+ eph.messageId = opts.messageId;
163
+ if (opts?.opencodeSessionId)
164
+ eph.opencodeSessionId = opts.opencodeSessionId;
165
+ await postJson(this.backend, this.apiKey, path, eph);
166
+ return;
167
+ }
168
+ await postJson(this.backend, this.apiKey, path, body);
142
169
  });
143
170
  return this.chain;
144
171
  }
@@ -236,6 +263,173 @@ function opencodeMessageId(prefix, part) {
236
263
  return undefined;
237
264
  return `${prefix}${raw}`;
238
265
  }
266
+ /**
267
+ * OpenCode `run --format json` only emits completed text (`part.time.end`).
268
+ * Live tokens come from the server SSE bus (`message.part.updated` + optional `delta`).
269
+ * Subscribe directly when attaching so the platform chat streams.
270
+ */
271
+ function startOpencodeSseRelay(attachUrl, callbacks) {
272
+ const base = attachUrl.replace(/\/$/, "");
273
+ const ac = new AbortController();
274
+ let stopped = false;
275
+ const textByPartId = new Map();
276
+ const sessionMatches = (sessionId) => {
277
+ const active = callbacks.getActiveSessionId();
278
+ if (!sessionId)
279
+ return !active;
280
+ if (!active)
281
+ return true;
282
+ return sessionId === active;
283
+ };
284
+ const handleBusEvent = (raw) => {
285
+ if (!raw || typeof raw !== "object")
286
+ return;
287
+ const ev = raw;
288
+ const type = ev.type || "";
289
+ const props = ev.properties || {};
290
+ if (type === "message.part.updated" || type === "message.part.delta") {
291
+ const part = props.part;
292
+ if (!part)
293
+ return;
294
+ const sessionId = part.sessionID || props.sessionID;
295
+ if (!sessionMatches(sessionId))
296
+ return;
297
+ callbacks.noteSessionId(sessionId);
298
+ if (part.type === "text") {
299
+ const partId = part.id || part.messageID;
300
+ if (!partId)
301
+ return;
302
+ let next = part.text || "";
303
+ if (props.delta) {
304
+ next = (textByPartId.get(partId) || "") + props.delta;
305
+ }
306
+ else if (!next && props.delta === undefined) {
307
+ return;
308
+ }
309
+ // Prefer cumulative part.text when present (idempotent); else delta accumulation.
310
+ if (part.text)
311
+ next = part.text;
312
+ textByPartId.set(partId, next);
313
+ if (!next)
314
+ return;
315
+ callbacks.postEvent(ROLE_ASSISTANT, next, {
316
+ throttle: true,
317
+ messageId: opencodeMessageId("oc_text_", part),
318
+ });
319
+ return;
320
+ }
321
+ if (part.type === "reasoning") {
322
+ const partId = part.id || part.messageID;
323
+ if (!partId)
324
+ return;
325
+ let next = part.text || "";
326
+ if (props.delta && !part.text) {
327
+ next = (textByPartId.get(`reasoning:${partId}`) || "") + props.delta;
328
+ }
329
+ if (part.text)
330
+ next = part.text;
331
+ textByPartId.set(`reasoning:${partId}`, next);
332
+ if (!next)
333
+ return;
334
+ callbacks.postEvent(ROLE_REASONING, next, {
335
+ throttle: true,
336
+ messageId: opencodeMessageId("oc_reasoning_", part),
337
+ });
338
+ return;
339
+ }
340
+ if (part.type === "tool") {
341
+ const status = part.state?.status;
342
+ if (!status || status === "pending" || status === "running")
343
+ return;
344
+ const toolContent = formatToolUseContent(part);
345
+ callbacks.postEvent(ROLE_TOOL, toolContent, {
346
+ messageId: opencodeMessageId("oc_tool_", part),
347
+ });
348
+ if (status === "completed") {
349
+ const detected = detectWorkingBranchFromToolOutput(toolContent);
350
+ if (detected.branch) {
351
+ callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
352
+ }
353
+ }
354
+ }
355
+ return;
356
+ }
357
+ if (type === "session.error") {
358
+ const sessionId = props.sessionID;
359
+ if (!sessionMatches(sessionId))
360
+ return;
361
+ const msg = props.error?.data?.message || props.error?.message || props.error?.name || "OpenCode session error";
362
+ callbacks.postEvent(ROLE_STATUS, String(msg), { status: STATUS_RUNNING });
363
+ }
364
+ };
365
+ const consume = async (body) => {
366
+ const reader = body.getReader();
367
+ const decoder = new TextDecoder();
368
+ let buf = "";
369
+ while (!stopped) {
370
+ const { done, value } = await reader.read();
371
+ if (done)
372
+ break;
373
+ buf += decoder.decode(value, { stream: true });
374
+ const chunks = buf.split("\n\n");
375
+ buf = chunks.pop() || "";
376
+ for (const chunk of chunks) {
377
+ const dataLines = chunk
378
+ .split("\n")
379
+ .filter((l) => l.startsWith("data:"))
380
+ .map((l) => l.slice(5).trimStart());
381
+ if (!dataLines.length)
382
+ continue;
383
+ const data = dataLines.join("\n");
384
+ if (!data || data === "[DONE]")
385
+ continue;
386
+ try {
387
+ handleBusEvent(JSON.parse(data));
388
+ }
389
+ catch {
390
+ /* ignore malformed */
391
+ }
392
+ }
393
+ }
394
+ };
395
+ void (async () => {
396
+ for (const path of ["/event", "/global/event"]) {
397
+ if (stopped)
398
+ return;
399
+ try {
400
+ const res = await fetch(`${base}${path}`, {
401
+ headers: { Accept: "text/event-stream" },
402
+ signal: ac.signal,
403
+ });
404
+ if (!res.ok || !res.body) {
405
+ console.error(`ChimpHands OpenCode SSE ${path} HTTP ${res.status}`);
406
+ continue;
407
+ }
408
+ console.error(`ChimpHands OpenCode SSE streaming via ${path}`);
409
+ await consume(res.body);
410
+ return;
411
+ }
412
+ catch (err) {
413
+ if (stopped || ac.signal.aborted)
414
+ return;
415
+ const detail = err instanceof Error ? err.message : String(err);
416
+ console.error(`ChimpHands OpenCode SSE ${path} failed: ${detail}`);
417
+ }
418
+ }
419
+ if (!stopped) {
420
+ console.error("ChimpHands OpenCode SSE unavailable — falling back to completed-only --format json events");
421
+ }
422
+ })();
423
+ return () => {
424
+ stopped = true;
425
+ try {
426
+ ac.abort();
427
+ }
428
+ catch {
429
+ /* ignore */
430
+ }
431
+ };
432
+ }
239
433
  function summarizeOpencodeFailure(stderr, stdout, exitCode) {
240
434
  for (const chunk of [stderr, stdout]) {
241
435
  for (const line of chunk.split("\n")) {
@@ -350,10 +544,12 @@ function writeOpencodeConfig(backend, apiKey, boot) {
350
544
  prompt: CHIMPHANDS_AGENT_PROMPT,
351
545
  steps: 80,
352
546
  permission: {
547
+ "*": "allow",
353
548
  skill: "allow",
354
549
  bash: "allow",
355
550
  edit: "allow",
356
551
  read: "allow",
552
+ question: "allow",
357
553
  },
358
554
  },
359
555
  },
@@ -364,7 +560,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
364
560
  testchimp: {
365
561
  type: "local",
366
562
  enabled: true,
367
- command: ["npx", "-y", "@testchimp/cli@latest", "mcp"],
563
+ // Prefer the already-installed global binary — `npx -y @latest` can hang in GHA.
564
+ command: ["testchimp", "mcp"],
368
565
  environment: mcpEnv,
369
566
  },
370
567
  },
@@ -372,6 +569,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
372
569
  return model;
373
570
  }
374
571
  function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
572
+ // --auto: headless CI must approve tool permissions (1.18+ otherwise auto-rejects).
573
+ // --print-logs: surface server/client progress on stderr while waiting for first token.
375
574
  const args = [
376
575
  "run",
377
576
  prompt,
@@ -381,6 +580,8 @@ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
381
580
  "json",
382
581
  "--agent",
383
582
  OPENCODE_AGENT_ID,
583
+ "--auto",
584
+ "--print-logs",
384
585
  ];
385
586
  if (opencodeSessionId?.trim()) {
386
587
  args.push("--session", opencodeSessionId.trim());
@@ -461,18 +662,31 @@ async function restartLocalOpencodeServer(attachUrl) {
461
662
  function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
462
663
  let activeSessionId = opencodeSessionId?.trim() || undefined;
463
664
  const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
665
+ const preview = prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt;
666
+ console.error(`ChimpHands invoking OpenCode: model=${model} attach=${attachUrl || "(local)"} session=${activeSessionId || "(new)"} prompt=${JSON.stringify(preview)}`);
667
+ // pipe+end stdin so OpenCode does not wait on Bun.stdin.text() (non-TTY).
464
668
  const child = spawn("opencode", baseArgs, {
465
- stdio: ["ignore", "pipe", "pipe"],
669
+ stdio: ["pipe", "pipe", "pipe"],
466
670
  env: childEnv,
467
671
  });
672
+ try {
673
+ child.stdin?.end();
674
+ }
675
+ catch {
676
+ /* ignore */
677
+ }
468
678
  let err = "";
469
679
  child.stderr.on("data", (d) => {
470
- err += d.toString();
680
+ const chunk = d.toString();
681
+ err += chunk;
682
+ // Live-forward so GHA shows progress while waiting for first JSON event.
683
+ process.stderr.write(chunk);
471
684
  });
472
685
  return new Promise((resolve) => {
473
686
  let buf = "";
474
687
  let fatalError = null;
475
688
  const textByPartId = new Map();
689
+ let sawStdout = false;
476
690
  const noteSessionId = (sessionId) => {
477
691
  const id = sessionId?.trim();
478
692
  if (!id || id === activeSessionId)
@@ -483,6 +697,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
483
697
  const handleOpencodeLine = (line) => {
484
698
  if (!line.trim())
485
699
  return;
700
+ if (!sawStdout) {
701
+ sawStdout = true;
702
+ console.error("ChimpHands OpenCode first stdout event received");
703
+ }
486
704
  const fatal = extractOpencodeFatalError(line);
487
705
  if (fatal) {
488
706
  fatalError = fatal;
@@ -685,6 +903,40 @@ export async function runChimphands(opts) {
685
903
  const boot = JSON.parse(bootText);
686
904
  const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
687
905
  const poster = new AgentEventPoster(backend, apiKey, sessionId);
906
+ poster.uiAttached = !!(boot.uiAttached ?? boot.ui_attached);
907
+ let stopLiveSse = null;
908
+ const syncLiveSse = (attached) => {
909
+ poster.uiAttached = attached;
910
+ if (!attachUrl)
911
+ return;
912
+ if (attached && !stopLiveSse) {
913
+ console.error("ChimpHands UI attached — starting OpenCode SSE fanout");
914
+ stopLiveSse = startOpencodeSseRelay(attachUrl, {
915
+ getActiveSessionId: () => opencodeSessionId,
916
+ noteSessionId: (id) => {
917
+ if (id?.trim())
918
+ opencodeSessionId = id.trim();
919
+ },
920
+ postEvent: (role, content, opts) => {
921
+ poster.fireAndForget(role, content, {
922
+ ...opts,
923
+ opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
924
+ });
925
+ },
926
+ onWorkingBranch: noteWorkingBranchPlaceholder,
927
+ });
928
+ }
929
+ else if (!attached && stopLiveSse) {
930
+ console.error("ChimpHands UI detached — stopping OpenCode SSE fanout");
931
+ stopLiveSse();
932
+ stopLiveSse = null;
933
+ }
934
+ };
935
+ // noteWorkingBranch is defined later; bind via mutable holder until then.
936
+ let noteWorkingBranch = () => { };
937
+ const noteWorkingBranchPlaceholder = (branch, prUrl) => {
938
+ noteWorkingBranch(branch, prUrl);
939
+ };
688
940
  if (githubRunId) {
689
941
  await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
690
942
  sessionId,
@@ -712,7 +964,9 @@ export async function runChimphands(opts) {
712
964
  }
713
965
  }
714
966
  const stopHeartbeat = runtimeId
715
- ? startRuntimeHeartbeat(backend, apiKey, runtimeId)
967
+ ? startRuntimeHeartbeat(backend, apiKey, runtimeId, (attached) => {
968
+ syncLiveSse(attached);
969
+ })
716
970
  : () => { };
717
971
  const stopTunnel = runtimeId && attachUrl
718
972
  ? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
@@ -758,7 +1012,7 @@ export async function runChimphands(opts) {
758
1012
  console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
759
1013
  }
760
1014
  };
761
- const noteWorkingBranch = (branch, prUrl) => {
1015
+ noteWorkingBranch = (branch, prUrl) => {
762
1016
  const normalizedBranch = branch.trim();
763
1017
  if (!normalizedBranch)
764
1018
  return;
@@ -774,6 +1028,8 @@ export async function runChimphands(opts) {
774
1028
  poster.reportWorkingBranch(normalizedBranch, nextPr);
775
1029
  }
776
1030
  };
1031
+ // Apply bootstrap ui_attached now that session id + branch hooks exist.
1032
+ syncLiveSse(poster.uiAttached);
777
1033
  const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
778
1034
  const queue = [];
779
1035
  const seenUserMessageIds = new Set();
@@ -849,6 +1105,10 @@ export async function runChimphands(opts) {
849
1105
  stopInbound();
850
1106
  stopTunnel();
851
1107
  stopHeartbeat();
1108
+ if (stopLiveSse) {
1109
+ stopLiveSse();
1110
+ stopLiveSse = null;
1111
+ }
852
1112
  await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
853
1113
  await poster.flush();
854
1114
  await snapshotExport();
@@ -902,6 +1162,9 @@ export async function runChimphands(opts) {
902
1162
  let useOpencodeSessionId = opencodeSessionId;
903
1163
  let isNewOpencodeSession = !useOpencodeSessionId;
904
1164
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
1165
+ // Visible in chat (not filtered as routine). OpenCode may not emit text until a
1166
+ // part completes — without this the UI looks empty while the turn is running.
1167
+ postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
905
1168
  let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
906
1169
  onSessionId: (id) => {
907
1170
  opencodeSessionId = id;
@@ -978,22 +1241,34 @@ export async function runChimphands(opts) {
978
1241
  process.exit(exitCode);
979
1242
  }
980
1243
  }
981
- function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
1244
+ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
982
1245
  let stopped = false;
1246
+ let lastAttached;
983
1247
  const tick = async () => {
984
1248
  if (stopped)
985
1249
  return;
986
1250
  try {
987
1251
  // Do not claim tunnel_connected here — only the tunnel poll loop should.
988
- await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
1252
+ const text = await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
989
1253
  runtimeId,
990
1254
  });
1255
+ try {
1256
+ const data = JSON.parse(text);
1257
+ const attached = !!(data.uiAttached ?? data.ui_attached);
1258
+ if (attached !== lastAttached) {
1259
+ lastAttached = attached;
1260
+ onUiAttached?.(attached);
1261
+ }
1262
+ }
1263
+ catch {
1264
+ /* ignore parse */
1265
+ }
991
1266
  }
992
1267
  catch (err) {
993
1268
  console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
994
1269
  }
995
1270
  if (!stopped)
996
- setTimeout(tick, 15_000);
1271
+ setTimeout(tick, 5_000);
997
1272
  };
998
1273
  void tick();
999
1274
  return () => {
@@ -1065,7 +1340,7 @@ async function commitAndPushDirtyWorktree(message) {
1065
1340
  }
1066
1341
  async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
1067
1342
  const exported = await new Promise((resolve, reject) => {
1068
- const child = spawn("opencode", ["export", opencodeSessionId, "--sanitize"], {
1343
+ const child = spawn("opencode", ["export", opencodeSessionId], {
1069
1344
  stdio: ["ignore", "pipe", "pipe"],
1070
1345
  });
1071
1346
  let out = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
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",