@testchimp/cli 0.1.70 → 0.1.71

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("/")) {
@@ -2099,11 +2198,16 @@ export async function runChimphands(opts) {
2099
2198
  void pollPendingUserMessages();
2100
2199
  }, 1500);
2101
2200
  if (runtimeId && attachUrl) {
2102
- stopTunnel = startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, {
2201
+ const tunnel = startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, {
2103
2202
  onUserFileEdit: handleUserFileEdit,
2104
2203
  onCancelTurn: handleCancelTurn,
2105
2204
  onUserMessage: enqueueUserMessage,
2106
2205
  });
2206
+ poster.sendUiMessage = (frame) => tunnel.sendUiMessage(frame);
2207
+ stopTunnel = () => {
2208
+ poster.sendUiMessage = null;
2209
+ tunnel.stop();
2210
+ };
2107
2211
  }
2108
2212
  const shutdownRuntime = async () => {
2109
2213
  sessionActive = false;
@@ -2531,6 +2635,18 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, han
2531
2635
  reconnectTimer = null;
2532
2636
  }
2533
2637
  };
2638
+ const sendUiMessage = (frame) => {
2639
+ const socket = ws;
2640
+ if (!socket || socket.readyState !== 1)
2641
+ return false;
2642
+ try {
2643
+ socket.send(JSON.stringify(frame));
2644
+ return true;
2645
+ }
2646
+ catch {
2647
+ return false;
2648
+ }
2649
+ };
2534
2650
  const handleHttpRequest = async (socket, req) => {
2535
2651
  if (!req.requestId || socket.readyState !== 1)
2536
2652
  return;
@@ -2710,18 +2826,21 @@ function startTunnelWorker(backend, apiKey, runtimeId, attachUrl, sessionId, han
2710
2826
  });
2711
2827
  };
2712
2828
  void connect();
2713
- return () => {
2714
- stopped = true;
2715
- clearReconnect();
2716
- if (ws) {
2717
- try {
2718
- ws.close();
2719
- }
2720
- catch {
2721
- // ignore
2829
+ return {
2830
+ sendUiMessage,
2831
+ stop: () => {
2832
+ stopped = true;
2833
+ clearReconnect();
2834
+ if (ws) {
2835
+ try {
2836
+ ws.close();
2837
+ }
2838
+ catch {
2839
+ // ignore
2840
+ }
2841
+ ws = null;
2722
2842
  }
2723
- ws = null;
2724
- }
2843
+ },
2725
2844
  };
2726
2845
  }
2727
2846
  /** 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.71",
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",