@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.
- package/dist/chimphands/run.js +186 -66
- 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("/")) {
|
|
@@ -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
|
-
|
|
1537
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
|
|
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
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
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
|
-
|
|
2724
|
-
}
|
|
2844
|
+
},
|
|
2725
2845
|
};
|
|
2726
2846
|
}
|
|
2727
2847
|
/** Runtime-aware entry: register + attach to local OpenCode server (Phase 1+). */
|
package/package.json
CHANGED