@testchimp/cli 0.1.44 → 0.1.46
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 +506 -94
- 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
|
@@ -33,6 +33,7 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
|
|
|
33
33
|
- This conversation uses ONE working branch and ONE pull request. Reuse them for all follow-up work in this chat.
|
|
34
34
|
- If bootstrap lists a working branch, checkout that branch and push additional commits there — update the same PR.
|
|
35
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\`).
|
|
36
|
+
- Commit and push on the session working branch after meaningful edit batches. The host also commits any dirty worktree before idle teardown — keep the branch pushed so the UI can show diffs from GitHub.
|
|
36
37
|
- Branch names MUST start with \`testchimp-\` or \`chimphands-\`.
|
|
37
38
|
- When creating a NEW working branch: create it, then IMMEDIATELY publish it with
|
|
38
39
|
\`git push -u origin <branch>\` BEFORE calling report-branch. Users open the branch URL in the UI —
|
|
@@ -370,16 +371,19 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
370
371
|
}, null, 2));
|
|
371
372
|
return model;
|
|
372
373
|
}
|
|
373
|
-
function buildOpencodeArgs(prompt, model, opencodeSessionId) {
|
|
374
|
+
function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
|
|
374
375
|
const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
|
|
375
376
|
if (opencodeSessionId?.trim()) {
|
|
376
377
|
args.push("--session", opencodeSessionId.trim());
|
|
377
378
|
}
|
|
379
|
+
if (attachUrl?.trim()) {
|
|
380
|
+
args.push("--attach", attachUrl.trim());
|
|
381
|
+
}
|
|
378
382
|
return args;
|
|
379
383
|
}
|
|
380
|
-
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks) {
|
|
384
|
+
function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
|
|
381
385
|
let activeSessionId = opencodeSessionId?.trim() || undefined;
|
|
382
|
-
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId);
|
|
386
|
+
const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
|
|
383
387
|
const child = spawn("opencode", baseArgs, {
|
|
384
388
|
stdio: ["ignore", "pipe", "pipe"],
|
|
385
389
|
env: childEnv,
|
|
@@ -596,6 +600,8 @@ export async function runChimphands(opts) {
|
|
|
596
600
|
throw new Error("session_id is required (pass --session-id or SESSION_ID)");
|
|
597
601
|
}
|
|
598
602
|
const promptInput = (opts.prompt ?? process.env.PROMPT ?? "").trim();
|
|
603
|
+
const attachUrl = (opts.attachUrl || process.env.OPENCODE_ATTACH_URL || "").trim() || undefined;
|
|
604
|
+
let runtimeId = (opts.runtimeId || process.env.CHIMPHANDS_RUNTIME_ID || "").trim() || undefined;
|
|
599
605
|
const bootText = await postJson(backend, apiKey, "/api/chimphands/bootstrap", {
|
|
600
606
|
sessionId,
|
|
601
607
|
});
|
|
@@ -610,6 +616,30 @@ export async function runChimphands(opts) {
|
|
|
610
616
|
console.error(`ChimpHands link run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
611
617
|
});
|
|
612
618
|
}
|
|
619
|
+
if (!runtimeId && attachUrl) {
|
|
620
|
+
try {
|
|
621
|
+
const regText = await postJson(backend, apiKey, "/api/chimphands/register_runtime", {
|
|
622
|
+
sessionId,
|
|
623
|
+
location: "CHIMPHANDS_RUNTIME_LOCATION_GITHUB_CI",
|
|
624
|
+
githubRunId: githubRunId || undefined,
|
|
625
|
+
});
|
|
626
|
+
const reg = JSON.parse(regText);
|
|
627
|
+
runtimeId = reg.runtime?.id || reg.runtimeId || undefined;
|
|
628
|
+
if (runtimeId) {
|
|
629
|
+
console.error(`ChimpHands runtime registered: ${runtimeId}`);
|
|
630
|
+
process.env.CHIMPHANDS_RUNTIME_ID = runtimeId;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
console.error(`ChimpHands register_runtime failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const stopHeartbeat = runtimeId
|
|
638
|
+
? startRuntimeHeartbeat(backend, apiKey, runtimeId)
|
|
639
|
+
: () => { };
|
|
640
|
+
const stopTunnel = runtimeId && attachUrl
|
|
641
|
+
? startTunnelWorker(backend, apiKey, runtimeId, attachUrl)
|
|
642
|
+
: () => { };
|
|
613
643
|
const userId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
|
|
614
644
|
if (userId) {
|
|
615
645
|
process.env.TESTCHIMP_USER_ID = userId;
|
|
@@ -617,10 +647,37 @@ export async function runChimphands(opts) {
|
|
|
617
647
|
mkdirSync(".opencode", { recursive: true });
|
|
618
648
|
const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
|
|
619
649
|
console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
|
|
650
|
+
if (attachUrl) {
|
|
651
|
+
console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
|
|
652
|
+
}
|
|
620
653
|
let opencodeSessionId = bootStr(boot, "opencode_session_id", "opencodeSessionId") || undefined;
|
|
621
654
|
const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
|
|
622
655
|
let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
|
|
623
656
|
let pullRequestUrl = bootStr(boot, "pull_request_url", "pullRequestUrl") || undefined;
|
|
657
|
+
const exportSignedUrl = bootStr(boot, "opencode_export_signed_url", "opencodeExportSignedUrl");
|
|
658
|
+
if (exportSignedUrl && attachUrl) {
|
|
659
|
+
try {
|
|
660
|
+
const importedId = await importOpencodeExportFromUrl(exportSignedUrl);
|
|
661
|
+
if (importedId) {
|
|
662
|
+
opencodeSessionId = importedId;
|
|
663
|
+
console.error(`ChimpHands rehydrated OpenCode session from export: ${importedId}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
console.error(`ChimpHands export import failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const snapshotExport = async () => {
|
|
671
|
+
const sid = opencodeSessionId?.trim();
|
|
672
|
+
if (!sid)
|
|
673
|
+
return;
|
|
674
|
+
try {
|
|
675
|
+
await putOpencodeExport(backend, apiKey, sessionId, sid);
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
console.error(`ChimpHands export snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
624
681
|
const noteWorkingBranch = (branch, prUrl) => {
|
|
625
682
|
const normalizedBranch = branch.trim();
|
|
626
683
|
if (!normalizedBranch)
|
|
@@ -643,6 +700,7 @@ export async function runChimphands(opts) {
|
|
|
643
700
|
let idle = false;
|
|
644
701
|
let sessionActive = true;
|
|
645
702
|
let lastUserActivity = Date.now();
|
|
703
|
+
let exitCode;
|
|
646
704
|
const enqueueUserMessage = (msg) => {
|
|
647
705
|
const id = msg.id?.trim();
|
|
648
706
|
if (id) {
|
|
@@ -706,67 +764,65 @@ export async function runChimphands(opts) {
|
|
|
706
764
|
},
|
|
707
765
|
shouldRun: () => sessionActive,
|
|
708
766
|
});
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
767
|
+
const shutdownRuntime = async () => {
|
|
768
|
+
sessionActive = false;
|
|
769
|
+
stopInbound();
|
|
770
|
+
stopTunnel();
|
|
771
|
+
stopHeartbeat();
|
|
772
|
+
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
773
|
+
await poster.flush();
|
|
774
|
+
await snapshotExport();
|
|
775
|
+
if (runtimeId) {
|
|
776
|
+
await postJson(backend, apiKey, "/api/chimphands/complete_runtime", {
|
|
777
|
+
runtimeId,
|
|
778
|
+
status: "CHIMPHANDS_RUNTIME_STATUS_TERMINATED",
|
|
779
|
+
}).catch(() => { });
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
try {
|
|
783
|
+
poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
|
|
784
|
+
let prompt = normalizeUserMessage(promptInput || bootStr(boot, "initial_prompt", "initialPrompt"));
|
|
785
|
+
const pending = boot.pending_user_messages || boot.pendingUserMessages || [];
|
|
786
|
+
for (const m of pending) {
|
|
787
|
+
if (m?.content)
|
|
788
|
+
enqueueUserMessage({ content: m.content });
|
|
789
|
+
}
|
|
790
|
+
const waitForNextPrompt = () => new Promise((resolve) => {
|
|
791
|
+
let lastPollAt = 0;
|
|
792
|
+
const tick = () => {
|
|
793
|
+
if (queue.length) {
|
|
794
|
+
resolve(normalizeUserMessage(queue.shift()));
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const now = Date.now();
|
|
798
|
+
if (now - lastPollAt >= 1500) {
|
|
799
|
+
lastPollAt = now;
|
|
800
|
+
void pollPendingUserMessages().then(() => {
|
|
801
|
+
if (queue.length) {
|
|
802
|
+
resolve(normalizeUserMessage(queue.shift()));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
806
|
+
resolve(null);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
setTimeout(tick, 500);
|
|
810
|
+
});
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
814
|
+
resolve(null);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
setTimeout(tick, 500);
|
|
818
|
+
};
|
|
819
|
+
tick();
|
|
761
820
|
});
|
|
762
|
-
|
|
763
|
-
useOpencodeSessionId
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
isNewOpencodeSession = true;
|
|
768
|
-
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
|
|
769
|
-
result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
|
|
821
|
+
while (prompt) {
|
|
822
|
+
let useOpencodeSessionId = opencodeSessionId;
|
|
823
|
+
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
824
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
825
|
+
let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
|
|
770
826
|
onSessionId: (id) => {
|
|
771
827
|
opencodeSessionId = id;
|
|
772
828
|
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
@@ -776,43 +832,399 @@ export async function runChimphands(opts) {
|
|
|
776
832
|
},
|
|
777
833
|
onWorkingBranch: noteWorkingBranch,
|
|
778
834
|
postEvent,
|
|
835
|
+
}, attachUrl);
|
|
836
|
+
if (result.code !== 0 &&
|
|
837
|
+
useOpencodeSessionId &&
|
|
838
|
+
isMissingOpencodeSessionError(result.err || "")) {
|
|
839
|
+
console.error(`ChimpHands OpenCode session ${useOpencodeSessionId} missing on runner; starting fresh thread.`);
|
|
840
|
+
opencodeSessionId = undefined;
|
|
841
|
+
isNewOpencodeSession = true;
|
|
842
|
+
effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
|
|
843
|
+
result = await runOpencode(effectivePrompt, opencodeModel, childEnv, undefined, {
|
|
844
|
+
onSessionId: (id) => {
|
|
845
|
+
opencodeSessionId = id;
|
|
846
|
+
void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
|
|
847
|
+
sessionId,
|
|
848
|
+
opencodeSessionId: id,
|
|
849
|
+
}).catch(() => { });
|
|
850
|
+
},
|
|
851
|
+
onWorkingBranch: noteWorkingBranch,
|
|
852
|
+
postEvent,
|
|
853
|
+
}, attachUrl);
|
|
854
|
+
}
|
|
855
|
+
await poster.flush();
|
|
856
|
+
if (result.opencodeSessionId) {
|
|
857
|
+
opencodeSessionId = result.opencodeSessionId;
|
|
858
|
+
}
|
|
859
|
+
if (result.code !== 0) {
|
|
860
|
+
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
861
|
+
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
862
|
+
try {
|
|
863
|
+
await poster.enqueue(ROLE_STATUS, errMsg, {
|
|
864
|
+
status: STATUS_FAILED,
|
|
865
|
+
opencodeSessionId,
|
|
866
|
+
});
|
|
867
|
+
await postJson(backend, apiKey, "/api/chimphands/complete_session", {
|
|
868
|
+
sessionId,
|
|
869
|
+
status: STATUS_FAILED,
|
|
870
|
+
errorMessage: errMsg,
|
|
871
|
+
githubRunId: githubRunId || undefined,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
catch (reportErr) {
|
|
875
|
+
const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
|
|
876
|
+
console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
|
|
877
|
+
postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
|
|
878
|
+
complete(STATUS_FAILED, errMsg);
|
|
879
|
+
}
|
|
880
|
+
exitCode = result.code || 1;
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
884
|
+
await snapshotExport();
|
|
885
|
+
lastUserActivity = Date.now();
|
|
886
|
+
idle = false;
|
|
887
|
+
prompt = (await waitForNextPrompt()) || "";
|
|
888
|
+
}
|
|
889
|
+
if (exitCode == null) {
|
|
890
|
+
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
891
|
+
complete(STATUS_IDLE);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
finally {
|
|
895
|
+
await shutdownRuntime();
|
|
896
|
+
}
|
|
897
|
+
if (exitCode != null) {
|
|
898
|
+
process.exit(exitCode);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function startRuntimeHeartbeat(backend, apiKey, runtimeId) {
|
|
902
|
+
let stopped = false;
|
|
903
|
+
const tick = async () => {
|
|
904
|
+
if (stopped)
|
|
905
|
+
return;
|
|
906
|
+
try {
|
|
907
|
+
// Do not claim tunnel_connected here — only the tunnel poll loop should.
|
|
908
|
+
await postJson(backend, apiKey, "/api/chimphands/runtime_heartbeat", {
|
|
909
|
+
runtimeId,
|
|
779
910
|
});
|
|
780
911
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
opencodeSessionId = result.opencodeSessionId;
|
|
912
|
+
catch (err) {
|
|
913
|
+
console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
784
914
|
}
|
|
785
|
-
if (
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
915
|
+
if (!stopped)
|
|
916
|
+
setTimeout(tick, 15_000);
|
|
917
|
+
};
|
|
918
|
+
void tick();
|
|
919
|
+
return () => {
|
|
920
|
+
stopped = true;
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
/** Commit+push dirty worktree on the session branch before idle/teardown (no default-branch writes). */
|
|
924
|
+
async function commitAndPushDirtyWorktree(message) {
|
|
925
|
+
const run = (args, env) => new Promise((resolve) => {
|
|
926
|
+
const child = spawn("git", args, {
|
|
927
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
928
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
929
|
+
});
|
|
930
|
+
let out = "";
|
|
931
|
+
let err = "";
|
|
932
|
+
child.stdout.on("data", (d) => {
|
|
933
|
+
out += d.toString();
|
|
934
|
+
});
|
|
935
|
+
child.stderr.on("data", (d) => {
|
|
936
|
+
err += d.toString();
|
|
937
|
+
});
|
|
938
|
+
child.on("close", (code) => resolve({ code: code ?? 1, out, err }));
|
|
939
|
+
});
|
|
940
|
+
try {
|
|
941
|
+
const branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
942
|
+
if (branch.code !== 0) {
|
|
943
|
+
console.error(`ChimpHands git rev-parse failed: ${branch.err || branch.out}`);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const current = branch.out.trim();
|
|
947
|
+
if (!current || current === "HEAD" || /^(main|master)$/i.test(current)) {
|
|
948
|
+
console.error(`ChimpHands skip commit-before-idle: refusing branch "${current || "(unknown)"}"`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const status = await run(["status", "--porcelain"]);
|
|
952
|
+
if (status.code !== 0) {
|
|
953
|
+
console.error(`ChimpHands git status failed: ${status.err || status.out}`);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (!status.out.trim()) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const add = await run(["add", "-A"]);
|
|
960
|
+
if (add.code !== 0) {
|
|
961
|
+
console.error(`ChimpHands git add failed: ${add.err || add.out}`);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const commitEnv = {
|
|
965
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "ChimpHands",
|
|
966
|
+
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || "chimphands@testchimp.io",
|
|
967
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "ChimpHands",
|
|
968
|
+
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || "chimphands@testchimp.io",
|
|
969
|
+
};
|
|
970
|
+
const commit = await run(["-c", "user.name=ChimpHands", "-c", "user.email=chimphands@testchimp.io", "commit", "-m", message], commitEnv);
|
|
971
|
+
if (commit.code !== 0) {
|
|
972
|
+
console.error(`ChimpHands git commit: ${commit.err || commit.out}`);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const push = await run(["push", "-u", "origin", "HEAD"]);
|
|
976
|
+
if (push.code !== 0) {
|
|
977
|
+
console.error(`ChimpHands git push failed: ${push.err || push.out}`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
|
|
981
|
+
}
|
|
982
|
+
catch (err) {
|
|
983
|
+
console.error(`ChimpHands commit-before-idle failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
async function putOpencodeExport(backend, apiKey, sessionId, opencodeSessionId) {
|
|
987
|
+
const exported = await new Promise((resolve, reject) => {
|
|
988
|
+
const child = spawn("opencode", ["export", opencodeSessionId, "--sanitize"], {
|
|
989
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
990
|
+
});
|
|
991
|
+
let out = "";
|
|
992
|
+
let err = "";
|
|
993
|
+
child.stdout.on("data", (d) => {
|
|
994
|
+
out += d.toString();
|
|
995
|
+
});
|
|
996
|
+
child.stderr.on("data", (d) => {
|
|
997
|
+
err += d.toString();
|
|
998
|
+
});
|
|
999
|
+
child.on("close", (code) => {
|
|
1000
|
+
if (code === 0 && out.trim())
|
|
1001
|
+
resolve(out);
|
|
1002
|
+
else
|
|
1003
|
+
reject(new Error(err.trim() || `opencode export exited ${code}`));
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
const exportBase64 = Buffer.from(exported, "utf8").toString("base64");
|
|
1007
|
+
await postJson(backend, apiKey, "/api/chimphands/put_opencode_export", {
|
|
1008
|
+
sessionId,
|
|
1009
|
+
exportBase64,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
async function importOpencodeExportFromUrl(signedUrl) {
|
|
1013
|
+
const res = await fetch(signedUrl);
|
|
1014
|
+
if (!res.ok) {
|
|
1015
|
+
throw new Error(`download export failed: ${res.status}`);
|
|
1016
|
+
}
|
|
1017
|
+
const text = await res.text();
|
|
1018
|
+
writeFileSync("/tmp/chimphands-opencode-export.json", text, "utf8");
|
|
1019
|
+
return await new Promise((resolve, reject) => {
|
|
1020
|
+
const child = spawn("opencode", ["import", "/tmp/chimphands-opencode-export.json"], {
|
|
1021
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1022
|
+
});
|
|
1023
|
+
let out = "";
|
|
1024
|
+
let err = "";
|
|
1025
|
+
child.stdout.on("data", (d) => {
|
|
1026
|
+
out += d.toString();
|
|
1027
|
+
});
|
|
1028
|
+
child.stderr.on("data", (d) => {
|
|
1029
|
+
err += d.toString();
|
|
1030
|
+
});
|
|
1031
|
+
child.on("close", (code) => {
|
|
1032
|
+
if (code !== 0) {
|
|
1033
|
+
reject(new Error(err.trim() || `opencode import exited ${code}`));
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const match = (out + "\n" + err).match(/ses_[A-Za-z0-9]+/);
|
|
1037
|
+
resolve(match?.[0]);
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
function startTunnelWorker(backend, apiKey, runtimeId, attachUrl) {
|
|
1042
|
+
let stopped = false;
|
|
1043
|
+
const base = attachUrl.replace(/\/$/, "");
|
|
1044
|
+
let ws = null;
|
|
1045
|
+
let reconnectTimer = null;
|
|
1046
|
+
let backoffMs = 1000;
|
|
1047
|
+
const wsBase = backend.replace(/^http/i, (m) => (m.toLowerCase() === "https" ? "wss" : "ws"));
|
|
1048
|
+
const tunnelUrl = `${wsBase.replace(/\/$/, "")}/api/chimphands/runtimes/${encodeURIComponent(runtimeId)}/tunnel`;
|
|
1049
|
+
const clearReconnect = () => {
|
|
1050
|
+
if (reconnectTimer) {
|
|
1051
|
+
clearTimeout(reconnectTimer);
|
|
1052
|
+
reconnectTimer = null;
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
const handleHttpRequest = async (socket, req) => {
|
|
1056
|
+
if (!req.requestId || socket.readyState !== 1)
|
|
1057
|
+
return;
|
|
1058
|
+
const requestId = req.requestId;
|
|
1059
|
+
const target = base + (req.path || "/") + (req.query ? `?${req.query}` : "");
|
|
1060
|
+
const headers = { ...(req.headers || {}) };
|
|
1061
|
+
const init = { method: req.method || "GET", headers };
|
|
1062
|
+
if (req.bodyBase64) {
|
|
1063
|
+
init.body = Buffer.from(req.bodyBase64, "base64");
|
|
1064
|
+
}
|
|
1065
|
+
// Long-running SSE / chat streams — no hard abort under ~5 minutes.
|
|
1066
|
+
const ac = new AbortController();
|
|
1067
|
+
const upstreamTimer = setTimeout(() => ac.abort(), 290_000);
|
|
1068
|
+
init.signal = ac.signal;
|
|
1069
|
+
const send = (obj) => {
|
|
1070
|
+
if (socket.readyState !== 1)
|
|
1071
|
+
return;
|
|
1072
|
+
socket.send(JSON.stringify(obj));
|
|
1073
|
+
};
|
|
1074
|
+
/** Keep each WS text frame small — Tomcat default max is 8KiB; GCLB is happier with modest frames. */
|
|
1075
|
+
const sendBodyChunk = (bytes) => {
|
|
1076
|
+
const MAX = 24 * 1024;
|
|
1077
|
+
for (let offset = 0; offset < bytes.length; offset += MAX) {
|
|
1078
|
+
const slice = bytes.subarray(offset, Math.min(offset + MAX, bytes.length));
|
|
1079
|
+
send({
|
|
1080
|
+
type: "http_response_chunk",
|
|
1081
|
+
requestId,
|
|
1082
|
+
bodyBase64: Buffer.from(slice).toString("base64"),
|
|
798
1083
|
});
|
|
799
1084
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1085
|
+
};
|
|
1086
|
+
try {
|
|
1087
|
+
const upstream = await fetch(target, init);
|
|
1088
|
+
const respHeaders = {};
|
|
1089
|
+
upstream.headers.forEach((v, k) => {
|
|
1090
|
+
respHeaders[k] = v;
|
|
1091
|
+
});
|
|
1092
|
+
send({
|
|
1093
|
+
type: "http_response_start",
|
|
1094
|
+
requestId,
|
|
1095
|
+
status: upstream.status,
|
|
1096
|
+
headers: respHeaders,
|
|
1097
|
+
});
|
|
1098
|
+
const body = upstream.body;
|
|
1099
|
+
if (body) {
|
|
1100
|
+
const reader = body.getReader();
|
|
1101
|
+
while (true) {
|
|
1102
|
+
const { done, value } = await reader.read();
|
|
1103
|
+
if (done)
|
|
1104
|
+
break;
|
|
1105
|
+
if (value && value.length) {
|
|
1106
|
+
sendBodyChunk(Buffer.from(value));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
1112
|
+
if (buf.length) {
|
|
1113
|
+
sendBodyChunk(buf);
|
|
1114
|
+
}
|
|
805
1115
|
}
|
|
806
|
-
|
|
1116
|
+
send({ type: "http_response_end", requestId });
|
|
807
1117
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1120
|
+
send({
|
|
1121
|
+
type: "http_response",
|
|
1122
|
+
requestId,
|
|
1123
|
+
status: 502,
|
|
1124
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
1125
|
+
bodyBase64: Buffer.from(msg, "utf8").toString("base64"),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
finally {
|
|
1129
|
+
clearTimeout(upstreamTimer);
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
const connect = async () => {
|
|
1133
|
+
if (stopped)
|
|
1134
|
+
return;
|
|
1135
|
+
clearReconnect();
|
|
1136
|
+
const { default: WebSocket } = await import("ws");
|
|
1137
|
+
const socket = new WebSocket(tunnelUrl, {
|
|
1138
|
+
headers: { "TestChimp-Api-Key": apiKey },
|
|
1139
|
+
handshakeTimeout: 30_000,
|
|
1140
|
+
});
|
|
1141
|
+
ws = socket;
|
|
1142
|
+
socket.on("open", () => {
|
|
1143
|
+
backoffMs = 1000;
|
|
1144
|
+
console.error(`ChimpHands agent tunnel WS connected: ${tunnelUrl}`);
|
|
1145
|
+
// Application ping keeps LBs from idling out the tunnel (and proves liveness).
|
|
1146
|
+
const ping = () => {
|
|
1147
|
+
if (stopped || socket.readyState !== 1)
|
|
1148
|
+
return;
|
|
1149
|
+
try {
|
|
1150
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
/* ignore */
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
ping();
|
|
1157
|
+
const pingTimer = setInterval(ping, 20_000);
|
|
1158
|
+
socket.once("close", () => clearInterval(pingTimer));
|
|
1159
|
+
});
|
|
1160
|
+
socket.on("message", (data) => {
|
|
1161
|
+
if (stopped)
|
|
1162
|
+
return;
|
|
1163
|
+
try {
|
|
1164
|
+
const text = typeof data === "string" ? data : data.toString("utf8");
|
|
1165
|
+
const frame = JSON.parse(text);
|
|
1166
|
+
if (frame.type === "pong")
|
|
1167
|
+
return;
|
|
1168
|
+
if (frame.type === "ping") {
|
|
1169
|
+
socket.send(JSON.stringify({ type: "pong" }));
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (frame.type === "http_request" || frame.requestId) {
|
|
1173
|
+
void handleHttpRequest(socket, frame);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
catch (err) {
|
|
1177
|
+
console.error(`ChimpHands tunnel frame error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
socket.on("close", (code, reason) => {
|
|
1181
|
+
ws = null;
|
|
1182
|
+
if (stopped)
|
|
1183
|
+
return;
|
|
1184
|
+
const why = reason?.toString?.() || "";
|
|
1185
|
+
console.error(`ChimpHands agent tunnel WS closed; code=${code} reason=${why || "(none)"} reconnecting in ${backoffMs}ms`);
|
|
1186
|
+
reconnectTimer = setTimeout(() => {
|
|
1187
|
+
void connect();
|
|
1188
|
+
}, backoffMs);
|
|
1189
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
1190
|
+
});
|
|
1191
|
+
socket.on("error", (err) => {
|
|
1192
|
+
console.error(`ChimpHands agent tunnel WS error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1193
|
+
});
|
|
1194
|
+
};
|
|
1195
|
+
void connect();
|
|
1196
|
+
return () => {
|
|
1197
|
+
stopped = true;
|
|
1198
|
+
clearReconnect();
|
|
1199
|
+
if (ws) {
|
|
1200
|
+
try {
|
|
1201
|
+
ws.close();
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
1204
|
+
// ignore
|
|
1205
|
+
}
|
|
1206
|
+
ws = null;
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
/** Runtime-aware entry: register + attach to local OpenCode server (Phase 1+). */
|
|
1211
|
+
export async function serveChimphands(opts) {
|
|
1212
|
+
const attachUrl = opts.attachUrl.trim();
|
|
1213
|
+
if (!attachUrl) {
|
|
1214
|
+
throw new Error("--attach URL is required for chimphands serve");
|
|
1215
|
+
}
|
|
1216
|
+
// Wait for OpenCode server readiness.
|
|
1217
|
+
const deadline = Date.now() + 60_000;
|
|
1218
|
+
while (Date.now() < deadline) {
|
|
1219
|
+
try {
|
|
1220
|
+
const res = await fetch(attachUrl.replace(/\/$/, "") + "/");
|
|
1221
|
+
if (res.ok || res.status === 401 || res.status === 404)
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
catch {
|
|
1225
|
+
/* retry */
|
|
1226
|
+
}
|
|
1227
|
+
await sleep(500);
|
|
812
1228
|
}
|
|
813
|
-
|
|
814
|
-
stopInbound();
|
|
815
|
-
await poster.flush();
|
|
816
|
-
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
817
|
-
complete(STATUS_IDLE);
|
|
1229
|
+
await runChimphands({ ...opts, attachUrl });
|
|
818
1230
|
}
|
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.46",
|
|
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": [
|