@testchimp/cli 0.1.63 → 0.1.65
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 +160 -40
- 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
|
@@ -67,7 +67,7 @@ const CHIMPHANDS_AGENT_PROMPT = `You are ChimpHands, TestChimp's coding agent. Y
|
|
|
67
67
|
- This conversation uses ONE working branch and ONE pull request. Reuse them for all follow-up work in this chat.
|
|
68
68
|
- If bootstrap lists a working branch, checkout that branch and push additional commits there — update the same PR.
|
|
69
69
|
- 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\`).
|
|
70
|
-
- Commit and push on the session working branch after meaningful edit batches.
|
|
70
|
+
- Commit and push on the session working branch after meaningful edit batches. When \`CHIMPHANDS_UI_ATTACHED\` is \`false\` (no browser watching live), **commit and push before ending every turn** so the user can review async via PR / Files changed. The host also commits any remaining dirty worktree after each turn and before idle teardown.
|
|
71
71
|
- Branch names MUST start with \`testchimp-\` or \`chimphands-\`.
|
|
72
72
|
- When creating a NEW working branch: create it, then IMMEDIATELY publish it with
|
|
73
73
|
\`git push -u origin <branch>\` BEFORE calling report-branch. Users open the branch URL in the UI —
|
|
@@ -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],
|
|
@@ -939,10 +966,12 @@ function writeOpencodeConfig(backend, apiKey, boot) {
|
|
|
939
966
|
const modelId = resolveOpencodeModelId(boot);
|
|
940
967
|
const model = `${TESTCHIMP_PROVIDER_ID}/${modelId}`;
|
|
941
968
|
const sessionId = bootStr(boot, "session_id", "sessionId");
|
|
969
|
+
const uiAttached = !!(boot.uiAttached ?? boot.ui_attached);
|
|
942
970
|
const mcpEnv = {
|
|
943
971
|
TESTCHIMP_API_KEY: apiKey,
|
|
944
972
|
TESTCHIMP_BACKEND_URL: backend,
|
|
945
973
|
TESTCHIMP_EXECUTION_SOURCE: "CLOUD_AGENT",
|
|
974
|
+
CHIMPHANDS_UI_ATTACHED: uiAttached ? "true" : "false",
|
|
946
975
|
};
|
|
947
976
|
const serviceUserId = bootStr(boot, "chimphands_service_account_user_id", "chimphandsServiceAccountUserId");
|
|
948
977
|
if (serviceUserId) {
|
|
@@ -1447,6 +1476,7 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
1447
1476
|
let stopped = false;
|
|
1448
1477
|
let reconnectTimer = null;
|
|
1449
1478
|
let reconnectDelayMs = 1000;
|
|
1479
|
+
let activeReq = null;
|
|
1450
1480
|
const scheduleReconnect = () => {
|
|
1451
1481
|
if (stopped || !handlers.shouldRun())
|
|
1452
1482
|
return;
|
|
@@ -1461,6 +1491,15 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
1461
1491
|
const connect = () => {
|
|
1462
1492
|
if (stopped || !handlers.shouldRun())
|
|
1463
1493
|
return;
|
|
1494
|
+
if (activeReq) {
|
|
1495
|
+
try {
|
|
1496
|
+
activeReq.destroy();
|
|
1497
|
+
}
|
|
1498
|
+
catch {
|
|
1499
|
+
/* ignore */
|
|
1500
|
+
}
|
|
1501
|
+
activeReq = null;
|
|
1502
|
+
}
|
|
1464
1503
|
const req = lib.request({
|
|
1465
1504
|
hostname: url.hostname,
|
|
1466
1505
|
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
@@ -1539,16 +1578,36 @@ function connectInboundStream(backend, apiKey, sessionId, handlers) {
|
|
|
1539
1578
|
}
|
|
1540
1579
|
}
|
|
1541
1580
|
});
|
|
1542
|
-
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();
|
|
1543
1592
|
});
|
|
1544
|
-
req.on("error", () => scheduleReconnect());
|
|
1545
1593
|
req.end();
|
|
1546
1594
|
};
|
|
1547
1595
|
connect();
|
|
1548
1596
|
return () => {
|
|
1549
1597
|
stopped = true;
|
|
1550
|
-
if (reconnectTimer)
|
|
1598
|
+
if (reconnectTimer) {
|
|
1551
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
|
+
}
|
|
1552
1611
|
};
|
|
1553
1612
|
}
|
|
1554
1613
|
export async function runChimphands(opts) {
|
|
@@ -1569,6 +1628,7 @@ export async function runChimphands(opts) {
|
|
|
1569
1628
|
const githubRunId = (process.env.GITHUB_RUN_ID || "").trim();
|
|
1570
1629
|
const poster = new AgentEventPoster(backend, apiKey, sessionId);
|
|
1571
1630
|
poster.uiAttached = !!(boot.uiAttached ?? boot.ui_attached);
|
|
1631
|
+
process.env.CHIMPHANDS_UI_ATTACHED = poster.uiAttached ? "true" : "false";
|
|
1572
1632
|
let stopLiveSse = null;
|
|
1573
1633
|
/** Do not open localhost OpenCode /event until serve has been (re)started with config. */
|
|
1574
1634
|
let opencodeHttpReady = !attachUrl;
|
|
@@ -1597,6 +1657,7 @@ export async function runChimphands(opts) {
|
|
|
1597
1657
|
};
|
|
1598
1658
|
const syncLiveSse = (attached) => {
|
|
1599
1659
|
pendingUiAttached = attached;
|
|
1660
|
+
process.env.CHIMPHANDS_UI_ATTACHED = attached ? "true" : "false";
|
|
1600
1661
|
if (!attachUrl) {
|
|
1601
1662
|
poster.uiAttached = attached;
|
|
1602
1663
|
return;
|
|
@@ -1714,6 +1775,13 @@ export async function runChimphands(opts) {
|
|
|
1714
1775
|
let cancelTurnRequested = false;
|
|
1715
1776
|
let activeOpencodeChild = null;
|
|
1716
1777
|
let agentTurnInProgress = false;
|
|
1778
|
+
/** Wakes waitForNextPrompt when idle is signaled (SSE) or a user message arrives. */
|
|
1779
|
+
let wakeWaitForPrompt = null;
|
|
1780
|
+
const wakePromptWaiter = () => {
|
|
1781
|
+
const wake = wakeWaitForPrompt;
|
|
1782
|
+
wakeWaitForPrompt = null;
|
|
1783
|
+
wake?.();
|
|
1784
|
+
};
|
|
1717
1785
|
const turnControl = {
|
|
1718
1786
|
getCancelRequested: () => cancelTurnRequested,
|
|
1719
1787
|
onActiveChild: (child) => {
|
|
@@ -1733,6 +1801,7 @@ export async function runChimphands(opts) {
|
|
|
1733
1801
|
queue.push(content);
|
|
1734
1802
|
lastUserActivity = Date.now();
|
|
1735
1803
|
idle = false;
|
|
1804
|
+
wakePromptWaiter();
|
|
1736
1805
|
const preview = content.length > 120 ? `${content.slice(0, 117)}...` : content;
|
|
1737
1806
|
console.error(`ChimpHands queued user message (turnActive=${agentTurnInProgress}, depth=${queue.length}): ${JSON.stringify(preview)}`);
|
|
1738
1807
|
};
|
|
@@ -1777,15 +1846,18 @@ export async function runChimphands(opts) {
|
|
|
1777
1846
|
}
|
|
1778
1847
|
postEvent(role, content, opts);
|
|
1779
1848
|
};
|
|
1780
|
-
const complete = (status, errorMessage) => {
|
|
1849
|
+
const complete = async (status, errorMessage) => {
|
|
1781
1850
|
const body = { sessionId, status };
|
|
1782
1851
|
if (errorMessage)
|
|
1783
1852
|
body.errorMessage = String(errorMessage).slice(0, 4000);
|
|
1784
1853
|
if (githubRunId)
|
|
1785
1854
|
body.githubRunId = githubRunId;
|
|
1786
|
-
|
|
1855
|
+
try {
|
|
1856
|
+
await postJson(backend, apiKey, "/api/chimphands/complete_session", body);
|
|
1857
|
+
}
|
|
1858
|
+
catch (err) {
|
|
1787
1859
|
console.error(`ChimpHands complete_session failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1788
|
-
}
|
|
1860
|
+
}
|
|
1789
1861
|
};
|
|
1790
1862
|
const childEnv = {
|
|
1791
1863
|
...process.env,
|
|
@@ -1825,6 +1897,7 @@ export async function runChimphands(opts) {
|
|
|
1825
1897
|
onUserMessage: enqueueUserMessage,
|
|
1826
1898
|
onIdle: () => {
|
|
1827
1899
|
idle = true;
|
|
1900
|
+
wakePromptWaiter();
|
|
1828
1901
|
},
|
|
1829
1902
|
onCancelTurn: handleCancelTurn,
|
|
1830
1903
|
onUserFileEdit: handleUserFileEdit,
|
|
@@ -1852,6 +1925,7 @@ export async function runChimphands(opts) {
|
|
|
1852
1925
|
stopLiveSse();
|
|
1853
1926
|
stopLiveSse = null;
|
|
1854
1927
|
}
|
|
1928
|
+
wakePromptWaiter();
|
|
1855
1929
|
await commitAndPushDirtyWorktree("chimphands: commit before session idle/shutdown");
|
|
1856
1930
|
await poster.flush();
|
|
1857
1931
|
if (runtimeId) {
|
|
@@ -1883,30 +1957,48 @@ export async function runChimphands(opts) {
|
|
|
1883
1957
|
}
|
|
1884
1958
|
const waitForNextPrompt = () => new Promise((resolve) => {
|
|
1885
1959
|
let lastPollAt = 0;
|
|
1960
|
+
let settled = false;
|
|
1961
|
+
const finish = (value) => {
|
|
1962
|
+
if (settled)
|
|
1963
|
+
return;
|
|
1964
|
+
settled = true;
|
|
1965
|
+
wakeWaitForPrompt = null;
|
|
1966
|
+
resolve(value);
|
|
1967
|
+
};
|
|
1968
|
+
wakeWaitForPrompt = () => {
|
|
1969
|
+
if (settled)
|
|
1970
|
+
return;
|
|
1971
|
+
if (queue.length) {
|
|
1972
|
+
finish(normalizeUserMessage(queue.shift()));
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (idle || Date.now() - lastUserActivity >= idleMs) {
|
|
1976
|
+
finish(null);
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1886
1979
|
const tick = () => {
|
|
1980
|
+
if (settled)
|
|
1981
|
+
return;
|
|
1887
1982
|
if (queue.length) {
|
|
1888
|
-
|
|
1983
|
+
finish(normalizeUserMessage(queue.shift()));
|
|
1889
1984
|
return;
|
|
1890
1985
|
}
|
|
1891
1986
|
const now = Date.now();
|
|
1987
|
+
if (idle || now - lastUserActivity >= idleMs) {
|
|
1988
|
+
finish(null);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
// Never gate the idle clock on consume_pending — a hung poll used to
|
|
1992
|
+
// block waitForNextPrompt forever and keep the Actions job alive.
|
|
1892
1993
|
if (now - lastPollAt >= 1500) {
|
|
1893
1994
|
lastPollAt = now;
|
|
1894
1995
|
void pollPendingUserMessages().then(() => {
|
|
1895
|
-
if (
|
|
1896
|
-
resolve(normalizeUserMessage(queue.shift()));
|
|
1897
|
-
return;
|
|
1898
|
-
}
|
|
1899
|
-
if (idle || now - lastUserActivity >= idleMs) {
|
|
1900
|
-
resolve(null);
|
|
1996
|
+
if (settled)
|
|
1901
1997
|
return;
|
|
1998
|
+
if (queue.length) {
|
|
1999
|
+
finish(normalizeUserMessage(queue.shift()));
|
|
1902
2000
|
}
|
|
1903
|
-
setTimeout(tick, 500);
|
|
1904
2001
|
});
|
|
1905
|
-
return;
|
|
1906
|
-
}
|
|
1907
|
-
if (idle || now - lastUserActivity >= idleMs) {
|
|
1908
|
-
resolve(null);
|
|
1909
|
-
return;
|
|
1910
2002
|
}
|
|
1911
2003
|
setTimeout(tick, 500);
|
|
1912
2004
|
};
|
|
@@ -1977,6 +2069,11 @@ export async function runChimphands(opts) {
|
|
|
1977
2069
|
console.error(`ChimpHands turn-end reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1978
2070
|
}
|
|
1979
2071
|
}
|
|
2072
|
+
// Async / no UI: checkpoint dirty worktree after each turn so PR + Files changed
|
|
2073
|
+
// are reviewable when the user opens the session later (live VCS tunnel may be idle).
|
|
2074
|
+
if (!poster.uiAttached) {
|
|
2075
|
+
await commitAndPushDirtyWorktree("chimphands: commit after turn (async / no UI attached)");
|
|
2076
|
+
}
|
|
1980
2077
|
if (result.code !== 0 && !result.cancelled) {
|
|
1981
2078
|
const errMsg = (result.err || "opencode failed").trim() || "opencode failed";
|
|
1982
2079
|
console.error(`ChimpHands OpenCode failed: ${errMsg}`);
|
|
@@ -1996,7 +2093,7 @@ export async function runChimphands(opts) {
|
|
|
1996
2093
|
const detail = reportErr instanceof Error ? reportErr.message : String(reportErr);
|
|
1997
2094
|
console.error(`ChimpHands failed to report OpenCode error to backend: ${detail}`);
|
|
1998
2095
|
postEvent(ROLE_STATUS, errMsg, { status: STATUS_FAILED });
|
|
1999
|
-
complete(STATUS_FAILED, errMsg);
|
|
2096
|
+
await complete(STATUS_FAILED, errMsg);
|
|
2000
2097
|
}
|
|
2001
2098
|
exitCode = result.code || 1;
|
|
2002
2099
|
break;
|
|
@@ -2007,21 +2104,22 @@ export async function runChimphands(opts) {
|
|
|
2007
2104
|
else {
|
|
2008
2105
|
postEvent(ROLE_STATUS, "Waiting for user input", { status: STATUS_WAITING_USER });
|
|
2009
2106
|
}
|
|
2107
|
+
await poster.flush();
|
|
2010
2108
|
lastUserActivity = Date.now();
|
|
2011
2109
|
idle = false;
|
|
2012
2110
|
prompt = (await waitForNextPrompt()) || "";
|
|
2013
2111
|
}
|
|
2014
2112
|
if (exitCode == null) {
|
|
2015
2113
|
console.error("ChimpHands session idle — no user input before timeout; completing.");
|
|
2016
|
-
complete(STATUS_IDLE);
|
|
2114
|
+
await complete(STATUS_IDLE);
|
|
2017
2115
|
}
|
|
2018
2116
|
}
|
|
2019
2117
|
finally {
|
|
2020
2118
|
await shutdownRuntime();
|
|
2021
2119
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2120
|
+
// Always exit: tunnel WS / inbound SSE / heartbeat timers otherwise keep the
|
|
2121
|
+
// Actions step alive after a successful idle teardown.
|
|
2122
|
+
process.exit(exitCode ?? 0);
|
|
2025
2123
|
}
|
|
2026
2124
|
function startRuntimeHeartbeat(backend, apiKey, runtimeId, onUiAttached) {
|
|
2027
2125
|
let stopped = false;
|
|
@@ -2111,10 +2209,29 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2111
2209
|
console.error(`ChimpHands git commit: ${commit.err || commit.out}`);
|
|
2112
2210
|
return;
|
|
2113
2211
|
}
|
|
2114
|
-
|
|
2212
|
+
let push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2115
2213
|
if (push.code !== 0) {
|
|
2214
|
+
const detail = `${push.err || ""}\n${push.out || ""}`;
|
|
2116
2215
|
console.error(`ChimpHands git push failed: ${push.err || push.out}`);
|
|
2117
|
-
|
|
2216
|
+
try {
|
|
2217
|
+
const { looksLikeGitAuthFailure, refreshGitAuth } = await import("./refreshGitAuth.js");
|
|
2218
|
+
if (looksLikeGitAuthFailure(detail)) {
|
|
2219
|
+
console.error("ChimpHands reminting GitHub write token after push auth failure…");
|
|
2220
|
+
await refreshGitAuth();
|
|
2221
|
+
push = await run(["push", "-u", "origin", "HEAD"]);
|
|
2222
|
+
if (push.code !== 0) {
|
|
2223
|
+
console.error(`ChimpHands git push failed after refresh: ${push.err || push.out}`);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
else {
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
catch (refreshErr) {
|
|
2232
|
+
console.error(`ChimpHands refresh-git-auth after push failure: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2118
2235
|
}
|
|
2119
2236
|
console.error(`ChimpHands committed and pushed dirty worktree on ${current} before shutdown`);
|
|
2120
2237
|
}
|
|
@@ -2125,7 +2242,10 @@ async function commitAndPushDirtyWorktree(message) {
|
|
|
2125
2242
|
function opencodeProxyNeedsDirectory(path) {
|
|
2126
2243
|
const p = path.startsWith("/") ? path : `/${path}`;
|
|
2127
2244
|
return (p.startsWith("/vcs") ||
|
|
2245
|
+
p.startsWith("/api/vcs") ||
|
|
2128
2246
|
p.startsWith("/file") ||
|
|
2247
|
+
p.startsWith("/api/fs") ||
|
|
2248
|
+
p.startsWith("/api/session") ||
|
|
2129
2249
|
p.startsWith("/path") ||
|
|
2130
2250
|
p.startsWith("/session") ||
|
|
2131
2251
|
p.startsWith("/instance") ||
|
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