@testchimp/cli 0.1.47 → 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 +288 -13
- 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, 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,6 +569,8 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
372
569
|
return model;
|
|
373
570
|
}
|
|
374
571
|
function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
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.
|
|
375
574
|
const args = [
|
|
376
575
|
"run",
|
|
377
576
|
prompt,
|
|
@@ -381,6 +580,8 @@ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
|
381
580
|
"json",
|
|
382
581
|
"--agent",
|
|
383
582
|
OPENCODE_AGENT_ID,
|
|
583
|
+
"--auto",
|
|
584
|
+
"--print-logs",
|
|
384
585
|
];
|
|
385
586
|
if (opencodeSessionId?.trim()) {
|
|
386
587
|
args.push("--session", opencodeSessionId.trim());
|
|
@@ -461,18 +662,31 @@ async function restartLocalOpencodeServer(attachUrl) {
|
|
|
461
662
|
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
|
|
462
663
|
let activeSessionId = opencodeSessionId?.trim() || undefined;
|
|
463
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).
|
|
464
668
|
const child = spawn("opencode", baseArgs, {
|
|
465
|
-
stdio: ["
|
|
669
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
466
670
|
env: childEnv,
|
|
467
671
|
});
|
|
672
|
+
try {
|
|
673
|
+
child.stdin?.end();
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
/* ignore */
|
|
677
|
+
}
|
|
468
678
|
let err = "";
|
|
469
679
|
child.stderr.on("data", (d) => {
|
|
470
|
-
|
|
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);
|
|
471
684
|
});
|
|
472
685
|
return new Promise((resolve) => {
|
|
473
686
|
let buf = "";
|
|
474
687
|
let fatalError = null;
|
|
475
688
|
const textByPartId = new Map();
|
|
689
|
+
let sawStdout = false;
|
|
476
690
|
const noteSessionId = (sessionId) => {
|
|
477
691
|
const id = sessionId?.trim();
|
|
478
692
|
if (!id || id === activeSessionId)
|
|
@@ -483,6 +697,10 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
483
697
|
const handleOpencodeLine = (line) => {
|
|
484
698
|
if (!line.trim())
|
|
485
699
|
return;
|
|
700
|
+
if (!sawStdout) {
|
|
701
|
+
sawStdout = true;
|
|
702
|
+
console.error("ChimpHands OpenCode first stdout event received");
|
|
703
|
+
}
|
|
486
704
|
const fatal = extractOpencodeFatalError(line);
|
|
487
705
|
if (fatal) {
|
|
488
706
|
fatalError = fatal;
|
|
@@ -685,6 +903,40 @@ export async function runChimphands(opts) {
|
|
|
685
903
|
const boot = JSON.parse(bootText);
|
|
686
904
|
const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
|
|
687
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
|
+
};
|
|
688
940
|
if (githubRunId) {
|
|
689
941
|
await postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
690
942
|
sessionId,
|
|
@@ -712,7 +964,9 @@ export async function runChimphands(opts) {
|
|
|
712
964
|
}
|
|
713
965
|
}
|
|
714
966
|
const stopHeartbeat = runtimeId
|
|
715
|
-
? startRuntimeHeartbeat(backend, apiKey, runtimeId)
|
|
967
|
+
? startRuntimeHeartbeat(backend, apiKey, runtimeId, (attached) => {
|
|
968
|
+
syncLiveSse(attached);
|
|
969
|
+
})
|
|
716
970
|
: () => { };
|
|
717
971
|
const stopTunnel = runtimeId && attachUrl
|
|
718
972
|
? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
|
|
@@ -758,7 +1012,7 @@ export async function runChimphands(opts) {
|
|
|
758
1012
|
console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
759
1013
|
}
|
|
760
1014
|
};
|
|
761
|
-
|
|
1015
|
+
noteWorkingBranch = (branch, prUrl) => {
|
|
762
1016
|
const normalizedBranch = branch.trim();
|
|
763
1017
|
if (!normalizedBranch)
|
|
764
1018
|
return;
|
|
@@ -774,6 +1028,8 @@ export async function runChimphands(opts) {
|
|
|
774
1028
|
poster.reportWorkingBranch(normalizedBranch, nextPr);
|
|
775
1029
|
}
|
|
776
1030
|
};
|
|
1031
|
+
// Apply bootstrap ui_attached now that session id + branch hooks exist.
|
|
1032
|
+
syncLiveSse(poster.uiAttached);
|
|
777
1033
|
const idleMs = (bootNum(boot, "idle_timeout_seconds", "idleTimeoutSeconds") || 600) * 1000;
|
|
778
1034
|
const queue = [];
|
|
779
1035
|
const seenUserMessageIds = new Set();
|
|
@@ -849,6 +1105,10 @@ export async function runChimphands(opts) {
|
|
|
849
1105
|
stopInbound();
|
|
850
1106
|
stopTunnel();
|
|
851
1107
|
stopHeartbeat();
|
|
1108
|
+
if (stopLiveSse) {
|
|
1109
|
+
stopLiveSse();
|
|
1110
|
+
stopLiveSse = null;
|
|
1111
|
+
}
|
|
852
1112
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
853
1113
|
await poster.flush();
|
|
854
1114
|
await snapshotExport();
|
|
@@ -902,6 +1162,9 @@ export async function runChimphands(opts) {
|
|
|
902
1162
|
let useOpencodeSessionId = opencodeSessionId;
|
|
903
1163
|
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
904
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 });
|
|
905
1168
|
let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
|
|
906
1169
|
onSessionId: (id) => {
|
|
907
1170
|
opencodeSessionId = id;
|
|
@@ -978,22 +1241,34 @@ export async function runChimphands(opts) {
|
|
|
978
1241
|
process.exit(exitCode);
|
|
979
1242
|
}
|
|
980
1243
|
}
|
|
981
|
-
function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
|
|
1244
|
+
function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
|
|
982
1245
|
let stopped = false;
|
|
1246
|
+
let lastAttached;
|
|
983
1247
|
const tick = async () => {
|
|
984
1248
|
if (stopped)
|
|
985
1249
|
return;
|
|
986
1250
|
try {
|
|
987
1251
|
// Do not claim tunnel_connected here — only the tunnel poll loop should.
|
|
988
|
-
await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
1252
|
+
const text = await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
989
1253
|
runtimeId,
|
|
990
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
|
+
}
|
|
991
1266
|
}
|
|
992
1267
|
catch (err) {
|
|
993
1268
|
console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
994
1269
|
}
|
|
995
1270
|
if (!stopped)
|
|
996
|
-
setTimeout(tick,
|
|
1271
|
+
setTimeout(tick, 5_000);
|
|
997
1272
|
};
|
|
998
1273
|
void tick();
|
|
999
1274
|
return () => {
|
|
@@ -1065,7 +1340,7 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
1065
1340
|
}
|
|
1066
1341
|
async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
|
|
1067
1342
|
const exported = await new Promise((resolve, reject) => {
|
|
1068
|
-
const child = spawn("opencode", ["export", opencodeSessionId
|
|
1343
|
+
const child = spawn("opencode", ["export", opencodeSessionId], {
|
|
1069
1344
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1070
1345
|
});
|
|
1071
1346
|
let out = "";
|
package/package.json
CHANGED