@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.
- package/dist/chimphands/run.js +176 -57
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -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 (~
|
|
29
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
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
|
-
|
|
2724
|
-
}
|
|
2843
|
+
},
|
|
2725
2844
|
};
|
|
2726
2845
|
}
|
|
2727
2846
|
/** Runtime-aware entry: register + attach to local OpenCode server (Phase 1+). */
|
package/package.json
CHANGED