@indigoai-us/hq-cli 5.302.0 → 5.303.0
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/CHANGELOG.md +4 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +72 -20
- package/dist/lib/bot/cli-update.d.ts +0 -8
- package/dist/lib/bot/cli-update.js +5 -5
- package/dist/lib/bot/local-jobs.d.ts +1 -21
- package/dist/lib/bot/local-jobs.js +12 -12
- package/dist/lib/daemon/config.d.ts +0 -1
- package/dist/lib/daemon/config.js +1 -1
- package/dist/lib/report-snapshot.d.ts +0 -2
- package/dist/lib/report-snapshot.js +2 -2
- package/dist/utils/run-bundled-script.js +103 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.303.0] — 2026-10-01
|
|
6
|
+
- Fixed bundled shell scripts failing on Windows when Git Bash is installed but `bash` is not on `PATH`.
|
|
7
|
+
- The bundled token usage script now reads Claude sessions from `CLAUDE_CONFIG_DIR/projects` and the standard Claude project directories, including nested session folders (subagent transcripts are skipped), when no project directory is given.
|
|
8
|
+
|
|
5
9
|
## [5.302.0] — 2026-10-01
|
|
6
10
|
|
|
7
11
|
- Generate company project indexes while preserving hand-written content unless `--accept` is used.
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
set -euo pipefail
|
|
14
14
|
|
|
15
|
-
PROJECT_DIR="
|
|
15
|
+
PROJECT_DIR=""
|
|
16
|
+
PROJECT_DIR_SET=0
|
|
16
17
|
LAST_DAYS=7
|
|
17
18
|
SINCE=""
|
|
18
19
|
JSON_MODE=0
|
|
@@ -31,6 +32,7 @@ while [ $# -gt 0 ]; do
|
|
|
31
32
|
exit 1
|
|
32
33
|
fi
|
|
33
34
|
PROJECT_DIR="$2"
|
|
35
|
+
PROJECT_DIR_SET=1
|
|
34
36
|
shift 2
|
|
35
37
|
;;
|
|
36
38
|
--compare-windows) shift ;; # flag, presence triggers compare mode via --before/--after
|
|
@@ -47,14 +49,47 @@ done
|
|
|
47
49
|
|
|
48
50
|
command -v node >/dev/null 2>&1 || { echo "token-usage-report: node is required" >&2; exit 1; }
|
|
49
51
|
|
|
52
|
+
# Resolve roots in the same precedence order as the native command. Explicit
|
|
53
|
+
# project directories and environment overrides select one exact activity root;
|
|
54
|
+
# otherwise include standard, desktop-saved, and discovered Claude roots.
|
|
55
|
+
PROJECT_DIRS=("")
|
|
56
|
+
add_project_dir() {
|
|
57
|
+
local candidate="$1" existing
|
|
58
|
+
[ -d "$candidate" ] || return 0
|
|
59
|
+
for existing in "${PROJECT_DIRS[@]}"; do [ "$existing" = "$candidate" ] && return 0; done
|
|
60
|
+
PROJECT_DIRS+=("$candidate")
|
|
61
|
+
}
|
|
62
|
+
trim_env_path() {
|
|
63
|
+
local value="$1"
|
|
64
|
+
value="${value#"${value%%[![:space:]]*}"}"
|
|
65
|
+
value="${value%"${value##*[![:space:]]}"}"
|
|
66
|
+
printf '%s' "$value"
|
|
67
|
+
}
|
|
68
|
+
if [ "$PROJECT_DIR_SET" -eq 1 ]; then
|
|
69
|
+
if [ ! -d "$PROJECT_DIR" ]; then echo "Project dir not found: $PROJECT_DIR" >&2; exit 1; fi
|
|
70
|
+
add_project_dir "$PROJECT_DIR"
|
|
71
|
+
elif [ -n "$(trim_env_path "${CLAUDE_PROJECTS_DIR:-}")" ]; then
|
|
72
|
+
add_project_dir "$(trim_env_path "${CLAUDE_PROJECTS_DIR:-}")"
|
|
73
|
+
elif [ -n "$(trim_env_path "${CLAUDE_CONFIG_DIR:-}")" ]; then
|
|
74
|
+
add_project_dir "$(trim_env_path "${CLAUDE_CONFIG_DIR:-}")/projects"
|
|
75
|
+
else
|
|
76
|
+
add_project_dir "$HOME/.claude/projects"
|
|
77
|
+
SAVED_PROJECT_DIR="$(node -e 'try { const value = require(process.argv[1]).claudeProjectsDir; if (typeof value === "string" && value.trim()) process.stdout.write(value.trim()); } catch {}' "$HOME/.hq/menubar.json")"
|
|
78
|
+
[ -z "$SAVED_PROJECT_DIR" ] || add_project_dir "$SAVED_PROJECT_DIR"
|
|
79
|
+
for candidate in "$HOME"/.claude-*; do
|
|
80
|
+
[ -d "$candidate/projects" ] && add_project_dir "$candidate/projects"
|
|
81
|
+
done
|
|
82
|
+
fi
|
|
83
|
+
if [ "${#PROJECT_DIRS[@]}" -le 1 ]; then echo "Claude activity folders not found" >&2; exit 1; fi
|
|
84
|
+
|
|
50
85
|
# --- Compare-windows mode (US-011) ---
|
|
51
86
|
# Format: --before YYYY-MM-DD:YYYY-MM-DD --after YYYY-MM-DD:YYYY-MM-DD
|
|
52
87
|
if [ -n "$COMPARE_BEFORE" ] && [ -n "$COMPARE_AFTER" ]; then
|
|
53
|
-
node - "$
|
|
88
|
+
node - "$COMPARE_BEFORE" "$COMPARE_AFTER" "$MIN_HOURS" "$JSON_MODE" "${PROJECT_DIRS[@]:1}" <<'JS'
|
|
54
89
|
const fs = require("fs");
|
|
55
90
|
const path = require("path");
|
|
56
91
|
|
|
57
|
-
const [
|
|
92
|
+
const [before, after, minHoursS, jsonModeS, ...projectDirs] = process.argv.slice(2);
|
|
58
93
|
const minHours = parseFloat(minHoursS);
|
|
59
94
|
const jsonMode = parseInt(jsonModeS, 10) === 1;
|
|
60
95
|
|
|
@@ -63,13 +98,24 @@ const [bStart, bEnd] = parseRange(before);
|
|
|
63
98
|
const [aStart, aEnd] = parseRange(after);
|
|
64
99
|
|
|
65
100
|
const fmt = (n) => n.toLocaleString("en-US");
|
|
66
|
-
const listJsonl = () => {
|
|
67
|
-
|
|
101
|
+
const listJsonl = (projectDir) => {
|
|
102
|
+
const found = [];
|
|
103
|
+
const visit = (current, relative) => {
|
|
104
|
+
let entries;
|
|
105
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { return; }
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const nextRelative = path.join(relative, entry.name);
|
|
108
|
+
if (entry.isDirectory() && entry.name !== "subagents") visit(path.join(current, entry.name), nextRelative);
|
|
109
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) found.push(nextRelative);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
visit(projectDir, "");
|
|
113
|
+
return found.sort();
|
|
68
114
|
};
|
|
69
115
|
|
|
70
116
|
function collect(windowStart, windowEnd) {
|
|
71
117
|
const rows = [];
|
|
72
|
-
for (const name of listJsonl()) {
|
|
118
|
+
for (const proj of projectDirs) for (const name of listJsonl(proj)) {
|
|
73
119
|
const f = path.join(proj, name);
|
|
74
120
|
let cr = 0, firstTs = null, lastTs = null;
|
|
75
121
|
const sid = name.replace(/\.jsonl$/, "");
|
|
@@ -130,16 +176,11 @@ JS
|
|
|
130
176
|
exit 0
|
|
131
177
|
fi
|
|
132
178
|
|
|
133
|
-
|
|
134
|
-
echo "Project dir not found: $PROJECT_DIR" >&2
|
|
135
|
-
exit 1
|
|
136
|
-
fi
|
|
137
|
-
|
|
138
|
-
node - "$PROJECT_DIR" "$LAST_DAYS" "$SINCE" "$JSON_MODE" <<'JS'
|
|
179
|
+
node - "$LAST_DAYS" "$SINCE" "$JSON_MODE" "${PROJECT_DIRS[@]:1}" <<'JS'
|
|
139
180
|
const fs = require("fs");
|
|
140
181
|
const path = require("path");
|
|
141
182
|
|
|
142
|
-
const [
|
|
183
|
+
const [lastDaysS, sinceS, jsonModeS, ...projectDirs] = process.argv.slice(2);
|
|
143
184
|
const lastDays = parseInt(lastDaysS, 10);
|
|
144
185
|
const jsonMode = parseInt(jsonModeS, 10) === 1;
|
|
145
186
|
|
|
@@ -156,11 +197,20 @@ const fmt = (n) => n.toLocaleString("en-US");
|
|
|
156
197
|
const dayTotals = {};
|
|
157
198
|
const sessionTotals = {};
|
|
158
199
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
200
|
+
// Parent sessions only; listJsonl skips each session's subagents/ tree.
|
|
201
|
+
for (const projectDir of projectDirs) {
|
|
202
|
+
const listJsonl = (current, relative = "") => {
|
|
203
|
+
let entries;
|
|
204
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { return []; }
|
|
205
|
+
return entries.flatMap((entry) => {
|
|
206
|
+
const nextRelative = path.join(relative, entry.name);
|
|
207
|
+
if (entry.isDirectory() && entry.name !== "subagents") return listJsonl(path.join(current, entry.name), nextRelative);
|
|
208
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) return [nextRelative];
|
|
209
|
+
return [];
|
|
210
|
+
}).sort();
|
|
211
|
+
};
|
|
212
|
+
const names = listJsonl(projectDir);
|
|
213
|
+
for (const name of names) {
|
|
164
214
|
const f = path.join(projectDir, name);
|
|
165
215
|
let mtime;
|
|
166
216
|
try { mtime = fs.statSync(f).mtime; } catch (e) { continue; }
|
|
@@ -170,6 +220,7 @@ for (const name of names) {
|
|
|
170
220
|
let firstTs = null, lastTs = null, firstUser = "";
|
|
171
221
|
let subCount = 0;
|
|
172
222
|
const sid = name.replace(/\.jsonl$/, "");
|
|
223
|
+
const sessionId = path.basename(path.dirname(projectDir)) + "/" + sid;
|
|
173
224
|
let content;
|
|
174
225
|
try { content = fs.readFileSync(f, "utf8"); } catch (e) { continue; }
|
|
175
226
|
for (const line of content.split(/\r?\n/)) {
|
|
@@ -196,15 +247,16 @@ for (const name of names) {
|
|
|
196
247
|
if (!day) day = mt;
|
|
197
248
|
if (day < cutoff) continue;
|
|
198
249
|
if (!dayTotals[day]) dayTotals[day] = { sessions: new Set(), inp: 0, out: 0, cc: 0, cr: 0 };
|
|
199
|
-
dayTotals[day].sessions.add(
|
|
250
|
+
dayTotals[day].sessions.add(sessionId);
|
|
200
251
|
dayTotals[day].inp += inp;
|
|
201
252
|
dayTotals[day].out += out;
|
|
202
253
|
dayTotals[day].cc += cc;
|
|
203
254
|
dayTotals[day].cr += cr;
|
|
204
|
-
sessionTotals[
|
|
255
|
+
sessionTotals[sessionId] = {
|
|
205
256
|
day: day, inp: inp, out: out, cc: cc, cr: cr,
|
|
206
257
|
eff: Math.trunc(eff), subagents: subCount, first_user: firstUser.split("\n").join(" "),
|
|
207
258
|
};
|
|
259
|
+
}
|
|
208
260
|
}
|
|
209
261
|
|
|
210
262
|
const sortedDays = Object.keys(dayTotals).sort();
|
|
@@ -35,16 +35,8 @@ import type { BotLogger } from "./log.js";
|
|
|
35
35
|
export declare const CLI_UPDATE_RESTART_EXIT_CODE = 75;
|
|
36
36
|
/** How often the installed package.json is read. */
|
|
37
37
|
export declare const CLI_UPDATE_CHECK_INTERVAL_MS = 60000;
|
|
38
|
-
/** Consecutive checks that must agree on the new version before acting. */
|
|
39
|
-
export declare const CLI_UPDATE_CONFIRMATIONS = 2;
|
|
40
|
-
/** At most this many update restarts per window, whatever the versions. */
|
|
41
|
-
export declare const CLI_UPDATE_RESTART_LIMIT = 4;
|
|
42
|
-
export declare const CLI_UPDATE_RESTART_WINDOW_MS: number;
|
|
43
|
-
/** A restart that came back on the old version blocks that target this long. */
|
|
44
|
-
export declare const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS: number;
|
|
45
38
|
/** Set to "1" to turn the watcher off (development runs). */
|
|
46
39
|
export declare const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
|
|
47
|
-
export declare const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
|
|
48
40
|
export type InstalledCliRead = {
|
|
49
41
|
ok: true;
|
|
50
42
|
version: string;
|
|
@@ -42,15 +42,15 @@ export const CLI_UPDATE_RESTART_EXIT_CODE = 75;
|
|
|
42
42
|
/** How often the installed package.json is read. */
|
|
43
43
|
export const CLI_UPDATE_CHECK_INTERVAL_MS = 60_000;
|
|
44
44
|
/** Consecutive checks that must agree on the new version before acting. */
|
|
45
|
-
|
|
45
|
+
const CLI_UPDATE_CONFIRMATIONS = 2;
|
|
46
46
|
/** At most this many update restarts per window, whatever the versions. */
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const CLI_UPDATE_RESTART_LIMIT = 4;
|
|
48
|
+
const CLI_UPDATE_RESTART_WINDOW_MS = 60 * 60_000;
|
|
49
49
|
/** A restart that came back on the old version blocks that target this long. */
|
|
50
|
-
|
|
50
|
+
const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS = 6 * 60 * 60_000;
|
|
51
51
|
/** Set to "1" to turn the watcher off (development runs). */
|
|
52
52
|
export const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
|
|
53
|
-
|
|
53
|
+
const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
|
|
54
54
|
/**
|
|
55
55
|
* The hq-cli version installed behind `entry`, resolved the way the next launch
|
|
56
56
|
* of the same command line will resolve it: follow the symlinks now (a pnpm or
|
|
@@ -18,9 +18,7 @@
|
|
|
18
18
|
* - A fire is recorded before it is handed over, so a crash never runs the
|
|
19
19
|
* same due time twice.
|
|
20
20
|
*/
|
|
21
|
-
import { type BotApi, type
|
|
22
|
-
export declare const LOCAL_JOBS_FILE = "jobs.json";
|
|
23
|
-
export declare const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
|
|
21
|
+
import { type BotApi, type InboxItem } from "./api.js";
|
|
24
22
|
export interface LocalJob {
|
|
25
23
|
jobId: string;
|
|
26
24
|
prompt: string;
|
|
@@ -40,9 +38,7 @@ export interface LocalJob {
|
|
|
40
38
|
lastStatus?: string;
|
|
41
39
|
runCount?: number;
|
|
42
40
|
}
|
|
43
|
-
export declare function localJobsPath(dir: string): string;
|
|
44
41
|
export declare function readLocalJobs(dir: string): LocalJob[];
|
|
45
|
-
export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
|
|
46
42
|
/**
|
|
47
43
|
* Run a read-change-write of `jobs.json` under an exclusive lock. The running
|
|
48
44
|
* bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
|
|
@@ -67,40 +63,24 @@ export interface CronMatcher {
|
|
|
67
63
|
export declare function toStandardCron(expr: string): string | null;
|
|
68
64
|
/** Parse a standard 5-field cron, or null when it is not one. */
|
|
69
65
|
export declare function parseCron(expr: string): CronMatcher | null;
|
|
70
|
-
export declare function cronMatches(cron: CronMatcher, date: Date, timeZone: string): boolean;
|
|
71
|
-
/**
|
|
72
|
-
* The newest time this job was due, at or before `now` and after the last one
|
|
73
|
-
* already fired. Null when nothing new is due.
|
|
74
|
-
*/
|
|
75
|
-
/** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
|
|
76
|
-
export declare function rateIntervalMs(expr: string): number | null;
|
|
77
|
-
export declare function isRunnableSchedule(expr: string): boolean;
|
|
78
66
|
export declare function latestDueFire(job: LocalJob, now: Date): Date | null;
|
|
79
67
|
/** The next time this job is due after `now`, within a week; null otherwise. */
|
|
80
68
|
export declare function nextDueFire(job: LocalJob, now: Date): Date | null;
|
|
81
|
-
export declare function toJobRecord(job: LocalJob, now?: Date): BotJobRecord;
|
|
82
69
|
export declare function addLocalJob(dir: string, input: {
|
|
83
70
|
prompt: string;
|
|
84
71
|
cron: string;
|
|
85
72
|
timezone: string;
|
|
86
73
|
context?: Record<string, unknown>;
|
|
87
74
|
}, now?: Date): LocalJob;
|
|
88
|
-
/** Remove a job. Returns false when the bot has no job with that id. */
|
|
89
|
-
export declare function removeLocalJob(dir: string, jobId: string): boolean;
|
|
90
75
|
/** Forget the once-per-minute check (tests, and a bot restarted in-process). */
|
|
91
76
|
export declare function resetLocalJobChecks(): void;
|
|
92
77
|
export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
|
|
93
|
-
export declare function isLocalJobMessage(messageId: string): boolean;
|
|
94
|
-
/** Record how a local run finished and release the job for its next fire. */
|
|
95
|
-
export declare function finishLocalJobRun(dir: string, jobId: string, messageId: string, completion: Pick<JobRunCompletion, "status">, now?: Date): void;
|
|
96
78
|
/**
|
|
97
79
|
* The bot's cloud client, with its scheduled runs answered on this computer:
|
|
98
80
|
* due local jobs join the inbox, and their ack and completion are recorded
|
|
99
81
|
* locally instead of being sent to the cloud.
|
|
100
82
|
*/
|
|
101
83
|
export declare function withLocalJobs(api: BotApi, dir: string, clock?: () => Date): BotApi;
|
|
102
|
-
/** The most jobs one bot may hold (the same cap the cloud uses). */
|
|
103
|
-
export declare const MAX_LOCAL_JOBS = 25;
|
|
104
84
|
/**
|
|
105
85
|
* `hq bot jobs` against the bot's own folder: the same three calls the cloud
|
|
106
86
|
* client offers, answered from `jobs.json`.
|
|
@@ -22,15 +22,15 @@ import * as fs from "node:fs";
|
|
|
22
22
|
import * as path from "node:path";
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
24
|
import { BotApiError } from "./api.js";
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const LOCAL_JOBS_FILE = "jobs.json";
|
|
26
|
+
const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
|
|
27
27
|
/** How far back a missed fire is still looked for (one week). */
|
|
28
28
|
const LOOKBACK_MINUTES = 7 * 24 * 60;
|
|
29
29
|
/** A run that never reported back releases its job after this long. */
|
|
30
30
|
const IN_FLIGHT_RELEASE_MS = 60 * 60 * 1000;
|
|
31
31
|
/** A fire is "late" (a catch-up) once this long past its due time. */
|
|
32
32
|
const STALE_AFTER_MS = 10 * 60 * 1000;
|
|
33
|
-
|
|
33
|
+
function localJobsPath(dir) {
|
|
34
34
|
return path.join(dir, LOCAL_JOBS_FILE);
|
|
35
35
|
}
|
|
36
36
|
export function readLocalJobs(dir) {
|
|
@@ -42,7 +42,7 @@ export function readLocalJobs(dir) {
|
|
|
42
42
|
return [];
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
function writeLocalJobs(dir, jobs) {
|
|
46
46
|
const file = localJobsPath(dir);
|
|
47
47
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
48
48
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, jobs }, null, 2));
|
|
@@ -210,7 +210,7 @@ function zonedParts(date, timeZone) {
|
|
|
210
210
|
weekday: WEEKDAY[get("weekday")] ?? -1,
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
|
-
|
|
213
|
+
function cronMatches(cron, date, timeZone) {
|
|
214
214
|
const t = zonedParts(date, timeZone);
|
|
215
215
|
if (!cron.minutes.has(t.minute) || !cron.hours.has(t.hour) || !cron.months.has(t.month))
|
|
216
216
|
return false;
|
|
@@ -226,7 +226,7 @@ export function cronMatches(cron, date, timeZone) {
|
|
|
226
226
|
* already fired. Null when nothing new is due.
|
|
227
227
|
*/
|
|
228
228
|
/** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
|
|
229
|
-
|
|
229
|
+
function rateIntervalMs(expr) {
|
|
230
230
|
const match = /^rate\((\d+)\s+(minute|minutes|hour|hours|day|days)\)$/.exec(expr.trim());
|
|
231
231
|
if (!match)
|
|
232
232
|
return null;
|
|
@@ -234,7 +234,7 @@ export function rateIntervalMs(expr) {
|
|
|
234
234
|
const unit = match[2].startsWith("minute") ? 60_000 : match[2].startsWith("hour") ? 3_600_000 : 86_400_000;
|
|
235
235
|
return n > 0 ? n * unit : null;
|
|
236
236
|
}
|
|
237
|
-
|
|
237
|
+
function isRunnableSchedule(expr) {
|
|
238
238
|
return rateIntervalMs(expr) !== null || parseCron(expr) !== null;
|
|
239
239
|
}
|
|
240
240
|
export function latestDueFire(job, now) {
|
|
@@ -281,7 +281,7 @@ export function nextDueFire(job, now) {
|
|
|
281
281
|
return null;
|
|
282
282
|
}
|
|
283
283
|
// ─── The job list (what `hq bot jobs` reads and writes) ──────────────────────
|
|
284
|
-
|
|
284
|
+
function toJobRecord(job, now = new Date()) {
|
|
285
285
|
const next = nextDueFire(job, now);
|
|
286
286
|
return {
|
|
287
287
|
jobId: job.jobId,
|
|
@@ -311,7 +311,7 @@ export function addLocalJob(dir, input, now = new Date()) {
|
|
|
311
311
|
return job;
|
|
312
312
|
}
|
|
313
313
|
/** Remove a job. Returns false when the bot has no job with that id. */
|
|
314
|
-
|
|
314
|
+
function removeLocalJob(dir, jobId) {
|
|
315
315
|
return withJobsLock(dir, () => {
|
|
316
316
|
const jobs = readLocalJobs(dir);
|
|
317
317
|
const next = jobs.filter((job) => job.jobId !== jobId);
|
|
@@ -375,11 +375,11 @@ export function takeDueLocalJobRuns(dir, now = new Date()) {
|
|
|
375
375
|
return items;
|
|
376
376
|
});
|
|
377
377
|
}
|
|
378
|
-
|
|
378
|
+
function isLocalJobMessage(messageId) {
|
|
379
379
|
return messageId.startsWith(LOCAL_JOB_MESSAGE_PREFIX);
|
|
380
380
|
}
|
|
381
381
|
/** Record how a local run finished and release the job for its next fire. */
|
|
382
|
-
|
|
382
|
+
function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
|
|
383
383
|
withJobsLock(dir, () => {
|
|
384
384
|
const jobs = readLocalJobs(dir);
|
|
385
385
|
let changed = false;
|
|
@@ -428,7 +428,7 @@ export function withLocalJobs(api, dir, clock = () => new Date()) {
|
|
|
428
428
|
return local;
|
|
429
429
|
}
|
|
430
430
|
/** The most jobs one bot may hold (the same cap the cloud uses). */
|
|
431
|
-
|
|
431
|
+
const MAX_LOCAL_JOBS = 25;
|
|
432
432
|
/**
|
|
433
433
|
* `hq bot jobs` against the bot's own folder: the same three calls the cloud
|
|
434
434
|
* client offers, answered from `jobs.json`.
|
|
@@ -23,7 +23,6 @@ export interface DaemonConfig {
|
|
|
23
23
|
}
|
|
24
24
|
export type SyncOwner = "daemon" | "desktop-app";
|
|
25
25
|
export declare function effectiveSyncOwner(config: Pick<DaemonConfig, "syncOwner">, machineType: MachineType): SyncOwner;
|
|
26
|
-
export declare function emptyDaemonConfig(): DaemonConfig;
|
|
27
26
|
export declare function readDaemonConfig(file: string): DaemonConfig;
|
|
28
27
|
export declare function writeDaemonConfig(file: string, config: DaemonConfig): void;
|
|
29
28
|
/**
|
|
@@ -13,7 +13,7 @@ import { ensurePrivateDir } from "./paths.js";
|
|
|
13
13
|
export function effectiveSyncOwner(config, machineType) {
|
|
14
14
|
return config.syncOwner ?? (machineType === "desktop" ? "desktop-app" : "daemon");
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
function emptyDaemonConfig() {
|
|
17
17
|
return { version: 1, syncPaused: false, enable: [], disable: [] };
|
|
18
18
|
}
|
|
19
19
|
function stringList(value) {
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/** File name of the snapshot inside the published report. */
|
|
2
2
|
export declare const REPORT_PREVIEW_FILE = "hq-preview.png";
|
|
3
|
-
export declare const REPORT_PREVIEW_WIDTH = 1280;
|
|
4
|
-
export declare const REPORT_PREVIEW_HEIGHT = 720;
|
|
5
3
|
/** First installed browser that can take a snapshot, or null. */
|
|
6
4
|
export declare function findSnapshotBrowser(env?: NodeJS.ProcessEnv, platform?: string, exists?: (file: string) => boolean): string | null;
|
|
7
5
|
export declare function snapshotArgs(indexFile: string, outFile: string, profileDir: string): string[];
|
|
@@ -13,8 +13,8 @@ import * as path from "node:path";
|
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
/** File name of the snapshot inside the published report. */
|
|
15
15
|
export const REPORT_PREVIEW_FILE = "hq-preview.png";
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const REPORT_PREVIEW_WIDTH = 1280;
|
|
17
|
+
const REPORT_PREVIEW_HEIGHT = 720;
|
|
18
18
|
const SNAPSHOT_TIMEOUT_MS = 30_000;
|
|
19
19
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
20
20
|
const BROWSER_PATHS = {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* script needs to see termination, not a success code.
|
|
30
30
|
*/
|
|
31
31
|
import { spawnSync } from "child_process";
|
|
32
|
+
import * as fs from "node:fs";
|
|
32
33
|
import * as path from "path";
|
|
33
34
|
import { resolveBundledAsset } from "./hq-roots.js";
|
|
34
35
|
/**
|
|
@@ -41,6 +42,93 @@ export function interpreterFor(asset) {
|
|
|
41
42
|
const ext = path.extname(asset).toLowerCase();
|
|
42
43
|
return ext === ".mjs" || ext === ".js" || ext === ".cjs" ? "node" : "bash";
|
|
43
44
|
}
|
|
45
|
+
function envValue(env, name) {
|
|
46
|
+
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
47
|
+
return key ? env[key] : undefined;
|
|
48
|
+
}
|
|
49
|
+
function isFile(file, lookupErrors) {
|
|
50
|
+
try {
|
|
51
|
+
return fs.statSync(file).isFile();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Missing PATH candidates are expected during lookup; retain the cause so
|
|
55
|
+
// the final actionable error can expose why no interpreter was resolved.
|
|
56
|
+
lookupErrors.push(error);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function isWithinWindowsDirectory(file, directory) {
|
|
61
|
+
const normalizedFile = path.win32.resolve(file).toLowerCase();
|
|
62
|
+
const normalizedDirectory = path.win32.resolve(directory).toLowerCase();
|
|
63
|
+
return (normalizedFile === normalizedDirectory ||
|
|
64
|
+
normalizedFile.startsWith(`${normalizedDirectory}${path.win32.sep}`));
|
|
65
|
+
}
|
|
66
|
+
function isExcludedWindowsBash(file, env) {
|
|
67
|
+
if (path.win32.basename(file).toLowerCase() !== "bash.exe")
|
|
68
|
+
return false;
|
|
69
|
+
const windowsDir = envValue(env, "SystemRoot") ?? envValue(env, "WINDIR") ?? "C:\\Windows";
|
|
70
|
+
const systemBash = path.win32.resolve(windowsDir, "System32", "bash.exe");
|
|
71
|
+
if (path.win32.resolve(file).toLowerCase() === systemBash.toLowerCase())
|
|
72
|
+
return true;
|
|
73
|
+
const localAppData = envValue(env, "LOCALAPPDATA");
|
|
74
|
+
return Boolean(localAppData &&
|
|
75
|
+
isWithinWindowsDirectory(file, path.win32.resolve(localAppData, "Microsoft", "WindowsApps")));
|
|
76
|
+
}
|
|
77
|
+
function findOnWindowsPath(executable, env, lookupErrors) {
|
|
78
|
+
const pathValue = envValue(env, "PATH") ?? "";
|
|
79
|
+
const extensions = executable.includes(".")
|
|
80
|
+
? [""]
|
|
81
|
+
: (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
|
|
82
|
+
.split(";")
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.map((ext) => ext.toLowerCase());
|
|
85
|
+
for (const entry of pathValue.split(";").filter(Boolean)) {
|
|
86
|
+
const directory = entry.trim().replace(/^"|"$/g, "");
|
|
87
|
+
for (const extension of extensions) {
|
|
88
|
+
const candidate = path.win32.join(directory, `${executable}${extension}`);
|
|
89
|
+
if (!isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors))
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function resolveWindowsBash(env) {
|
|
96
|
+
const lookupErrors = [];
|
|
97
|
+
const configured = envValue(env, "CLAUDE_CODE_GIT_BASH_PATH");
|
|
98
|
+
if (configured && !isExcludedWindowsBash(configured, env) && isFile(configured, lookupErrors)) {
|
|
99
|
+
return { executable: configured, cause: lookupErrors.at(-1) };
|
|
100
|
+
}
|
|
101
|
+
const pathBash = findOnWindowsPath("bash", env, lookupErrors);
|
|
102
|
+
if (pathBash)
|
|
103
|
+
return { executable: pathBash, cause: lookupErrors.at(-1) };
|
|
104
|
+
const gitExe = findOnWindowsPath("git.exe", env, lookupErrors);
|
|
105
|
+
if (gitExe) {
|
|
106
|
+
const derived = path.win32.resolve(path.win32.dirname(gitExe), "..", "bin", "bash.exe");
|
|
107
|
+
if (!isExcludedWindowsBash(derived, env) && isFile(derived, lookupErrors)) {
|
|
108
|
+
return { executable: derived, cause: lookupErrors.at(-1) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const programFiles = envValue(env, "ProgramFiles") ?? "C:\\Program Files";
|
|
112
|
+
const localAppData = envValue(env, "LOCALAPPDATA");
|
|
113
|
+
const standardPaths = [
|
|
114
|
+
path.win32.join(programFiles, "Git", "bin", "bash.exe"),
|
|
115
|
+
...(localAppData ? [path.win32.join(localAppData, "Programs", "Git", "bin", "bash.exe")] : []),
|
|
116
|
+
];
|
|
117
|
+
const executable = standardPaths.find((candidate) => !isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors));
|
|
118
|
+
return {
|
|
119
|
+
executable,
|
|
120
|
+
cause: lookupErrors.at(-1) ??
|
|
121
|
+
Object.assign(new Error("Bash was not found (ENOENT)"), { code: "ENOENT" }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function bashNotFoundError(asset, cause) {
|
|
125
|
+
const isHandoff = asset.replaceAll("\\", "/").endsWith("core/scripts/handoff-finalize.sh");
|
|
126
|
+
const recovery = isHandoff
|
|
127
|
+
? "The handoff was not saved; rerun the command."
|
|
128
|
+
: "The script did not run; rerun this command.";
|
|
129
|
+
return new Error(`Bash was not found to run bundled script ${asset}. Install Git for Windows or ` +
|
|
130
|
+
`set CLAUDE_CODE_GIT_BASH_PATH. ${recovery}`, { cause });
|
|
131
|
+
}
|
|
44
132
|
/**
|
|
45
133
|
* Execute a bundled scaffold script and return its exit code.
|
|
46
134
|
*
|
|
@@ -56,12 +144,26 @@ export function runBundledScript(opts) {
|
|
|
56
144
|
env.CLAUDE_PROJECT_DIR = opts.hqRoot;
|
|
57
145
|
}
|
|
58
146
|
Object.assign(env, opts.env ?? {});
|
|
59
|
-
const
|
|
147
|
+
const interpreter = interpreterFor(opts.asset);
|
|
148
|
+
const windowsBash = process.platform === "win32" && interpreter === "bash"
|
|
149
|
+
? resolveWindowsBash(env)
|
|
150
|
+
: undefined;
|
|
151
|
+
const executable = windowsBash?.executable ?? interpreter;
|
|
152
|
+
if (windowsBash && !windowsBash.executable) {
|
|
153
|
+
throw bashNotFoundError(opts.asset, windowsBash.cause);
|
|
154
|
+
}
|
|
155
|
+
const result = spawnSync(executable, [script, ...(opts.args ?? [])], {
|
|
60
156
|
cwd: opts.cwd,
|
|
61
157
|
env,
|
|
62
158
|
stdio: "inherit",
|
|
63
159
|
});
|
|
64
160
|
if (result.error) {
|
|
161
|
+
const spawnError = result.error;
|
|
162
|
+
if (process.platform === "win32" &&
|
|
163
|
+
interpreter === "bash" &&
|
|
164
|
+
spawnError.code === "ENOENT") {
|
|
165
|
+
throw bashNotFoundError(opts.asset, spawnError);
|
|
166
|
+
}
|
|
65
167
|
throw new Error(`Failed to run bundled script ${opts.asset}: ${result.error.message}`);
|
|
66
168
|
}
|
|
67
169
|
if (result.signal) {
|