@testchimp/cli 0.1.48 → 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.
@@ -113,7 +113,7 @@ class AgentEventPoster {
113
113
  sessionId;
114
114
  chain = Promise.resolve();
115
115
  lastStreamPostAt = 0;
116
- /** When false, assistant/tool/reasoning tokens are dropped (async / no UI). */
116
+ /** When false, liveStream tokens are dropped; completed events still persist. */
117
117
  uiAttached = false;
118
118
  constructor(backend, apiKey, sessionId) {
119
119
  this.backend = backend;
@@ -136,23 +136,21 @@ class AgentEventPoster {
136
136
  body.workingBranch = opts.workingBranch;
137
137
  if (opts?.pullRequestUrl)
138
138
  body.pullRequestUrl = opts.pullRequestUrl;
139
- const ephemeral = isStreamFanoutRole(role);
140
- if (ephemeral && !this.uiAttached) {
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) {
141
142
  return this.chain;
142
143
  }
143
144
  this.chain = this.chain.then(async () => {
144
- if (opts?.throttle || ephemeral) {
145
+ if (opts?.throttle || (streamRole && opts?.liveStream)) {
145
146
  const now = Date.now();
146
147
  const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
147
148
  if (wait > 0)
148
149
  await sleep(wait);
149
150
  this.lastStreamPostAt = Date.now();
150
151
  }
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) {
152
+ const tryEphemeral = streamRole && this.uiAttached;
153
+ if (tryEphemeral) {
156
154
  const eph = {
157
155
  sessionId: this.sessionId,
158
156
  role,
@@ -162,10 +160,27 @@ class AgentEventPoster {
162
160
  eph.messageId = opts.messageId;
163
161
  if (opts?.opencodeSessionId)
164
162
  eph.opencodeSessionId = opts.opencodeSessionId;
165
- await postJson(this.backend, this.apiKey, path, eph);
166
- return;
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
+ }
167
182
  }
168
- await postJson(this.backend, this.apiKey, path, body);
183
+ await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
169
184
  });
170
185
  return this.chain;
171
186
  }
@@ -242,6 +257,122 @@ function parseOpencodeEvent(line) {
242
257
  return null;
243
258
  }
244
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
+ }
245
376
  function formatToolUseContent(part) {
246
377
  const title = part.state?.title || part.tool || "tool";
247
378
  const status = part.state?.status?.trim();
@@ -273,6 +404,7 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
273
404
  const ac = new AbortController();
274
405
  let stopped = false;
275
406
  const textByPartId = new Map();
407
+ const directory = process.cwd();
276
408
  const sessionMatches = (sessionId) => {
277
409
  const active = callbacks.getActiveSessionId();
278
410
  if (!sessionId)
@@ -281,10 +413,27 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
281
413
  return true;
282
414
  return sessionId === active;
283
415
  };
284
- const handleBusEvent = (raw) => {
416
+ const liveOpts = (extra) => ({
417
+ ...extra,
418
+ throttle: true,
419
+ liveStream: true,
420
+ });
421
+ const unwrapBusPayload = (raw) => {
285
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")
286
435
  return;
287
- const ev = raw;
436
+ const ev = unwrapped;
288
437
  const type = ev.type || "";
289
438
  const props = ev.properties || {};
290
439
  if (type === "message.part.updated" || type === "message.part.delta") {
@@ -312,10 +461,9 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
312
461
  textByPartId.set(partId, next);
313
462
  if (!next)
314
463
  return;
315
- callbacks.postEvent(ROLE_ASSISTANT, next, {
316
- throttle: true,
464
+ callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
317
465
  messageId: opencodeMessageId("oc_text_", part),
318
- });
466
+ }));
319
467
  return;
320
468
  }
321
469
  if (part.type === "reasoning") {
@@ -331,10 +479,9 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
331
479
  textByPartId.set(`reasoning:${partId}`, next);
332
480
  if (!next)
333
481
  return;
334
- callbacks.postEvent(ROLE_REASONING, next, {
335
- throttle: true,
482
+ callbacks.postEvent(ROLE_REASONING, next, liveOpts({
336
483
  messageId: opencodeMessageId("oc_reasoning_", part),
337
- });
484
+ }));
338
485
  return;
339
486
  }
340
487
  if (part.type === "tool") {
@@ -342,9 +489,10 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
342
489
  if (!status || status === "pending" || status === "running")
343
490
  return;
344
491
  const toolContent = formatToolUseContent(part);
345
- callbacks.postEvent(ROLE_TOOL, toolContent, {
492
+ callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
346
493
  messageId: opencodeMessageId("oc_tool_", part),
347
- });
494
+ throttle: false,
495
+ }));
348
496
  if (status === "completed") {
349
497
  const detected = detectWorkingBranchFromToolOutput(toolContent);
350
498
  if (detected.branch) {
@@ -392,32 +540,55 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
392
540
  }
393
541
  }
394
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
+ };
395
552
  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;
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}`);
407
582
  }
408
- console.error(`ChimpHands OpenCode SSE streaming via ${path}`);
409
- await consume(res.body);
410
- return;
411
583
  }
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}`);
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)");
417
588
  }
418
- }
419
- if (!stopped) {
420
- console.error("ChimpHands OpenCode SSE unavailable — falling back to completed-only --format json events");
589
+ attempt += 1;
590
+ const backoff = Math.min(30_000, 2_000 * attempt);
591
+ await sleep(backoff);
421
592
  }
422
593
  })();
423
594
  return () => {
@@ -456,21 +627,15 @@ function isMissingOpencodeSessionError(message) {
456
627
  m.includes("unknown session") ||
457
628
  m.includes("invalid session"));
458
629
  }
459
- function isNonInteractivePrompt(userPrompt) {
460
- return /(?:^|\s)--mode\s*=?\s*non-interactive\b|mode\s*=\s*non-interactive\b/i.test(userPrompt);
461
- }
462
- function isTestchimpWorkflowPrompt(userPrompt) {
463
- return /(?:^|\s)\/?testchimp\b/i.test(userPrompt.trim());
464
- }
465
630
  function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
466
631
  const parts = [];
467
632
  if (workingBranch?.trim()) {
468
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.", "");
469
634
  }
470
635
  const task = normalizeUserMessage(userPrompt);
471
- if (isTestchimpWorkflowPrompt(task) && !isNonInteractivePrompt(task)) {
472
- 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.", "");
473
- }
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.
474
639
  if (isNewOpencodeSession && conversationSummary.trim()) {
475
640
  parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
476
641
  return parts.filter(Boolean).join("\n");
@@ -687,6 +852,18 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
687
852
  let fatalError = null;
688
853
  const textByPartId = new Map();
689
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);
690
867
  const noteSessionId = (sessionId) => {
691
868
  const id = sessionId?.trim();
692
869
  if (!id || id === activeSessionId)
@@ -700,6 +877,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
700
877
  if (!sawStdout) {
701
878
  sawStdout = true;
702
879
  console.error("ChimpHands OpenCode first stdout event received");
880
+ if (progressTicker) {
881
+ clearInterval(progressTicker);
882
+ progressTicker = null;
883
+ }
703
884
  }
704
885
  const fatal = extractOpencodeFatalError(line);
705
886
  if (fatal) {
@@ -786,6 +967,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
786
967
  }
787
968
  });
788
969
  child.on("close", (code) => {
970
+ if (progressTicker) {
971
+ clearInterval(progressTicker);
972
+ progressTicker = null;
973
+ }
789
974
  if (buf.trim()) {
790
975
  handleOpencodeLine(buf.trim());
791
976
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.48",
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",