@testchimp/cli 0.1.47 → 0.1.49
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 +481 -21
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -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, liveStream tokens are dropped; completed events still persist. */
|
|
117
|
+
uiAttached = false;
|
|
112
118
|
constructor(backend, apiKey, sessionId) {
|
|
113
119
|
this.backend = backend;
|
|
114
120
|
this.apiKey = apiKey;
|
|
@@ -130,14 +136,50 @@ class AgentEventPoster {
|
|
|
130
136
|
body.workingBranch = opts.workingBranch;
|
|
131
137
|
if (opts?.pullRequestUrl)
|
|
132
138
|
body.pullRequestUrl = opts.pullRequestUrl;
|
|
139
|
+
const streamRole = isStreamFanoutRole(role);
|
|
140
|
+
// Live token fanout only while UI watching; completed json events always go durable.
|
|
141
|
+
if (streamRole && opts?.liveStream && !this.uiAttached) {
|
|
142
|
+
return this.chain;
|
|
143
|
+
}
|
|
133
144
|
this.chain = this.chain.then(async () => {
|
|
134
|
-
if (opts?.throttle) {
|
|
145
|
+
if (opts?.throttle || (streamRole && opts?.liveStream)) {
|
|
135
146
|
const now = Date.now();
|
|
136
147
|
const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
|
|
137
148
|
if (wait > 0)
|
|
138
149
|
await sleep(wait);
|
|
139
150
|
this.lastStreamPostAt = Date.now();
|
|
140
151
|
}
|
|
152
|
+
const tryEphemeral = streamRole && this.uiAttached;
|
|
153
|
+
if (tryEphemeral) {
|
|
154
|
+
const eph = {
|
|
155
|
+
sessionId: this.sessionId,
|
|
156
|
+
role,
|
|
157
|
+
content: body.content,
|
|
158
|
+
};
|
|
159
|
+
if (opts?.messageId)
|
|
160
|
+
eph.messageId = opts.messageId;
|
|
161
|
+
if (opts?.opencodeSessionId)
|
|
162
|
+
eph.opencodeSessionId = opts.opencodeSessionId;
|
|
163
|
+
try {
|
|
164
|
+
const text = await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
|
|
165
|
+
let delivered = false;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(text);
|
|
168
|
+
delivered = !!parsed.delivered;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
if (delivered)
|
|
174
|
+
return;
|
|
175
|
+
// Cross-replica: UI SSE not on this FS pod — fall through to durable.
|
|
176
|
+
console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
180
|
+
console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
141
183
|
await postJson(this.backend, this.apiKey, "/api/chimphands/post_agent_event", body);
|
|
142
184
|
});
|
|
143
185
|
return this.chain;
|
|
@@ -215,6 +257,122 @@ function parseOpencodeEvent(line) {
|
|
|
215
257
|
return null;
|
|
216
258
|
}
|
|
217
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Newer OpenCode `--format json` lines often use the SSE bus shape
|
|
262
|
+
* (`message.part.updated` + `properties.part`) instead of legacy `type: "text"`.
|
|
263
|
+
* Normalize both into the same OpencodeEvent used by the stdout switch.
|
|
264
|
+
*/
|
|
265
|
+
function normalizeStdoutOpencodeEvent(raw) {
|
|
266
|
+
if (!raw || typeof raw !== "object")
|
|
267
|
+
return null;
|
|
268
|
+
const o = raw;
|
|
269
|
+
// Global envelope
|
|
270
|
+
const inner = o.payload && typeof o.payload === "object"
|
|
271
|
+
? o.payload
|
|
272
|
+
: o.event && typeof o.event === "object"
|
|
273
|
+
? o.event
|
|
274
|
+
: o;
|
|
275
|
+
const type = String(inner.type || "");
|
|
276
|
+
const props = (inner.properties && typeof inner.properties === "object"
|
|
277
|
+
? inner.properties
|
|
278
|
+
: {});
|
|
279
|
+
if (type === "message.part.updated" || type === "message.part.delta") {
|
|
280
|
+
const part = props.part;
|
|
281
|
+
if (!part)
|
|
282
|
+
return null;
|
|
283
|
+
const partType = part.type || "";
|
|
284
|
+
let mapped;
|
|
285
|
+
if (partType === "text")
|
|
286
|
+
mapped = "text";
|
|
287
|
+
else if (partType === "reasoning")
|
|
288
|
+
mapped = "reasoning";
|
|
289
|
+
else if (partType === "tool")
|
|
290
|
+
mapped = "tool_use";
|
|
291
|
+
else
|
|
292
|
+
return null;
|
|
293
|
+
// Prefer cumulative text; append delta when that's all we got.
|
|
294
|
+
if (props.delta && !part.text) {
|
|
295
|
+
part.text = props.delta;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
type: mapped,
|
|
299
|
+
sessionID: part.sessionID || props.sessionID,
|
|
300
|
+
part,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (type === "session.error") {
|
|
304
|
+
return {
|
|
305
|
+
type: "error",
|
|
306
|
+
sessionID: props.sessionID,
|
|
307
|
+
error: props.error,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// Legacy flat shape: { type: "text"|"tool_use"|..., part, sessionID }
|
|
311
|
+
if (type) {
|
|
312
|
+
return inner;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
/** Pull assistant/tool parts from OpenCode HTTP after attach exits early. */
|
|
317
|
+
async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, postEvent, onWorkingBranch) {
|
|
318
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
319
|
+
const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
|
|
320
|
+
let res;
|
|
321
|
+
try {
|
|
322
|
+
res = await fetch(url, {
|
|
323
|
+
headers: { Accept: "application/json", "x-opencode-directory": process.cwd() },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
console.error(`ChimpHands reconcile messages fetch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
if (!res.ok) {
|
|
331
|
+
console.error(`ChimpHands reconcile messages HTTP ${res.status}`);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
const data = (await res.json());
|
|
335
|
+
if (!Array.isArray(data))
|
|
336
|
+
return 0;
|
|
337
|
+
let posted = 0;
|
|
338
|
+
for (const msg of data) {
|
|
339
|
+
if ((msg.info?.role || "").toLowerCase() !== "assistant")
|
|
340
|
+
continue;
|
|
341
|
+
for (const part of msg.parts || []) {
|
|
342
|
+
if (part.type === "text" && part.text?.trim()) {
|
|
343
|
+
postEvent(ROLE_ASSISTANT, part.text.trim(), {
|
|
344
|
+
messageId: opencodeMessageId("oc_text_", part),
|
|
345
|
+
});
|
|
346
|
+
posted += 1;
|
|
347
|
+
}
|
|
348
|
+
else if (part.type === "reasoning" && part.text?.trim()) {
|
|
349
|
+
postEvent(ROLE_REASONING, part.text.trim(), {
|
|
350
|
+
messageId: opencodeMessageId("oc_reasoning_", part),
|
|
351
|
+
});
|
|
352
|
+
posted += 1;
|
|
353
|
+
}
|
|
354
|
+
else if (part.type === "tool") {
|
|
355
|
+
const status = part.state?.status;
|
|
356
|
+
if (!status || status === "pending" || status === "running")
|
|
357
|
+
continue;
|
|
358
|
+
const toolContent = formatToolUseContent(part);
|
|
359
|
+
postEvent(ROLE_TOOL, toolContent, {
|
|
360
|
+
messageId: opencodeMessageId("oc_tool_", part),
|
|
361
|
+
});
|
|
362
|
+
posted += 1;
|
|
363
|
+
if (status === "completed") {
|
|
364
|
+
const detected = detectWorkingBranchFromToolOutput(toolContent);
|
|
365
|
+
if (detected.branch)
|
|
366
|
+
onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (posted) {
|
|
372
|
+
console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API`);
|
|
373
|
+
}
|
|
374
|
+
return posted;
|
|
375
|
+
}
|
|
218
376
|
function formatToolUseContent(part) {
|
|
219
377
|
const title = part.state?.title || part.tool || "tool";
|
|
220
378
|
const status = part.state?.status?.trim();
|
|
@@ -236,6 +394,213 @@ function opencodeMessageId(prefix, part) {
|
|
|
236
394
|
return undefined;
|
|
237
395
|
return `${prefix}${raw}`;
|
|
238
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* OpenCode `run --format json` only emits completed text (`part.time.end`).
|
|
399
|
+
* Live tokens come from the server SSE bus (`message.part.updated` + optional `delta`).
|
|
400
|
+
* Subscribe directly when attaching so the platform chat streams.
|
|
401
|
+
*/
|
|
402
|
+
function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
403
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
404
|
+
const ac = new AbortController();
|
|
405
|
+
let stopped = false;
|
|
406
|
+
const textByPartId = new Map();
|
|
407
|
+
const directory = process.cwd();
|
|
408
|
+
const sessionMatches = (sessionId) => {
|
|
409
|
+
const active = callbacks.getActiveSessionId();
|
|
410
|
+
if (!sessionId)
|
|
411
|
+
return !active;
|
|
412
|
+
if (!active)
|
|
413
|
+
return true;
|
|
414
|
+
return sessionId === active;
|
|
415
|
+
};
|
|
416
|
+
const liveOpts = (extra) => ({
|
|
417
|
+
...extra,
|
|
418
|
+
throttle: true,
|
|
419
|
+
liveStream: true,
|
|
420
|
+
});
|
|
421
|
+
const unwrapBusPayload = (raw) => {
|
|
422
|
+
if (!raw || typeof raw !== "object")
|
|
423
|
+
return raw;
|
|
424
|
+
const o = raw;
|
|
425
|
+
// /global/event wraps as { directory, payload } or { directory, event }
|
|
426
|
+
if (o.payload && typeof o.payload === "object")
|
|
427
|
+
return o.payload;
|
|
428
|
+
if (o.event && typeof o.event === "object")
|
|
429
|
+
return o.event;
|
|
430
|
+
return raw;
|
|
431
|
+
};
|
|
432
|
+
const handleBusEvent = (raw) => {
|
|
433
|
+
const unwrapped = unwrapBusPayload(raw);
|
|
434
|
+
if (!unwrapped || typeof unwrapped !== "object")
|
|
435
|
+
return;
|
|
436
|
+
const ev = unwrapped;
|
|
437
|
+
const type = ev.type || "";
|
|
438
|
+
const props = ev.properties || {};
|
|
439
|
+
if (type === "message.part.updated" || type === "message.part.delta") {
|
|
440
|
+
const part = props.part;
|
|
441
|
+
if (!part)
|
|
442
|
+
return;
|
|
443
|
+
const sessionId = part.sessionID || props.sessionID;
|
|
444
|
+
if (!sessionMatches(sessionId))
|
|
445
|
+
return;
|
|
446
|
+
callbacks.noteSessionId(sessionId);
|
|
447
|
+
if (part.type === "text") {
|
|
448
|
+
const partId = part.id || part.messageID;
|
|
449
|
+
if (!partId)
|
|
450
|
+
return;
|
|
451
|
+
let next = part.text || "";
|
|
452
|
+
if (props.delta) {
|
|
453
|
+
next = (textByPartId.get(partId) || "") + props.delta;
|
|
454
|
+
}
|
|
455
|
+
else if (!next && props.delta === undefined) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Prefer cumulative part.text when present (idempotent); else delta accumulation.
|
|
459
|
+
if (part.text)
|
|
460
|
+
next = part.text;
|
|
461
|
+
textByPartId.set(partId, next);
|
|
462
|
+
if (!next)
|
|
463
|
+
return;
|
|
464
|
+
callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
|
|
465
|
+
messageId: opencodeMessageId("oc_text_", part),
|
|
466
|
+
}));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (part.type === "reasoning") {
|
|
470
|
+
const partId = part.id || part.messageID;
|
|
471
|
+
if (!partId)
|
|
472
|
+
return;
|
|
473
|
+
let next = part.text || "";
|
|
474
|
+
if (props.delta && !part.text) {
|
|
475
|
+
next = (textByPartId.get(`reasoning:${partId}`) || "") + props.delta;
|
|
476
|
+
}
|
|
477
|
+
if (part.text)
|
|
478
|
+
next = part.text;
|
|
479
|
+
textByPartId.set(`reasoning:${partId}`, next);
|
|
480
|
+
if (!next)
|
|
481
|
+
return;
|
|
482
|
+
callbacks.postEvent(ROLE_REASONING, next, liveOpts({
|
|
483
|
+
messageId: opencodeMessageId("oc_reasoning_", part),
|
|
484
|
+
}));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (part.type === "tool") {
|
|
488
|
+
const status = part.state?.status;
|
|
489
|
+
if (!status || status === "pending" || status === "running")
|
|
490
|
+
return;
|
|
491
|
+
const toolContent = formatToolUseContent(part);
|
|
492
|
+
callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
|
|
493
|
+
messageId: opencodeMessageId("oc_tool_", part),
|
|
494
|
+
throttle: false,
|
|
495
|
+
}));
|
|
496
|
+
if (status === "completed") {
|
|
497
|
+
const detected = detectWorkingBranchFromToolOutput(toolContent);
|
|
498
|
+
if (detected.branch) {
|
|
499
|
+
callbacks.onWorkingBranch?.(detected.branch, detected.pullRequestUrl);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (type === "session.error") {
|
|
506
|
+
const sessionId = props.sessionID;
|
|
507
|
+
if (!sessionMatches(sessionId))
|
|
508
|
+
return;
|
|
509
|
+
const msg = props.error?.data?.message || props.error?.message || props.error?.name || "OpenCode session error";
|
|
510
|
+
callbacks.postEvent(ROLE_STATUS, String(msg), { status: STATUS_RUNNING });
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const consume = async (body) => {
|
|
514
|
+
const reader = body.getReader();
|
|
515
|
+
const decoder = new TextDecoder();
|
|
516
|
+
let buf = "";
|
|
517
|
+
while (!stopped) {
|
|
518
|
+
const { done, value } = await reader.read();
|
|
519
|
+
if (done)
|
|
520
|
+
break;
|
|
521
|
+
buf += decoder.decode(value, { stream: true });
|
|
522
|
+
const chunks = buf.split("\n\n");
|
|
523
|
+
buf = chunks.pop() || "";
|
|
524
|
+
for (const chunk of chunks) {
|
|
525
|
+
const dataLines = chunk
|
|
526
|
+
.split("\n")
|
|
527
|
+
.filter((l) => l.startsWith("data:"))
|
|
528
|
+
.map((l) => l.slice(5).trimStart());
|
|
529
|
+
if (!dataLines.length)
|
|
530
|
+
continue;
|
|
531
|
+
const data = dataLines.join("\n");
|
|
532
|
+
if (!data || data === "[DONE]")
|
|
533
|
+
continue;
|
|
534
|
+
try {
|
|
535
|
+
handleBusEvent(JSON.parse(data));
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
/* ignore malformed */
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
const candidates = () => {
|
|
544
|
+
const dirQ = `directory=${encodeURIComponent(directory)}`;
|
|
545
|
+
return [
|
|
546
|
+
`/event?${dirQ}`,
|
|
547
|
+
`/event`,
|
|
548
|
+
`/global/event?${dirQ}`,
|
|
549
|
+
`/global/event`,
|
|
550
|
+
];
|
|
551
|
+
};
|
|
552
|
+
void (async () => {
|
|
553
|
+
let attempt = 0;
|
|
554
|
+
while (!stopped) {
|
|
555
|
+
let connected = false;
|
|
556
|
+
for (const path of candidates()) {
|
|
557
|
+
if (stopped)
|
|
558
|
+
return;
|
|
559
|
+
try {
|
|
560
|
+
const res = await fetch(`${base}${path}`, {
|
|
561
|
+
headers: {
|
|
562
|
+
Accept: "text/event-stream",
|
|
563
|
+
"x-opencode-directory": directory,
|
|
564
|
+
},
|
|
565
|
+
signal: ac.signal,
|
|
566
|
+
});
|
|
567
|
+
if (!res.ok || !res.body) {
|
|
568
|
+
console.error(`ChimpHands OpenCode SSE ${path} HTTP ${res.status}`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
console.error(`ChimpHands OpenCode SSE streaming via ${path}`);
|
|
572
|
+
connected = true;
|
|
573
|
+
await consume(res.body);
|
|
574
|
+
// Stream ended — retry if still attached.
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
if (stopped || ac.signal.aborted)
|
|
579
|
+
return;
|
|
580
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
581
|
+
console.error(`ChimpHands OpenCode SSE ${path} failed: ${detail}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (stopped || ac.signal.aborted)
|
|
585
|
+
return;
|
|
586
|
+
if (!connected && attempt === 0) {
|
|
587
|
+
console.error("ChimpHands OpenCode SSE unavailable — falling back to completed-only --format json events (will keep retrying SSE)");
|
|
588
|
+
}
|
|
589
|
+
attempt += 1;
|
|
590
|
+
const backoff = Math.min(30_000, 2_000 * attempt);
|
|
591
|
+
await sleep(backoff);
|
|
592
|
+
}
|
|
593
|
+
})();
|
|
594
|
+
return () => {
|
|
595
|
+
stopped = true;
|
|
596
|
+
try {
|
|
597
|
+
ac.abort();
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
/* ignore */
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
}
|
|
239
604
|
function summarizeOpencodeFailure(stderr, stdout, exitCode) {
|
|
240
605
|
for (const chunk of [stderr, stdout]) {
|
|
241
606
|
for (const line of chunk.split("\n")) {
|
|
@@ -262,21 +627,15 @@ function isMissingOpencodeSessionError(message) {
|
|
|
262
627
|
m.includes("unknown session") ||
|
|
263
628
|
m.includes("invalid session"));
|
|
264
629
|
}
|
|
265
|
-
function isNonInteractivePrompt(userPrompt) {
|
|
266
|
-
return /(?:^|\s)--mode\s*=?\s*non-interactive\b|mode\s*=\s*non-interactive\b/i.test(userPrompt);
|
|
267
|
-
}
|
|
268
|
-
function isTestchimpWorkflowPrompt(userPrompt) {
|
|
269
|
-
return /(?:^|\s)\/?testchimp\b/i.test(userPrompt.trim());
|
|
270
|
-
}
|
|
271
630
|
function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
|
|
272
631
|
const parts = [];
|
|
273
632
|
if (workingBranch?.trim()) {
|
|
274
633
|
parts.push("## Conversation working branch (reuse for this thread)", `Branch: \`${workingBranch.trim()}\``, pullRequestUrl?.trim() ? `PR: ${pullRequestUrl.trim()}` : "", "Checkout this branch, commit and push here. Do NOT open a new PR unless the one above was merged/closed.", "");
|
|
275
634
|
}
|
|
276
635
|
const task = normalizeUserMessage(userPrompt);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
636
|
+
// Do NOT inject "Interactive turn reminder" into the user prompt — it is already in
|
|
637
|
+
// CHIMPHANDS_AGENT_PROMPT (system). Putting it here makes OpenCode store it as the
|
|
638
|
+
// user message and the UI shows host control text in chat.
|
|
280
639
|
if (isNewOpencodeSession && conversationSummary.trim()) {
|
|
281
640
|
parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
|
|
282
641
|
return parts.filter(Boolean).join("\n");
|
|
@@ -350,10 +709,12 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
350
709
|
prompt: CHIMPHANDS_AGENT_PROMPT,
|
|
351
710
|
steps: 80,
|
|
352
711
|
permission: {
|
|
712
|
+
"*": "allow",
|
|
353
713
|
skill: "allow",
|
|
354
714
|
bash: "allow",
|
|
355
715
|
edit: "allow",
|
|
356
716
|
read: "allow",
|
|
717
|
+
question: "allow",
|
|
357
718
|
},
|
|
358
719
|
},
|
|
359
720
|
},
|
|
@@ -364,7 +725,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
364
725
|
testchimp: {
|
|
365
726
|
type: "local",
|
|
366
727
|
enabled: true,
|
|
367
|
-
|
|
728
|
+
// Prefer the already-installed global binary — `npx -y @latest` can hang in GHA.
|
|
729
|
+
command: ["testchimp", "mcp"],
|
|
368
730
|
environment: mcpEnv,
|
|
369
731
|
},
|
|
370
732
|
},
|
|
@@ -372,6 +734,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
372
734
|
return model;
|
|
373
735
|
}
|
|
374
736
|
function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
737
|
+
// --auto: headless CI must approve tool permissions (1.18+ otherwise auto-rejects).
|
|
738
|
+
// --print-logs: surface server/client progress on stderr while waiting for first token.
|
|
375
739
|
const args = [
|
|
376
740
|
"run",
|
|
377
741
|
prompt,
|
|
@@ -381,6 +745,8 @@ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
|
381
745
|
"json",
|
|
382
746
|
"--agent",
|
|
383
747
|
OPENCODE_AGENT_ID,
|
|
748
|
+
"--auto",
|
|
749
|
+
"--print-logs",
|
|
384
750
|
];
|
|
385
751
|
if (opencodeSessionId?.trim()) {
|
|
386
752
|
args.push("--session", opencodeSessionId.trim());
|
|
@@ -461,18 +827,43 @@ async function restartLocalOpencodeServer(attachUrl) {
|
|
|
461
827
|
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
|
|
462
828
|
let activeSessionId = opencodeSessionId?.trim() || undefined;
|
|
463
829
|
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
|
|
830
|
+
const preview = prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt;
|
|
831
|
+
console.error(`ChimpHands invoking OpenCode: model=${model} attach=${attachUrl || "(local)"} session=${activeSessionId || "(new)"} prompt=${JSON.stringify(preview)}`);
|
|
832
|
+
// pipe+end stdin so OpenCode does not wait on Bun.stdin.text() (non-TTY).
|
|
464
833
|
const child = spawn("opencode", baseArgs, {
|
|
465
|
-
stdio: ["
|
|
834
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
466
835
|
env: childEnv,
|
|
467
836
|
});
|
|
837
|
+
try {
|
|
838
|
+
child.stdin?.end();
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
/* ignore */
|
|
842
|
+
}
|
|
468
843
|
let err = "";
|
|
469
844
|
child.stderr.on("data", (d) => {
|
|
470
|
-
|
|
845
|
+
const chunk = d.toString();
|
|
846
|
+
err += chunk;
|
|
847
|
+
// Live-forward so GHA shows progress while waiting for first JSON event.
|
|
848
|
+
process.stderr.write(chunk);
|
|
471
849
|
});
|
|
472
850
|
return new Promise((resolve) => {
|
|
473
851
|
let buf = "";
|
|
474
852
|
let fatalError = null;
|
|
475
853
|
const textByPartId = new Map();
|
|
854
|
+
let sawStdout = false;
|
|
855
|
+
let progressTicker = setInterval(() => {
|
|
856
|
+
if (sawStdout) {
|
|
857
|
+
if (progressTicker) {
|
|
858
|
+
clearInterval(progressTicker);
|
|
859
|
+
progressTicker = null;
|
|
860
|
+
}
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
callbacks.postEvent(ROLE_STATUS, "Agent is still working… (waiting for OpenCode output)", {
|
|
864
|
+
status: STATUS_RUNNING,
|
|
865
|
+
});
|
|
866
|
+
}, 45_000);
|
|
476
867
|
const noteSessionId = (sessionId) => {
|
|
477
868
|
const id = sessionId?.trim();
|
|
478
869
|
if (!id || id === activeSessionId)
|
|
@@ -483,6 +874,14 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
483
874
|
const handleOpencodeLine = (line) => {
|
|
484
875
|
if (!line.trim())
|
|
485
876
|
return;
|
|
877
|
+
if (!sawStdout) {
|
|
878
|
+
sawStdout = true;
|
|
879
|
+
console.error("ChimpHands OpenCode first stdout event received");
|
|
880
|
+
if (progressTicker) {
|
|
881
|
+
clearInterval(progressTicker);
|
|
882
|
+
progressTicker = null;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
486
885
|
const fatal = extractOpencodeFatalError(line);
|
|
487
886
|
if (fatal) {
|
|
488
887
|
fatalError = fatal;
|
|
@@ -568,6 +967,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
568
967
|
}
|
|
569
968
|
});
|
|
570
969
|
child.on("close", (code) => {
|
|
970
|
+
if (progressTicker) {
|
|
971
|
+
clearInterval(progressTicker);
|
|
972
|
+
progressTicker = null;
|
|
973
|
+
}
|
|
571
974
|
if (buf.trim()) {
|
|
572
975
|
handleOpencodeLine(buf.trim());
|
|
573
976
|
}
|
|
@@ -685,6 +1088,40 @@ export async function runChimphands(opts) {
|
|
|
685
1088
|
const boot = JSON.parse(bootText);
|
|
686
1089
|
const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
|
|
687
1090
|
const poster = new AgentEventPoster(backend, apiKey, sessionId);
|
|
1091
|
+
poster.uiAttached = !!(boot.uiAttached ?? boot.ui_attached);
|
|
1092
|
+
let stopLiveSse = null;
|
|
1093
|
+
const syncLiveSse = (attached) => {
|
|
1094
|
+
poster.uiAttached = attached;
|
|
1095
|
+
if (!attachUrl)
|
|
1096
|
+
return;
|
|
1097
|
+
if (attached && !stopLiveSse) {
|
|
1098
|
+
console.error("ChimpHands UI attached — starting OpenCode SSE fanout");
|
|
1099
|
+
stopLiveSse = startOpencodeSseRelay(attachUrl, {
|
|
1100
|
+
getActiveSessionId: () => opencodeSessionId,
|
|
1101
|
+
noteSessionId: (id) => {
|
|
1102
|
+
if (id?.trim())
|
|
1103
|
+
opencodeSessionId = id.trim();
|
|
1104
|
+
},
|
|
1105
|
+
postEvent: (role, content, opts) => {
|
|
1106
|
+
poster.fireAndForget(role, content, {
|
|
1107
|
+
...opts,
|
|
1108
|
+
opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
|
|
1109
|
+
});
|
|
1110
|
+
},
|
|
1111
|
+
onWorkingBranch: noteWorkingBranchPlaceholder,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
else if (!attached && stopLiveSse) {
|
|
1115
|
+
console.error("ChimpHands UI detached — stopping OpenCode SSE fanout");
|
|
1116
|
+
stopLiveSse();
|
|
1117
|
+
stopLiveSse = null;
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
// noteWorkingBranch is defined later; bind via mutable holder until then.
|
|
1121
|
+
let noteWorkingBranch = () => { };
|
|
1122
|
+
const noteWorkingBranchPlaceholder = (branch, prUrl) => {
|
|
1123
|
+
noteWorkingBranch(branch, prUrl);
|
|
1124
|
+
};
|
|
688
1125
|
if (githubRunId) {
|
|
689
1126
|
await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
690
1127
|
sessionId,
|
|
@@ -712,7 +1149,9 @@ export async function runChimphands(opts) {
|
|
|
712
1149
|
}
|
|
713
1150
|
}
|
|
714
1151
|
const stopHeartbeat = runtimeId
|
|
715
|
-
? startRuntimeHeartbeat(backend, apiKey, runtimeId)
|
|
1152
|
+
? startRuntimeHeartbeat(backend, apiKey, runtimeId, (attached) => {
|
|
1153
|
+
syncLiveSse(attached);
|
|
1154
|
+
})
|
|
716
1155
|
: () => { };
|
|
717
1156
|
const stopTunnel = runtimeId && attachUrl
|
|
718
1157
|
? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
|
|
@@ -758,7 +1197,7 @@ export async function runChimphands(opts) {
|
|
|
758
1197
|
console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
759
1198
|
}
|
|
760
1199
|
};
|
|
761
|
-
|
|
1200
|
+
noteWorkingBranch = (branch, prUrl) => {
|
|
762
1201
|
const normalizedBranch = branch.trim();
|
|
763
1202
|
if (!normalizedBranch)
|
|
764
1203
|
return;
|
|
@@ -774,6 +1213,8 @@ export async function runChimphands(opts) {
|
|
|
774
1213
|
poster.reportWorkingBranch(normalizedBranch, nextPr);
|
|
775
1214
|
}
|
|
776
1215
|
};
|
|
1216
|
+
// Apply bootstrap ui_attached now that session id + branch hooks exist.
|
|
1217
|
+
syncLiveSse(poster.uiAttached);
|
|
777
1218
|
const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
|
|
778
1219
|
const queue = [];
|
|
779
1220
|
const seenUserMessageIds = new Set();
|
|
@@ -849,6 +1290,10 @@ export async function runChimphands(opts) {
|
|
|
849
1290
|
stopInbound();
|
|
850
1291
|
stopTunnel();
|
|
851
1292
|
stopHeartbeat();
|
|
1293
|
+
if (stopLiveSse) {
|
|
1294
|
+
stopLiveSse();
|
|
1295
|
+
stopLiveSse = null;
|
|
1296
|
+
}
|
|
852
1297
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
853
1298
|
await poster.flush();
|
|
854
1299
|
await snapshotExport();
|
|
@@ -902,6 +1347,9 @@ export async function runChimphands(opts) {
|
|
|
902
1347
|
let useOpencodeSessionId = opencodeSessionId;
|
|
903
1348
|
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
904
1349
|
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
1350
|
+
// Visible in chat (not filtered as routine). OpenCode may not emit text until a
|
|
1351
|
+
// part completes — without this the UI looks empty while the turn is running.
|
|
1352
|
+
postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
|
|
905
1353
|
let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
|
|
906
1354
|
onSessionId: (id) => {
|
|
907
1355
|
opencodeSessionId = id;
|
|
@@ -978,22 +1426,34 @@ export async function runChimphands(opts) {
|
|
|
978
1426
|
process.exit(exitCode);
|
|
979
1427
|
}
|
|
980
1428
|
}
|
|
981
|
-
function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
|
|
1429
|
+
function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
|
|
982
1430
|
let stopped = false;
|
|
1431
|
+
let lastAttached;
|
|
983
1432
|
const tick = async () => {
|
|
984
1433
|
if (stopped)
|
|
985
1434
|
return;
|
|
986
1435
|
try {
|
|
987
1436
|
// Do not claim tunnel_connected here — only the tunnel poll loop should.
|
|
988
|
-
await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
1437
|
+
const text = await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
989
1438
|
runtimeId,
|
|
990
1439
|
});
|
|
1440
|
+
try {
|
|
1441
|
+
const data = JSON.parse(text);
|
|
1442
|
+
const attached = !!(data.uiAttached ?? data.ui_attached);
|
|
1443
|
+
if (attached !== lastAttached) {
|
|
1444
|
+
lastAttached = attached;
|
|
1445
|
+
onUiAttached?.(attached);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
/* ignore parse */
|
|
1450
|
+
}
|
|
991
1451
|
}
|
|
992
1452
|
catch (err) {
|
|
993
1453
|
console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
994
1454
|
}
|
|
995
1455
|
if (!stopped)
|
|
996
|
-
setTimeout(tick,
|
|
1456
|
+
setTimeout(tick, 5_000);
|
|
997
1457
|
};
|
|
998
1458
|
void tick();
|
|
999
1459
|
return () => {
|
|
@@ -1065,7 +1525,7 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
1065
1525
|
}
|
|
1066
1526
|
async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
|
|
1067
1527
|
const exported = await new Promise((resolve, reject) => {
|
|
1068
|
-
const child = spawn("opencode", ["export", opencodeSessionId
|
|
1528
|
+
const child = spawn("opencode", ["export", opencodeSessionId], {
|
|
1069
1529
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1070
1530
|
});
|
|
1071
1531
|
let out = "";
|
package/package.json
CHANGED