@testchimp/cli 0.1.50 → 0.1.52
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 +223 -123
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -137,11 +137,12 @@ class AgentEventPoster {
|
|
|
137
137
|
if (opts?.pullRequestUrl)
|
|
138
138
|
body.pullRequestUrl = opts.pullRequestUrl;
|
|
139
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
|
-
}
|
|
144
140
|
this.chain = this.chain.then(async () => {
|
|
141
|
+
// Mid-turn live tokens only while UI is attached — async runs skip FS fanout
|
|
142
|
+
// and rely on turn-end reconcile for durable PG.
|
|
143
|
+
if (opts?.liveStream && !this.uiAttached) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
145
146
|
if (opts?.throttle || (streamRole && opts?.liveStream)) {
|
|
146
147
|
const now = Date.now();
|
|
147
148
|
const wait = STREAM_POST_MIN_INTERVAL_MS - (now - this.lastStreamPostAt);
|
|
@@ -172,11 +173,21 @@ class AgentEventPoster {
|
|
|
172
173
|
}
|
|
173
174
|
if (delivered)
|
|
174
175
|
return;
|
|
175
|
-
//
|
|
176
|
+
// Mid-turn liveStream must not fall through to durable — that would write
|
|
177
|
+
// every token to PG. Turn-end reconcile persists the final transcript.
|
|
178
|
+
if (opts?.liveStream) {
|
|
179
|
+
console.error("ChimpHands ephemeral not delivered (replica miss?) — skipping durable for liveStream");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Completed/non-live: durable fallback for cross-replica UI.
|
|
176
183
|
console.error("ChimpHands ephemeral not delivered (replica miss?) — persisting via post_agent_event");
|
|
177
184
|
}
|
|
178
185
|
catch (err) {
|
|
179
186
|
const detail = err instanceof Error ? err.message : String(err);
|
|
187
|
+
if (opts?.liveStream) {
|
|
188
|
+
console.error(`ChimpHands ephemeral post failed — skipping durable for liveStream: ${detail}`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
180
191
|
console.error(`ChimpHands ephemeral post failed — durable fallback: ${detail}`);
|
|
181
192
|
}
|
|
182
193
|
}
|
|
@@ -276,7 +287,43 @@ function normalizeStdoutOpencodeEvent(raw) {
|
|
|
276
287
|
const props = (inner.properties && typeof inner.properties === "object"
|
|
277
288
|
? inner.properties
|
|
278
289
|
: {});
|
|
279
|
-
if (type === "message.part.
|
|
290
|
+
if (type === "message.part.delta") {
|
|
291
|
+
// OpenCode streams tokens as { partID, field, delta } with no `part`.
|
|
292
|
+
const deltaProps = props;
|
|
293
|
+
const part = deltaProps.part;
|
|
294
|
+
if (part) {
|
|
295
|
+
const partType = part.type || deltaProps.field || "";
|
|
296
|
+
let mapped;
|
|
297
|
+
if (partType === "text")
|
|
298
|
+
mapped = "text";
|
|
299
|
+
else if (partType === "reasoning")
|
|
300
|
+
mapped = "reasoning";
|
|
301
|
+
else if (partType === "tool")
|
|
302
|
+
mapped = "tool_use";
|
|
303
|
+
else
|
|
304
|
+
return null;
|
|
305
|
+
if (deltaProps.delta && !part.text)
|
|
306
|
+
part.text = deltaProps.delta;
|
|
307
|
+
return {
|
|
308
|
+
type: mapped,
|
|
309
|
+
sessionID: part.sessionID || deltaProps.sessionID,
|
|
310
|
+
part,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const partID = deltaProps.partID;
|
|
314
|
+
const field = deltaProps.field || "text";
|
|
315
|
+
const delta = deltaProps.delta;
|
|
316
|
+
if (!partID || delta == null || delta === "")
|
|
317
|
+
return null;
|
|
318
|
+
if (field !== "text" && field !== "reasoning")
|
|
319
|
+
return null;
|
|
320
|
+
return {
|
|
321
|
+
type: field === "reasoning" ? "reasoning" : "text",
|
|
322
|
+
sessionID: deltaProps.sessionID,
|
|
323
|
+
part: { id: partID, type: field, text: delta },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (type === "message.part.updated") {
|
|
280
327
|
const part = props.part;
|
|
281
328
|
if (!part)
|
|
282
329
|
return null;
|
|
@@ -290,7 +337,6 @@ function normalizeStdoutOpencodeEvent(raw) {
|
|
|
290
337
|
mapped = "tool_use";
|
|
291
338
|
else
|
|
292
339
|
return null;
|
|
293
|
-
// Prefer cumulative text; append delta when that's all we got.
|
|
294
340
|
if (props.delta && !part.text) {
|
|
295
341
|
part.text = props.delta;
|
|
296
342
|
}
|
|
@@ -313,7 +359,7 @@ function normalizeStdoutOpencodeEvent(raw) {
|
|
|
313
359
|
}
|
|
314
360
|
return null;
|
|
315
361
|
}
|
|
316
|
-
/** Pull assistant/tool parts from OpenCode HTTP
|
|
362
|
+
/** Pull assistant/tool parts from OpenCode HTTP for durable PG (turn-end). */
|
|
317
363
|
async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, postEvent, onWorkingBranch) {
|
|
318
364
|
const base = attachUrl.replace(/\/$/, "");
|
|
319
365
|
const url = `${base}/session/${encodeURIComponent(opencodeSessionId)}/message`;
|
|
@@ -334,8 +380,18 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
|
|
|
334
380
|
const data = (await res.json());
|
|
335
381
|
if (!Array.isArray(data))
|
|
336
382
|
return 0;
|
|
383
|
+
// Only parts after the latest user message (this turn). Full-history reconcile
|
|
384
|
+
// every turn would O(n) upsert the entire transcript for no benefit.
|
|
385
|
+
let lastUserIdx = -1;
|
|
386
|
+
for (let i = data.length - 1; i >= 0; i--) {
|
|
387
|
+
if ((data[i]?.info?.role || "").toLowerCase() === "user") {
|
|
388
|
+
lastUserIdx = i;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const turnSlice = lastUserIdx >= 0 ? data.slice(lastUserIdx + 1) : data;
|
|
337
393
|
let posted = 0;
|
|
338
|
-
for (const msg of
|
|
394
|
+
for (const msg of turnSlice) {
|
|
339
395
|
if ((msg.info?.role || "").toLowerCase() !== "assistant")
|
|
340
396
|
continue;
|
|
341
397
|
for (const part of msg.parts || []) {
|
|
@@ -369,7 +425,7 @@ async function reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, po
|
|
|
369
425
|
}
|
|
370
426
|
}
|
|
371
427
|
if (posted) {
|
|
372
|
-
console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API`);
|
|
428
|
+
console.error(`ChimpHands reconciled ${posted} part(s) from OpenCode session API (this turn)`);
|
|
373
429
|
}
|
|
374
430
|
return posted;
|
|
375
431
|
}
|
|
@@ -436,7 +492,36 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
|
436
492
|
const ev = unwrapped;
|
|
437
493
|
const type = ev.type || "";
|
|
438
494
|
const props = ev.properties || {};
|
|
439
|
-
|
|
495
|
+
// Token stream: { partID, field, delta } — often no `part` object.
|
|
496
|
+
if (type === "message.part.delta") {
|
|
497
|
+
const part = props.part;
|
|
498
|
+
const partId = props.partID || part?.id || part?.messageID;
|
|
499
|
+
const field = props.field || part?.type || "text";
|
|
500
|
+
const sessionId = part?.sessionID || props.sessionID;
|
|
501
|
+
if (!sessionMatches(sessionId))
|
|
502
|
+
return;
|
|
503
|
+
callbacks.noteSessionId(sessionId);
|
|
504
|
+
if (!partId || props.delta == null || props.delta === "")
|
|
505
|
+
return;
|
|
506
|
+
if (field === "text") {
|
|
507
|
+
const next = (textByPartId.get(partId) || "") + props.delta;
|
|
508
|
+
textByPartId.set(partId, next);
|
|
509
|
+
callbacks.postEvent(ROLE_ASSISTANT, next, liveOpts({
|
|
510
|
+
messageId: `oc_text_${partId}`,
|
|
511
|
+
}));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (field === "reasoning") {
|
|
515
|
+
const key = `reasoning:${partId}`;
|
|
516
|
+
const next = (textByPartId.get(key) || "") + props.delta;
|
|
517
|
+
textByPartId.set(key, next);
|
|
518
|
+
callbacks.postEvent(ROLE_REASONING, next, liveOpts({
|
|
519
|
+
messageId: `oc_reasoning_${partId}`,
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (type === "message.part.updated") {
|
|
440
525
|
const part = props.part;
|
|
441
526
|
if (!part)
|
|
442
527
|
return;
|
|
@@ -449,13 +534,12 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
|
449
534
|
if (!partId)
|
|
450
535
|
return;
|
|
451
536
|
let next = part.text || "";
|
|
452
|
-
if (props.delta) {
|
|
537
|
+
if (props.delta && !part.text) {
|
|
453
538
|
next = (textByPartId.get(partId) || "") + props.delta;
|
|
454
539
|
}
|
|
455
540
|
else if (!next && props.delta === undefined) {
|
|
456
541
|
return;
|
|
457
542
|
}
|
|
458
|
-
// Prefer cumulative part.text when present (idempotent); else delta accumulation.
|
|
459
543
|
if (part.text)
|
|
460
544
|
next = part.text;
|
|
461
545
|
textByPartId.set(partId, next);
|
|
@@ -486,8 +570,17 @@ function startOpencodeSseRelay(attachUrl, callbacks) {
|
|
|
486
570
|
}
|
|
487
571
|
if (part.type === "tool") {
|
|
488
572
|
const status = part.state?.status;
|
|
489
|
-
if (!status || status === "pending"
|
|
573
|
+
if (!status || status === "pending")
|
|
490
574
|
return;
|
|
575
|
+
// Ephemeral "(running)" bubbles while UI attached (liveStream gated in poster).
|
|
576
|
+
if (status === "running") {
|
|
577
|
+
const toolContent = formatToolUseContent(part);
|
|
578
|
+
callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
|
|
579
|
+
messageId: opencodeMessageId("oc_tool_", part),
|
|
580
|
+
throttle: false,
|
|
581
|
+
}));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
491
584
|
const toolContent = formatToolUseContent(part);
|
|
492
585
|
callbacks.postEvent(ROLE_TOOL, toolContent, liveOpts({
|
|
493
586
|
messageId: opencodeMessageId("oc_tool_", part),
|
|
@@ -863,7 +956,7 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
863
956
|
callbacks.postEvent(ROLE_STATUS, "Agent is still working… (waiting for OpenCode output)", {
|
|
864
957
|
status: STATUS_RUNNING,
|
|
865
958
|
});
|
|
866
|
-
},
|
|
959
|
+
}, 15_000);
|
|
867
960
|
const noteSessionId = (sessionId) => {
|
|
868
961
|
const id = sessionId?.trim();
|
|
869
962
|
if (!id || id === activeSessionId)
|
|
@@ -887,10 +980,31 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
887
980
|
fatalError = fatal;
|
|
888
981
|
return;
|
|
889
982
|
}
|
|
890
|
-
|
|
983
|
+
let parsed;
|
|
984
|
+
try {
|
|
985
|
+
parsed = JSON.parse(line);
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
// Newer OpenCode --format json uses bus shape (message.part.updated); normalize first.
|
|
991
|
+
const ev = normalizeStdoutOpencodeEvent(parsed) || parseOpencodeEvent(line);
|
|
891
992
|
if (!ev?.type)
|
|
892
993
|
return;
|
|
893
994
|
noteSessionId(ev.sessionID);
|
|
995
|
+
switch (ev.type) {
|
|
996
|
+
case "text":
|
|
997
|
+
case "reasoning":
|
|
998
|
+
case "tool_use": {
|
|
999
|
+
// Attach mode: live tokens come from OpenCode SSE; durable from turn-end
|
|
1000
|
+
// reconcile. Avoid double-fanout / double-accumulation with stdout.
|
|
1001
|
+
if (attachUrl)
|
|
1002
|
+
return;
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
default:
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
894
1008
|
switch (ev.type) {
|
|
895
1009
|
case "text": {
|
|
896
1010
|
const chunk = ev.part?.text;
|
|
@@ -901,7 +1015,14 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
901
1015
|
callbacks.postEvent(ROLE_ASSISTANT, chunk, { throttle: true });
|
|
902
1016
|
return;
|
|
903
1017
|
}
|
|
904
|
-
|
|
1018
|
+
// Without attach, --format json may emit completed cumulative or deltas.
|
|
1019
|
+
// Prefer replace when we already have longer text (cumulative); else append.
|
|
1020
|
+
const prev = textByPartId.get(partId) || "";
|
|
1021
|
+
const next = !prev
|
|
1022
|
+
? chunk
|
|
1023
|
+
: chunk.startsWith(prev)
|
|
1024
|
+
? chunk
|
|
1025
|
+
: prev + chunk;
|
|
905
1026
|
textByPartId.set(partId, next);
|
|
906
1027
|
callbacks.postEvent(ROLE_ASSISTANT, next, {
|
|
907
1028
|
throttle: true,
|
|
@@ -919,7 +1040,12 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
919
1040
|
return;
|
|
920
1041
|
}
|
|
921
1042
|
const reasoningKey = `reasoning:${partId}`;
|
|
922
|
-
const
|
|
1043
|
+
const prev = textByPartId.get(reasoningKey) || "";
|
|
1044
|
+
const next = !prev
|
|
1045
|
+
? chunk
|
|
1046
|
+
: chunk.startsWith(prev)
|
|
1047
|
+
? chunk
|
|
1048
|
+
: prev + chunk;
|
|
923
1049
|
textByPartId.set(reasoningKey, next);
|
|
924
1050
|
callbacks.postEvent(ROLE_REASONING, next, {
|
|
925
1051
|
throttle: true,
|
|
@@ -928,8 +1054,9 @@ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, atta
|
|
|
928
1054
|
return;
|
|
929
1055
|
}
|
|
930
1056
|
case "tool_use": {
|
|
1057
|
+
// Durable-only path (no attach). Skip in-progress.
|
|
931
1058
|
const status = ev.part?.state?.status;
|
|
932
|
-
if (!status || status === "pending")
|
|
1059
|
+
if (!status || status === "pending" || status === "running")
|
|
933
1060
|
return;
|
|
934
1061
|
const toolContent = formatToolUseContent(ev.part);
|
|
935
1062
|
callbacks.postEvent(ROLE_TOOL, toolContent, {
|
|
@@ -1093,36 +1220,58 @@ export async function runChimphands(opts) {
|
|
|
1093
1220
|
/** Do not open localhost OpenCode /event until serve has been (re)started with config. */
|
|
1094
1221
|
let opencodeHttpReady = !attachUrl;
|
|
1095
1222
|
let pendingUiAttached = !!(boot.uiAttached ?? boot.ui_attached);
|
|
1223
|
+
/** Consecutive uiAttached=false heartbeats before stopping OpenCode SSE (multi-replica poison). */
|
|
1224
|
+
let consecutiveUiDetached = 0;
|
|
1225
|
+
const UI_DETACHED_STOP_AFTER = 3;
|
|
1226
|
+
const startLiveSseIfNeeded = (reason) => {
|
|
1227
|
+
if (!attachUrl || !opencodeHttpReady || stopLiveSse)
|
|
1228
|
+
return;
|
|
1229
|
+
console.error(`ChimpHands ${reason}`);
|
|
1230
|
+
stopLiveSse = startOpencodeSseRelay(attachUrl, {
|
|
1231
|
+
getActiveSessionId: () => opencodeSessionId,
|
|
1232
|
+
noteSessionId: (id) => {
|
|
1233
|
+
if (id?.trim())
|
|
1234
|
+
opencodeSessionId = id.trim();
|
|
1235
|
+
},
|
|
1236
|
+
postEvent: (role, content, opts) => {
|
|
1237
|
+
poster.fireAndForget(role, content, {
|
|
1238
|
+
...opts,
|
|
1239
|
+
opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
|
|
1240
|
+
});
|
|
1241
|
+
},
|
|
1242
|
+
onWorkingBranch: noteWorkingBranchPlaceholder,
|
|
1243
|
+
});
|
|
1244
|
+
};
|
|
1096
1245
|
const syncLiveSse = (attached) => {
|
|
1097
|
-
poster.uiAttached = attached;
|
|
1098
1246
|
pendingUiAttached = attached;
|
|
1099
|
-
if (!attachUrl)
|
|
1247
|
+
if (!attachUrl) {
|
|
1248
|
+
poster.uiAttached = attached;
|
|
1100
1249
|
return;
|
|
1250
|
+
}
|
|
1101
1251
|
if (!opencodeHttpReady) {
|
|
1252
|
+
// Defer SSE; still track intent so first sync after ready is correct.
|
|
1253
|
+
poster.uiAttached = attached;
|
|
1102
1254
|
if (attached) {
|
|
1103
1255
|
console.error("ChimpHands UI attached — deferring OpenCode SSE until serve is ready");
|
|
1104
1256
|
}
|
|
1105
1257
|
return;
|
|
1106
1258
|
}
|
|
1107
|
-
if (attached
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
opencodeSessionId = id.trim();
|
|
1114
|
-
},
|
|
1115
|
-
postEvent: (role, content, opts) => {
|
|
1116
|
-
poster.fireAndForget(role, content, {
|
|
1117
|
-
...opts,
|
|
1118
|
-
opencodeSessionId: opts?.opencodeSessionId || opencodeSessionId,
|
|
1119
|
-
});
|
|
1120
|
-
},
|
|
1121
|
-
onWorkingBranch: noteWorkingBranchPlaceholder,
|
|
1122
|
-
});
|
|
1259
|
+
if (attached) {
|
|
1260
|
+
consecutiveUiDetached = 0;
|
|
1261
|
+
// Allow liveStream immediately when heartbeat says attached.
|
|
1262
|
+
poster.uiAttached = true;
|
|
1263
|
+
startLiveSseIfNeeded("UI attached — starting OpenCode SSE fanout");
|
|
1264
|
+
return;
|
|
1123
1265
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1266
|
+
// Detached / async: hysteresis avoids flap from cross-replica heartbeats.
|
|
1267
|
+
// Keep poster.uiAttached true + SSE up during the window so we don't drop
|
|
1268
|
+
// live tokens while the UI is still actually listening on another replica.
|
|
1269
|
+
consecutiveUiDetached += 1;
|
|
1270
|
+
if (consecutiveUiDetached < UI_DETACHED_STOP_AFTER)
|
|
1271
|
+
return;
|
|
1272
|
+
poster.uiAttached = false;
|
|
1273
|
+
if (stopLiveSse) {
|
|
1274
|
+
console.error(`ChimpHands UI detached for ${consecutiveUiDetached} heartbeats — stopping OpenCode SSE fanout`);
|
|
1126
1275
|
stopLiveSse();
|
|
1127
1276
|
stopLiveSse = null;
|
|
1128
1277
|
}
|
|
@@ -1186,30 +1335,6 @@ export async function runChimphands(opts) {
|
|
|
1186
1335
|
const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
|
|
1187
1336
|
let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
|
|
1188
1337
|
let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
|
|
1189
|
-
const exportSignedUrl = bootStr(boot, "opencode_export_signed_url", "opencodeExportSignedUrl");
|
|
1190
|
-
if (exportSignedUrl && attachUrl) {
|
|
1191
|
-
try {
|
|
1192
|
-
const importedId = await importOpencodeExportFromUrl(exportSignedUrl);
|
|
1193
|
-
if (importedId) {
|
|
1194
|
-
opencodeSessionId = importedId;
|
|
1195
|
-
console.error(`ChimpHands rehydrated OpenCode session from export: ${importedId}`);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
catch (err) {
|
|
1199
|
-
console.error(`ChimpHands export import failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
const snapshotExport = async () => {
|
|
1203
|
-
const sid = opencodeSessionId?.trim();
|
|
1204
|
-
if (!sid)
|
|
1205
|
-
return;
|
|
1206
|
-
try {
|
|
1207
|
-
await putOpencodeExport(backend, apiKey, sessionId, sid);
|
|
1208
|
-
}
|
|
1209
|
-
catch (err) {
|
|
1210
|
-
console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1211
|
-
}
|
|
1212
|
-
};
|
|
1213
1338
|
noteWorkingBranch = (branch, prUrl) => {
|
|
1214
1339
|
const normalizedBranch = branch.trim();
|
|
1215
1340
|
if (!normalizedBranch)
|
|
@@ -1309,7 +1434,6 @@ export async function runChimphands(opts) {
|
|
|
1309
1434
|
}
|
|
1310
1435
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
1311
1436
|
await poster.flush();
|
|
1312
|
-
await snapshotExport();
|
|
1313
1437
|
if (runtimeId) {
|
|
1314
1438
|
await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
|
|
1315
1439
|
runtimeId,
|
|
@@ -1320,10 +1444,22 @@ export async function runChimphands(opts) {
|
|
|
1320
1444
|
try {
|
|
1321
1445
|
poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
|
|
1322
1446
|
let prompt = normalizeUserMessage(promptInput || bootStr(boot, "initial_prompt", "initialPrompt"));
|
|
1447
|
+
// Bootstrap sets initialPrompt from the last pending user message AND returns
|
|
1448
|
+
// pendingUserMessages — enqueue only messages that are not the initial prompt
|
|
1449
|
+
// (otherwise the same turn runs twice: session=new then session=ses_…).
|
|
1323
1450
|
const pending = boot.pending_user_messages || boot.pendingUserMessages || [];
|
|
1451
|
+
const initialNorm = normalizeUserMessage(prompt);
|
|
1324
1452
|
for (const m of pending) {
|
|
1325
|
-
|
|
1326
|
-
|
|
1453
|
+
const content = normalizeUserMessage(m?.content || "");
|
|
1454
|
+
if (!content)
|
|
1455
|
+
continue;
|
|
1456
|
+
if (initialNorm && content === initialNorm)
|
|
1457
|
+
continue;
|
|
1458
|
+
const id = ("id" in m && m.id) ||
|
|
1459
|
+
("message_id" in m && m.message_id) ||
|
|
1460
|
+
("messageId" in m && m.messageId) ||
|
|
1461
|
+
undefined;
|
|
1462
|
+
enqueueUserMessage({ id: id || undefined, content });
|
|
1327
1463
|
}
|
|
1328
1464
|
const waitForNextPrompt = () => new Promise((resolve) => {
|
|
1329
1465
|
let lastPollAt = 0;
|
|
@@ -1397,6 +1533,24 @@ export async function runChimphands(opts) {
|
|
|
1397
1533
|
if (result.opencodeSessionId) {
|
|
1398
1534
|
opencodeSessionId = result.opencodeSessionId;
|
|
1399
1535
|
}
|
|
1536
|
+
// Turn-end durable reconcile into PG (idempotent messageIds). Mid-turn was
|
|
1537
|
+
// ephemeral-only when UI attached; async runs get their transcript here.
|
|
1538
|
+
// Also reconcile on failure so partial assistant/tool output is not lost.
|
|
1539
|
+
if (attachUrl && opencodeSessionId) {
|
|
1540
|
+
try {
|
|
1541
|
+
await reconcileOpencodeSessionMessages(attachUrl, opencodeSessionId, (role, content, opts) => {
|
|
1542
|
+
void poster.enqueue(role, content, {
|
|
1543
|
+
...opts,
|
|
1544
|
+
opencodeSessionId,
|
|
1545
|
+
// Explicitly not liveStream → always durable post_agent_event.
|
|
1546
|
+
});
|
|
1547
|
+
}, noteWorkingBranch);
|
|
1548
|
+
await poster.flush();
|
|
1549
|
+
}
|
|
1550
|
+
catch (err) {
|
|
1551
|
+
console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1400
1554
|
if (result.code !== 0) {
|
|
1401
1555
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
1402
1556
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
@@ -1422,7 +1576,6 @@ export async function runChimphands(opts) {
|
|
|
1422
1576
|
break;
|
|
1423
1577
|
}
|
|
1424
1578
|
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
1425
|
-
await snapshotExport();
|
|
1426
1579
|
lastUserActivity = Date.now();
|
|
1427
1580
|
idle = false;
|
|
1428
1581
|
prompt = (await waitForNextPrompt()) || "";
|
|
@@ -1453,7 +1606,9 @@ function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
|
|
|
1453
1606
|
try {
|
|
1454
1607
|
const data = JSON.parse(text);
|
|
1455
1608
|
const attached = !!(data.uiAttached ?? data.ui_attached);
|
|
1456
|
-
|
|
1609
|
+
// Always notify on false so syncLiveSse can accumulate detach hysteresis;
|
|
1610
|
+
// still skip duplicate true→true noise.
|
|
1611
|
+
if (attached !== lastAttached || !attached) {
|
|
1457
1612
|
lastAttached = attached;
|
|
1458
1613
|
onUiAttached?.(attached);
|
|
1459
1614
|
}
|
|
@@ -1536,61 +1691,6 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
1536
1691
|
console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1537
1692
|
}
|
|
1538
1693
|
}
|
|
1539
|
-
async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
|
|
1540
|
-
const exported = await new Promise((resolve, reject) => {
|
|
1541
|
-
const child = spawn("opencode", ["export", opencodeSessionId], {
|
|
1542
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1543
|
-
});
|
|
1544
|
-
let out = "";
|
|
1545
|
-
let err = "";
|
|
1546
|
-
child.stdout.on("data", (d) => {
|
|
1547
|
-
out += d.toString();
|
|
1548
|
-
});
|
|
1549
|
-
child.stderr.on("data", (d) => {
|
|
1550
|
-
err += d.toString();
|
|
1551
|
-
});
|
|
1552
|
-
child.on("close", (code) => {
|
|
1553
|
-
if (code === 0 && out.trim())
|
|
1554
|
-
resolve(out);
|
|
1555
|
-
else
|
|
1556
|
-
reject(new Error(err.trim() || `opencode export exited ${code}`));
|
|
1557
|
-
});
|
|
1558
|
-
});
|
|
1559
|
-
const exportBase64 = Buffer.from(exported, "utf8").toString("base64");
|
|
1560
|
-
await postJson(backend, apiKey, "/api/chimphands/put_opencode_export", {
|
|
1561
|
-
sessionId,
|
|
1562
|
-
exportBase64,
|
|
1563
|
-
});
|
|
1564
|
-
}
|
|
1565
|
-
async function importOpencodeExportFromUrl(signedUrl) {
|
|
1566
|
-
const res = await fetch(signedUrl);
|
|
1567
|
-
if (!res.ok) {
|
|
1568
|
-
throw new Error(`download export failed: ${res.status}`);
|
|
1569
|
-
}
|
|
1570
|
-
const text = await res.text();
|
|
1571
|
-
writeFileSync("/tmp/chimphands-opencode-export.json", text, "utf8");
|
|
1572
|
-
return await new Promise((resolve, reject) => {
|
|
1573
|
-
const child = spawn("opencode", ["import", "/tmp/chimphands-opencode-export.json"], {
|
|
1574
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1575
|
-
});
|
|
1576
|
-
let out = "";
|
|
1577
|
-
let err = "";
|
|
1578
|
-
child.stdout.on("data", (d) => {
|
|
1579
|
-
out += d.toString();
|
|
1580
|
-
});
|
|
1581
|
-
child.stderr.on("data", (d) => {
|
|
1582
|
-
err += d.toString();
|
|
1583
|
-
});
|
|
1584
|
-
child.on("close", (code) => {
|
|
1585
|
-
if (code !== 0) {
|
|
1586
|
-
reject(new Error(err.trim() || `opencode import exited ${code}`));
|
|
1587
|
-
return;
|
|
1588
|
-
}
|
|
1589
|
-
const match = (out + "\n" + err).match(/ses_[A-Za-z0-9]+/);
|
|
1590
|
-
resolve(match?.[0]);
|
|
1591
|
-
});
|
|
1592
|
-
});
|
|
1593
|
-
}
|
|
1594
1694
|
function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
|
|
1595
1695
|
let stopped = false;
|
|
1596
1696
|
const base = attachUrl.replace(/\/$/, "");
|
package/package.json
CHANGED