@testchimp/cli 0.1.64 → 0.1.66
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/refreshGitAuth.d.ts +22 -0
- package/dist/chimphands/refreshGitAuth.js +139 -0
- package/dist/chimphands/run.js +203 -65
- package/dist/cli/program.js +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remint a short-lived GitHub App installation token and apply it for git/gh.
|
|
3
|
+
* Never prints the token. Safe for ChimpHands agents and GHA runners.
|
|
4
|
+
*/
|
|
5
|
+
export type RefreshGitAuthResult = {
|
|
6
|
+
ok: true;
|
|
7
|
+
repositoryFullName: string;
|
|
8
|
+
expiresAtMillis: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Mint a ~1h GitHub App installation token and apply it for subsequent git/gh
|
|
12
|
+
* in this process (and this GHA job when GITHUB_ENV is set).
|
|
13
|
+
*
|
|
14
|
+
* Does **not** print the token. Applies via:
|
|
15
|
+
* - process.env GH_TOKEN / GITHUB_TOKEN
|
|
16
|
+
* - $GITHUB_ENV when present (GitHub Actions)
|
|
17
|
+
* - origin remote URL rewrite (https github.com) when possible
|
|
18
|
+
* - `gh auth login --with-token` + `gh auth setup-git` when `gh` exists
|
|
19
|
+
*/
|
|
20
|
+
export declare function refreshGitAuth(): Promise<RefreshGitAuthResult>;
|
|
21
|
+
/** True when stderr/stdout looks like expired / rejected GitHub credentials. */
|
|
22
|
+
export declare function looksLikeGitAuthFailure(text: string): boolean;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remint a short-lived GitHub App installation token and apply it for git/gh.
|
|
3
|
+
* Never prints the token. Safe for ChimpHands agents and GHA runners.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { appendFileSync } from "node:fs";
|
|
7
|
+
import { getBackendUrl, requireApiKey } from "../core/client.js";
|
|
8
|
+
function run(cmd, args, env) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const child = spawn(cmd, args, {
|
|
11
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
12
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
13
|
+
});
|
|
14
|
+
let out = "";
|
|
15
|
+
let err = "";
|
|
16
|
+
child.stdout.on("data", (d) => {
|
|
17
|
+
out += d.toString();
|
|
18
|
+
});
|
|
19
|
+
child.stderr.on("data", (d) => {
|
|
20
|
+
err += d.toString();
|
|
21
|
+
});
|
|
22
|
+
child.on("close", (code) => resolve({ code: code ?? 1, out, err }));
|
|
23
|
+
child.on("error", (e) => resolve({ code: 1, out: "", err: e instanceof Error ? e.message : String(e) }));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function pipeTokenToGh(token) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const child = spawn("gh", ["auth", "login", "--with-token"], {
|
|
29
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
30
|
+
env: process.env,
|
|
31
|
+
});
|
|
32
|
+
let err = "";
|
|
33
|
+
child.stderr.on("data", (d) => {
|
|
34
|
+
err += d.toString();
|
|
35
|
+
});
|
|
36
|
+
child.on("close", (code) => resolve({ code: code ?? 1, err }));
|
|
37
|
+
child.on("error", (e) => resolve({ code: 1, err: e instanceof Error ? e.message : String(e) }));
|
|
38
|
+
child.stdin.write(token);
|
|
39
|
+
child.stdin.end();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function injectTokenIntoGithubHttpsUrl(remoteUrl, token) {
|
|
43
|
+
// https://github.com/owner/repo.git or https://x-access-token:OLD@github.com/owner/repo.git
|
|
44
|
+
const m = remoteUrl.match(/^https:\/\/(?:[^@]+@)?github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?\s*$/i);
|
|
45
|
+
if (!m)
|
|
46
|
+
return null;
|
|
47
|
+
const owner = m[1];
|
|
48
|
+
const repo = m[2].replace(/\.git$/i, "");
|
|
49
|
+
return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
|
|
50
|
+
}
|
|
51
|
+
async function mintGithubWriteToken() {
|
|
52
|
+
const apiKey = requireApiKey();
|
|
53
|
+
const backend = getBackendUrl();
|
|
54
|
+
const res = await fetch(`${backend}/api/chimphands/github_write_token`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"TestChimp-Api-Key": apiKey,
|
|
59
|
+
},
|
|
60
|
+
body: "{}",
|
|
61
|
+
});
|
|
62
|
+
const text = await res.text();
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
throw new Error(`github_write_token failed HTTP ${res.status}: ${text}`);
|
|
65
|
+
}
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error("github_write_token returned non-JSON");
|
|
72
|
+
}
|
|
73
|
+
const token = (parsed.token || "").trim();
|
|
74
|
+
if (!token) {
|
|
75
|
+
throw new Error("github_write_token returned empty token");
|
|
76
|
+
}
|
|
77
|
+
const repositoryFullName = (parsed.repositoryFullName ||
|
|
78
|
+
parsed.repository_full_name ||
|
|
79
|
+
"").trim();
|
|
80
|
+
const expiresAtMillis = Number(parsed.expiresAtMillis ?? parsed.expires_at_millis) ||
|
|
81
|
+
Date.now() + 60 * 60 * 1000;
|
|
82
|
+
return { token, repositoryFullName, expiresAtMillis };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Mint a ~1h GitHub App installation token and apply it for subsequent git/gh
|
|
86
|
+
* in this process (and this GHA job when GITHUB_ENV is set).
|
|
87
|
+
*
|
|
88
|
+
* Does **not** print the token. Applies via:
|
|
89
|
+
* - process.env GH_TOKEN / GITHUB_TOKEN
|
|
90
|
+
* - $GITHUB_ENV when present (GitHub Actions)
|
|
91
|
+
* - origin remote URL rewrite (https github.com) when possible
|
|
92
|
+
* - `gh auth login --with-token` + `gh auth setup-git` when `gh` exists
|
|
93
|
+
*/
|
|
94
|
+
export async function refreshGitAuth() {
|
|
95
|
+
const { token, repositoryFullName, expiresAtMillis } = await mintGithubWriteToken();
|
|
96
|
+
process.env.GH_TOKEN = token;
|
|
97
|
+
process.env.GITHUB_TOKEN = token;
|
|
98
|
+
const githubEnvPath = process.env.GITHUB_ENV?.trim();
|
|
99
|
+
if (githubEnvPath) {
|
|
100
|
+
try {
|
|
101
|
+
appendFileSync(githubEnvPath, `GH_TOKEN=${token}\nGITHUB_TOKEN=${token}\n`, "utf8");
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
console.error(`ChimpHands refresh-git-auth: could not write GITHUB_ENV: ${e instanceof Error ? e.message : String(e)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const remote = await run("git", ["remote", "get-url", "origin"]);
|
|
108
|
+
if (remote.code === 0) {
|
|
109
|
+
const current = remote.out.trim();
|
|
110
|
+
const next = injectTokenIntoGithubHttpsUrl(current, token);
|
|
111
|
+
if (next) {
|
|
112
|
+
const set = await run("git", ["remote", "set-url", "origin", next]);
|
|
113
|
+
if (set.code !== 0) {
|
|
114
|
+
console.error(`ChimpHands refresh-git-auth: git remote set-url failed: ${set.err || set.out}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const ghLogin = await pipeTokenToGh(token);
|
|
119
|
+
if (ghLogin.code !== 0) {
|
|
120
|
+
// setup-git alone still helps when login is already present / unavailable
|
|
121
|
+
console.error(`ChimpHands refresh-git-auth: gh auth login skipped/failed: ${ghLogin.err || "non-zero"}`);
|
|
122
|
+
}
|
|
123
|
+
const setup = await run("gh", ["auth", "setup-git"], {
|
|
124
|
+
GH_TOKEN: token,
|
|
125
|
+
GITHUB_TOKEN: token,
|
|
126
|
+
});
|
|
127
|
+
if (setup.code !== 0) {
|
|
128
|
+
console.error(`ChimpHands refresh-git-auth: gh auth setup-git: ${setup.err || setup.out}`);
|
|
129
|
+
}
|
|
130
|
+
console.error(`ChimpHands refresh-git-auth: reminted GitHub App installation token` +
|
|
131
|
+
(repositoryFullName ? ` for ${repositoryFullName}` : "") +
|
|
132
|
+
` (expires ~${new Date(expiresAtMillis).toISOString()})`);
|
|
133
|
+
return { ok: true, repositoryFullName, expiresAtMillis };
|
|
134
|
+
}
|
|
135
|
+
/** True when stderr/stdout looks like expired / rejected GitHub credentials. */
|
|
136
|
+
export function looksLikeGitAuthFailure(text) {
|
|
137
|
+
const t = text.toLowerCase();
|
|
138
|
+
return (/authentication failed|could not read username|invalid username or password|bad credentials|401\b|403\b|permission.*denied|remote:.*denied|write access not granted|repository not found/.test(t) || /gh:\s*to get started|gh auth login|HTTP\s*401|HTTP\s*403/.test(t));
|
|
139
|
+
}
|
package/dist/chimphands/run.js
CHANGED
|
@@ -75,6 +75,7 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
|
|
|
75
75
|
- After the branch is on the remote (and after opening a PR), IMMEDIATELY run:
|
|
76
76
|
\`testchimp chimphands report-branch --branch <name> [--pr-url <url>]\`
|
|
77
77
|
- Tell the user which branch you are on and include the PR URL when available.
|
|
78
|
+
- **GitHub auth expiry (self-fix):** Job-start App installation tokens expire after ~1 hour. On \`git push\` / \`gh\` auth failures (401/403 / Authentication failed / write access not granted), run \`testchimp chimphands refresh-git-auth\` then retry — **never** ask the user to paste or reconnect a GitHub token in chat. See skill \`references/chimphands-faq.md\`.
|
|
78
79
|
|
|
79
80
|
## TestChimp workflows (/testchimp …)
|
|
80
81
|
- Load and follow the \`testchimp\` skill under \`.agents/skills/testchimp/SKILL.md\`.
|
|
@@ -124,17 +125,26 @@ function apiHeaders(apiKey) {
|
|
|
124
125
|
"TestChimp-Api-Key": apiKey,
|
|
125
126
|
};
|
|
126
127
|
}
|
|
128
|
+
const POST_JSON_TIMEOUT_MS = 30_000;
|
|
127
129
|
async function postJson(backend, apiKey, path, body) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
130
|
+
const ac = new AbortController();
|
|
131
|
+
const timer = setTimeout(() => ac.abort(), POST_JSON_TIMEOUT_MS);
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(`${backend}${path}`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: apiHeaders(apiKey),
|
|
136
|
+
body: JSON.stringify(body ?? {}),
|
|
137
|
+
signal: ac.signal,
|
|
138
|
+
});
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
throw new Error(`ChimpHands API ${res.status} ${path}: ${text}`);
|
|
142
|
+
}
|
|
143
|
+
return text;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
clearTimeout(timer);
|
|
136
147
|
}
|
|
137
|
-
return text;
|
|
138
148
|
}
|
|
139
149
|
/** Serializes agent event posts so streaming chunks fan out in order. */
|
|
140
150
|
class AgentEventPoster {
|
|
@@ -319,12 +329,29 @@ function mapToolExecutionStatus(status) {
|
|
|
319
329
|
return TOOL_STATUS_ERROR;
|
|
320
330
|
return undefined;
|
|
321
331
|
}
|
|
332
|
+
/** First observed start time per OpenCode tool part — progress updates must not reset the UI countdown. */
|
|
333
|
+
const toolStartedAtByPartId = new Map();
|
|
322
334
|
function toolExecutionFromOpencodePart(part) {
|
|
323
335
|
const status = mapToolExecutionStatus(part.state?.status);
|
|
324
336
|
if (!status)
|
|
325
337
|
return undefined;
|
|
326
|
-
const
|
|
327
|
-
|
|
338
|
+
const partKey = String(part.id || part.messageID || "").trim();
|
|
339
|
+
const fromOpencode = normalizeEpochMillis(part.state?.time?.start);
|
|
340
|
+
const cached = partKey ? toolStartedAtByPartId.get(partKey) : undefined;
|
|
341
|
+
const isActive = status === TOOL_STATUS_PENDING || status === TOOL_STATUS_RUNNING;
|
|
342
|
+
let startedAtMillis = cached ?? fromOpencode;
|
|
343
|
+
if (startedAtMillis == null && isActive) {
|
|
344
|
+
startedAtMillis = Date.now();
|
|
345
|
+
}
|
|
346
|
+
if (partKey) {
|
|
347
|
+
if (isActive && startedAtMillis != null) {
|
|
348
|
+
toolStartedAtByPartId.set(partKey, startedAtMillis);
|
|
349
|
+
}
|
|
350
|
+
else if (status === TOOL_STATUS_COMPLETED ||
|
|
351
|
+
status === TOOL_STATUS_ERROR) {
|
|
352
|
+
toolStartedAtByPartId.delete(partKey);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
328
355
|
const endedAtMillis = normalizeEpochMillis(part.state?.time?.end);
|
|
329
356
|
const toolName = String(part.state?.title || part.tool || "").trim() || undefined;
|
|
330
357
|
const timeoutMillis = coerceTimeoutMillis(part.state?.input);
|
|
@@ -926,8 +953,8 @@ function detectWorkingBranchFromToolOutput(output) {
|
|
|
926
953
|
const prMatch = text.match(/https:\/\/github\.com\/[^\s)\]]+\/pull\/\d+/);
|
|
927
954
|
// Only auto-detect after a successful push to origin — local checkout -b alone would
|
|
928
955
|
// report a branch URL that 404s until the remote ref exists.
|
|
929
|
-
const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"]+)/i);
|
|
930
|
-
const branch = pushMatch?.[1]?.replace(/[`'"]/g, "");
|
|
956
|
+
const pushMatch = text.match(/push\s+(?:--set-upstream\s+|-u\s+)?origin\s+((?:testchimp-|chimphands-)[^\s'"\[\]]+)/i);
|
|
957
|
+
const branch = pushMatch?.[1]?.replace(/[`'"\[\]]/g, "");
|
|
931
958
|
return {
|
|
932
959
|
branch,
|
|
933
960
|
pullRequestUrl: prMatch?.[0],
|
|
@@ -1449,6 +1476,7 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
1449
1476
|
let stopped = false;
|
|
1450
1477
|
let reconnectTimer = null;
|
|
1451
1478
|
let reconnectDelayMs = 1000;
|
|
1479
|
+
let activeReq = null;
|
|
1452
1480
|
const scheduleReconnect = () => {
|
|
1453
1481
|
if (stopped || !handlers.shouldRun())
|
|
1454
1482
|
return;
|
|
@@ -1463,6 +1491,15 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
1463
1491
|
const connect = () => {
|
|
1464
1492
|
if (stopped || !handlers.shouldRun())
|
|
1465
1493
|
return;
|
|
1494
|
+
if (activeReq) {
|
|
1495
|
+
try {
|
|
1496
|
+
activeReq.destroy();
|
|
1497
|
+
}
|
|
1498
|
+
catch {
|
|
1499
|
+
/* ignore */
|
|
1500
|
+
}
|
|
1501
|
+
activeReq = null;
|
|
1502
|
+
}
|
|
1466
1503
|
const req = lib.request({
|
|
1467
1504
|
hostname: url.hostname,
|
|
1468
1505
|
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
@@ -1541,16 +1578,36 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
1541
1578
|
}
|
|
1542
1579
|
}
|
|
1543
1580
|
});
|
|
1544
|
-
res.on("end", () =>
|
|
1581
|
+
res.on("end", () => {
|
|
1582
|
+
if (activeReq === req)
|
|
1583
|
+
activeReq = null;
|
|
1584
|
+
scheduleReconnect();
|
|
1585
|
+
});
|
|
1586
|
+
});
|
|
1587
|
+
activeReq = req;
|
|
1588
|
+
req.on("error", () => {
|
|
1589
|
+
if (activeReq === req)
|
|
1590
|
+
activeReq = null;
|
|
1591
|
+
scheduleReconnect();
|
|
1545
1592
|
});
|
|
1546
|
-
req.on("error", () => scheduleReconnect());
|
|
1547
1593
|
req.end();
|
|
1548
1594
|
};
|
|
1549
1595
|
connect();
|
|
1550
1596
|
return () => {
|
|
1551
1597
|
stopped = true;
|
|
1552
|
-
if (reconnectTimer)
|
|
1598
|
+
if (reconnectTimer) {
|
|
1553
1599
|
clearTimeout(reconnectTimer);
|
|
1600
|
+
reconnectTimer = null;
|
|
1601
|
+
}
|
|
1602
|
+
if (activeReq) {
|
|
1603
|
+
try {
|
|
1604
|
+
activeReq.destroy();
|
|
1605
|
+
}
|
|
1606
|
+
catch {
|
|
1607
|
+
/* ignore */
|
|
1608
|
+
}
|
|
1609
|
+
activeReq = null;
|
|
1610
|
+
}
|
|
1554
1611
|
};
|
|
1555
1612
|
}
|
|
1556
1613
|
export async function runChimphands(opts) {
|
|
@@ -1716,14 +1773,52 @@ export async function runChimphands(opts) {
|
|
|
1716
1773
|
let lastUserActivity = Date.now();
|
|
1717
1774
|
let exitCode;
|
|
1718
1775
|
let cancelTurnRequested = false;
|
|
1776
|
+
/** True when cancel was triggered by a mid-turn user message (steer), not Stop. */
|
|
1777
|
+
let steerAbortRequested = false;
|
|
1719
1778
|
let activeOpencodeChild = null;
|
|
1720
1779
|
let agentTurnInProgress = false;
|
|
1780
|
+
/** Wakes waitForNextPrompt when idle is signaled (SSE) or a user message arrives. */
|
|
1781
|
+
let wakeWaitForPrompt = null;
|
|
1782
|
+
const wakePromptWaiter = () => {
|
|
1783
|
+
const wake = wakeWaitForPrompt;
|
|
1784
|
+
wakeWaitForPrompt = null;
|
|
1785
|
+
wake?.();
|
|
1786
|
+
};
|
|
1721
1787
|
const turnControl = {
|
|
1722
1788
|
getCancelRequested: () => cancelTurnRequested,
|
|
1723
1789
|
onActiveChild: (child) => {
|
|
1724
1790
|
activeOpencodeChild = child;
|
|
1725
1791
|
},
|
|
1726
1792
|
};
|
|
1793
|
+
/** Abort in-flight OpenCode turn (session HTTP abort + kill `opencode run` child). */
|
|
1794
|
+
const abortActiveTurn = (reason) => {
|
|
1795
|
+
cancelTurnRequested = true;
|
|
1796
|
+
if (reason === "steer") {
|
|
1797
|
+
steerAbortRequested = true;
|
|
1798
|
+
}
|
|
1799
|
+
const sessionToAbort = opencodeSessionId?.trim();
|
|
1800
|
+
if (attachUrl && sessionToAbort) {
|
|
1801
|
+
void abortOpencodeSession(attachUrl, sessionToAbort);
|
|
1802
|
+
}
|
|
1803
|
+
const ch = activeOpencodeChild;
|
|
1804
|
+
if (ch && !ch.killed) {
|
|
1805
|
+
try {
|
|
1806
|
+
ch.kill("SIGTERM");
|
|
1807
|
+
setTimeout(() => {
|
|
1808
|
+
try {
|
|
1809
|
+
if (!ch.killed)
|
|
1810
|
+
ch.kill("SIGKILL");
|
|
1811
|
+
}
|
|
1812
|
+
catch {
|
|
1813
|
+
/* ignore */
|
|
1814
|
+
}
|
|
1815
|
+
}, 2000);
|
|
1816
|
+
}
|
|
1817
|
+
catch {
|
|
1818
|
+
/* ignore */
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1727
1822
|
const enqueueUserMessage = (msg) => {
|
|
1728
1823
|
const id = msg.id?.trim();
|
|
1729
1824
|
if (id) {
|
|
@@ -1739,6 +1834,13 @@ export async function runChimphands(opts) {
|
|
|
1739
1834
|
idle = false;
|
|
1740
1835
|
const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
|
|
1741
1836
|
console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
|
|
1837
|
+
// OpenCode has no mid-turn inject: abort the active turn so the queued
|
|
1838
|
+
// message is consumed immediately as the next prompt (course-correct).
|
|
1839
|
+
if (agentTurnInProgress && !cancelTurnRequested) {
|
|
1840
|
+
console.error(`ChimpHands steering: aborting active turn so queued user message reaches OpenCode`);
|
|
1841
|
+
abortActiveTurn("steer");
|
|
1842
|
+
}
|
|
1843
|
+
wakePromptWaiter();
|
|
1742
1844
|
};
|
|
1743
1845
|
const pollPendingUserMessages = async () => {
|
|
1744
1846
|
try {
|
|
@@ -1781,15 +1883,18 @@ export async function runChimphands(opts) {
|
|
|
1781
1883
|
}
|
|
1782
1884
|
postEvent(role, content, opts);
|
|
1783
1885
|
};
|
|
1784
|
-
const complete = (status, errorMessage) => {
|
|
1886
|
+
const complete = async (status, errorMessage) => {
|
|
1785
1887
|
const body = { sessionId, status };
|
|
1786
1888
|
if (errorMessage)
|
|
1787
1889
|
body.errorMessage = String(errorMessage).slice(0, 4000);
|
|
1788
1890
|
if (githubRunId)
|
|
1789
1891
|
body.githubRunId = githubRunId;
|
|
1790
|
-
|
|
1892
|
+
try {
|
|
1893
|
+
await postJson(backend, apiKey, "/api/chimphands/complete_session", body);
|
|
1894
|
+
}
|
|
1895
|
+
catch (err) {
|
|
1791
1896
|
console.error(`ChimpHands complete_session failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1792
|
-
}
|
|
1897
|
+
}
|
|
1793
1898
|
};
|
|
1794
1899
|
const childEnv = {
|
|
1795
1900
|
...process.env,
|
|
@@ -1801,34 +1906,14 @@ export async function runChimphands(opts) {
|
|
|
1801
1906
|
childEnv.TESTCHIMP_USER_ID = userId;
|
|
1802
1907
|
const handleCancelTurn = () => {
|
|
1803
1908
|
console.error(`ChimpHands cancel_turn received for session ${sessionId}`);
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
if (attachUrl && sessionToAbort) {
|
|
1807
|
-
void abortOpencodeSession(attachUrl, sessionToAbort);
|
|
1808
|
-
}
|
|
1809
|
-
const ch = activeOpencodeChild;
|
|
1810
|
-
if (ch && !ch.killed) {
|
|
1811
|
-
try {
|
|
1812
|
-
ch.kill("SIGTERM");
|
|
1813
|
-
setTimeout(() => {
|
|
1814
|
-
try {
|
|
1815
|
-
if (!ch.killed)
|
|
1816
|
-
ch.kill("SIGKILL");
|
|
1817
|
-
}
|
|
1818
|
-
catch {
|
|
1819
|
-
/* ignore */
|
|
1820
|
-
}
|
|
1821
|
-
}, 2000);
|
|
1822
|
-
}
|
|
1823
|
-
catch {
|
|
1824
|
-
/* ignore */
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1909
|
+
steerAbortRequested = false;
|
|
1910
|
+
abortActiveTurn("cancel");
|
|
1827
1911
|
};
|
|
1828
1912
|
const stopInbound = connectInboundStream(backend, apiKey, sessionId, {
|
|
1829
1913
|
onUserMessage: enqueueUserMessage,
|
|
1830
1914
|
onIdle: () => {
|
|
1831
1915
|
idle = true;
|
|
1916
|
+
wakePromptWaiter();
|
|
1832
1917
|
},
|
|
1833
1918
|
onCancelTurn: handleCancelTurn,
|
|
1834
1919
|
onUserFileEdit: handleUserFileEdit,
|
|
@@ -1856,6 +1941,7 @@ export async function runChimphands(opts) {
|
|
|
1856
1941
|
stopLiveSse();
|
|
1857
1942
|
stopLiveSse = null;
|
|
1858
1943
|
}
|
|
1944
|
+
wakePromptWaiter();
|
|
1859
1945
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
1860
1946
|
await poster.flush();
|
|
1861
1947
|
if (runtimeId) {
|
|
@@ -1887,30 +1973,48 @@ export async function runChimphands(opts) {
|
|
|
1887
1973
|
}
|
|
1888
1974
|
const waitForNextPrompt = () => new Promise((resolve) => {
|
|
1889
1975
|
let lastPollAt = 0;
|
|
1976
|
+
let settled = false;
|
|
1977
|
+
const finish = (value) => {
|
|
1978
|
+
if (settled)
|
|
1979
|
+
return;
|
|
1980
|
+
settled = true;
|
|
1981
|
+
wakeWaitForPrompt = null;
|
|
1982
|
+
resolve(value);
|
|
1983
|
+
};
|
|
1984
|
+
wakeWaitForPrompt = () => {
|
|
1985
|
+
if (settled)
|
|
1986
|
+
return;
|
|
1987
|
+
if (queue.length) {
|
|
1988
|
+
finish(normalizeUserMessage(queue.shift()));
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (idle || Date.now() - lastUserActivity >= idleMs) {
|
|
1992
|
+
finish(null);
|
|
1993
|
+
}
|
|
1994
|
+
};
|
|
1890
1995
|
const tick = () => {
|
|
1996
|
+
if (settled)
|
|
1997
|
+
return;
|
|
1891
1998
|
if (queue.length) {
|
|
1892
|
-
|
|
1999
|
+
finish(normalizeUserMessage(queue.shift()));
|
|
1893
2000
|
return;
|
|
1894
2001
|
}
|
|
1895
2002
|
const now = Date.now();
|
|
2003
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
2004
|
+
finish(null);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
// Never gate the idle clock on consume_pending — a hung poll used to
|
|
2008
|
+
// block waitForNextPrompt forever and keep the Actions job alive.
|
|
1896
2009
|
if (now - lastPollAt >= 1500) {
|
|
1897
2010
|
lastPollAt = now;
|
|
1898
2011
|
void pollPendingUserMessages().then(() => {
|
|
1899
|
-
if (
|
|
1900
|
-
resolve(normalizeUserMessage(queue.shift()));
|
|
1901
|
-
return;
|
|
1902
|
-
}
|
|
1903
|
-
if (idle || now - lastUserActivity >= idleMs) {
|
|
1904
|
-
resolve(null);
|
|
2012
|
+
if (settled)
|
|
1905
2013
|
return;
|
|
2014
|
+
if (queue.length) {
|
|
2015
|
+
finish(normalizeUserMessage(queue.shift()));
|
|
1906
2016
|
}
|
|
1907
|
-
setTimeout(tick, 500);
|
|
1908
2017
|
});
|
|
1909
|
-
return;
|
|
1910
|
-
}
|
|
1911
|
-
if (idle || now - lastUserActivity >= idleMs) {
|
|
1912
|
-
resolve(null);
|
|
1913
|
-
return;
|
|
1914
2018
|
}
|
|
1915
2019
|
setTimeout(tick, 500);
|
|
1916
2020
|
};
|
|
@@ -1918,11 +2022,21 @@ export async function runChimphands(opts) {
|
|
|
1918
2022
|
});
|
|
1919
2023
|
while (prompt) {
|
|
1920
2024
|
cancelTurnRequested = false;
|
|
2025
|
+
const wasSteered = steerAbortRequested;
|
|
2026
|
+
steerAbortRequested = false;
|
|
1921
2027
|
agentTurnInProgress = true;
|
|
1922
2028
|
let useOpencodeSessionId = opencodeSessionId;
|
|
1923
2029
|
let isNewOpencodeSession = !useOpencodeSessionId;
|
|
1924
|
-
|
|
1925
|
-
|
|
2030
|
+
const promptForOpencode = wasSteered
|
|
2031
|
+
? [
|
|
2032
|
+
"The previous agent turn was interrupted because the user sent a new message.",
|
|
2033
|
+
"Stop any long-running watch/poll that no longer applies. Course-correct using this guidance:",
|
|
2034
|
+
"",
|
|
2035
|
+
prompt,
|
|
2036
|
+
].join("\n")
|
|
2037
|
+
: prompt;
|
|
2038
|
+
let effectivePrompt = wrapPromptWithContext(conversationSummary, promptForOpencode, isNewOpencodeSession, workingBranch, pullRequestUrl);
|
|
2039
|
+
const turnPostEvent = (role, content, opts) => postEventForTurn(promptForOpencode, effectivePrompt, role, content, opts);
|
|
1926
2040
|
// Visible in chat (not filtered as routine). OpenCode may not emit text until a
|
|
1927
2041
|
// part completes — without this the UI looks empty while the turn is running.
|
|
1928
2042
|
postEvent(ROLE_STATUS, "Agent is working…", { status: STATUS_RUNNING });
|
|
@@ -2005,32 +2119,37 @@ export async function runChimphands(opts) {
|
|
|
2005
2119
|
const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
|
|
2006
2120
|
console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
|
|
2007
2121
|
postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
|
|
2008
|
-
complete(STATUS_FAILED, errMsg);
|
|
2122
|
+
await complete(STATUS_FAILED, errMsg);
|
|
2009
2123
|
}
|
|
2010
2124
|
exitCode = result.code || 1;
|
|
2011
2125
|
break;
|
|
2012
2126
|
}
|
|
2013
|
-
if (result.cancelled) {
|
|
2127
|
+
if (result.cancelled && (steerAbortRequested || queue.length > 0)) {
|
|
2128
|
+
// Mid-turn user message aborted this turn — run the queued prompt next.
|
|
2129
|
+
postEvent(ROLE_STATUS, "Incorporating your message…", { status: STATUS_RUNNING });
|
|
2130
|
+
}
|
|
2131
|
+
else if (result.cancelled) {
|
|
2014
2132
|
postEvent(ROLE_STATUS, "Turn stopped", { status: STATUS_WAITING_USER });
|
|
2015
2133
|
}
|
|
2016
2134
|
else {
|
|
2017
2135
|
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
2018
2136
|
}
|
|
2137
|
+
await poster.flush();
|
|
2019
2138
|
lastUserActivity = Date.now();
|
|
2020
2139
|
idle = false;
|
|
2021
2140
|
prompt = (await waitForNextPrompt()) || "";
|
|
2022
2141
|
}
|
|
2023
2142
|
if (exitCode == null) {
|
|
2024
2143
|
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
2025
|
-
complete(STATUS_IDLE);
|
|
2144
|
+
await complete(STATUS_IDLE);
|
|
2026
2145
|
}
|
|
2027
2146
|
}
|
|
2028
2147
|
finally {
|
|
2029
2148
|
await shutdownRuntime();
|
|
2030
2149
|
}
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2150
|
+
// Always exit: tunnel WS / inbound SSE / heartbeat timers otherwise keep the
|
|
2151
|
+
// Actions step alive after a successful idle teardown.
|
|
2152
|
+
process.exit(exitCode ?? 0);
|
|
2034
2153
|
}
|
|
2035
2154
|
function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
|
|
2036
2155
|
let stopped = false;
|
|
@@ -2120,10 +2239,29 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2120
2239
|
console.error(`ChimpHands git commit: ${commit.err || commit.out}`);
|
|
2121
2240
|
return;
|
|
2122
2241
|
}
|
|
2123
|
-
|
|
2242
|
+
let push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2124
2243
|
if (push.code !== 0) {
|
|
2244
|
+
const detail = `${push.err || ""}\n${push.out || ""}`;
|
|
2125
2245
|
console.error(`ChimpHands git push failed: ${push.err || push.out}`);
|
|
2126
|
-
|
|
2246
|
+
try {
|
|
2247
|
+
const { looksLikeGitAuthFailure, refreshGitAuth } = await import("./refreshGitAuth.js");
|
|
2248
|
+
if (looksLikeGitAuthFailure(detail)) {
|
|
2249
|
+
console.error("ChimpHands reminting GitHub write token after push auth failure…");
|
|
2250
|
+
await refreshGitAuth();
|
|
2251
|
+
push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2252
|
+
if (push.code !== 0) {
|
|
2253
|
+
console.error(`ChimpHands git push failed after refresh: ${push.err || push.out}`);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
else {
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
catch (refreshErr) {
|
|
2262
|
+
console.error(`ChimpHands refresh-git-auth after push failure: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2127
2265
|
}
|
|
2128
2266
|
console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
|
|
2129
2267
|
}
|
package/dist/cli/program.js
CHANGED
|
@@ -1584,6 +1584,21 @@ export function buildCliProgram() {
|
|
|
1584
1584
|
process.exit(1);
|
|
1585
1585
|
}
|
|
1586
1586
|
});
|
|
1587
|
+
chimphands
|
|
1588
|
+
.command("refresh-git-auth")
|
|
1589
|
+
.description("Remint a short-lived GitHub App installation token and apply it for git/gh (never prints the token)")
|
|
1590
|
+
.action(async () => {
|
|
1591
|
+
const { refreshGitAuth } = await import("../chimphands/refreshGitAuth.js");
|
|
1592
|
+
try {
|
|
1593
|
+
const result = await refreshGitAuth();
|
|
1594
|
+
console.log(JSON.stringify(result));
|
|
1595
|
+
}
|
|
1596
|
+
catch (e) {
|
|
1597
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1598
|
+
console.error(`[testchimp chimphands refresh-git-auth] ${msg}`);
|
|
1599
|
+
process.exit(1);
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1587
1602
|
chimphands
|
|
1588
1603
|
.command("run")
|
|
1589
1604
|
.description("Bootstrap session, configure OpenCode, and run the interactive bridge")
|
package/package.json
CHANGED