@testchimp/cli 0.1.38 → 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.
@@ -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 ensureTestchimpPrompt(content) {
20
- const trimmed = content.trim();
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 {
@@ -291,57 +287,83 @@ function runOpencode(prompt, model, childEnv, postEvent) {
291
287
  return Promise.resolve({ code: errObj.status || 1, err: fatal });
292
288
  }
293
289
  }
294
- function connectInbound(backend, apiKey, sessionId, onUserMessage, onIdle, onClosed) {
290
+ function connectInboundStream(backend, apiKey, sessionId, handlers) {
295
291
  const url = new URL(`${backend}/api/chimphands/sessions/${encodeURIComponent(sessionId)}/inbound`);
296
292
  const lib = url.protocol === "https:" ? https : http;
297
- const req = lib.request({
298
- hostname: url.hostname,
299
- port: url.port || (url.protocol === "https:" ? 443 : 80),
300
- path: url.pathname + url.search,
301
- method: "GET",
302
- headers: {
303
- "TestChimp-Api-Key": apiKey,
304
- Accept: "text/event-stream",
305
- "Cache-Control": "no-cache",
306
- },
307
- }, (res) => {
308
- let buf = "";
309
- let eventName = "message";
310
- res.on("data", (chunk) => {
311
- buf += chunk.toString();
312
- const parts = buf.split("\n");
313
- buf = parts.pop() || "";
314
- for (const line of parts) {
315
- if (line.startsWith("event:")) {
316
- eventName = line.slice(6).trim() || "message";
317
- }
318
- else if (line.startsWith("data:")) {
319
- const data = line.slice(5).trim();
320
- if (eventName === "idle") {
321
- onIdle();
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";
322
331
  }
323
- else if (eventName === "user_message" || eventName === "message") {
324
- try {
325
- const msg = JSON.parse(data);
326
- const content = msg.content || "";
327
- if (content)
328
- onUserMessage(content);
332
+ else if (line.startsWith("data:")) {
333
+ const data = line.slice(5).trim();
334
+ if (eventName === "idle") {
335
+ handlers.onIdle();
329
336
  }
330
- catch {
331
- /* ignore */
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
+ }
332
348
  }
349
+ eventName = "message";
350
+ }
351
+ else if (line === "") {
352
+ eventName = "message";
333
353
  }
334
- eventName = "message";
335
- }
336
- else if (line === "") {
337
- eventName = "message";
338
354
  }
339
- }
355
+ });
356
+ res.on("end", () => scheduleReconnect());
340
357
  });
341
- res.on("end", () => onClosed());
342
- });
343
- req.on("error", () => onClosed());
344
- req.end();
358
+ req.on("error", () => scheduleReconnect());
359
+ req.end();
360
+ };
361
+ connect();
362
+ return () => {
363
+ stopped = true;
364
+ if (reconnectTimer)
365
+ clearTimeout(reconnectTimer);
366
+ };
345
367
  }
346
368
  export async function runChimphands(opts) {
347
369
  const apiKey = requireApiKey();
@@ -373,9 +395,41 @@ export async function runChimphands(opts) {
373
395
  console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
374
396
  const idleMs = (Number(boot.idle_timeout_seconds) || 600) * 1000;
375
397
  const queue = [];
398
+ const seenUserMessageIds = new Set();
376
399
  let idle = false;
377
- let closed = false;
400
+ let sessionActive = true;
378
401
  let lastUserActivity = Date.now();
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
+ };
379
433
  const postEvent = (role, content, status, messageId) => {
380
434
  const body = {
381
435
  sessionId,
@@ -403,30 +457,46 @@ export async function runChimphands(opts) {
403
457
  };
404
458
  if (userId)
405
459
  childEnv.TESTCHIMP_USER_ID = userId;
406
- connectInbound(backend, apiKey, sessionId, (content) => {
407
- queue.push(ensureTestchimpPrompt(content));
408
- lastUserActivity = Date.now();
409
- }, () => {
410
- idle = true;
411
- }, () => {
412
- closed = true;
460
+ const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
461
+ onUserMessage: enqueueUserMessage,
462
+ onIdle: () => {
463
+ idle = true;
464
+ },
465
+ shouldRun: () => sessionActive,
413
466
  });
414
467
  postEvent(ROLE_STATUS, "Agent ready", STATUS_RUNNING);
415
- let prompt = ensureTestchimpPrompt(promptInput || boot.initial_prompt || "");
468
+ let prompt = normalizeUserMessage(promptInput || boot.initial_prompt || "");
416
469
  if (boot.conversation_summary) {
417
470
  prompt = `Conversation so far:\n${boot.conversation_summary}\n\nCurrent task:\n${prompt}`;
418
471
  }
419
472
  for (const m of boot.pending_user_messages || []) {
420
473
  if (m?.content)
421
- queue.push(ensureTestchimpPrompt(m.content));
474
+ enqueueUserMessage({ content: m.content });
422
475
  }
423
476
  const waitForNextPrompt = () => new Promise((resolve) => {
477
+ let lastPollAt = 0;
424
478
  const tick = () => {
425
479
  if (queue.length) {
426
- resolve(ensureTestchimpPrompt(queue.shift()));
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
+ });
427
497
  return;
428
498
  }
429
- if (idle || closed || Date.now() - lastUserActivity >= idleMs) {
499
+ if (idle || now - lastUserActivity >= idleMs) {
430
500
  resolve(null);
431
501
  return;
432
502
  }
@@ -435,7 +505,7 @@ export async function runChimphands(opts) {
435
505
  tick();
436
506
  });
437
507
  while (prompt) {
438
- const result = await runOpencode(ensureTestchimpPrompt(prompt), opencodeModel, childEnv, postEvent);
508
+ const result = await runOpencode(prompt, opencodeModel, childEnv, postEvent);
439
509
  if (result.code !== 0) {
440
510
  const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
441
511
  console.error(`ChimpHands OpenCode failed: ${errMsg}`);
@@ -468,6 +538,8 @@ export async function runChimphands(opts) {
468
538
  idle = false;
469
539
  prompt = (await waitForNextPrompt()) || "";
470
540
  }
541
+ sessionActive = false;
542
+ stopInbound();
471
543
  console.error("ChimpHands session idle — no user input before timeout; completing.");
472
544
  complete(STATUS_IDLE);
473
545
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "TestChimp CLI and MCP server — coverage, plans, EaaS, TrueCoverage, API operations (calls /api/mcp/*)",
5
5
  "type": "module",
6
6
  "main": "dist/bin/testchimp.js",