@testchimp/cli 0.1.37 → 0.1.39
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 +213 -90
- package/package.json +1 -1
package/dist/chimphands/run.js
CHANGED
|
@@ -16,12 +16,8 @@ const STATUS_RUNNING = "CHIMPHANDS_SESSION_STATUS_RUNNING";
|
|
|
16
16
|
const STATUS_WAITING_USER = "CHIMPHANDS_SESSION_STATUS_WAITING_USER";
|
|
17
17
|
const STATUS_IDLE = "CHIMPHANDS_SESSION_STATUS_IDLE";
|
|
18
18
|
const STATUS_FAILED = "CHIMPHANDS_SESSION_STATUS_FAILED";
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
if (!trimmed)
|
|
22
|
-
return trimmed;
|
|
23
|
-
const rest = trimmed.replace(/^\/testchimp\s*/i, "").trim();
|
|
24
|
-
return rest ? `/testchimp ${rest}` : "/testchimp";
|
|
19
|
+
function normalizeUserMessage(content) {
|
|
20
|
+
return content.trim();
|
|
25
21
|
}
|
|
26
22
|
function apiHeaders(apiKey) {
|
|
27
23
|
return {
|
|
@@ -63,7 +59,11 @@ function extractOpencodeFatalError(raw) {
|
|
|
63
59
|
try {
|
|
64
60
|
const ev = JSON.parse(line);
|
|
65
61
|
if (ev.type === "error" || ev.name === "UnknownError") {
|
|
66
|
-
const msg = ev.data?.message ||
|
|
62
|
+
const msg = ev.error?.data?.message ||
|
|
63
|
+
ev.error?.message ||
|
|
64
|
+
ev.data?.message ||
|
|
65
|
+
ev.message ||
|
|
66
|
+
line;
|
|
67
67
|
const ref = ev.data?.ref ? ` (ref ${ev.data.ref})` : "";
|
|
68
68
|
return `${msg}${ref}`;
|
|
69
69
|
}
|
|
@@ -83,6 +83,27 @@ function extractOpencodeFatalError(raw) {
|
|
|
83
83
|
}
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
|
+
function parseOpencodeEvent(line) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(line);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function formatToolUseContent(part) {
|
|
95
|
+
const title = part.state?.title || part.tool || "tool";
|
|
96
|
+
const output = part.state?.output?.trim();
|
|
97
|
+
if (output)
|
|
98
|
+
return `[${title}]\n${output}`;
|
|
99
|
+
return `[${title}]`;
|
|
100
|
+
}
|
|
101
|
+
function opencodeMessageId(prefix, part) {
|
|
102
|
+
const raw = part?.id || part?.messageID;
|
|
103
|
+
if (!raw)
|
|
104
|
+
return undefined;
|
|
105
|
+
return `${prefix}${raw}`;
|
|
106
|
+
}
|
|
86
107
|
function summarizeOpencodeFailure(stderr, stdout, exitCode) {
|
|
87
108
|
for (const chunk of [stderr, stdout]) {
|
|
88
109
|
for (const line of chunk.split("\n")) {
|
|
@@ -169,41 +190,65 @@ function runOpencode(prompt, model, childEnv, postEvent) {
|
|
|
169
190
|
return new Promise((resolve) => {
|
|
170
191
|
let buf = "";
|
|
171
192
|
let fatalError = null;
|
|
193
|
+
const textByPartId = new Map();
|
|
194
|
+
const handleOpencodeLine = (line) => {
|
|
195
|
+
if (!line.trim())
|
|
196
|
+
return;
|
|
197
|
+
const fatal = extractOpencodeFatalError(line);
|
|
198
|
+
if (fatal) {
|
|
199
|
+
fatalError = fatal;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const ev = parseOpencodeEvent(line);
|
|
203
|
+
if (!ev?.type)
|
|
204
|
+
return;
|
|
205
|
+
switch (ev.type) {
|
|
206
|
+
case "text": {
|
|
207
|
+
const chunk = ev.part?.text;
|
|
208
|
+
if (!chunk)
|
|
209
|
+
return;
|
|
210
|
+
const partId = ev.part?.id || ev.part?.messageID;
|
|
211
|
+
if (!partId) {
|
|
212
|
+
postEvent(ROLE_ASSISTANT, chunk);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const next = (textByPartId.get(partId) || "") + chunk;
|
|
216
|
+
textByPartId.set(partId, next);
|
|
217
|
+
postEvent(ROLE_ASSISTANT, next, undefined, opencodeMessageId("oc_text_", ev.part));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case "tool_use": {
|
|
221
|
+
if (ev.part?.state?.status !== "completed")
|
|
222
|
+
return;
|
|
223
|
+
postEvent(ROLE_TOOL, formatToolUseContent(ev.part), undefined, opencodeMessageId("oc_tool_", ev.part));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
case "error": {
|
|
227
|
+
const msg = ev.error?.data?.message ||
|
|
228
|
+
ev.error?.message ||
|
|
229
|
+
line.trim();
|
|
230
|
+
if (msg)
|
|
231
|
+
fatalError = msg;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
case "step_start":
|
|
235
|
+
case "step_finish":
|
|
236
|
+
return;
|
|
237
|
+
default:
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
172
241
|
child.stdout.on("data", (chunk) => {
|
|
173
242
|
buf += chunk.toString();
|
|
174
243
|
const lines = buf.split("\n");
|
|
175
244
|
buf = lines.pop() || "";
|
|
176
245
|
for (const line of lines) {
|
|
177
|
-
|
|
178
|
-
continue;
|
|
179
|
-
const fatal = extractOpencodeFatalError(line);
|
|
180
|
-
if (fatal) {
|
|
181
|
-
fatalError = fatal;
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
let content = line;
|
|
185
|
-
let role = ROLE_ASSISTANT;
|
|
186
|
-
try {
|
|
187
|
-
const ev = JSON.parse(line);
|
|
188
|
-
content = ev.content || ev.message || ev.text || JSON.stringify(ev);
|
|
189
|
-
if (ev.type === "tool" || ev.role === "tool")
|
|
190
|
-
role = ROLE_TOOL;
|
|
191
|
-
if (ev.type === "status")
|
|
192
|
-
role = ROLE_STATUS;
|
|
193
|
-
}
|
|
194
|
-
catch {
|
|
195
|
-
/* plain line */
|
|
196
|
-
}
|
|
197
|
-
postEvent(role, content);
|
|
246
|
+
handleOpencodeLine(line);
|
|
198
247
|
}
|
|
199
248
|
});
|
|
200
249
|
child.on("close", (code) => {
|
|
201
250
|
if (buf.trim()) {
|
|
202
|
-
|
|
203
|
-
if (fatal)
|
|
204
|
-
fatalError = fatal;
|
|
205
|
-
else if (!fatalError)
|
|
206
|
-
postEvent(ROLE_ASSISTANT, buf.trim());
|
|
251
|
+
handleOpencodeLine(buf.trim());
|
|
207
252
|
}
|
|
208
253
|
const stderrFatal = extractOpencodeFatalError(err);
|
|
209
254
|
if (stderrFatal)
|
|
@@ -242,57 +287,83 @@ function runOpencode(prompt, model, childEnv, postEvent) {
|
|
|
242
287
|
return Promise.resolve({ code: errObj.status || 1, err: fatal });
|
|
243
288
|
}
|
|
244
289
|
}
|
|
245
|
-
function
|
|
290
|
+
function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
246
291
|
const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
|
|
247
292
|
const lib = url.protocol === "https:" ? https : http;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
293
|
+
let stopped = false;
|
|
294
|
+
let reconnectTimer = null;
|
|
295
|
+
let reconnectDelayMs = 1000;
|
|
296
|
+
const scheduleReconnect = () => {
|
|
297
|
+
if (stopped || !handlers.shouldRun())
|
|
298
|
+
return;
|
|
299
|
+
if (reconnectTimer)
|
|
300
|
+
return;
|
|
301
|
+
reconnectTimer = setTimeout(() => {
|
|
302
|
+
reconnectTimer = null;
|
|
303
|
+
connect();
|
|
304
|
+
}, reconnectDelayMs);
|
|
305
|
+
reconnectDelayMs = Math.min(reconnectDelayMs * 2, 15_000);
|
|
306
|
+
};
|
|
307
|
+
const connect = () => {
|
|
308
|
+
if (stopped || !handlers.shouldRun())
|
|
309
|
+
return;
|
|
310
|
+
const req = lib.request({
|
|
311
|
+
hostname: url.hostname,
|
|
312
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
313
|
+
path: url.pathname + url.search,
|
|
314
|
+
method: "GET",
|
|
315
|
+
headers: {
|
|
316
|
+
"TestChimp-Api-Key": apiKey,
|
|
317
|
+
Accept: "text/event-stream",
|
|
318
|
+
"Cache-Control": "no-cache",
|
|
319
|
+
},
|
|
320
|
+
}, (res) => {
|
|
321
|
+
reconnectDelayMs = 1000;
|
|
322
|
+
let buf = "";
|
|
323
|
+
let eventName = "message";
|
|
324
|
+
res.on("data", (chunk) => {
|
|
325
|
+
buf += chunk.toString();
|
|
326
|
+
const parts = buf.split("\n");
|
|
327
|
+
buf = parts.pop() || "";
|
|
328
|
+
for (const line of parts) {
|
|
329
|
+
if (line.startsWith("event:")) {
|
|
330
|
+
eventName = line.slice(6).trim() || "message";
|
|
273
331
|
}
|
|
274
|
-
else if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (content)
|
|
279
|
-
onUserMessage(content);
|
|
332
|
+
else if (line.startsWith("data:")) {
|
|
333
|
+
const data = line.slice(5).trim();
|
|
334
|
+
if (eventName === "idle") {
|
|
335
|
+
handlers.onIdle();
|
|
280
336
|
}
|
|
281
|
-
|
|
282
|
-
|
|
337
|
+
else if (eventName === "user_message" || eventName === "message") {
|
|
338
|
+
try {
|
|
339
|
+
const msg = JSON.parse(data);
|
|
340
|
+
handlers.onUserMessage({
|
|
341
|
+
id: msg.id || msg.message_id,
|
|
342
|
+
content: msg.content || "",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
/* ignore */
|
|
347
|
+
}
|
|
283
348
|
}
|
|
349
|
+
eventName = "message";
|
|
350
|
+
}
|
|
351
|
+
else if (line === "") {
|
|
352
|
+
eventName = "message";
|
|
284
353
|
}
|
|
285
|
-
eventName = "message";
|
|
286
|
-
}
|
|
287
|
-
else if (line === "") {
|
|
288
|
-
eventName = "message";
|
|
289
354
|
}
|
|
290
|
-
}
|
|
355
|
+
});
|
|
356
|
+
res.on("end", () => scheduleReconnect());
|
|
291
357
|
});
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
358
|
+
req.on("error", () => scheduleReconnect());
|
|
359
|
+
req.end();
|
|
360
|
+
};
|
|
361
|
+
connect();
|
|
362
|
+
return () => {
|
|
363
|
+
stopped = true;
|
|
364
|
+
if (reconnectTimer)
|
|
365
|
+
clearTimeout(reconnectTimer);
|
|
366
|
+
};
|
|
296
367
|
}
|
|
297
368
|
export async function runChimphands(opts) {
|
|
298
369
|
const apiKey = requireApiKey();
|
|
@@ -324,15 +395,49 @@ export async function runChimphands(opts) {
|
|
|
324
395
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
325
396
|
const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
|
|
326
397
|
const queue = [];
|
|
398
|
+
const seenUserMessageIds = new Set();
|
|
327
399
|
let idle = false;
|
|
328
|
-
let
|
|
400
|
+
let sessionActive = true;
|
|
329
401
|
let lastUserActivity = Date.now();
|
|
330
|
-
const
|
|
402
|
+
const enqueueUserMessage = (msg) => {
|
|
403
|
+
const id = msg.id?.trim();
|
|
404
|
+
if (id) {
|
|
405
|
+
if (seenUserMessageIds.has(id))
|
|
406
|
+
return;
|
|
407
|
+
seenUserMessageIds.add(id);
|
|
408
|
+
}
|
|
409
|
+
const content = normalizeUserMessage(msg.content || "");
|
|
410
|
+
if (!content)
|
|
411
|
+
return;
|
|
412
|
+
queue.push(content);
|
|
413
|
+
lastUserActivity = Date.now();
|
|
414
|
+
idle = false;
|
|
415
|
+
};
|
|
416
|
+
const pollPendingUserMessages = async () => {
|
|
417
|
+
try {
|
|
418
|
+
const text = await postJson(backend, apiKey, "/api/chimphands/consume_pending_user_messages", {
|
|
419
|
+
sessionId,
|
|
420
|
+
});
|
|
421
|
+
const data = JSON.parse(text);
|
|
422
|
+
for (const msg of data.messages || []) {
|
|
423
|
+
enqueueUserMessage({
|
|
424
|
+
id: msg.id || msg.message_id,
|
|
425
|
+
content: msg.content || "",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Polling is best-effort when inbound SSE misses an event.
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
const postEvent = (role, content, status, messageId) => {
|
|
331
434
|
const body = {
|
|
332
435
|
sessionId,
|
|
333
436
|
role,
|
|
334
437
|
content: String(content || "").slice(0, 20000),
|
|
335
438
|
};
|
|
439
|
+
if (messageId)
|
|
440
|
+
body.messageId = messageId;
|
|
336
441
|
if (status != null)
|
|
337
442
|
body.status = status;
|
|
338
443
|
postJsonFireAndForget(backend, apiKey, "/api/chimphands/post_agent_event", body);
|
|
@@ -352,30 +457,46 @@ export async function runChimphands(opts) {
|
|
|
352
457
|
};
|
|
353
458
|
if (userId)
|
|
354
459
|
childEnv.TESTCHIMP_USER_ID = userId;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
closed = true;
|
|
460
|
+
const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
|
|
461
|
+
onUserMessage: enqueueUserMessage,
|
|
462
|
+
onIdle: () => {
|
|
463
|
+
idle = true;
|
|
464
|
+
},
|
|
465
|
+
shouldRun: () => sessionActive,
|
|
362
466
|
});
|
|
363
467
|
postEvent(ROLE_STATUS, "Agent ready", STATUS_RUNNING);
|
|
364
|
-
let prompt =
|
|
468
|
+
let prompt = normalizeUserMessage(promptInput || boot.initial_prompt || "");
|
|
365
469
|
if (boot.conversation_summary) {
|
|
366
470
|
prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
|
|
367
471
|
}
|
|
368
472
|
for (const m of boot.pending_user_messages || []) {
|
|
369
473
|
if (m?.content)
|
|
370
|
-
|
|
474
|
+
enqueueUserMessage({ content: m.content });
|
|
371
475
|
}
|
|
372
476
|
const waitForNextPrompt = () => new Promise((resolve) => {
|
|
477
|
+
let lastPollAt = 0;
|
|
373
478
|
const tick = () => {
|
|
374
479
|
if (queue.length) {
|
|
375
|
-
resolve(
|
|
480
|
+
resolve(normalizeUserMessage(queue.shift()));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
if (now - lastPollAt >= 1500) {
|
|
485
|
+
lastPollAt = now;
|
|
486
|
+
void pollPendingUserMessages().then(() => {
|
|
487
|
+
if (queue.length) {
|
|
488
|
+
resolve(normalizeUserMessage(queue.shift()));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
492
|
+
resolve(null);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
setTimeout(tick, 500);
|
|
496
|
+
});
|
|
376
497
|
return;
|
|
377
498
|
}
|
|
378
|
-
if (idle ||
|
|
499
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
379
500
|
resolve(null);
|
|
380
501
|
return;
|
|
381
502
|
}
|
|
@@ -384,7 +505,7 @@ export async function runChimphands(opts) {
|
|
|
384
505
|
tick();
|
|
385
506
|
});
|
|
386
507
|
while (prompt) {
|
|
387
|
-
const result = await runOpencode(
|
|
508
|
+
const result = await runOpencode(prompt, opencodeModel, childEnv, postEvent);
|
|
388
509
|
if (result.code !== 0) {
|
|
389
510
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
390
511
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
@@ -417,6 +538,8 @@ export async function runChimphands(opts) {
|
|
|
417
538
|
idle = false;
|
|
418
539
|
prompt = (await waitForNextPrompt()) || "";
|
|
419
540
|
}
|
|
541
|
+
sessionActive = false;
|
|
542
|
+
stopInbound();
|
|
420
543
|
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
421
544
|
complete(STATUS_IDLE);
|
|
422
545
|
}
|
package/package.json
CHANGED