@testchimp/cli 0.1.44 → 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.
@@ -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 {};
@@ -370,16 +370,19 @@ function writeOpencodeConfig(backend, apiKey, boot) {
370
370
  }, null, 2));
371
371
  return model;
372
372
  }
373
- function buildOpencodeArgs(prompt, model, opencodeSessionId) {
373
+ function buildOpencodeArgs(prompt, model, opencodeSessionId, attachUrl) {
374
374
  const args = ["run", prompt, "--model", model, "--format", "json", "--agent", OPENCODE_AGENT_ID];
375
375
  if (opencodeSessionId?.trim()) {
376
376
  args.push("--session", opencodeSessionId.trim());
377
377
  }
378
+ if (attachUrl?.trim()) {
379
+ args.push("--attach", attachUrl.trim());
380
+ }
378
381
  return args;
379
382
  }
380
- function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks) {
383
+ function runOpencode(prompt, model, childEnv, opencodeSessionId, callbacks, attachUrl) {
381
384
  let activeSessionId = opencodeSessionId?.trim() || undefined;
382
- const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId);
385
+ const baseArgs = buildOpencodeArgs(prompt, model, activeSessionId, attachUrl);
383
386
  const child = spawn("opencode", baseArgs, {
384
387
  stdio: ["ignore", "pipe", "pipe"],
385
388
  env: childEnv,
@@ -596,6 +599,8 @@ export async function runChimphands(opts) {
596
599
  throw new Error("session_id is required (pass --session-id or SESSION_ID)");
597
600
  }
598
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;
599
604
  const bootText = await postJson(backend, apiKey, "/api/chimphands/bootstrap", {
600
605
  sessionId,
601
606
  });
@@ -610,6 +615,30 @@ export async function runChimphands(opts) {
610
615
  console.error(`ChimpHands link run failed: ${err instanceof Error ? err.message : String(err)}`);
611
616
  });
612
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
+ : () => { };
613
642
  const userId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
614
643
  if (userId) {
615
644
  process.env.TESTCHIMP_USER_ID = userId;
@@ -617,10 +646,37 @@ export async function runChimphands(opts) {
617
646
  mkdirSync(".opencode", { recursive: true });
618
647
  const opencodeModel = writeOpencodeConfig(backend, apiKey, boot);
619
648
  console.error(`ChimpHands OpenCode model: ${opencodeModel}`);
649
+ if (attachUrl) {
650
+ console.error(`ChimpHands OpenCode attach: ${attachUrl}`);
651
+ }
620
652
  let opencodeSessionId = bootStr(boot, "opencode_session_id", "opencodeSessionId") || undefined;
621
653
  const conversationSummary = bootStr(boot, "conversation_summary", "conversationSummary");
622
654
  let workingBranch = bootStr(boot, "working_branch", "workingBranch") || undefined;
623
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
+ };
624
680
  const noteWorkingBranch = (branch, prUrl) => {
625
681
  const normalizedBranch = branch.trim();
626
682
  if (!normalizedBranch)
@@ -643,6 +699,7 @@ export async function runChimphands(opts) {
643
699
  let idle = false;
644
700
  let sessionActive = true;
645
701
  let lastUserActivity = Date.now();
702
+ let exitCode;
646
703
  const enqueueUserMessage = (msg) => {
647
704
  const id = msg.id?.trim();
648
705
  if (id) {
@@ -706,67 +763,64 @@ export async function runChimphands(opts) {
706
763
  },
707
764
  shouldRun: () => sessionActive,
708
765
  });
709
- poster.fireAndForget(ROLE_STATUS, "Agent ready", { status: STATUS_RUNNING });
710
- let prompt = normalizeUserMessage(promptInput || bootStr(boot, "initial_prompt", "initialPrompt"));
711
- const pending = boot.pending_user_messages || boot.pendingUserMessages || [];
712
- for (const m of pending) {
713
- if (m?.content)
714
- enqueueUserMessage({ content: m.content });
715
- }
716
- const waitForNextPrompt = () => new Promise((resolve) => {
717
- let lastPollAt = 0;
718
- const tick = () => {
719
- if (queue.length) {
720
- resolve(normalizeUserMessage(queue.shift()));
721
- return;
722
- }
723
- const now = Date.now();
724
- if (now - lastPollAt >= 1500) {
725
- lastPollAt = now;
726
- void pollPendingUserMessages().then(() => {
727
- if (queue.length) {
728
- resolve(normalizeUserMessage(queue.shift()));
729
- return;
730
- }
731
- if (idle || now - lastUserActivity >= idleMs) {
732
- resolve(null);
733
- return;
734
- }
735
- setTimeout(tick, 500);
736
- });
737
- return;
738
- }
739
- if (idle || now - lastUserActivity >= idleMs) {
740
- resolve(null);
741
- return;
742
- }
743
- setTimeout(tick, 500);
744
- };
745
- tick();
746
- });
747
- while (prompt) {
748
- let useOpencodeSessionId = opencodeSessionId;
749
- let isNewOpencodeSession = !useOpencodeSessionId;
750
- let effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, isNewOpencodeSession, workingBranch, pullRequestUrl);
751
- let result = await runOpencode(effectivePrompt, opencodeModel, childEnv, useOpencodeSessionId, {
752
- onSessionId: (id) => {
753
- opencodeSessionId = id;
754
- void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
755
- sessionId,
756
- opencodeSessionId: id,
757
- }).catch(() => { });
758
- },
759
- onWorkingBranch: noteWorkingBranch,
760
- postEvent,
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();
761
818
  });
762
- if (result.code !== 0 &&
763
- useOpencodeSessionId &&
764
- isMissingOpencodeSessionError(result.err || "")) {
765
- console.error(`ChimpHands OpenCode session ${useOpencodeSessionId} missing on runner; starting fresh thread.`);
766
- opencodeSessionId = undefined;
767
- isNewOpencodeSession = true;
768
- effectivePrompt = wrapPromptWithContext(conversationSummary, prompt, true, workingBranch, pullRequestUrl);
769
- 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, {
770
824
  onSessionId: (id) => {
771
825
  opencodeSessionId = id;
772
826
  void postJson(backend, apiKey, "/api/chimphands/post_agent_event", {
@@ -776,43 +830,317 @@ export async function runChimphands(opts) {
776
830
  },
777
831
  onWorkingBranch: noteWorkingBranch,
778
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,
779
908
  });
780
909
  }
781
- await poster.flush();
782
- if (result.opencodeSessionId) {
783
- opencodeSessionId = result.opencodeSessionId;
910
+ catch (err) {
911
+ console.error(`ChimpHands runtime_heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
784
912
  }
785
- if (result.code !== 0) {
786
- const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
787
- console.error(`ChimpHands OpenCode failed: ${errMsg}`);
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;
788
1080
  try {
789
- await poster.enqueue(ROLE_STATUS, errMsg, {
790
- status: STATUS_FAILED,
791
- opencodeSessionId,
792
- });
793
- await postJson(backend, apiKey, "/api/chimphands/complete_session", {
794
- sessionId,
795
- status: STATUS_FAILED,
796
- errorMessage: errMsg,
797
- githubRunId: githubRunId || undefined,
798
- });
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
+ }
1092
+ }
1093
+ catch (err) {
1094
+ console.error(`ChimpHands tunnel frame error: ${err instanceof Error ? err.message : String(err)}`);
799
1095
  }
800
- catch (reportErr) {
801
- const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
802
- console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
803
- postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
804
- complete(STATUS_FAILED, errMsg);
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
805
1121
  }
806
- process.exit(result.code || 1);
1122
+ ws = null;
807
1123
  }
808
- postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
809
- lastUserActivity = Date.now();
810
- idle = false;
811
- prompt = (await waitForNextPrompt()) || "";
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);
812
1144
  }
813
- sessionActive = false;
814
- stopInbound();
815
- await poster.flush();
816
- console.error("ChimpHands session idle — no user input before timeout; completing.");
817
- complete(STATUS_IDLE);
1145
+ await runChimphands({ ...opts, attachUrl });
818
1146
  }
@@ -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.44",
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": [