@testchimp/cli 0.1.51 → 0.1.53
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 +198 -110
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -115,6 +115,8 @@ class AgentEventPoster {
|
|
|
115
115
|
lastStreamPostAt = 0;
|
|
116
116
|
/** When false, liveStream tokens are dropped; completed events still persist. */
|
|
117
117
|
uiAttached = false;
|
|
118
|
+
ephemeralFailLogAt = 0;
|
|
119
|
+
ephemeralFailCount = 0;
|
|
118
120
|
constructor(backend, apiKey, sessionId) {
|
|
119
121
|
this.backend = backend;
|
|
120
122
|
this.apiKey = apiKey;
|
|
@@ -137,9 +139,12 @@ class AgentEventPoster {
|
|
|
137
139
|
if (opts?.pullRequestUrl)
|
|
138
140
|
body.pullRequestUrl = opts.pullRequestUrl;
|
|
139
141
|
const streamRole = isStreamFanoutRole(role);
|
|
140
|
-
// Never drop liveStream tokens: ephemeral when UI is on this replica path,
|
|
141
|
-
// otherwise durable post_agent_event (cross-replica / detached safe).
|
|
142
142
|
this.chain = this.chain.then(async () => {
|
|
143
|
+
// Mid-turn live tokens only while UI is attached — async runs skip FS fanout
|
|
144
|
+
// and rely on turn-end reconcile for durable PG.
|
|
145
|
+
if (opts?.liveStream && !this.uiAttached) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
143
148
|
if (opts?.throttle || (streamRole && opts?.liveStream)) {
|
|
144
149
|
const now = Date.now();
|
|
145
150
|
const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
|
|
@@ -160,6 +165,7 @@ class AgentEventPoster {
|
|
|
160
165
|
eph.opencodeSessionId = opts.opencodeSessionId;
|
|
161
166
|
try {
|
|
162
167
|
const text = await postJson(this.backend, this.apiKey, "/api/chimphands/post_ephemeral_agent_event", eph);
|
|
168
|
+
this.ephemeralFailCount = 0;
|
|
163
169
|
let delivered = false;
|
|
164
170
|
try {
|
|
165
171
|
const parsed = JSON.parse(text);
|
|
@@ -170,11 +176,21 @@ class AgentEventPoster {
|
|
|
170
176
|
}
|
|
171
177
|
if (delivered)
|
|
172
178
|
return;
|
|
173
|
-
//
|
|
179
|
+
// Mid-turn liveStream must not fall through to durable — that would write
|
|
180
|
+
// every token to PG. Turn-end reconcile persists the final transcript.
|
|
181
|
+
if (opts?.liveStream) {
|
|
182
|
+
this.logEphemeralIssue("ephemeral not delivered (replica miss?) — skipping durable for liveStream");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Completed/non-live: durable fallback for cross-replica UI.
|
|
174
186
|
console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
|
|
175
187
|
}
|
|
176
188
|
catch (err) {
|
|
177
189
|
const detail = err instanceof Error ? err.message : String(err);
|
|
190
|
+
if (opts?.liveStream) {
|
|
191
|
+
this.logEphemeralIssue(`ephemeral post failed — skipping durable for liveStream: ${detail}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
178
194
|
console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
|
|
179
195
|
}
|
|
180
196
|
}
|
|
@@ -182,6 +198,16 @@ class AgentEventPoster {
|
|
|
182
198
|
});
|
|
183
199
|
return this.chain;
|
|
184
200
|
}
|
|
201
|
+
/** Avoid flooding GHA logs when ephemeral 500s every ~150ms. */
|
|
202
|
+
logEphemeralIssue(message) {
|
|
203
|
+
this.ephemeralFailCount += 1;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
if (this.ephemeralFailCount <= 2 || now - this.ephemeralFailLogAt >= 10_000) {
|
|
206
|
+
this.ephemeralFailLogAt = now;
|
|
207
|
+
const suffix = this.ephemeralFailCount > 2 ? ` (x${this.ephemeralFailCount} since last log)` : "";
|
|
208
|
+
console.error(`ChimpHands ${message}${suffix}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
185
211
|
fireAndForget(role, content, opts) {
|
|
186
212
|
void this.enqueue(role, content, opts).catch((err) => {
|
|
187
213
|
const detail = err instanceof Error ? err.message : String(err);
|
|
@@ -274,7 +300,43 @@ function normalizeStdoutOpencodeEvent(raw) {
|
|
|
274
300
|
const props = (inner.properties && typeof inner.properties === "object"
|
|
275
301
|
? inner.properties
|
|
276
302
|
: {});
|
|
277
|
-
if (type === "message.part.
|
|
303
|
+
if (type === "message.part.delta") {
|
|
304
|
+
// OpenCode streams tokens as { partID, field, delta } with no `part`.
|
|
305
|
+
const deltaProps = props;
|
|
306
|
+
const part = deltaProps.part;
|
|
307
|
+
if (part) {
|
|
308
|
+
const partType = part.type || deltaProps.field || "";
|
|
309
|
+
let mapped;
|
|
310
|
+
if (partType === "text")
|
|
311
|
+
mapped = "text";
|
|
312
|
+
else if (partType === "reasoning")
|
|
313
|
+
mapped = "reasoning";
|
|
314
|
+
else if (partType === "tool")
|
|
315
|
+
mapped = "tool_use";
|
|
316
|
+
else
|
|
317
|
+
return null;
|
|
318
|
+
if (deltaProps.delta && !part.text)
|
|
319
|
+
part.text = deltaProps.delta;
|
|
320
|
+
return {
|
|
321
|
+
type: mapped,
|
|
322
|
+
sessionID: part.sessionID || deltaProps.sessionID,
|
|
323
|
+
part,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const partID = deltaProps.partID;
|
|
327
|
+
const field = deltaProps.field || "text";
|
|
328
|
+
const delta = deltaProps.delta;
|
|
329
|
+
if (!partID || delta == null || delta === "")
|
|
330
|
+
return null;
|
|
331
|
+
if (field !== "text" && field !== "reasoning")
|
|
332
|
+
return null;
|
|
333
|
+
return {
|
|
334
|
+
type: field === "reasoning" ? "reasoning" : "text",
|
|
335
|
+
sessionID: deltaProps.sessionID,
|
|
336
|
+
part: { id: partID, type: field, text: delta },
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (type === "message.part.updated") {
|
|
278
340
|
const part = props.part;
|
|
279
341
|
if (!part)
|
|
280
342
|
return null;
|
|
@@ -288,7 +350,6 @@ function normalizeStdoutOpencodeEvent(raw) {
|
|
|
288
350
|
mapped = "tool_use";
|
|
289
351
|
else
|
|
290
352
|
return null;
|
|
291
|
-
// Prefer cumulative text; append delta when that's all we got.
|
|
292
353
|
if (props.delta && !part.text) {
|
|
293
354
|
part.text = props.delta;
|
|
294
355
|
}
|
|
@@ -311,7 +372,7 @@ function normalizeStdoutOpencodeEvent(raw) {
|
|
|
311
372
|
}
|
|
312
373
|
return null;
|
|
313
374
|
}
|
|
314
|
-
/** Pull assistant/tool parts from OpenCode HTTP
|
|
375
|
+
/** Pull assistant/tool parts from OpenCode HTTP for durable PG (turn-end). */
|
|
315
376
|
async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, postEvent, onWorkingBranch) {
|
|
316
377
|
const base = attachUrl.replace(/\/$/, "");
|
|
317
378
|
const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
|
|
@@ -332,8 +393,18 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
|
|
|
332
393
|
const data = (await res.json());
|
|
333
394
|
if (!Array.isArray(data))
|
|
334
395
|
return 0;
|
|
396
|
+
// Only parts after the latest user message (this turn). Full-history reconcile
|
|
397
|
+
// every turn would O(n) upsert the entire transcript for no benefit.
|
|
398
|
+
let lastUserIdx = -1;
|
|
399
|
+
for (let i = data.length - 1; i >= 0; i--) {
|
|
400
|
+
if ((data[i]?.info?.role || "").toLowerCase() === "user") {
|
|
401
|
+
lastUserIdx = i;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const turnSlice = lastUserIdx >= 0 ? data.slice(lastUserIdx + 1) : data;
|
|
335
406
|
let posted = 0;
|
|
336
|
-
for (const msg of
|
|
407
|
+
for (const msg of turnSlice) {
|
|
337
408
|
if ((msg.info?.role || "").toLowerCase() !== "assistant")
|
|
338
409
|
continue;
|
|
339
410
|
for (const part of msg.parts || []) {
|
|
@@ -367,7 +438,7 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
|
|
|
367
438
|
}
|
|
368
439
|
}
|
|
369
440
|
if (posted) {
|
|
370
|
-
console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API`);
|
|
441
|
+
console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API (this turn)`);
|
|
371
442
|
}
|
|
372
443
|
return posted;
|
|
373
444
|
}
|
|
@@ -434,7 +505,36 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
|
434
505
|
const ev = unwrapped;
|
|
435
506
|
const type = ev.type || "";
|
|
436
507
|
const props = ev.properties || {};
|
|
437
|
-
|
|
508
|
+
// Token stream: { partID, field, delta } — often no `part` object.
|
|
509
|
+
if (type === "message.part.delta") {
|
|
510
|
+
const part = props.part;
|
|
511
|
+
const partId = props.partID || part?.id || part?.messageID;
|
|
512
|
+
const field = props.field || part?.type || "text";
|
|
513
|
+
const sessionId = part?.sessionID || props.sessionID;
|
|
514
|
+
if (!sessionMatches(sessionId))
|
|
515
|
+
return;
|
|
516
|
+
callbacks.noteSessionId(sessionId);
|
|
517
|
+
if (!partId || props.delta == null || props.delta === "")
|
|
518
|
+
return;
|
|
519
|
+
if (field === "text") {
|
|
520
|
+
const next = (textByPartId.get(partId) || "") + props.delta;
|
|
521
|
+
textByPartId.set(partId, next);
|
|
522
|
+
callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
|
|
523
|
+
messageId: `oc_text_${partId}`,
|
|
524
|
+
}));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (field === "reasoning") {
|
|
528
|
+
const key = `reasoning:${partId}`;
|
|
529
|
+
const next = (textByPartId.get(key) || "") + props.delta;
|
|
530
|
+
textByPartId.set(key, next);
|
|
531
|
+
callbacks.postEvent(ROLE_REASONING, next, liveOpts({
|
|
532
|
+
messageId: `oc_reasoning_${partId}`,
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (type === "message.part.updated") {
|
|
438
538
|
const part = props.part;
|
|
439
539
|
if (!part)
|
|
440
540
|
return;
|
|
@@ -447,13 +547,12 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
|
447
547
|
if (!partId)
|
|
448
548
|
return;
|
|
449
549
|
let next = part.text || "";
|
|
450
|
-
if (props.delta) {
|
|
550
|
+
if (props.delta && !part.text) {
|
|
451
551
|
next = (textByPartId.get(partId) || "") + props.delta;
|
|
452
552
|
}
|
|
453
553
|
else if (!next && props.delta === undefined) {
|
|
454
554
|
return;
|
|
455
555
|
}
|
|
456
|
-
// Prefer cumulative part.text when present (idempotent); else delta accumulation.
|
|
457
556
|
if (part.text)
|
|
458
557
|
next = part.text;
|
|
459
558
|
textByPartId.set(partId, next);
|
|
@@ -484,8 +583,17 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
|
484
583
|
}
|
|
485
584
|
if (part.type === "tool") {
|
|
486
585
|
const status = part.state?.status;
|
|
487
|
-
if (!status || status === "pending"
|
|
586
|
+
if (!status || status === "pending")
|
|
587
|
+
return;
|
|
588
|
+
// Ephemeral "(running)" bubbles while UI attached (liveStream gated in poster).
|
|
589
|
+
if (status === "running") {
|
|
590
|
+
const toolContent = formatToolUseContent(part);
|
|
591
|
+
callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
|
|
592
|
+
messageId: opencodeMessageId("oc_tool_", part),
|
|
593
|
+
throttle: false,
|
|
594
|
+
}));
|
|
488
595
|
return;
|
|
596
|
+
}
|
|
489
597
|
const toolContent = formatToolUseContent(part);
|
|
490
598
|
callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
|
|
491
599
|
messageId: opencodeMessageId("oc_tool_", part),
|
|
@@ -779,20 +887,34 @@ function killListenersOnPort(port) {
|
|
|
779
887
|
async function waitForOpencodeHttp(attachUrl, timeoutMs) {
|
|
780
888
|
const base = attachUrl.replace(/\/$/, "");
|
|
781
889
|
const deadline = Date.now() + timeoutMs;
|
|
890
|
+
const perTryMs = 2_000;
|
|
891
|
+
let attempt = 0;
|
|
892
|
+
let lastErr = "";
|
|
782
893
|
while (Date.now() < deadline) {
|
|
894
|
+
attempt += 1;
|
|
783
895
|
for (const path of ["/global/health", "/"]) {
|
|
784
896
|
try {
|
|
785
|
-
const res = await fetch(`${base}${path}
|
|
786
|
-
|
|
897
|
+
const res = await fetch(`${base}${path}`, {
|
|
898
|
+
signal: AbortSignal.timeout(perTryMs),
|
|
899
|
+
});
|
|
900
|
+
if (res.ok || res.status === 401 || res.status === 404) {
|
|
901
|
+
console.error(`ChimpHands OpenCode HTTP ready at ${attachUrl}${path} (http ${res.status}, attempt ${attempt})`);
|
|
787
902
|
return;
|
|
903
|
+
}
|
|
904
|
+
lastErr = `HTTP ${res.status}`;
|
|
788
905
|
}
|
|
789
|
-
catch {
|
|
790
|
-
|
|
906
|
+
catch (err) {
|
|
907
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
791
908
|
}
|
|
792
909
|
}
|
|
910
|
+
if (attempt === 1 || attempt % 5 === 0) {
|
|
911
|
+
const left = Math.max(0, deadline - Date.now());
|
|
912
|
+
console.error(`ChimpHands waiting for OpenCode HTTP at ${attachUrl} (attempt ${attempt}, ${Math.ceil(left / 1000)}s left): ${lastErr || "not ready"}`);
|
|
913
|
+
}
|
|
793
914
|
await sleep(400);
|
|
794
915
|
}
|
|
795
|
-
throw new Error(`OpenCode server not ready at ${attachUrl} within ${timeoutMs}ms`
|
|
916
|
+
throw new Error(`OpenCode server not ready at ${attachUrl} within ${timeoutMs}ms` +
|
|
917
|
+
(lastErr ? ` (last: ${lastErr})` : ""));
|
|
796
918
|
}
|
|
797
919
|
/**
|
|
798
920
|
* Restart local `opencode serve` after writing opencode.json so the server loads
|
|
@@ -897,6 +1019,19 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
897
1019
|
if (!ev?.type)
|
|
898
1020
|
return;
|
|
899
1021
|
noteSessionId(ev.sessionID);
|
|
1022
|
+
switch (ev.type) {
|
|
1023
|
+
case "text":
|
|
1024
|
+
case "reasoning":
|
|
1025
|
+
case "tool_use": {
|
|
1026
|
+
// Attach mode: live tokens come from OpenCode SSE; durable from turn-end
|
|
1027
|
+
// reconcile. Avoid double-fanout / double-accumulation with stdout.
|
|
1028
|
+
if (attachUrl)
|
|
1029
|
+
return;
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
default:
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
900
1035
|
switch (ev.type) {
|
|
901
1036
|
case "text": {
|
|
902
1037
|
const chunk = ev.part?.text;
|
|
@@ -907,7 +1042,14 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
907
1042
|
callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
|
|
908
1043
|
return;
|
|
909
1044
|
}
|
|
910
|
-
|
|
1045
|
+
// Without attach, --format json may emit completed cumulative or deltas.
|
|
1046
|
+
// Prefer replace when we already have longer text (cumulative); else append.
|
|
1047
|
+
const prev = textByPartId.get(partId) || "";
|
|
1048
|
+
const next = !prev
|
|
1049
|
+
? chunk
|
|
1050
|
+
: chunk.startsWith(prev)
|
|
1051
|
+
? chunk
|
|
1052
|
+
: prev + chunk;
|
|
911
1053
|
textByPartId.set(partId, next);
|
|
912
1054
|
callbacks.postEvent(ROLE_ASSISTANT, next, {
|
|
913
1055
|
throttle: true,
|
|
@@ -925,7 +1067,12 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
925
1067
|
return;
|
|
926
1068
|
}
|
|
927
1069
|
const reasoningKey = `reasoning:${partId}`;
|
|
928
|
-
const
|
|
1070
|
+
const prev = textByPartId.get(reasoningKey) || "";
|
|
1071
|
+
const next = !prev
|
|
1072
|
+
? chunk
|
|
1073
|
+
: chunk.startsWith(prev)
|
|
1074
|
+
? chunk
|
|
1075
|
+
: prev + chunk;
|
|
929
1076
|
textByPartId.set(reasoningKey, next);
|
|
930
1077
|
callbacks.postEvent(ROLE_REASONING, next, {
|
|
931
1078
|
throttle: true,
|
|
@@ -934,8 +1081,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
934
1081
|
return;
|
|
935
1082
|
}
|
|
936
1083
|
case "tool_use": {
|
|
1084
|
+
// Durable-only path (no attach). Skip in-progress.
|
|
937
1085
|
const status = ev.part?.state?.status;
|
|
938
|
-
if (!status || status === "pending")
|
|
1086
|
+
if (!status || status === "pending" || status === "running")
|
|
939
1087
|
return;
|
|
940
1088
|
const toolContent = formatToolUseContent(ev.part);
|
|
941
1089
|
callbacks.postEvent(ROLE_TOOL, toolContent, {
|
|
@@ -1122,11 +1270,14 @@ export async function runChimphands(opts) {
|
|
|
1122
1270
|
});
|
|
1123
1271
|
};
|
|
1124
1272
|
const syncLiveSse = (attached) => {
|
|
1125
|
-
poster.uiAttached = attached;
|
|
1126
1273
|
pendingUiAttached = attached;
|
|
1127
|
-
if (!attachUrl)
|
|
1274
|
+
if (!attachUrl) {
|
|
1275
|
+
poster.uiAttached = attached;
|
|
1128
1276
|
return;
|
|
1277
|
+
}
|
|
1129
1278
|
if (!opencodeHttpReady) {
|
|
1279
|
+
// Defer SSE; still track intent so first sync after ready is correct.
|
|
1280
|
+
poster.uiAttached = attached;
|
|
1130
1281
|
if (attached) {
|
|
1131
1282
|
console.error("ChimpHands UI attached — deferring OpenCode SSE until serve is ready");
|
|
1132
1283
|
}
|
|
@@ -1134,18 +1285,18 @@ export async function runChimphands(opts) {
|
|
|
1134
1285
|
}
|
|
1135
1286
|
if (attached) {
|
|
1136
1287
|
consecutiveUiDetached = 0;
|
|
1288
|
+
// Allow liveStream immediately when heartbeat says attached.
|
|
1289
|
+
poster.uiAttached = true;
|
|
1137
1290
|
startLiveSseIfNeeded("UI attached — starting OpenCode SSE fanout");
|
|
1138
1291
|
return;
|
|
1139
1292
|
}
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1293
|
+
// Detached / async: hysteresis avoids flap from cross-replica heartbeats.
|
|
1294
|
+
// Keep poster.uiAttached true + SSE up during the window so we don't drop
|
|
1295
|
+
// live tokens while the UI is still actually listening on another replica.
|
|
1142
1296
|
consecutiveUiDetached += 1;
|
|
1143
|
-
if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER)
|
|
1144
|
-
if (!stopLiveSse) {
|
|
1145
|
-
startLiveSseIfNeeded("starting OpenCode SSE fanout (durable until UI attaches)");
|
|
1146
|
-
}
|
|
1297
|
+
if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER)
|
|
1147
1298
|
return;
|
|
1148
|
-
|
|
1299
|
+
poster.uiAttached = false;
|
|
1149
1300
|
if (stopLiveSse) {
|
|
1150
1301
|
console.error(`ChimpHands UI detached for ${consecutiveUiDetached} heartbeats — stopping OpenCode SSE fanout`);
|
|
1151
1302
|
stopLiveSse();
|
|
@@ -1211,30 +1362,6 @@ export async function runChimphands(opts) {
|
|
|
1211
1362
|
const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
|
|
1212
1363
|
let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
|
|
1213
1364
|
let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
|
|
1214
|
-
const exportSignedUrl = bootStr(boot, "opencode_export_signed_url", "opencodeExportSignedUrl");
|
|
1215
|
-
if (exportSignedUrl && attachUrl) {
|
|
1216
|
-
try {
|
|
1217
|
-
const importedId = await importOpencodeExportFromUrl(exportSignedUrl);
|
|
1218
|
-
if (importedId) {
|
|
1219
|
-
opencodeSessionId = importedId;
|
|
1220
|
-
console.error(`ChimpHands rehydrated OpenCode session from export: ${importedId}`);
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
catch (err) {
|
|
1224
|
-
console.error(`ChimpHands export import failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
const snapshotExport = async () => {
|
|
1228
|
-
const sid = opencodeSessionId?.trim();
|
|
1229
|
-
if (!sid)
|
|
1230
|
-
return;
|
|
1231
|
-
try {
|
|
1232
|
-
await putOpencodeExport(backend, apiKey, sessionId, sid);
|
|
1233
|
-
}
|
|
1234
|
-
catch (err) {
|
|
1235
|
-
console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1236
|
-
}
|
|
1237
|
-
};
|
|
1238
1365
|
noteWorkingBranch = (branch, prUrl) => {
|
|
1239
1366
|
const normalizedBranch = branch.trim();
|
|
1240
1367
|
if (!normalizedBranch)
|
|
@@ -1334,7 +1461,6 @@ export async function runChimphands(opts) {
|
|
|
1334
1461
|
}
|
|
1335
1462
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
1336
1463
|
await poster.flush();
|
|
1337
|
-
await snapshotExport();
|
|
1338
1464
|
if (runtimeId) {
|
|
1339
1465
|
await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
|
|
1340
1466
|
runtimeId,
|
|
@@ -1434,6 +1560,24 @@ export async function runChimphands(opts) {
|
|
|
1434
1560
|
if (result.opencodeSessionId) {
|
|
1435
1561
|
opencodeSessionId = result.opencodeSessionId;
|
|
1436
1562
|
}
|
|
1563
|
+
// Turn-end durable reconcile into PG (idempotent messageIds). Mid-turn was
|
|
1564
|
+
// ephemeral-only when UI attached; async runs get their transcript here.
|
|
1565
|
+
// Also reconcile on failure so partial assistant/tool output is not lost.
|
|
1566
|
+
if (attachUrl && opencodeSessionId) {
|
|
1567
|
+
try {
|
|
1568
|
+
await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
|
|
1569
|
+
void poster.enqueue(role, content, {
|
|
1570
|
+
...opts,
|
|
1571
|
+
opencodeSessionId,
|
|
1572
|
+
// Explicitly not liveStream → always durable post_agent_event.
|
|
1573
|
+
});
|
|
1574
|
+
}, noteWorkingBranch);
|
|
1575
|
+
await poster.flush();
|
|
1576
|
+
}
|
|
1577
|
+
catch (err) {
|
|
1578
|
+
console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1437
1581
|
if (result.code !== 0) {
|
|
1438
1582
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
1439
1583
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
@@ -1459,7 +1603,6 @@ export async function runChimphands(opts) {
|
|
|
1459
1603
|
break;
|
|
1460
1604
|
}
|
|
1461
1605
|
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
1462
|
-
await snapshotExport();
|
|
1463
1606
|
lastUserActivity = Date.now();
|
|
1464
1607
|
idle = false;
|
|
1465
1608
|
prompt = (await waitForNextPrompt()) || "";
|
|
@@ -1575,61 +1718,6 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
1575
1718
|
console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1576
1719
|
}
|
|
1577
1720
|
}
|
|
1578
|
-
async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
|
|
1579
|
-
const exported = await new Promise((resolve, reject) => {
|
|
1580
|
-
const child = spawn("opencode", ["export", opencodeSessionId], {
|
|
1581
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1582
|
-
});
|
|
1583
|
-
let out = "";
|
|
1584
|
-
let err = "";
|
|
1585
|
-
child.stdout.on("data", (d) => {
|
|
1586
|
-
out += d.toString();
|
|
1587
|
-
});
|
|
1588
|
-
child.stderr.on("data", (d) => {
|
|
1589
|
-
err += d.toString();
|
|
1590
|
-
});
|
|
1591
|
-
child.on("close", (code) => {
|
|
1592
|
-
if (code === 0 && out.trim())
|
|
1593
|
-
resolve(out);
|
|
1594
|
-
else
|
|
1595
|
-
reject(new Error(err.trim() || `opencode export exited ${code}`));
|
|
1596
|
-
});
|
|
1597
|
-
});
|
|
1598
|
-
const exportBase64 = Buffer.from(exported, "utf8").toString("base64");
|
|
1599
|
-
await postJson(backend, apiKey, "/api/chimphands/put_opencode_export", {
|
|
1600
|
-
sessionId,
|
|
1601
|
-
exportBase64,
|
|
1602
|
-
});
|
|
1603
|
-
}
|
|
1604
|
-
async function importOpencodeExportFromUrl(signedUrl) {
|
|
1605
|
-
const res = await fetch(signedUrl);
|
|
1606
|
-
if (!res.ok) {
|
|
1607
|
-
throw new Error(`download export failed: ${res.status}`);
|
|
1608
|
-
}
|
|
1609
|
-
const text = await res.text();
|
|
1610
|
-
writeFileSync("/tmp/chimphands-opencode-export.json", text, "utf8");
|
|
1611
|
-
return await new Promise((resolve, reject) => {
|
|
1612
|
-
const child = spawn("opencode", ["import", "/tmp/chimphands-opencode-export.json"], {
|
|
1613
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1614
|
-
});
|
|
1615
|
-
let out = "";
|
|
1616
|
-
let err = "";
|
|
1617
|
-
child.stdout.on("data", (d) => {
|
|
1618
|
-
out += d.toString();
|
|
1619
|
-
});
|
|
1620
|
-
child.stderr.on("data", (d) => {
|
|
1621
|
-
err += d.toString();
|
|
1622
|
-
});
|
|
1623
|
-
child.on("close", (code) => {
|
|
1624
|
-
if (code !== 0) {
|
|
1625
|
-
reject(new Error(err.trim() || `opencode import exited ${code}`));
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
const match = (out + "\n" + err).match(/ses_[A-Za-z0-9]+/);
|
|
1629
|
-
resolve(match?.[0]);
|
|
1630
|
-
});
|
|
1631
|
-
});
|
|
1632
|
-
}
|
|
1633
1721
|
function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
|
|
1634
1722
|
let stopped = false;
|
|
1635
1723
|
const base = attachUrl.replace(/\/$/, "");
|
package/package.json
CHANGED