@testchimp/cli 0.1.46 → 0.1.48
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 +372 -29
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Relies on TESTCHIMP_API_KEY (+ optional TESTCHIMP_BACKEND_URL; defaults to prod).
|
|
4
4
|
* Does not write mcp.json — TestChimp MCP is wired via opencode.json for OpenCode.
|
|
5
5
|
*/
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { execSync, spawn } from "node:child_process";
|
|
7
|
+
import { mkdirSync, openSync, writeFileSync } from "node:fs";
|
|
8
8
|
import http from "node:http";
|
|
9
9
|
import https from "node:https";
|
|
10
10
|
import { URL } from "node:url";
|
|
@@ -18,7 +18,11 @@ const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
|
|
|
18
18
|
const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
|
|
19
19
|
const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
|
|
20
20
|
const OPENCODE_AGENT_ID = "chimphands";
|
|
21
|
-
|
|
21
|
+
/** Coalesce live token fanout (~6–10 posts/s/session) while UI is attached. */
|
|
22
|
+
const STREAM_POST_MIN_INTERVAL_MS = 150;
|
|
23
|
+
function isStreamFanoutRole(role) {
|
|
24
|
+
return role === ROLE_ASSISTANT || role === ROLE_TOOL || role === ROLE_REASONING;
|
|
25
|
+
}
|
|
22
26
|
const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. You run on GitHub Actions, but this chat is an **interactive** conversation with the user in the TestChimp UI — same expectations as Cursor/Claude Code locally.
|
|
23
27
|
|
|
24
28
|
## Interactive session (mandatory — default)
|
|
@@ -102,13 +106,15 @@ async function postJson(backend, apiKey, path, body) {
|
|
|
102
106
|
}
|
|
103
107
|
return text;
|
|
104
108
|
}
|
|
105
|
-
/** Serializes
|
|
109
|
+
/** Serializes agent event posts so streaming chunks fan out in order. */
|
|
106
110
|
class AgentEventPoster {
|
|
107
111
|
backend;
|
|
108
112
|
apiKey;
|
|
109
113
|
sessionId;
|
|
110
114
|
chain = Promise.resolve();
|
|
111
115
|
lastStreamPostAt = 0;
|
|
116
|
+
/** When false, assistant/tool/reasoning tokens are dropped (async / no UI). */
|
|
117
|
+
uiAttached = false;
|
|
112
118
|
constructor(backend, apiKey, sessionId) {
|
|
113
119
|
this.backend = backend;
|
|
114
120
|
this.apiKey = apiKey;
|
|
@@ -130,15 +136,36 @@ class AgentEventPoster {
|
|
|
130
136
|
body.workingBranch = opts.workingBranch;
|
|
131
137
|
if (opts?.pullRequestUrl)
|
|
132
138
|
body.pullRequestUrl = opts.pullRequestUrl;
|
|
139
|
+
const ephemeral = isStreamFanoutRole(role);
|
|
140
|
+
if (ephemeral && !this.uiAttached) {
|
|
141
|
+
return this.chain;
|
|
142
|
+
}
|
|
133
143
|
this.chain = this.chain.then(async () => {
|
|
134
|
-
if (opts?.throttle) {
|
|
144
|
+
if (opts?.throttle || ephemeral) {
|
|
135
145
|
const now = Date.now();
|
|
136
146
|
const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
|
|
137
147
|
if (wait > 0)
|
|
138
148
|
await sleep(wait);
|
|
139
149
|
this.lastStreamPostAt = Date.now();
|
|
140
150
|
}
|
|
141
|
-
|
|
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) {
|
|
156
|
+
const eph = {
|
|
157
|
+
sessionId: this.sessionId,
|
|
158
|
+
role,
|
|
159
|
+
content: body.content,
|
|
160
|
+
};
|
|
161
|
+
if (opts?.messageId)
|
|
162
|
+
eph.messageId = opts.messageId;
|
|
163
|
+
if (opts?.opencodeSessionId)
|
|
164
|
+
eph.opencodeSessionId = opts.opencodeSessionId;
|
|
165
|
+
await postJson(this.backend, this.apiKey, path, eph);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await postJson(this.backend, this.apiKey, path, body);
|
|
142
169
|
});
|
|
143
170
|
return this.chain;
|
|
144
171
|
}
|
|
@@ -236,6 +263,173 @@ function opencodeMessageId(prefix, part) {
|
|
|
236
263
|
return undefined;
|
|
237
264
|
return `${prefix}${raw}`;
|
|
238
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* OpenCode `run --format json` only emits completed text (`part.time.end`).
|
|
268
|
+
* Live tokens come from the server SSE bus (`message.part.updated` + optional `delta`).
|
|
269
|
+
* Subscribe directly when attaching so the platform chat streams.
|
|
270
|
+
*/
|
|
271
|
+
function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
272
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
273
|
+
const ac = new AbortController();
|
|
274
|
+
let stopped = false;
|
|
275
|
+
const textByPartId = new Map();
|
|
276
|
+
const sessionMatches = (sessionId) => {
|
|
277
|
+
const active = callbacks.getActiveSessionId();
|
|
278
|
+
if (!sessionId)
|
|
279
|
+
return !active;
|
|
280
|
+
if (!active)
|
|
281
|
+
return true;
|
|
282
|
+
return sessionId === active;
|
|
283
|
+
};
|
|
284
|
+
const handleBusEvent = (raw) => {
|
|
285
|
+
if (!raw || typeof raw !== "object")
|
|
286
|
+
return;
|
|
287
|
+
const ev = raw;
|
|
288
|
+
const type = ev.type || "";
|
|
289
|
+
const props = ev.properties || {};
|
|
290
|
+
if (type === "message.part.updated" || type === "message.part.delta") {
|
|
291
|
+
const part = props.part;
|
|
292
|
+
if (!part)
|
|
293
|
+
return;
|
|
294
|
+
const sessionId = part.sessionID || props.sessionID;
|
|
295
|
+
if (!sessionMatches(sessionId))
|
|
296
|
+
return;
|
|
297
|
+
callbacks.noteSessionId(sessionId);
|
|
298
|
+
if (part.type === "text") {
|
|
299
|
+
const partId = part.id || part.messageID;
|
|
300
|
+
if (!partId)
|
|
301
|
+
return;
|
|
302
|
+
let next = part.text || "";
|
|
303
|
+
if (props.delta) {
|
|
304
|
+
next = (textByPartId.get(partId) || "") + props.delta;
|
|
305
|
+
}
|
|
306
|
+
else if (!next && props.delta === undefined) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Prefer cumulative part.text when present (idempotent); else delta accumulation.
|
|
310
|
+
if (part.text)
|
|
311
|
+
next = part.text;
|
|
312
|
+
textByPartId.set(partId, next);
|
|
313
|
+
if (!next)
|
|
314
|
+
return;
|
|
315
|
+
callbacks.postEvent(ROLE_ASSISTANT, next, {
|
|
316
|
+
throttle: true,
|
|
317
|
+
messageId: opencodeMessageId("oc_text_", part),
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (part.type === "reasoning") {
|
|
322
|
+
const partId = part.id || part.messageID;
|
|
323
|
+
if (!partId)
|
|
324
|
+
return;
|
|
325
|
+
let next = part.text || "";
|
|
326
|
+
if (props.delta && !part.text) {
|
|
327
|
+
next = (textByPartId.get(`reasoning:${partId}`) || "") + props.delta;
|
|
328
|
+
}
|
|
329
|
+
if (part.text)
|
|
330
|
+
next = part.text;
|
|
331
|
+
textByPartId.set(`reasoning:${partId}`, next);
|
|
332
|
+
if (!next)
|
|
333
|
+
return;
|
|
334
|
+
callbacks.postEvent(ROLE_REASONING, next, {
|
|
335
|
+
throttle: true,
|
|
336
|
+
messageId: opencodeMessageId("oc_reasoning_", part),
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (part.type === "tool") {
|
|
341
|
+
const status = part.state?.status;
|
|
342
|
+
if (!status || status === "pending" || status === "running")
|
|
343
|
+
return;
|
|
344
|
+
const toolContent = formatToolUseContent(part);
|
|
345
|
+
callbacks.postEvent(ROLE_TOOL, toolContent, {
|
|
346
|
+
messageId: opencodeMessageId("oc_tool_", part),
|
|
347
|
+
});
|
|
348
|
+
if (status === "completed") {
|
|
349
|
+
const detected = detectWorkingBranchFromToolOutput(toolContent);
|
|
350
|
+
if (detected.branch) {
|
|
351
|
+
callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (type === "session.error") {
|
|
358
|
+
const sessionId = props.sessionID;
|
|
359
|
+
if (!sessionMatches(sessionId))
|
|
360
|
+
return;
|
|
361
|
+
const msg = props.error?.data?.message || props.error?.message || props.error?.name || "OpenCode session error";
|
|
362
|
+
callbacks.postEvent(ROLE_STATUS, String(msg), { status: STATUS_RUNNING });
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const consume = async (body) => {
|
|
366
|
+
const reader = body.getReader();
|
|
367
|
+
const decoder = new TextDecoder();
|
|
368
|
+
let buf = "";
|
|
369
|
+
while (!stopped) {
|
|
370
|
+
const { done, value } = await reader.read();
|
|
371
|
+
if (done)
|
|
372
|
+
break;
|
|
373
|
+
buf += decoder.decode(value, { stream: true });
|
|
374
|
+
const chunks = buf.split("\n\n");
|
|
375
|
+
buf = chunks.pop() || "";
|
|
376
|
+
for (const chunk of chunks) {
|
|
377
|
+
const dataLines = chunk
|
|
378
|
+
.split("\n")
|
|
379
|
+
.filter((l) => l.startsWith("data:"))
|
|
380
|
+
.map((l) => l.slice(5).trimStart());
|
|
381
|
+
if (!dataLines.length)
|
|
382
|
+
continue;
|
|
383
|
+
const data = dataLines.join("\n");
|
|
384
|
+
if (!data || data === "[DONE]")
|
|
385
|
+
continue;
|
|
386
|
+
try {
|
|
387
|
+
handleBusEvent(JSON.parse(data));
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* ignore malformed */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
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;
|
|
407
|
+
}
|
|
408
|
+
console.error(`ChimpHands OpenCode SSE streaming via ${path}`);
|
|
409
|
+
await consume(res.body);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
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}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!stopped) {
|
|
420
|
+
console.error("ChimpHands OpenCode SSE unavailable — falling back to completed-only --format json events");
|
|
421
|
+
}
|
|
422
|
+
})();
|
|
423
|
+
return () => {
|
|
424
|
+
stopped = true;
|
|
425
|
+
try {
|
|
426
|
+
ac.abort();
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
/* ignore */
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
239
433
|
function summarizeOpencodeFailure(stderr, stdout, exitCode) {
|
|
240
434
|
for (const chunk of [stderr, stdout]) {
|
|
241
435
|
for (const line of chunk.split("\n")) {
|
|
@@ -350,10 +544,12 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
350
544
|
prompt: CHIMPHANDS_AGENT_PROMPT,
|
|
351
545
|
steps: 80,
|
|
352
546
|
permission: {
|
|
547
|
+
"*": "allow",
|
|
353
548
|
skill: "allow",
|
|
354
549
|
bash: "allow",
|
|
355
550
|
edit: "allow",
|
|
356
551
|
read: "allow",
|
|
552
|
+
question: "allow",
|
|
357
553
|
},
|
|
358
554
|
},
|
|
359
555
|
},
|
|
@@ -364,7 +560,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
364
560
|
testchimp: {
|
|
365
561
|
type: "local",
|
|
366
562
|
enabled: true,
|
|
367
|
-
|
|
563
|
+
// Prefer the already-installed global binary — `npx -y @latest` can hang in GHA.
|
|
564
|
+
command: ["testchimp", "mcp"],
|
|
368
565
|
environment: mcpEnv,
|
|
369
566
|
},
|
|
370
567
|
},
|
|
@@ -372,7 +569,20 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
372
569
|
return model;
|
|
373
570
|
}
|
|
374
571
|
function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
375
|
-
|
|
572
|
+
// --auto: headless CI must approve tool permissions (1.18+ otherwise auto-rejects).
|
|
573
|
+
// --print-logs: surface server/client progress on stderr while waiting for first token.
|
|
574
|
+
const args = [
|
|
575
|
+
"run",
|
|
576
|
+
prompt,
|
|
577
|
+
"--model",
|
|
578
|
+
model,
|
|
579
|
+
"--format",
|
|
580
|
+
"json",
|
|
581
|
+
"--agent",
|
|
582
|
+
OPENCODE_AGENT_ID,
|
|
583
|
+
"--auto",
|
|
584
|
+
"--print-logs",
|
|
585
|
+
];
|
|
376
586
|
if (opencodeSessionId?.trim()) {
|
|
377
587
|
args.push("--session", opencodeSessionId.trim());
|
|
378
588
|
}
|
|
@@ -381,21 +591,102 @@ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
|
381
591
|
}
|
|
382
592
|
return args;
|
|
383
593
|
}
|
|
594
|
+
function killListenersOnPort(port) {
|
|
595
|
+
try {
|
|
596
|
+
const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, {
|
|
597
|
+
encoding: "utf8",
|
|
598
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
599
|
+
}).trim();
|
|
600
|
+
for (const pid of out.split(/\s+/).filter(Boolean)) {
|
|
601
|
+
const n = Number(pid);
|
|
602
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
603
|
+
continue;
|
|
604
|
+
try {
|
|
605
|
+
process.kill(n, "SIGTERM");
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
/* already gone */
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* nothing listening */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function waitForOpencodeHttp(attachUrl, timeoutMs) {
|
|
617
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
618
|
+
const deadline = Date.now() + timeoutMs;
|
|
619
|
+
while (Date.now() < deadline) {
|
|
620
|
+
for (const path of ["/global/health", "/"]) {
|
|
621
|
+
try {
|
|
622
|
+
const res = await fetch(`${base}${path}`);
|
|
623
|
+
if (res.ok || res.status === 401 || res.status === 404)
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
/* retry */
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
await sleep(400);
|
|
631
|
+
}
|
|
632
|
+
throw new Error(`OpenCode server not ready at ${attachUrl} within ${timeoutMs}ms`);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Restart local `opencode serve` after writing opencode.json so the server loads
|
|
636
|
+
* TestChimp provider + default_agent. Workflow may have started serve earlier without config.
|
|
637
|
+
*/
|
|
638
|
+
async function restartLocalOpencodeServer(attachUrl) {
|
|
639
|
+
const u = new URL(attachUrl);
|
|
640
|
+
const hostname = u.hostname || "127.0.0.1";
|
|
641
|
+
const port = u.port || (u.protocol === "https:" ? "443" : "80");
|
|
642
|
+
killListenersOnPort(port);
|
|
643
|
+
await sleep(300);
|
|
644
|
+
const logFd = openSync("opencode-server.log", "a");
|
|
645
|
+
const child = spawn("opencode", ["serve", "--port", port, "--hostname", hostname], {
|
|
646
|
+
detached: true,
|
|
647
|
+
stdio: ["ignore", logFd, logFd],
|
|
648
|
+
env: process.env,
|
|
649
|
+
});
|
|
650
|
+
child.unref();
|
|
651
|
+
if (child.pid) {
|
|
652
|
+
try {
|
|
653
|
+
writeFileSync("/tmp/opencode-server.pid", String(child.pid));
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
/* best effort */
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
console.error(`ChimpHands restarted OpenCode serve on ${hostname}:${port} (pid ${child.pid ?? "?"})`);
|
|
660
|
+
await waitForOpencodeHttp(attachUrl, 60_000);
|
|
661
|
+
}
|
|
384
662
|
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
|
|
385
663
|
let activeSessionId = opencodeSessionId?.trim() || undefined;
|
|
386
664
|
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
|
|
665
|
+
const preview = prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt;
|
|
666
|
+
console.error(`ChimpHands invoking OpenCode: model=${model} attach=${attachUrl || "(local)"} session=${activeSessionId || "(new)"} prompt=${JSON.stringify(preview)}`);
|
|
667
|
+
// pipe+end stdin so OpenCode does not wait on Bun.stdin.text() (non-TTY).
|
|
387
668
|
const child = spawn("opencode", baseArgs, {
|
|
388
|
-
stdio: ["
|
|
669
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
389
670
|
env: childEnv,
|
|
390
671
|
});
|
|
672
|
+
try {
|
|
673
|
+
child.stdin?.end();
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
/* ignore */
|
|
677
|
+
}
|
|
391
678
|
let err = "";
|
|
392
679
|
child.stderr.on("data", (d) => {
|
|
393
|
-
|
|
680
|
+
const chunk = d.toString();
|
|
681
|
+
err += chunk;
|
|
682
|
+
// Live-forward so GHA shows progress while waiting for first JSON event.
|
|
683
|
+
process.stderr.write(chunk);
|
|
394
684
|
});
|
|
395
685
|
return new Promise((resolve) => {
|
|
396
686
|
let buf = "";
|
|
397
687
|
let fatalError = null;
|
|
398
688
|
const textByPartId = new Map();
|
|
689
|
+
let sawStdout = false;
|
|
399
690
|
const noteSessionId = (sessionId) => {
|
|
400
691
|
const id = sessionId?.trim();
|
|
401
692
|
if (!id || id === activeSessionId)
|
|
@@ -406,6 +697,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
406
697
|
const handleOpencodeLine = (line) => {
|
|
407
698
|
if (!line.trim())
|
|
408
699
|
return;
|
|
700
|
+
if (!sawStdout) {
|
|
701
|
+
sawStdout = true;
|
|
702
|
+
console.error("ChimpHands OpenCode first stdout event received");
|
|
703
|
+
}
|
|
409
704
|
const fatal = extractOpencodeFatalError(line);
|
|
410
705
|
if (fatal) {
|
|
411
706
|
fatalError = fatal;
|
|
@@ -608,6 +903,40 @@ export async function runChimphands(opts) {
|
|
|
608
903
|
const boot = JSON.parse(bootText);
|
|
609
904
|
const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
|
|
610
905
|
const poster = new AgentEventPoster(backend, apiKey, sessionId);
|
|
906
|
+
poster.uiAttached = !!(boot.uiAttached ?? boot.ui_attached);
|
|
907
|
+
let stopLiveSse = null;
|
|
908
|
+
const syncLiveSse = (attached) => {
|
|
909
|
+
poster.uiAttached = attached;
|
|
910
|
+
if (!attachUrl)
|
|
911
|
+
return;
|
|
912
|
+
if (attached && !stopLiveSse) {
|
|
913
|
+
console.error("ChimpHands UI attached — starting OpenCode SSE fanout");
|
|
914
|
+
stopLiveSse = startOpencodeSseRelay(attachUrl, {
|
|
915
|
+
getActiveSessionId: () => opencodeSessionId,
|
|
916
|
+
noteSessionId: (id) => {
|
|
917
|
+
if (id?.trim())
|
|
918
|
+
opencodeSessionId = id.trim();
|
|
919
|
+
},
|
|
920
|
+
postEvent: (role, content, opts) => {
|
|
921
|
+
poster.fireAndForget(role, content, {
|
|
922
|
+
...opts,
|
|
923
|
+
opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
|
|
924
|
+
});
|
|
925
|
+
},
|
|
926
|
+
onWorkingBranch: noteWorkingBranchPlaceholder,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
else if (!attached && stopLiveSse) {
|
|
930
|
+
console.error("ChimpHands UI detached — stopping OpenCode SSE fanout");
|
|
931
|
+
stopLiveSse();
|
|
932
|
+
stopLiveSse = null;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
// noteWorkingBranch is defined later; bind via mutable holder until then.
|
|
936
|
+
let noteWorkingBranch = () => { };
|
|
937
|
+
const noteWorkingBranchPlaceholder = (branch, prUrl) => {
|
|
938
|
+
noteWorkingBranch(branch, prUrl);
|
|
939
|
+
};
|
|
611
940
|
if (githubRunId) {
|
|
612
941
|
await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
613
942
|
sessionId,
|
|
@@ -635,7 +964,9 @@ export async function runChimphands(opts) {
|
|
|
635
964
|
}
|
|
636
965
|
}
|
|
637
966
|
const stopHeartbeat = runtimeId
|
|
638
|
-
? startRuntimeHeartbeat(backend, apiKey, runtimeId)
|
|
967
|
+
? startRuntimeHeartbeat(backend, apiKey, runtimeId, (attached) => {
|
|
968
|
+
syncLiveSse(attached);
|
|
969
|
+
})
|
|
639
970
|
: () => { };
|
|
640
971
|
const stopTunnel = runtimeId && attachUrl
|
|
641
972
|
? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
|
|
@@ -649,6 +980,9 @@ export async function runChimphands(opts) {
|
|
|
649
980
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
650
981
|
if (attachUrl) {
|
|
651
982
|
console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
|
|
983
|
+
// Serve must load opencode.json (provider + default_agent). Workflow often starts
|
|
984
|
+
// serve before this file exists; restart so attach mode can omit --agent safely.
|
|
985
|
+
await restartLocalOpencodeServer(attachUrl);
|
|
652
986
|
}
|
|
653
987
|
let opencodeSessionId = bootStr(boot, "opencode_session_id", "opencodeSessionId") || undefined;
|
|
654
988
|
const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
|
|
@@ -678,7 +1012,7 @@ export async function runChimphands(opts) {
|
|
|
678
1012
|
console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
679
1013
|
}
|
|
680
1014
|
};
|
|
681
|
-
|
|
1015
|
+
noteWorkingBranch = (branch, prUrl) => {
|
|
682
1016
|
const normalizedBranch = branch.trim();
|
|
683
1017
|
if (!normalizedBranch)
|
|
684
1018
|
return;
|
|
@@ -694,6 +1028,8 @@ export async function runChimphands(opts) {
|
|
|
694
1028
|
poster.reportWorkingBranch(normalizedBranch, nextPr);
|
|
695
1029
|
}
|
|
696
1030
|
};
|
|
1031
|
+
// Apply bootstrap ui_attached now that session id + branch hooks exist.
|
|
1032
|
+
syncLiveSse(poster.uiAttached);
|
|
697
1033
|
const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
|
|
698
1034
|
const queue = [];
|
|
699
1035
|
const seenUserMessageIds = new Set();
|
|
@@ -769,6 +1105,10 @@ export async function runChimphands(opts) {
|
|
|
769
1105
|
stopInbound();
|
|
770
1106
|
stopTunnel();
|
|
771
1107
|
stopHeartbeat();
|
|
1108
|
+
if (stopLiveSse) {
|
|
1109
|
+
stopLiveSse();
|
|
1110
|
+
stopLiveSse = null;
|
|
1111
|
+
}
|
|
772
1112
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
773
1113
|
await poster.flush();
|
|
774
1114
|
await snapshotExport();
|
|
@@ -822,6 +1162,9 @@ export async function runChimphands(opts) {
|
|
|
822
1162
|
let useOpencodeSessionId = opencodeSessionId;
|
|
823
1163
|
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
824
1164
|
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
1165
|
+
// Visible in chat (not filtered as routine). OpenCode may not emit text until a
|
|
1166
|
+
// part completes — without this the UI looks empty while the turn is running.
|
|
1167
|
+
postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
|
|
825
1168
|
let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
|
|
826
1169
|
onSessionId: (id) => {
|
|
827
1170
|
opencodeSessionId = id;
|
|
@@ -898,22 +1241,34 @@ export async function runChimphands(opts) {
|
|
|
898
1241
|
process.exit(exitCode);
|
|
899
1242
|
}
|
|
900
1243
|
}
|
|
901
|
-
function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
|
|
1244
|
+
function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
|
|
902
1245
|
let stopped = false;
|
|
1246
|
+
let lastAttached;
|
|
903
1247
|
const tick = async () => {
|
|
904
1248
|
if (stopped)
|
|
905
1249
|
return;
|
|
906
1250
|
try {
|
|
907
1251
|
// Do not claim tunnel_connected here — only the tunnel poll loop should.
|
|
908
|
-
await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
1252
|
+
const text = await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
909
1253
|
runtimeId,
|
|
910
1254
|
});
|
|
1255
|
+
try {
|
|
1256
|
+
const data = JSON.parse(text);
|
|
1257
|
+
const attached = !!(data.uiAttached ?? data.ui_attached);
|
|
1258
|
+
if (attached !== lastAttached) {
|
|
1259
|
+
lastAttached = attached;
|
|
1260
|
+
onUiAttached?.(attached);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
/* ignore parse */
|
|
1265
|
+
}
|
|
911
1266
|
}
|
|
912
1267
|
catch (err) {
|
|
913
1268
|
console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
914
1269
|
}
|
|
915
1270
|
if (!stopped)
|
|
916
|
-
setTimeout(tick,
|
|
1271
|
+
setTimeout(tick, 5_000);
|
|
917
1272
|
};
|
|
918
1273
|
void tick();
|
|
919
1274
|
return () => {
|
|
@@ -985,7 +1340,7 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
985
1340
|
}
|
|
986
1341
|
async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
|
|
987
1342
|
const exported = await new Promise((resolve, reject) => {
|
|
988
|
-
const child = spawn("opencode", ["export", opencodeSessionId
|
|
1343
|
+
const child = spawn("opencode", ["export", opencodeSessionId], {
|
|
989
1344
|
stdio: ["ignore", "pipe", "pipe"],
|
|
990
1345
|
});
|
|
991
1346
|
let out = "";
|
|
@@ -1213,18 +1568,6 @@ export async function serveChimphands(opts) {
|
|
|
1213
1568
|
if (!attachUrl) {
|
|
1214
1569
|
throw new Error("--attach URL is required for chimphands serve");
|
|
1215
1570
|
}
|
|
1216
|
-
//
|
|
1217
|
-
const deadline = Date.now() + 60_000;
|
|
1218
|
-
while (Date.now() < deadline) {
|
|
1219
|
-
try {
|
|
1220
|
-
const res = await fetch(attachUrl.replace(/\/$/, "") + "/");
|
|
1221
|
-
if (res.ok || res.status === 401 || res.status === 404)
|
|
1222
|
-
break;
|
|
1223
|
-
}
|
|
1224
|
-
catch {
|
|
1225
|
-
/* retry */
|
|
1226
|
-
}
|
|
1227
|
-
await sleep(500);
|
|
1228
|
-
}
|
|
1571
|
+
// Server is (re)started inside runChimphands after opencode.json is written.
|
|
1229
1572
|
await runChimphands({ ...opts, attachUrl });
|
|
1230
1573
|
}
|
package/package.json
CHANGED