@testchimp/cli 0.1.47 → 0.1.49

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, liveStream tokens are dropped; completed events still persist. */
117
+ uiAttached = false;
112
118
  constructor(backend, apiKey, sessionId) {
113
119
  this.backend = backend;
114
120
  this.apiKey = apiKey;
@@ -130,14 +136,50 @@ class AgentEventPoster {
130
136
  body.workingBranch = opts.workingBranch;
131
137
  if (opts?.pullRequestUrl)
132
138
  body.pullRequestUrl = opts.pullRequestUrl;
139
+ const streamRole = isStreamFanoutRole(role);
140
+ // Live token fanout only while UI watching; completed json events always go durable.
141
+ if (streamRole && opts?.liveStream && !this.uiAttached) {
142
+ return this.chain;
143
+ }
133
144
  this.chain = this.chain.then(async () => {
134
- if (opts?.throttle) {
145
+ if (opts?.throttle || (streamRole && opts?.liveStream)) {
135
146
  const now = Date.now();
136
147
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
137
148
  if (wait > 0)
138
149
  await sleep(wait);
139
150
  this.lastStreamPostAt = Date.now();
140
151
  }
152
+ const tryEphemeral = streamRole && this.uiAttached;
153
+ if (tryEphemeral) {
154
+ const eph = {
155
+ sessionId: this.sessionId,
156
+ role,
157
+ content: body.content,
158
+ };
159
+ if (opts?.messageId)
160
+ eph.messageId = opts.messageId;
161
+ if (opts?.opencodeSessionId)
162
+ eph.opencodeSessionId = opts.opencodeSessionId;
163
+ try {
164
+ const text = await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
165
+ let delivered = false;
166
+ try {
167
+ const parsed = JSON.parse(text);
168
+ delivered = !!parsed.delivered;
169
+ }
170
+ catch {
171
+ /* ignore */
172
+ }
173
+ if (delivered)
174
+ return;
175
+ // Cross-replica: UI SSE not on this FS pod — fall through to durable.
176
+ console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
177
+ }
178
+ catch (err) {
179
+ const detail = err instanceof Error ? err.message : String(err);
180
+ console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
181
+ }
182
+ }
141
183
  await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
142
184
  });
143
185
  return this.chain;
@@ -215,6 +257,122 @@ function parseOpencodeEvent(line) {
215
257
  return null;
216
258
  }
217
259
  }
260
+ /**
261
+ * Newer OpenCode `--format json` lines often use the SSE bus shape
262
+ * (`message.part.updated` + `properties.part`) instead of legacy `type: "text"`.
263
+ * Normalize both into the same OpencodeEvent used by the stdout switch.
264
+ */
265
+ function normalizeStdoutOpencodeEvent(raw) {
266
+ if (!raw || typeof raw !== "object")
267
+ return null;
268
+ const o = raw;
269
+ // Global envelope
270
+ const inner = o.payload && typeof o.payload === "object"
271
+ ? o.payload
272
+ : o.event && typeof o.event === "object"
273
+ ? o.event
274
+ : o;
275
+ const type = String(inner.type || "");
276
+ const props = (inner.properties && typeof inner.properties === "object"
277
+ ? inner.properties
278
+ : {});
279
+ if (type === "message.part.updated" || type === "message.part.delta") {
280
+ const part = props.part;
281
+ if (!part)
282
+ return null;
283
+ const partType = part.type || "";
284
+ let mapped;
285
+ if (partType === "text")
286
+ mapped = "text";
287
+ else if (partType === "reasoning")
288
+ mapped = "reasoning";
289
+ else if (partType === "tool")
290
+ mapped = "tool_use";
291
+ else
292
+ return null;
293
+ // Prefer cumulative text; append delta when that's all we got.
294
+ if (props.delta && !part.text) {
295
+ part.text = props.delta;
296
+ }
297
+ return {
298
+ type: mapped,
299
+ sessionID: part.sessionID || props.sessionID,
300
+ part,
301
+ };
302
+ }
303
+ if (type === "session.error") {
304
+ return {
305
+ type: "error",
306
+ sessionID: props.sessionID,
307
+ error: props.error,
308
+ };
309
+ }
310
+ // Legacy flat shape: { type: "text"|"tool_use"|..., part, sessionID }
311
+ if (type) {
312
+ return inner;
313
+ }
314
+ return null;
315
+ }
316
+ /** Pull assistant/tool parts from OpenCode HTTP after attach exits early. */
317
+ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, postEvent, onWorkingBranch) {
318
+ const base = attachUrl.replace(/\/$/, "");
319
+ const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
320
+ let res;
321
+ try {
322
+ res = await fetch(url, {
323
+ headers: { Accept: "application/json", "x-opencode-directory": process.cwd() },
324
+ });
325
+ }
326
+ catch (err) {
327
+ console.error(`ChimpHands reconcile messages fetch failed: ${err instanceof Error ? err.message : String(err)}`);
328
+ return 0;
329
+ }
330
+ if (!res.ok) {
331
+ console.error(`ChimpHands reconcile messages HTTP ${res.status}`);
332
+ return 0;
333
+ }
334
+ const data = (await res.json());
335
+ if (!Array.isArray(data))
336
+ return 0;
337
+ let posted = 0;
338
+ for (const msg of data) {
339
+ if ((msg.info?.role || "").toLowerCase() !== "assistant")
340
+ continue;
341
+ for (const part of msg.parts || []) {
342
+ if (part.type === "text" && part.text?.trim()) {
343
+ postEvent(ROLE_ASSISTANT, part.text.trim(), {
344
+ messageId: opencodeMessageId("oc_text_", part),
345
+ });
346
+ posted += 1;
347
+ }
348
+ else if (part.type === "reasoning" && part.text?.trim()) {
349
+ postEvent(ROLE_REASONING, part.text.trim(), {
350
+ messageId: opencodeMessageId("oc_reasoning_", part),
351
+ });
352
+ posted += 1;
353
+ }
354
+ else if (part.type === "tool") {
355
+ const status = part.state?.status;
356
+ if (!status || status === "pending" || status === "running")
357
+ continue;
358
+ const toolContent = formatToolUseContent(part);
359
+ postEvent(ROLE_TOOL, toolContent, {
360
+ messageId: opencodeMessageId("oc_tool_", part),
361
+ });
362
+ posted += 1;
363
+ if (status === "completed") {
364
+ const detected = detectWorkingBranchFromToolOutput(toolContent);
365
+ if (detected.branch)
366
+ onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
367
+ }
368
+ }
369
+ }
370
+ }
371
+ if (posted) {
372
+ console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API`);
373
+ }
374
+ return posted;
375
+ }
218
376
  function formatToolUseContent(part) {
219
377
  const title = part.state?.title || part.tool || "tool";
220
378
  const status = part.state?.status?.trim();
@@ -236,6 +394,213 @@ function opencodeMessageId(prefix, part) {
236
394
  return undefined;
237
395
  return `${prefix}${raw}`;
238
396
  }
397
+ /**
398
+ * OpenCode `run --format json` only emits completed text (`part.time.end`).
399
+ * Live tokens come from the server SSE bus (`message.part.updated` + optional `delta`).
400
+ * Subscribe directly when attaching so the platform chat streams.
401
+ */
402
+ function startOpencodeSseRelay(attachUrl, callbacks) {
403
+ const base = attachUrl.replace(/\/$/, "");
404
+ const ac = new AbortController();
405
+ let stopped = false;
406
+ const textByPartId = new Map();
407
+ const directory = process.cwd();
408
+ const sessionMatches = (sessionId) => {
409
+ const active = callbacks.getActiveSessionId();
410
+ if (!sessionId)
411
+ return !active;
412
+ if (!active)
413
+ return true;
414
+ return sessionId === active;
415
+ };
416
+ const liveOpts = (extra) => ({
417
+ ...extra,
418
+ throttle: true,
419
+ liveStream: true,
420
+ });
421
+ const unwrapBusPayload = (raw) => {
422
+ if (!raw || typeof raw !== "object")
423
+ return raw;
424
+ const o = raw;
425
+ // /global/event wraps as { directory, payload } or { directory, event }
426
+ if (o.payload && typeof o.payload === "object")
427
+ return o.payload;
428
+ if (o.event && typeof o.event === "object")
429
+ return o.event;
430
+ return raw;
431
+ };
432
+ const handleBusEvent = (raw) => {
433
+ const unwrapped = unwrapBusPayload(raw);
434
+ if (!unwrapped || typeof unwrapped !== "object")
435
+ return;
436
+ const ev = unwrapped;
437
+ const type = ev.type || "";
438
+ const props = ev.properties || {};
439
+ if (type === "message.part.updated" || type === "message.part.delta") {
440
+ const part = props.part;
441
+ if (!part)
442
+ return;
443
+ const sessionId = part.sessionID || props.sessionID;
444
+ if (!sessionMatches(sessionId))
445
+ return;
446
+ callbacks.noteSessionId(sessionId);
447
+ if (part.type === "text") {
448
+ const partId = part.id || part.messageID;
449
+ if (!partId)
450
+ return;
451
+ let next = part.text || "";
452
+ if (props.delta) {
453
+ next = (textByPartId.get(partId) || "") + props.delta;
454
+ }
455
+ else if (!next && props.delta === undefined) {
456
+ return;
457
+ }
458
+ // Prefer cumulative part.text when present (idempotent); else delta accumulation.
459
+ if (part.text)
460
+ next = part.text;
461
+ textByPartId.set(partId, next);
462
+ if (!next)
463
+ return;
464
+ callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
465
+ messageId: opencodeMessageId("oc_text_", part),
466
+ }));
467
+ return;
468
+ }
469
+ if (part.type === "reasoning") {
470
+ const partId = part.id || part.messageID;
471
+ if (!partId)
472
+ return;
473
+ let next = part.text || "";
474
+ if (props.delta && !part.text) {
475
+ next = (textByPartId.get(`reasoning:${partId}`) || "") + props.delta;
476
+ }
477
+ if (part.text)
478
+ next = part.text;
479
+ textByPartId.set(`reasoning:${partId}`, next);
480
+ if (!next)
481
+ return;
482
+ callbacks.postEvent(ROLE_REASONING, next, liveOpts({
483
+ messageId: opencodeMessageId("oc_reasoning_", part),
484
+ }));
485
+ return;
486
+ }
487
+ if (part.type === "tool") {
488
+ const status = part.state?.status;
489
+ if (!status || status === "pending" || status === "running")
490
+ return;
491
+ const toolContent = formatToolUseContent(part);
492
+ callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
493
+ messageId: opencodeMessageId("oc_tool_", part),
494
+ throttle: false,
495
+ }));
496
+ if (status === "completed") {
497
+ const detected = detectWorkingBranchFromToolOutput(toolContent);
498
+ if (detected.branch) {
499
+ callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
500
+ }
501
+ }
502
+ }
503
+ return;
504
+ }
505
+ if (type === "session.error") {
506
+ const sessionId = props.sessionID;
507
+ if (!sessionMatches(sessionId))
508
+ return;
509
+ const msg = props.error?.data?.message || props.error?.message || props.error?.name || "OpenCode session error";
510
+ callbacks.postEvent(ROLE_STATUS, String(msg), { status: STATUS_RUNNING });
511
+ }
512
+ };
513
+ const consume = async (body) => {
514
+ const reader = body.getReader();
515
+ const decoder = new TextDecoder();
516
+ let buf = "";
517
+ while (!stopped) {
518
+ const { done, value } = await reader.read();
519
+ if (done)
520
+ break;
521
+ buf += decoder.decode(value, { stream: true });
522
+ const chunks = buf.split("\n\n");
523
+ buf = chunks.pop() || "";
524
+ for (const chunk of chunks) {
525
+ const dataLines = chunk
526
+ .split("\n")
527
+ .filter((l) => l.startsWith("data:"))
528
+ .map((l) => l.slice(5).trimStart());
529
+ if (!dataLines.length)
530
+ continue;
531
+ const data = dataLines.join("\n");
532
+ if (!data || data === "[DONE]")
533
+ continue;
534
+ try {
535
+ handleBusEvent(JSON.parse(data));
536
+ }
537
+ catch {
538
+ /* ignore malformed */
539
+ }
540
+ }
541
+ }
542
+ };
543
+ const candidates = () => {
544
+ const dirQ = `directory=${encodeURIComponent(directory)}`;
545
+ return [
546
+ `/event?${dirQ}`,
547
+ `/event`,
548
+ `/global/event?${dirQ}`,
549
+ `/global/event`,
550
+ ];
551
+ };
552
+ void (async () => {
553
+ let attempt = 0;
554
+ while (!stopped) {
555
+ let connected = false;
556
+ for (const path of candidates()) {
557
+ if (stopped)
558
+ return;
559
+ try {
560
+ const res = await fetch(`${base}${path}`, {
561
+ headers: {
562
+ Accept: "text/event-stream",
563
+ "x-opencode-directory": directory,
564
+ },
565
+ signal: ac.signal,
566
+ });
567
+ if (!res.ok || !res.body) {
568
+ console.error(`ChimpHands OpenCode SSE ${path} HTTP ${res.status}`);
569
+ continue;
570
+ }
571
+ console.error(`ChimpHands OpenCode SSE streaming via ${path}`);
572
+ connected = true;
573
+ await consume(res.body);
574
+ // Stream ended — retry if still attached.
575
+ break;
576
+ }
577
+ catch (err) {
578
+ if (stopped || ac.signal.aborted)
579
+ return;
580
+ const detail = err instanceof Error ? err.message : String(err);
581
+ console.error(`ChimpHands OpenCode SSE ${path} failed: ${detail}`);
582
+ }
583
+ }
584
+ if (stopped || ac.signal.aborted)
585
+ return;
586
+ if (!connected && attempt === 0) {
587
+ console.error("ChimpHands OpenCode SSE unavailable — falling back to completed-only --format json events (will keep retrying SSE)");
588
+ }
589
+ attempt += 1;
590
+ const backoff = Math.min(30_000, 2_000 * attempt);
591
+ await sleep(backoff);
592
+ }
593
+ })();
594
+ return () => {
595
+ stopped = true;
596
+ try {
597
+ ac.abort();
598
+ }
599
+ catch {
600
+ /* ignore */
601
+ }
602
+ };
603
+ }
239
604
  function summarizeOpencodeFailure(stderr, stdout, exitCode) {
240
605
  for (const chunk of [stderr, stdout]) {
241
606
  for (const line of chunk.split("\n")) {
@@ -262,21 +627,15 @@ function isMissingOpencodeSessionError(message) {
262
627
  m.includes("unknown session") ||
263
628
  m.includes("invalid session"));
264
629
  }
265
- function isNonInteractivePrompt(userPrompt) {
266
- return /(?:^|\s)--mode\s*=?\s*non-interactive\b|mode\s*=\s*non-interactive\b/i.test(userPrompt);
267
- }
268
- function isTestchimpWorkflowPrompt(userPrompt) {
269
- return /(?:^|\s)\/?testchimp\b/i.test(userPrompt.trim());
270
- }
271
630
  function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
272
631
  const parts = [];
273
632
  if (workingBranch?.trim()) {
274
633
  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.", "");
275
634
  }
276
635
  const task = normalizeUserMessage(userPrompt);
277
- if (isTestchimpWorkflowPrompt(task) && !isNonInteractivePrompt(task)) {
278
- parts.push("## Interactive turn reminder", "This is an interactive ChimpHands chat (not autonomous CI). Ask clarifying questions / seek plan approval, then end the turn and wait — do not invent defaults or Execute until the user replies. Only `--mode=non-interactive` skips that pause.", "");
279
- }
636
+ // Do NOT inject "Interactive turn reminder" into the user prompt — it is already in
637
+ // CHIMPHANDS_AGENT_PROMPT (system). Putting it here makes OpenCode store it as the
638
+ // user message and the UI shows host control text in chat.
280
639
  if (isNewOpencodeSession && conversationSummary.trim()) {
281
640
  parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
282
641
  return parts.filter(Boolean).join("\n");
@@ -350,10 +709,12 @@ function writeOpencodeConfig(backend, apiKey, boot) {
350
709
  prompt: CHIMPHANDS_AGENT_PROMPT,
351
710
  steps: 80,
352
711
  permission: {
712
+ "*": "allow",
353
713
  skill: "allow",
354
714
  bash: "allow",
355
715
  edit: "allow",
356
716
  read: "allow",
717
+ question: "allow",
357
718
  },
358
719
  },
359
720
  },
@@ -364,7 +725,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
364
725
  testchimp: {
365
726
  type: "local",
366
727
  enabled: true,
367
- command: ["npx", "-y", "@testchimp/cli@latest", "mcp"],
728
+ // Prefer the already-installed global binary — `npx -y @latest` can hang in GHA.
729
+ command: ["testchimp", "mcp"],
368
730
  environment: mcpEnv,
369
731
  },
370
732
  },
@@ -372,6 +734,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
372
734
  return model;
373
735
  }
374
736
  function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
737
+ // --auto: headless CI must approve tool permissions (1.18+ otherwise auto-rejects).
738
+ // --print-logs: surface server/client progress on stderr while waiting for first token.
375
739
  const args = [
376
740
  "run",
377
741
  prompt,
@@ -381,6 +745,8 @@ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
381
745
  "json",
382
746
  "--agent",
383
747
  OPENCODE_AGENT_ID,
748
+ "--auto",
749
+ "--print-logs",
384
750
  ];
385
751
  if (opencodeSessionId?.trim()) {
386
752
  args.push("--session", opencodeSessionId.trim());
@@ -461,18 +827,43 @@ async function restartLocalOpencodeServer(attachUrl) {
461
827
  function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
462
828
  let activeSessionId = opencodeSessionId?.trim() || undefined;
463
829
  const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
830
+ const preview = prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt;
831
+ console.error(`ChimpHands invoking OpenCode: model=${model} attach=${attachUrl || "(local)"} session=${activeSessionId || "(new)"} prompt=${JSON.stringify(preview)}`);
832
+ // pipe+end stdin so OpenCode does not wait on Bun.stdin.text() (non-TTY).
464
833
  const child = spawn("opencode", baseArgs, {
465
- stdio: ["ignore", "pipe", "pipe"],
834
+ stdio: ["pipe", "pipe", "pipe"],
466
835
  env: childEnv,
467
836
  });
837
+ try {
838
+ child.stdin?.end();
839
+ }
840
+ catch {
841
+ /* ignore */
842
+ }
468
843
  let err = "";
469
844
  child.stderr.on("data", (d) => {
470
- err += d.toString();
845
+ const chunk = d.toString();
846
+ err += chunk;
847
+ // Live-forward so GHA shows progress while waiting for first JSON event.
848
+ process.stderr.write(chunk);
471
849
  });
472
850
  return new Promise((resolve) => {
473
851
  let buf = "";
474
852
  let fatalError = null;
475
853
  const textByPartId = new Map();
854
+ let sawStdout = false;
855
+ let progressTicker = setInterval(() => {
856
+ if (sawStdout) {
857
+ if (progressTicker) {
858
+ clearInterval(progressTicker);
859
+ progressTicker = null;
860
+ }
861
+ return;
862
+ }
863
+ callbacks.postEvent(ROLE_STATUS, "Agent is still working… (waiting for OpenCode output)", {
864
+ status: STATUS_RUNNING,
865
+ });
866
+ }, 45_000);
476
867
  const noteSessionId = (sessionId) => {
477
868
  const id = sessionId?.trim();
478
869
  if (!id || id === activeSessionId)
@@ -483,6 +874,14 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
483
874
  const handleOpencodeLine = (line) => {
484
875
  if (!line.trim())
485
876
  return;
877
+ if (!sawStdout) {
878
+ sawStdout = true;
879
+ console.error("ChimpHands OpenCode first stdout event received");
880
+ if (progressTicker) {
881
+ clearInterval(progressTicker);
882
+ progressTicker = null;
883
+ }
884
+ }
486
885
  const fatal = extractOpencodeFatalError(line);
487
886
  if (fatal) {
488
887
  fatalError = fatal;
@@ -568,6 +967,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
568
967
  }
569
968
  });
570
969
  child.on("close", (code) => {
970
+ if (progressTicker) {
971
+ clearInterval(progressTicker);
972
+ progressTicker = null;
973
+ }
571
974
  if (buf.trim()) {
572
975
  handleOpencodeLine(buf.trim());
573
976
  }
@@ -685,6 +1088,40 @@ export async function runChimphands(opts) {
685
1088
  const boot = JSON.parse(bootText);
686
1089
  const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
687
1090
  const poster = new AgentEventPoster(backend, apiKey, sessionId);
1091
+ poster.uiAttached = !!(boot.uiAttached ?? boot.ui_attached);
1092
+ let stopLiveSse = null;
1093
+ const syncLiveSse = (attached) => {
1094
+ poster.uiAttached = attached;
1095
+ if (!attachUrl)
1096
+ return;
1097
+ if (attached && !stopLiveSse) {
1098
+ console.error("ChimpHands UI attached — starting OpenCode SSE fanout");
1099
+ stopLiveSse = startOpencodeSseRelay(attachUrl, {
1100
+ getActiveSessionId: () => opencodeSessionId,
1101
+ noteSessionId: (id) => {
1102
+ if (id?.trim())
1103
+ opencodeSessionId = id.trim();
1104
+ },
1105
+ postEvent: (role, content, opts) => {
1106
+ poster.fireAndForget(role, content, {
1107
+ ...opts,
1108
+ opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
1109
+ });
1110
+ },
1111
+ onWorkingBranch: noteWorkingBranchPlaceholder,
1112
+ });
1113
+ }
1114
+ else if (!attached && stopLiveSse) {
1115
+ console.error("ChimpHands UI detached — stopping OpenCode SSE fanout");
1116
+ stopLiveSse();
1117
+ stopLiveSse = null;
1118
+ }
1119
+ };
1120
+ // noteWorkingBranch is defined later; bind via mutable holder until then.
1121
+ let noteWorkingBranch = () => { };
1122
+ const noteWorkingBranchPlaceholder = (branch, prUrl) => {
1123
+ noteWorkingBranch(branch, prUrl);
1124
+ };
688
1125
  if (githubRunId) {
689
1126
  await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
690
1127
  sessionId,
@@ -712,7 +1149,9 @@ export async function runChimphands(opts) {
712
1149
  }
713
1150
  }
714
1151
  const stopHeartbeat = runtimeId
715
- ? startRuntimeHeartbeat(backend, apiKey, runtimeId)
1152
+ ? startRuntimeHeartbeat(backend, apiKey, runtimeId, (attached) => {
1153
+ syncLiveSse(attached);
1154
+ })
716
1155
  : () => { };
717
1156
  const stopTunnel = runtimeId && attachUrl
718
1157
  ? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
@@ -758,7 +1197,7 @@ export async function runChimphands(opts) {
758
1197
  console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
759
1198
  }
760
1199
  };
761
- const noteWorkingBranch = (branch, prUrl) => {
1200
+ noteWorkingBranch = (branch, prUrl) => {
762
1201
  const normalizedBranch = branch.trim();
763
1202
  if (!normalizedBranch)
764
1203
  return;
@@ -774,6 +1213,8 @@ export async function runChimphands(opts) {
774
1213
  poster.reportWorkingBranch(normalizedBranch, nextPr);
775
1214
  }
776
1215
  };
1216
+ // Apply bootstrap ui_attached now that session id + branch hooks exist.
1217
+ syncLiveSse(poster.uiAttached);
777
1218
  const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
778
1219
  const queue = [];
779
1220
  const seenUserMessageIds = new Set();
@@ -849,6 +1290,10 @@ export async function runChimphands(opts) {
849
1290
  stopInbound();
850
1291
  stopTunnel();
851
1292
  stopHeartbeat();
1293
+ if (stopLiveSse) {
1294
+ stopLiveSse();
1295
+ stopLiveSse = null;
1296
+ }
852
1297
  await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
853
1298
  await poster.flush();
854
1299
  await snapshotExport();
@@ -902,6 +1347,9 @@ export async function runChimphands(opts) {
902
1347
  let useOpencodeSessionId = opencodeSessionId;
903
1348
  let isNewOpencodeSession = !useOpencodeSessionId;
904
1349
  let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
1350
+ // Visible in chat (not filtered as routine). OpenCode may not emit text until a
1351
+ // part completes — without this the UI looks empty while the turn is running.
1352
+ postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
905
1353
  let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
906
1354
  onSessionId: (id) => {
907
1355
  opencodeSessionId = id;
@@ -978,22 +1426,34 @@ export async function runChimphands(opts) {
978
1426
  process.exit(exitCode);
979
1427
  }
980
1428
  }
981
- function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
1429
+ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
982
1430
  let stopped = false;
1431
+ let lastAttached;
983
1432
  const tick = async () => {
984
1433
  if (stopped)
985
1434
  return;
986
1435
  try {
987
1436
  // Do not claim tunnel_connected here — only the tunnel poll loop should.
988
- await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
1437
+ const text = await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
989
1438
  runtimeId,
990
1439
  });
1440
+ try {
1441
+ const data = JSON.parse(text);
1442
+ const attached = !!(data.uiAttached ?? data.ui_attached);
1443
+ if (attached !== lastAttached) {
1444
+ lastAttached = attached;
1445
+ onUiAttached?.(attached);
1446
+ }
1447
+ }
1448
+ catch {
1449
+ /* ignore parse */
1450
+ }
991
1451
  }
992
1452
  catch (err) {
993
1453
  console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
994
1454
  }
995
1455
  if (!stopped)
996
- setTimeout(tick, 15_000);
1456
+ setTimeout(tick, 5_000);
997
1457
  };
998
1458
  void tick();
999
1459
  return () => {
@@ -1065,7 +1525,7 @@ async function commitAndPushDirtyWorktree(message) {
1065
1525
  }
1066
1526
  async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
1067
1527
  const exported = await new Promise((resolve, reject) => {
1068
- const child = spawn("opencode", ["export", opencodeSessionId, "--sanitize"], {
1528
+ const child = spawn("opencode", ["export", opencodeSessionId], {
1069
1529
  stdio: ["ignore", "pipe", "pipe"],
1070
1530
  });
1071
1531
  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.49",
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",