@testchimp/cli 0.1.70 → 0.1.72

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.
@@ -25,8 +25,8 @@ const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
25
25
  const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
26
26
  const OPENCODE_AGENT_ID = "chimphands";
27
27
  const MID_TURN_DELIVERED_STATUS = "Answering your question while the current step continues…";
28
- /** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
29
- const STREAM_POST_MIN_INTERVAL_MS = 150;
28
+ /** Coalesce live token fanout (~30fps) on tunnel WS / HTTP fallback while UI is attached. */
29
+ const STREAM_FANOUT_MIN_INTERVAL_MS = 33;
30
30
  /** Set for the active ChimpHands session (PR base branch + callbacks). */
31
31
  let activeSessionGitContext = null;
32
32
  function isStreamFanoutRole(role) {
@@ -69,6 +69,11 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
69
69
  - When you need clarification (e.g. import plans/tests, env strategy, CI choices) or plan approval: write the questions / plan summary as assistant text, then **stop this turn**. Do not invent answers or continue into Execute. The host will wait for the next chat message and revive you.
70
70
  - Prefer a short numbered list of concrete questions over a long monologue. One decision gate at a time when possible (especially \`/testchimp project init\` Phase 1).
71
71
 
72
+ ## Paths (mandatory)
73
+ - Process cwd **is** the git worktree root. On GitHub Actions that is \`$GITHUB_WORKSPACE\`, which looks like \`/home/runner/work/<repo>/<repo>\` — the repeated folder name is the normal Actions layout, **not** a nested second checkout.
74
+ - User \`ref:\` paths are **repo-relative to cwd** (e.g. \`ref: ui/plans/foo.md\` → open \`ui/plans/foo.md\` from cwd). Never prepend the repository name. Never open or create \`<repo>/<repo>/…\` under cwd. Never treat \`/home/runner/work\` (the parent of the twin) as the worktree root.
75
+ - If a tool returns an absolute path under cwd, relativize with the worktree root before reusing it. Do **not** strip a leading \`/\` and re-join under cwd — that nests the workspace path inside itself.
76
+
72
77
  ## Repo changes (mandatory)
73
78
  - NEVER commit or push directly to the default branch (main/master) or to a user **base branch** (the \`Base branch:\` line in the prompt is the parent to branch *from*, not where you commit).
74
79
  - This conversation uses ONE **agent branch** (\`testchimp-*\` only) and ONE pull request targeting the base branch. Reuse them for all follow-up work in this chat.
@@ -162,23 +167,42 @@ async function postJson(backend, apiKey, path, body) {
162
167
  clearTimeout(timer);
163
168
  }
164
169
  }
165
- /** Serializes agent event posts so streaming chunks fan out in order. */
170
+ /** Serializes durable agent event posts; live mid-turn tokens prefer the agent tunnel WS. */
166
171
  class AgentEventPoster {
167
172
  backend;
168
173
  apiKey;
169
174
  sessionId;
170
175
  chain = Promise.resolve();
171
- lastStreamPostAt = 0;
176
+ lastLiveFanoutAt = 0;
172
177
  /** When false, liveStream tokens are dropped; completed events still persist. */
173
178
  uiAttached = false;
179
+ /**
180
+ * Prefer agent tunnel WS for live tokens. Returns true when the frame was queued on an open socket.
181
+ * Set by {@link startTunnelWorker} once the runtime tunnel is connected.
182
+ */
183
+ sendUiMessage = null;
174
184
  ephemeralFailLogAt = 0;
175
185
  ephemeralFailCount = 0;
186
+ /** Latest pending live snapshot per messageId (coalesce-to-latest). */
187
+ pendingLive = new Map();
188
+ liveFlushTimer = null;
189
+ liveFlushInFlight = false;
176
190
  constructor(backend, apiKey, sessionId) {
177
191
  this.backend = backend;
178
192
  this.apiKey = apiKey;
179
193
  this.sessionId = sessionId;
180
194
  }
181
195
  enqueue(role, content, opts) {
196
+ const streamRole = isStreamFanoutRole(role);
197
+ const live = !!opts?.liveStream && !opts?.durable;
198
+ // Mid-turn tokens: coalesce + WS (HTTP fallback). Never hit the durable chain.
199
+ if (live && streamRole) {
200
+ if (!this.uiAttached) {
201
+ return Promise.resolve();
202
+ }
203
+ this.enqueueLive(role, content, opts);
204
+ return Promise.resolve();
205
+ }
182
206
  const body = {
183
207
  sessionId: this.sessionId,
184
208
  role,
@@ -196,48 +220,95 @@ class AgentEventPoster {
196
220
  body.pullRequestUrl = opts.pullRequestUrl;
197
221
  if (opts?.toolExecution)
198
222
  body.toolExecution = protoJsonToolExecution(opts.toolExecution);
199
- const streamRole = isStreamFanoutRole(role);
200
223
  this.chain = this.chain.then(async () => {
201
- const live = !!opts?.liveStream && !opts?.durable;
202
- // Mid-turn tokens never hit PG. Async / detached: drop. Attached: ephemeral only.
203
- if (live && !this.uiAttached) {
204
- return;
224
+ await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
225
+ });
226
+ return this.chain;
227
+ }
228
+ enqueueLive(role, content, opts) {
229
+ const key = (opts?.messageId || "").trim() || `${role}:default`;
230
+ this.pendingLive.set(key, { role, content: String(content || "").slice(0, 20000), opts });
231
+ this.scheduleLiveFlush();
232
+ }
233
+ scheduleLiveFlush() {
234
+ if (this.liveFlushInFlight || this.liveFlushTimer)
235
+ return;
236
+ const wait = Math.max(0, STREAM_FANOUT_MIN_INTERVAL_MS - (Date.now() - this.lastLiveFanoutAt));
237
+ this.liveFlushTimer = setTimeout(() => {
238
+ this.liveFlushTimer = null;
239
+ void this.flushLivePending();
240
+ }, wait);
241
+ }
242
+ async flushLivePending() {
243
+ if (this.liveFlushInFlight)
244
+ return;
245
+ if (this.pendingLive.size === 0)
246
+ return;
247
+ if (!this.uiAttached) {
248
+ this.pendingLive.clear();
249
+ return;
250
+ }
251
+ this.liveFlushInFlight = true;
252
+ try {
253
+ const batch = [...this.pendingLive.values()];
254
+ this.pendingLive.clear();
255
+ this.lastLiveFanoutAt = Date.now();
256
+ for (const item of batch) {
257
+ await this.deliverLive(item.role, item.content, item.opts);
205
258
  }
206
- if (opts?.throttle || (streamRole && live)) {
207
- const now = Date.now();
208
- const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
209
- if (wait > 0)
210
- await sleep(wait);
211
- this.lastStreamPostAt = Date.now();
212
- }
213
- const tryEphemeral = live && streamRole && this.uiAttached;
214
- if (tryEphemeral) {
215
- const eph = {
216
- sessionId: this.sessionId,
217
- role,
218
- content: body.content,
219
- };
220
- if (opts?.messageId)
221
- eph.messageId = opts.messageId;
222
- if (opts?.opencodeSessionId)
223
- eph.opencodeSessionId = opts.opencodeSessionId;
224
- if (opts?.toolExecution)
225
- eph.toolExecution = protoJsonToolExecution(opts.toolExecution);
226
- try {
227
- await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
259
+ }
260
+ finally {
261
+ this.liveFlushInFlight = false;
262
+ if (this.pendingLive.size > 0 && this.uiAttached) {
263
+ this.scheduleLiveFlush();
264
+ }
265
+ }
266
+ }
267
+ async deliverLive(role, content, opts) {
268
+ const frame = {
269
+ type: "ui_message",
270
+ sessionId: this.sessionId,
271
+ role,
272
+ content,
273
+ };
274
+ if (opts?.messageId)
275
+ frame.messageId = opts.messageId;
276
+ if (opts?.toolExecution)
277
+ frame.toolExecution = protoJsonToolExecution(opts.toolExecution);
278
+ if (this.sendUiMessage) {
279
+ try {
280
+ if (this.sendUiMessage(frame)) {
228
281
  this.ephemeralFailCount = 0;
282
+ return;
229
283
  }
230
- catch (err) {
231
- const detail = err instanceof Error ? err.message : String(err);
232
- this.logEphemeralIssue(`ephemeral post failed — not persisting liveStream: ${detail}`);
233
- }
234
- return;
235
284
  }
236
- await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
237
- });
238
- return this.chain;
285
+ catch (err) {
286
+ const detail = err instanceof Error ? err.message : String(err);
287
+ this.logEphemeralIssue(`tunnel ui_message send failed — falling back to HTTP: ${detail}`);
288
+ }
289
+ }
290
+ // HTTP ephemeral fallback when tunnel is down / unavailable.
291
+ const eph = {
292
+ sessionId: this.sessionId,
293
+ role,
294
+ content,
295
+ };
296
+ if (opts?.messageId)
297
+ eph.messageId = opts.messageId;
298
+ if (opts?.opencodeSessionId)
299
+ eph.opencodeSessionId = opts.opencodeSessionId;
300
+ if (opts?.toolExecution)
301
+ eph.toolExecution = protoJsonToolExecution(opts.toolExecution);
302
+ try {
303
+ await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
304
+ this.ephemeralFailCount = 0;
305
+ }
306
+ catch (err) {
307
+ const detail = err instanceof Error ? err.message : String(err);
308
+ this.logEphemeralIssue(`ephemeral post failed — not persisting liveStream: ${detail}`);
309
+ }
239
310
  }
240
- /** Avoid flooding GHA logs when ephemeral 500s every ~150ms. */
311
+ /** Avoid flooding GHA logs when live fanout fails repeatedly. */
241
312
  logEphemeralIssue(message) {
242
313
  this.ephemeralFailCount += 1;
243
314
  const now = Date.now();
@@ -254,7 +325,11 @@ class AgentEventPoster {
254
325
  });
255
326
  }
256
327
  flush() {
257
- return this.chain;
328
+ if (this.liveFlushTimer) {
329
+ clearTimeout(this.liveFlushTimer);
330
+ this.liveFlushTimer = null;
331
+ }
332
+ return this.flushLivePending().then(() => this.chain);
258
333
  }
259
334
  reportWorkingBranch(branch, pullRequestUrl) {
260
335
  this.chain = this.chain.then(async () => {
@@ -1497,12 +1572,36 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
1497
1572
  });
1498
1573
  });
1499
1574
  }
1500
- function normalizeWorktreeRelativePath(filePath) {
1501
- let p = String(filePath || "").trim().replace(/\\/g, "/");
1575
+ function normalizeWorktreeRelativePath(filePath, worktreeRoot = process.cwd()) {
1576
+ const rootResolved = path.resolve(worktreeRoot);
1577
+ const rawInput = String(filePath || "").trim();
1578
+ if (!rawInput || rawInput.includes("\0")) {
1579
+ throw new Error("invalid path");
1580
+ }
1581
+ // Absolute path under the worktree → repo-relative (never strip "/" then re-join).
1582
+ if (path.isAbsolute(rawInput)) {
1583
+ const abs = path.resolve(rawInput);
1584
+ if (abs === rootResolved) {
1585
+ throw new Error("invalid path");
1586
+ }
1587
+ if (abs.startsWith(rootResolved + path.sep)) {
1588
+ return path.relative(rootResolved, abs).split(path.sep).join("/");
1589
+ }
1590
+ }
1591
+ let p = rawInput.replace(/\\/g, "/");
1502
1592
  while (p.startsWith("/"))
1503
1593
  p = p.slice(1);
1504
- if (!p || p.includes("\0")) {
1505
- throw new Error("invalid path");
1594
+ // GHA workspace is /home/runner/work/<repo>/<repo>. Strip twin prefixes that agents/OpenCode
1595
+ // sometimes emit as if relative to /home/runner/work.
1596
+ const base = path.basename(rootResolved);
1597
+ const parentBase = path.basename(path.dirname(rootResolved));
1598
+ if (base && base === parentBase) {
1599
+ const twin = `${base}/${base}/`;
1600
+ const gha = `home/runner/work/${base}/${base}/`;
1601
+ if (p.startsWith(gha))
1602
+ p = p.slice(gha.length);
1603
+ else if (p.startsWith(twin))
1604
+ p = p.slice(twin.length);
1506
1605
  }
1507
1606
  const segments = [];
1508
1607
  for (const part of p.split("/")) {
@@ -1533,10 +1632,8 @@ async function ackWorktreeFileWrite(backend, apiKey, sessionId, requestId, ok, e
1533
1632
  });
1534
1633
  }
1535
1634
  async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1536
- if (turnActive()) {
1537
- await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, "agent turn in progress");
1538
- return;
1539
- }
1635
+ // Never hard-nack mid-turn: write to disk immediately so the next agent prompt
1636
+ // sees the edit. Defer commit/push while a turn is active — post-turn always commits.
1540
1637
  try {
1541
1638
  const relative = normalizeWorktreeRelativePath(edit.path);
1542
1639
  const root = process.cwd();
@@ -1547,6 +1644,11 @@ async function applyUserFileEdit(backend, apiKey, sessionId, edit, turnActive) {
1547
1644
  }
1548
1645
  await fs.mkdir(path.dirname(full), { recursive: true });
1549
1646
  await fs.writeFile(full, edit.content, "utf8");
1647
+ if (turnActive()) {
1648
+ console.error(`ChimpHands applied user_file_edit mid-turn (deferred commit): ${relative}`);
1649
+ await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, true);
1650
+ return;
1651
+ }
1550
1652
  const pushed = await commitAndPushDirtyWorktree("User Update");
1551
1653
  if (!pushed.ok) {
1552
1654
  await ackWorktreeFileWrite(backend, apiKey, sessionId, edit.requestId, false, pushed.error || "commit/push failed");
@@ -2099,11 +2201,16 @@ export async function runChimphands(opts) {
2099
2201
  void pollPendingUserMessages();
2100
2202
  }, 1500);
2101
2203
  if (runtimeId && attachUrl) {
2102
- stopTunnel = startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, {
2204
+ const tunnel = startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, {
2103
2205
  onUserFileEdit: handleUserFileEdit,
2104
2206
  onCancelTurn: handleCancelTurn,
2105
2207
  onUserMessage: enqueueUserMessage,
2106
2208
  });
2209
+ poster.sendUiMessage = (frame) => tunnel.sendUiMessage(frame);
2210
+ stopTunnel = () => {
2211
+ poster.sendUiMessage = null;
2212
+ tunnel.stop();
2213
+ };
2107
2214
  }
2108
2215
  const shutdownRuntime = async () => {
2109
2216
  sessionActive = false;
@@ -2298,11 +2405,9 @@ export async function runChimphands(opts) {
2298
2405
  console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
2299
2406
  }
2300
2407
  }
2301
- // Async / no UI: checkpoint dirty worktree after each turn so PR + Files changed
2302
- // are reviewable when the user opens the session later (live VCS tunnel may be idle).
2303
- if (!poster.uiAttached) {
2304
- await commitAndPushDirtyWorktree("chimphands: commit after turn (async / no UI attached)");
2305
- }
2408
+ // Always checkpoint dirty worktree after each turn so PR + Files pane
2409
+ // (GitHub compare) stay current whether or not a browser UI is attached.
2410
+ await commitAndPushDirtyWorktree("chimphands: commit after turn");
2306
2411
  if (attachUrl && opencodeSessionId && midTurnInjectedById.size) {
2307
2412
  try {
2308
2413
  await releaseUnansweredMidTurnMessages(attachUrl, opencodeSessionId, midTurnInjectedById, queue, wakePromptWaiter, midTurnSidecarAnswered);
@@ -2531,6 +2636,18 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, han
2531
2636
  reconnectTimer = null;
2532
2637
  }
2533
2638
  };
2639
+ const sendUiMessage = (frame) => {
2640
+ const socket = ws;
2641
+ if (!socket || socket.readyState !== 1)
2642
+ return false;
2643
+ try {
2644
+ socket.send(JSON.stringify(frame));
2645
+ return true;
2646
+ }
2647
+ catch {
2648
+ return false;
2649
+ }
2650
+ };
2534
2651
  const handleHttpRequest = async (socket, req) => {
2535
2652
  if (!req.requestId || socket.readyState !== 1)
2536
2653
  return;
@@ -2710,18 +2827,21 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, han
2710
2827
  });
2711
2828
  };
2712
2829
  void connect();
2713
- return () => {
2714
- stopped = true;
2715
- clearReconnect();
2716
- if (ws) {
2717
- try {
2718
- ws.close();
2719
- }
2720
- catch {
2721
- // ignore
2830
+ return {
2831
+ sendUiMessage,
2832
+ stop: () => {
2833
+ stopped = true;
2834
+ clearReconnect();
2835
+ if (ws) {
2836
+ try {
2837
+ ws.close();
2838
+ }
2839
+ catch {
2840
+ // ignore
2841
+ }
2842
+ ws = null;
2722
2843
  }
2723
- ws = null;
2724
- }
2844
+ },
2725
2845
  };
2726
2846
  }
2727
2847
  /** Runtime-aware entry: register + attach to local OpenCode server (Phase 1+). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
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",