@testchimp/cli 0.1.43 → 0.1.45
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.d.ts +8 -0
- package/dist/chimphands/run.js +447 -100
- package/dist/cli/program.js +23 -0
- package/package.json +3 -1
package/dist/chimphands/run.d.ts
CHANGED
|
@@ -13,6 +13,14 @@ export declare function reportWorkingBranch(opts: ReportWorkingBranchOptions): P
|
|
|
13
13
|
type RunOptions = {
|
|
14
14
|
sessionId: string;
|
|
15
15
|
prompt?: string;
|
|
16
|
+
/** When set, `opencode run --attach` to a local OpenCode server. */
|
|
17
|
+
attachUrl?: string;
|
|
18
|
+
/** Registered runtime id (from register_runtime); enables heartbeat + tunnel + complete_runtime. */
|
|
19
|
+
runtimeId?: string;
|
|
16
20
|
};
|
|
17
21
|
export declare function runChimphands(opts: RunOptions): Promise<void>;
|
|
22
|
+
/** Runtime-aware entry: register + attach to local OpenCode server (Phase 1+). */
|
|
23
|
+
export declare function serveChimphands(opts: RunOptions & {
|
|
24
|
+
attachUrl: string;
|
|
25
|
+
}): Promise<void>;
|
|
18
26
|
export {};
|
package/dist/chimphands/run.js
CHANGED
|
@@ -19,7 +19,14 @@ 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
|
const STREAM_POST_MIN_INTERVAL_MS = 60;
|
|
22
|
-
const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's
|
|
22
|
+
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
|
+
|
|
24
|
+
## Interactive session (mandatory — default)
|
|
25
|
+
- Default mode is **interactive**. Ask clarifying questions, seek plan approval, and wait for the user's reply — just as you would in Cursor.
|
|
26
|
+
- \`GITHUB_ACTIONS\`, \`CLOUD_AGENT\`, and "running in CI" mean **where** you execute (runner + \`TESTCHIMP_EXECUTION_SOURCE\`). They do **NOT** mean skip questions, invent defaults, or auto-approve.
|
|
27
|
+
- Only treat the run as non-interactive when the **user prompt** literally includes \`--mode=non-interactive\` (or \`mode=non-interactive\`), or the resolved skill policy explicitly sets \`allow-execute-without-approval\`.
|
|
28
|
+
- When you need clarification (e.g. import plans/tests, env strategy, CI choices) or plan approval: write the questions / plan summary as assistant text, then **stop this turn**. Do not invent answers or continue into Execute. The host will wait for the next chat message and revive you.
|
|
29
|
+
- Prefer a short numbered list of concrete questions over a long monologue. One decision gate at a time when possible (especially \`/testchimp project init\` Phase 1).
|
|
23
30
|
|
|
24
31
|
## Repo changes (mandatory)
|
|
25
32
|
- NEVER commit or push directly to the default branch (main/master).
|
|
@@ -27,14 +34,17 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's cloud coding ag
|
|
|
27
34
|
- If bootstrap lists a working branch, checkout that branch and push additional commits there — update the same PR.
|
|
28
35
|
- Only create a NEW branch/PR when (a) no working branch exists yet for this conversation, or (b) the prior PR was merged/closed (verify with \`gh pr view\`).
|
|
29
36
|
- Branch names MUST start with \`testchimp-\` or \`chimphands-\`.
|
|
30
|
-
-
|
|
37
|
+
- When creating a NEW working branch: create it, then IMMEDIATELY publish it with
|
|
38
|
+
\`git push -u origin <branch>\` BEFORE calling report-branch. Users open the branch URL in the UI —
|
|
39
|
+
do not report a branch that only exists locally (that causes GitHub 404).
|
|
40
|
+
- After the branch is on the remote (and after opening a PR), IMMEDIATELY run:
|
|
31
41
|
\`testchimp chimphands report-branch --branch <name> [--pr-url <url>]\`
|
|
32
42
|
- Tell the user which branch you are on and include the PR URL when available.
|
|
33
43
|
|
|
34
44
|
## TestChimp workflows (/testchimp …)
|
|
35
45
|
- Load and follow the \`testchimp\` skill under \`.agents/skills/testchimp/SKILL.md\`.
|
|
36
46
|
- For any /testchimp command: use TestChimp MCP tools (preferred) or \`testchimp\` CLI — never invent API results.
|
|
37
|
-
- Follow plan → explicit user approval → execute. Do not skip MCP calls or claim done without tool evidence.
|
|
47
|
+
- Follow plan → explicit user approval → execute (interactive default). Do not skip MCP calls or claim done without tool evidence.
|
|
38
48
|
- Export \`TESTCHIMP_EXECUTION_SOURCE=CLOUD_AGENT\` before Playwright/Mobilewright runs.
|
|
39
49
|
|
|
40
50
|
## Honesty
|
|
@@ -251,12 +261,21 @@ function isMissingOpencodeSessionError(message) {
|
|
|
251
261
|
m.includes("unknown session") ||
|
|
252
262
|
m.includes("invalid session"));
|
|
253
263
|
}
|
|
264
|
+
function isNonInteractivePrompt(userPrompt) {
|
|
265
|
+
return /(?:^|\s)--mode\s*=?\s*non-interactive\b|mode\s*=\s*non-interactive\b/i.test(userPrompt);
|
|
266
|
+
}
|
|
267
|
+
function isTestchimpWorkflowPrompt(userPrompt) {
|
|
268
|
+
return /(?:^|\s)\/?testchimp\b/i.test(userPrompt.trim());
|
|
269
|
+
}
|
|
254
270
|
function wrapPromptWithContext(conversationSummary, userPrompt, isNewOpencodeSession, workingBranch, pullRequestUrl) {
|
|
255
271
|
const parts = [];
|
|
256
272
|
if (workingBranch?.trim()) {
|
|
257
273
|
parts.push("## Conversation working branch (reuse for this thread)", `Branch: \`${workingBranch.trim()}\``, pullRequestUrl?.trim() ? `PR: ${pullRequestUrl.trim()}` : "", "Checkout this branch, commit and push here. Do NOT open a new PR unless the one above was merged/closed.", "");
|
|
258
274
|
}
|
|
259
275
|
const task = normalizeUserMessage(userPrompt);
|
|
276
|
+
if (isTestchimpWorkflowPrompt(task) && !isNonInteractivePrompt(task)) {
|
|
277
|
+
parts.push("## Interactive turn reminder", "This is an interactive ChimpHands chat (not autonomous CI). Ask clarifying questions / seek plan approval, then end the turn and wait — do not invent defaults or Execute until the user replies. Only `--mode=non-interactive` skips that pause.", "");
|
|
278
|
+
}
|
|
260
279
|
if (isNewOpencodeSession && conversationSummary.trim()) {
|
|
261
280
|
parts.push(`Conversation so far:\n${conversationSummary.trim()}`, "", `Current task:\n${task}`);
|
|
262
281
|
return parts.filter(Boolean).join("\n");
|
|
@@ -272,10 +291,10 @@ function detectWorkingBranchFromToolOutput(output) {
|
|
|
272
291
|
if (!text)
|
|
273
292
|
return {};
|
|
274
293
|
const prMatch = text.match(/https:\/\/github\.com\/[^\s)\]]+\/pull\/\d+/);
|
|
275
|
-
|
|
294
|
+
// Only auto-detect after a successful push to origin — local checkout -b alone would
|
|
295
|
+
// report a branch URL that 404s until the remote ref exists.
|
|
276
296
|
const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
277
|
-
const
|
|
278
|
-
const branch = (checkoutMatch?.[1] || pushMatch?.[1] || branchMatch?.[1])?.replace(/[`'"]/g, "");
|
|
297
|
+
const branch = pushMatch?.[1]?.replace(/[`'"]/g, "");
|
|
279
298
|
return {
|
|
280
299
|
branch,
|
|
281
300
|
pullRequestUrl: prMatch?.[0],
|
|
@@ -351,16 +370,19 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
351
370
|
}, null, 2));
|
|
352
371
|
return model;
|
|
353
372
|
}
|
|
354
|
-
function buildOpencodeArgs(prompt, model, opencodeSessionId) {
|
|
373
|
+
function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
355
374
|
const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
|
|
356
375
|
if (opencodeSessionId?.trim()) {
|
|
357
376
|
args.push("--session", opencodeSessionId.trim());
|
|
358
377
|
}
|
|
378
|
+
if (attachUrl?.trim()) {
|
|
379
|
+
args.push("--attach", attachUrl.trim());
|
|
380
|
+
}
|
|
359
381
|
return args;
|
|
360
382
|
}
|
|
361
|
-
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks) {
|
|
383
|
+
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
|
|
362
384
|
let activeSessionId = opencodeSessionId?.trim() || undefined;
|
|
363
|
-
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId);
|
|
385
|
+
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
|
|
364
386
|
const child = spawn("opencode", baseArgs, {
|
|
365
387
|
stdio: ["ignore", "pipe", "pipe"],
|
|
366
388
|
env: childEnv,
|
|
@@ -577,6 +599,8 @@ export async function runChimphands(opts) {
|
|
|
577
599
|
throw new Error("session_id is required (pass --session-id or SESSION_ID)");
|
|
578
600
|
}
|
|
579
601
|
const promptInput = (opts.prompt ?? process.env.PROMPT ?? "").trim();
|
|
602
|
+
const attachUrl = (opts.attachUrl || process.env.OPENCODE_ATTACH_URL || "").trim() || undefined;
|
|
603
|
+
let runtimeId = (opts.runtimeId || process.env.CHIMPHANDS_RUNTIME_ID || "").trim() || undefined;
|
|
580
604
|
const bootText = await postJson(backend, apiKey, "/api/chimphands/bootstrap", {
|
|
581
605
|
sessionId,
|
|
582
606
|
});
|
|
@@ -591,6 +615,30 @@ export async function runChimphands(opts) {
|
|
|
591
615
|
console.error(`ChimpHands link run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
592
616
|
});
|
|
593
617
|
}
|
|
618
|
+
if (!runtimeId && attachUrl) {
|
|
619
|
+
try {
|
|
620
|
+
const regText = await postJson(backend, apiKey, "/api/chimphands/register_runtime", {
|
|
621
|
+
sessionId,
|
|
622
|
+
location: "CHIMPHANDS_RUNTIME_LOCATION_GITHUB_CI",
|
|
623
|
+
githubRunId: githubRunId || undefined,
|
|
624
|
+
});
|
|
625
|
+
const reg = JSON.parse(regText);
|
|
626
|
+
runtimeId = reg.runtime?.id || reg.runtimeId || undefined;
|
|
627
|
+
if (runtimeId) {
|
|
628
|
+
console.error(`ChimpHands runtime registered: ${runtimeId}`);
|
|
629
|
+
process.env.CHIMPHANDS_RUNTIME_ID = runtimeId;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
console.error(`ChimpHands register_runtime failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const stopHeartbeat = runtimeId
|
|
637
|
+
? startRuntimeHeartbeat(backend, apiKey, runtimeId)
|
|
638
|
+
: () => { };
|
|
639
|
+
const stopTunnel = runtimeId && attachUrl
|
|
640
|
+
? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
|
|
641
|
+
: () => { };
|
|
594
642
|
const userId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
|
|
595
643
|
if (userId) {
|
|
596
644
|
process.env.TESTCHIMP_USER_ID = userId;
|
|
@@ -598,10 +646,37 @@ export async function runChimphands(opts) {
|
|
|
598
646
|
mkdirSync(".opencode", { recursive: true });
|
|
599
647
|
const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
|
|
600
648
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
649
|
+
if (attachUrl) {
|
|
650
|
+
console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
|
|
651
|
+
}
|
|
601
652
|
let opencodeSessionId = bootStr(boot, "opencode_session_id", "opencodeSessionId") || undefined;
|
|
602
653
|
const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
|
|
603
654
|
let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
|
|
604
655
|
let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
|
|
656
|
+
const exportSignedUrl = bootStr(boot, "opencode_export_signed_url", "opencodeExportSignedUrl");
|
|
657
|
+
if (exportSignedUrl && attachUrl) {
|
|
658
|
+
try {
|
|
659
|
+
const importedId = await importOpencodeExportFromUrl(exportSignedUrl);
|
|
660
|
+
if (importedId) {
|
|
661
|
+
opencodeSessionId = importedId;
|
|
662
|
+
console.error(`ChimpHands rehydrated OpenCode session from export: ${importedId}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
console.error(`ChimpHands export import failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const snapshotExport = async () => {
|
|
670
|
+
const sid = opencodeSessionId?.trim();
|
|
671
|
+
if (!sid)
|
|
672
|
+
return;
|
|
673
|
+
try {
|
|
674
|
+
await putOpencodeExport(backend, apiKey, sessionId, sid);
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
605
680
|
const noteWorkingBranch = (branch, prUrl) => {
|
|
606
681
|
const normalizedBranch = branch.trim();
|
|
607
682
|
if (!normalizedBranch)
|
|
@@ -624,6 +699,7 @@ export async function runChimphands(opts) {
|
|
|
624
699
|
let idle = false;
|
|
625
700
|
let sessionActive = true;
|
|
626
701
|
let lastUserActivity = Date.now();
|
|
702
|
+
let exitCode;
|
|
627
703
|
const enqueueUserMessage = (msg) => {
|
|
628
704
|
const id = msg.id?.trim();
|
|
629
705
|
if (id) {
|
|
@@ -687,67 +763,64 @@ export async function runChimphands(opts) {
|
|
|
687
763
|
},
|
|
688
764
|
shouldRun: () => sessionActive,
|
|
689
765
|
});
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
766
|
+
const shutdownRuntime = async () => {
|
|
767
|
+
sessionActive = false;
|
|
768
|
+
stopInbound();
|
|
769
|
+
stopTunnel();
|
|
770
|
+
stopHeartbeat();
|
|
771
|
+
await poster.flush();
|
|
772
|
+
await snapshotExport();
|
|
773
|
+
if (runtimeId) {
|
|
774
|
+
await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
|
|
775
|
+
runtimeId,
|
|
776
|
+
status: "CHIMPHANDS_RUNTIME_STATUS_TERMINATED",
|
|
777
|
+
}).catch(() => { });
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
try {
|
|
781
|
+
poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
|
|
782
|
+
let prompt = normalizeUserMessage(promptInput || bootStr(boot, "initial_prompt", "initialPrompt"));
|
|
783
|
+
const pending = boot.pending_user_messages || boot.pendingUserMessages || [];
|
|
784
|
+
for (const m of pending) {
|
|
785
|
+
if (m?.content)
|
|
786
|
+
enqueueUserMessage({ content: m.content });
|
|
787
|
+
}
|
|
788
|
+
const waitForNextPrompt = () => new Promise((resolve) => {
|
|
789
|
+
let lastPollAt = 0;
|
|
790
|
+
const tick = () => {
|
|
791
|
+
if (queue.length) {
|
|
792
|
+
resolve(normalizeUserMessage(queue.shift()));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const now = Date.now();
|
|
796
|
+
if (now - lastPollAt >= 1500) {
|
|
797
|
+
lastPollAt = now;
|
|
798
|
+
void pollPendingUserMessages().then(() => {
|
|
799
|
+
if (queue.length) {
|
|
800
|
+
resolve(normalizeUserMessage(queue.shift()));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
804
|
+
resolve(null);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
setTimeout(tick, 500);
|
|
808
|
+
});
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
812
|
+
resolve(null);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
setTimeout(tick, 500);
|
|
816
|
+
};
|
|
817
|
+
tick();
|
|
742
818
|
});
|
|
743
|
-
|
|
744
|
-
useOpencodeSessionId
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
isNewOpencodeSession = true;
|
|
749
|
-
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
|
|
750
|
-
result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
|
|
819
|
+
while (prompt) {
|
|
820
|
+
let useOpencodeSessionId = opencodeSessionId;
|
|
821
|
+
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
822
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
823
|
+
let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
|
|
751
824
|
onSessionId: (id) => {
|
|
752
825
|
opencodeSessionId = id;
|
|
753
826
|
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
@@ -757,43 +830,317 @@ export async function runChimphands(opts) {
|
|
|
757
830
|
},
|
|
758
831
|
onWorkingBranch: noteWorkingBranch,
|
|
759
832
|
postEvent,
|
|
833
|
+
}, attachUrl);
|
|
834
|
+
if (result.code !== 0 &&
|
|
835
|
+
useOpencodeSessionId &&
|
|
836
|
+
isMissingOpencodeSessionError(result.err || "")) {
|
|
837
|
+
console.error(`ChimpHands OpenCode session ${useOpencodeSessionId} missing on runner; starting fresh thread.`);
|
|
838
|
+
opencodeSessionId = undefined;
|
|
839
|
+
isNewOpencodeSession = true;
|
|
840
|
+
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
|
|
841
|
+
result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
|
|
842
|
+
onSessionId: (id) => {
|
|
843
|
+
opencodeSessionId = id;
|
|
844
|
+
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
845
|
+
sessionId,
|
|
846
|
+
opencodeSessionId: id,
|
|
847
|
+
}).catch(() => { });
|
|
848
|
+
},
|
|
849
|
+
onWorkingBranch: noteWorkingBranch,
|
|
850
|
+
postEvent,
|
|
851
|
+
}, attachUrl);
|
|
852
|
+
}
|
|
853
|
+
await poster.flush();
|
|
854
|
+
if (result.opencodeSessionId) {
|
|
855
|
+
opencodeSessionId = result.opencodeSessionId;
|
|
856
|
+
}
|
|
857
|
+
if (result.code !== 0) {
|
|
858
|
+
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
859
|
+
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
860
|
+
try {
|
|
861
|
+
await poster.enqueue(ROLE_STATUS, errMsg, {
|
|
862
|
+
status: STATUS_FAILED,
|
|
863
|
+
opencodeSessionId,
|
|
864
|
+
});
|
|
865
|
+
await postJson(backend, apiKey, "/api/chimphands/complete_session", {
|
|
866
|
+
sessionId,
|
|
867
|
+
status: STATUS_FAILED,
|
|
868
|
+
errorMessage: errMsg,
|
|
869
|
+
githubRunId: githubRunId || undefined,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
catch (reportErr) {
|
|
873
|
+
const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
|
|
874
|
+
console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
|
|
875
|
+
postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
|
|
876
|
+
complete(STATUS_FAILED, errMsg);
|
|
877
|
+
}
|
|
878
|
+
exitCode = result.code || 1;
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
882
|
+
await snapshotExport();
|
|
883
|
+
lastUserActivity = Date.now();
|
|
884
|
+
idle = false;
|
|
885
|
+
prompt = (await waitForNextPrompt()) || "";
|
|
886
|
+
}
|
|
887
|
+
if (exitCode == null) {
|
|
888
|
+
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
889
|
+
complete(STATUS_IDLE);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
await shutdownRuntime();
|
|
894
|
+
}
|
|
895
|
+
if (exitCode != null) {
|
|
896
|
+
process.exit(exitCode);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
|
|
900
|
+
let stopped = false;
|
|
901
|
+
const tick = async () => {
|
|
902
|
+
if (stopped)
|
|
903
|
+
return;
|
|
904
|
+
try {
|
|
905
|
+
// Do not claim tunnel_connected here — only the tunnel poll loop should.
|
|
906
|
+
await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
907
|
+
runtimeId,
|
|
760
908
|
});
|
|
761
909
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
opencodeSessionId = result.opencodeSessionId;
|
|
910
|
+
catch (err) {
|
|
911
|
+
console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
765
912
|
}
|
|
766
|
-
if (
|
|
767
|
-
|
|
768
|
-
|
|
913
|
+
if (!stopped)
|
|
914
|
+
setTimeout(tick, 15_000);
|
|
915
|
+
};
|
|
916
|
+
void tick();
|
|
917
|
+
return () => {
|
|
918
|
+
stopped = true;
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
|
|
922
|
+
const exported = await new Promise((resolve, reject) => {
|
|
923
|
+
const child = spawn("opencode", ["export", opencodeSessionId, "--sanitize"], {
|
|
924
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
925
|
+
});
|
|
926
|
+
let out = "";
|
|
927
|
+
let err = "";
|
|
928
|
+
child.stdout.on("data", (d) => {
|
|
929
|
+
out += d.toString();
|
|
930
|
+
});
|
|
931
|
+
child.stderr.on("data", (d) => {
|
|
932
|
+
err += d.toString();
|
|
933
|
+
});
|
|
934
|
+
child.on("close", (code) => {
|
|
935
|
+
if (code === 0 && out.trim())
|
|
936
|
+
resolve(out);
|
|
937
|
+
else
|
|
938
|
+
reject(new Error(err.trim() || `opencode export exited ${code}`));
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
const exportBase64 = Buffer.from(exported, "utf8").toString("base64");
|
|
942
|
+
await postJson(backend, apiKey, "/api/chimphands/put_opencode_export", {
|
|
943
|
+
sessionId,
|
|
944
|
+
exportBase64,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
async function importOpencodeExportFromUrl(signedUrl) {
|
|
948
|
+
const res = await fetch(signedUrl);
|
|
949
|
+
if (!res.ok) {
|
|
950
|
+
throw new Error(`download export failed: ${res.status}`);
|
|
951
|
+
}
|
|
952
|
+
const text = await res.text();
|
|
953
|
+
writeFileSync("/tmp/chimphands-opencode-export.json", text, "utf8");
|
|
954
|
+
return await new Promise((resolve, reject) => {
|
|
955
|
+
const child = spawn("opencode", ["import", "/tmp/chimphands-opencode-export.json"], {
|
|
956
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
957
|
+
});
|
|
958
|
+
let out = "";
|
|
959
|
+
let err = "";
|
|
960
|
+
child.stdout.on("data", (d) => {
|
|
961
|
+
out += d.toString();
|
|
962
|
+
});
|
|
963
|
+
child.stderr.on("data", (d) => {
|
|
964
|
+
err += d.toString();
|
|
965
|
+
});
|
|
966
|
+
child.on("close", (code) => {
|
|
967
|
+
if (code !== 0) {
|
|
968
|
+
reject(new Error(err.trim() || `opencode import exited ${code}`));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const match = (out + "\n" + err).match(/ses_[A-Za-z0-9]+/);
|
|
972
|
+
resolve(match?.[0]);
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
|
|
977
|
+
let stopped = false;
|
|
978
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
979
|
+
let ws = null;
|
|
980
|
+
let reconnectTimer = null;
|
|
981
|
+
let backoffMs = 1000;
|
|
982
|
+
const wsBase = backend.replace(/^http/i, (m) => (m.toLowerCase() === "https" ? "wss" : "ws"));
|
|
983
|
+
const tunnelUrl = `${wsBase.replace(/\/$/, "")}/api/chimphands/runtimes/${encodeURIComponent(runtimeId)}/tunnel`;
|
|
984
|
+
const clearReconnect = () => {
|
|
985
|
+
if (reconnectTimer) {
|
|
986
|
+
clearTimeout(reconnectTimer);
|
|
987
|
+
reconnectTimer = null;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
const handleHttpRequest = async (socket, req) => {
|
|
991
|
+
if (!req.requestId || socket.readyState !== 1)
|
|
992
|
+
return;
|
|
993
|
+
const requestId = req.requestId;
|
|
994
|
+
const target = base + (req.path || "/") + (req.query ? `?${req.query}` : "");
|
|
995
|
+
const headers = { ...(req.headers || {}) };
|
|
996
|
+
const init = { method: req.method || "GET", headers };
|
|
997
|
+
if (req.bodyBase64) {
|
|
998
|
+
init.body = Buffer.from(req.bodyBase64, "base64");
|
|
999
|
+
}
|
|
1000
|
+
// Long-running SSE / chat streams — no hard abort under ~5 minutes.
|
|
1001
|
+
const ac = new AbortController();
|
|
1002
|
+
const upstreamTimer = setTimeout(() => ac.abort(), 290_000);
|
|
1003
|
+
init.signal = ac.signal;
|
|
1004
|
+
const send = (obj) => {
|
|
1005
|
+
if (socket.readyState !== 1)
|
|
1006
|
+
return;
|
|
1007
|
+
socket.send(JSON.stringify(obj));
|
|
1008
|
+
};
|
|
1009
|
+
try {
|
|
1010
|
+
const upstream = await fetch(target, init);
|
|
1011
|
+
const respHeaders = {};
|
|
1012
|
+
upstream.headers.forEach((v, k) => {
|
|
1013
|
+
respHeaders[k] = v;
|
|
1014
|
+
});
|
|
1015
|
+
send({
|
|
1016
|
+
type: "http_response_start",
|
|
1017
|
+
requestId,
|
|
1018
|
+
status: upstream.status,
|
|
1019
|
+
headers: respHeaders,
|
|
1020
|
+
});
|
|
1021
|
+
const body = upstream.body;
|
|
1022
|
+
if (body) {
|
|
1023
|
+
const reader = body.getReader();
|
|
1024
|
+
while (true) {
|
|
1025
|
+
const { done, value } = await reader.read();
|
|
1026
|
+
if (done)
|
|
1027
|
+
break;
|
|
1028
|
+
if (value && value.length) {
|
|
1029
|
+
send({
|
|
1030
|
+
type: "http_response_chunk",
|
|
1031
|
+
requestId,
|
|
1032
|
+
bodyBase64: Buffer.from(value).toString("base64"),
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
1039
|
+
if (buf.length) {
|
|
1040
|
+
send({
|
|
1041
|
+
type: "http_response_chunk",
|
|
1042
|
+
requestId,
|
|
1043
|
+
bodyBase64: buf.toString("base64"),
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
send({ type: "http_response_end", requestId });
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1051
|
+
send({
|
|
1052
|
+
type: "http_response",
|
|
1053
|
+
requestId,
|
|
1054
|
+
status: 502,
|
|
1055
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
1056
|
+
bodyBase64: Buffer.from(msg, "utf8").toString("base64"),
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
finally {
|
|
1060
|
+
clearTimeout(upstreamTimer);
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
const connect = async () => {
|
|
1064
|
+
if (stopped)
|
|
1065
|
+
return;
|
|
1066
|
+
clearReconnect();
|
|
1067
|
+
const { default: WebSocket } = await import("ws");
|
|
1068
|
+
const socket = new WebSocket(tunnelUrl, {
|
|
1069
|
+
headers: { "TestChimp-Api-Key": apiKey },
|
|
1070
|
+
handshakeTimeout: 30_000,
|
|
1071
|
+
});
|
|
1072
|
+
ws = socket;
|
|
1073
|
+
socket.on("open", () => {
|
|
1074
|
+
backoffMs = 1000;
|
|
1075
|
+
console.error(`ChimpHands agent tunnel WS connected: ${tunnelUrl}`);
|
|
1076
|
+
});
|
|
1077
|
+
socket.on("message", (data) => {
|
|
1078
|
+
if (stopped)
|
|
1079
|
+
return;
|
|
769
1080
|
try {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1081
|
+
const text = typeof data === "string" ? data : data.toString("utf8");
|
|
1082
|
+
const frame = JSON.parse(text);
|
|
1083
|
+
if (frame.type === "pong")
|
|
1084
|
+
return;
|
|
1085
|
+
if (frame.type === "ping") {
|
|
1086
|
+
socket.send(JSON.stringify({ type: "pong" }));
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (frame.type === "http_request" || frame.requestId) {
|
|
1090
|
+
void handleHttpRequest(socket, frame);
|
|
1091
|
+
}
|
|
780
1092
|
}
|
|
781
|
-
catch (
|
|
782
|
-
|
|
783
|
-
console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
|
|
784
|
-
postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
|
|
785
|
-
complete(STATUS_FAILED, errMsg);
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
console.error(`ChimpHands tunnel frame error: ${err instanceof Error ? err.message : String(err)}`);
|
|
786
1095
|
}
|
|
787
|
-
|
|
1096
|
+
});
|
|
1097
|
+
socket.on("close", () => {
|
|
1098
|
+
ws = null;
|
|
1099
|
+
if (stopped)
|
|
1100
|
+
return;
|
|
1101
|
+
console.error(`ChimpHands agent tunnel WS closed; reconnecting in ${backoffMs}ms`);
|
|
1102
|
+
reconnectTimer = setTimeout(() => {
|
|
1103
|
+
void connect();
|
|
1104
|
+
}, backoffMs);
|
|
1105
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
1106
|
+
});
|
|
1107
|
+
socket.on("error", (err) => {
|
|
1108
|
+
console.error(`ChimpHands agent tunnel WS error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1109
|
+
});
|
|
1110
|
+
};
|
|
1111
|
+
void connect();
|
|
1112
|
+
return () => {
|
|
1113
|
+
stopped = true;
|
|
1114
|
+
clearReconnect();
|
|
1115
|
+
if (ws) {
|
|
1116
|
+
try {
|
|
1117
|
+
ws.close();
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
// ignore
|
|
1121
|
+
}
|
|
1122
|
+
ws = null;
|
|
788
1123
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/** Runtime-aware entry: register + attach to local OpenCode server (Phase 1+). */
|
|
1127
|
+
export async function serveChimphands(opts) {
|
|
1128
|
+
const attachUrl = opts.attachUrl.trim();
|
|
1129
|
+
if (!attachUrl) {
|
|
1130
|
+
throw new Error("--attach URL is required for chimphands serve");
|
|
1131
|
+
}
|
|
1132
|
+
// Wait for OpenCode server readiness.
|
|
1133
|
+
const deadline = Date.now() + 60_000;
|
|
1134
|
+
while (Date.now() < deadline) {
|
|
1135
|
+
try {
|
|
1136
|
+
const res = await fetch(attachUrl.replace(/\/$/, "") + "/");
|
|
1137
|
+
if (res.ok || res.status === 401 || res.status === 404)
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
/* retry */
|
|
1142
|
+
}
|
|
1143
|
+
await sleep(500);
|
|
793
1144
|
}
|
|
794
|
-
|
|
795
|
-
stopInbound();
|
|
796
|
-
await poster.flush();
|
|
797
|
-
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
798
|
-
complete(STATUS_IDLE);
|
|
1145
|
+
await runChimphands({ ...opts, attachUrl });
|
|
799
1146
|
}
|
package/dist/cli/program.js
CHANGED
|
@@ -1589,12 +1589,14 @@ export function buildCliProgram() {
|
|
|
1589
1589
|
.description("Bootstrap session, configure OpenCode, and run the interactive bridge")
|
|
1590
1590
|
.option("--session-id <id>", "ChimpHands session id (or SESSION_ID env)")
|
|
1591
1591
|
.option("--prompt <text>", "Initial prompt (or PROMPT env)")
|
|
1592
|
+
.option("--attach <url>", "Attach to OpenCode server (e.g. http://127.0.0.1:4096)")
|
|
1592
1593
|
.action(async (opts) => {
|
|
1593
1594
|
const { runChimphands } = await import("../chimphands/run.js");
|
|
1594
1595
|
try {
|
|
1595
1596
|
await runChimphands({
|
|
1596
1597
|
sessionId: String(opts.sessionId || process.env.SESSION_ID || "").trim(),
|
|
1597
1598
|
prompt: opts.prompt != null ? String(opts.prompt) : undefined,
|
|
1599
|
+
attachUrl: opts.attach != null ? String(opts.attach).trim() : undefined,
|
|
1598
1600
|
});
|
|
1599
1601
|
}
|
|
1600
1602
|
catch (e) {
|
|
@@ -1603,6 +1605,27 @@ export function buildCliProgram() {
|
|
|
1603
1605
|
process.exit(1);
|
|
1604
1606
|
}
|
|
1605
1607
|
});
|
|
1608
|
+
chimphands
|
|
1609
|
+
.command("serve")
|
|
1610
|
+
.description("Register ChimpHands Runtime, attach to OpenCode server, run session bridge + UI tunnel")
|
|
1611
|
+
.option("--session-id <id>", "ChimpHands session id (or SESSION_ID env)")
|
|
1612
|
+
.option("--prompt <text>", "Initial prompt (or PROMPT env)")
|
|
1613
|
+
.requiredOption("--attach <url>", "OpenCode server URL (e.g. http://127.0.0.1:4096)")
|
|
1614
|
+
.action(async (opts) => {
|
|
1615
|
+
const { serveChimphands } = await import("../chimphands/run.js");
|
|
1616
|
+
try {
|
|
1617
|
+
await serveChimphands({
|
|
1618
|
+
sessionId: String(opts.sessionId || process.env.SESSION_ID || "").trim(),
|
|
1619
|
+
prompt: opts.prompt != null ? String(opts.prompt) : undefined,
|
|
1620
|
+
attachUrl: String(opts.attach || "").trim(),
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
catch (e) {
|
|
1624
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1625
|
+
console.error(`[testchimp chimphands serve] ${msg}`);
|
|
1626
|
+
process.exit(1);
|
|
1627
|
+
}
|
|
1628
|
+
});
|
|
1606
1629
|
program.on("--help", () => {
|
|
1607
1630
|
/* default */
|
|
1608
1631
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testchimp/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.45",
|
|
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",
|
|
@@ -23,10 +23,12 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
25
|
"commander": "^12.1.0",
|
|
26
|
+
"ws": "^8.21.3",
|
|
26
27
|
"zod": "^4.3.6"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@types/node": "^25.6.0",
|
|
31
|
+
"@types/ws": "^8.18.1",
|
|
30
32
|
"typescript": "^6.0.2"
|
|
31
33
|
},
|
|
32
34
|
"keywords": [
|