@indigoai-us/hq-cli 5.302.0 → 5.304.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 +7 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +72 -20
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +21 -6
- 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/doctor/deep/executor.d.ts +4 -10
- package/dist/lib/doctor/deep/executor.js +1 -1
- package/dist/lib/doctor/deep/parity.d.ts +2 -29
- package/dist/lib/doctor/deep/parity.js +7 -7
- package/dist/lib/doctor/deep/sandbox.d.ts +7 -25
- package/dist/lib/doctor/deep/sandbox.js +4 -4
- 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,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.304.0] — 2026-10-01
|
|
6
|
+
- `hq lanes show` skips owner refresh only when a failed Outpost dispatch explicitly recorded `pickup_persisted: false` and no owner heartbeat; it reports `remote_refresh: "skipped_undispatched"` in that case.
|
|
7
|
+
|
|
8
|
+
## [5.303.0] — 2026-10-01
|
|
9
|
+
- Fixed bundled shell scripts failing on Windows when Git Bash is installed but `bash` is not on `PATH`.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
5
12
|
## [5.302.0] — 2026-10-01
|
|
6
13
|
|
|
7
14
|
- 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();
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -132,6 +132,8 @@ export declare function parseMaxRuntimeSeconds(raw: unknown, workerId: string):
|
|
|
132
132
|
export declare const PICKUP_TERMINAL_REASONS: readonly ["account_not_logged_in", "unknown_account", "account_disabled", "model_unsupported"];
|
|
133
133
|
export declare const DISPATCH_TERMINAL_REASON: "outpost_dispatch_failed";
|
|
134
134
|
export type PickupTerminalReason = (typeof PICKUP_TERMINAL_REASONS)[number];
|
|
135
|
+
/** Only explicit non-persistence evidence can prove a failed dispatch has no owner snapshot. */
|
|
136
|
+
export declare function isUndispatchedOutpostMirror(lane: LaneRecord): boolean;
|
|
135
137
|
/**
|
|
136
138
|
* The CAS updater a pickup failure applies. A lane that is already terminal
|
|
137
139
|
* for any reason (a cancel that landed after the caller's last read, an
|
package/dist/commands/lanes.js
CHANGED
|
@@ -1284,9 +1284,7 @@ async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds, opt
|
|
|
1284
1284
|
((parentLaneIds === undefined || (lane.senior.kind === "lane" &&
|
|
1285
1285
|
lane.senior.id !== undefined &&
|
|
1286
1286
|
parentLaneIds.has(lane.senior.id))) || options.laneIds?.has(lane.lane_id) === true) &&
|
|
1287
|
-
!(
|
|
1288
|
-
lane.terminal_reason === DISPATCH_TERMINAL_REASON &&
|
|
1289
|
-
lane.last_host_report_at === undefined) &&
|
|
1287
|
+
!isUndispatchedOutpostMirror(lane) &&
|
|
1290
1288
|
(options.includeTerminalMirrors === true || !isTerminalLaneState(lane.state)))
|
|
1291
1289
|
.sort((a, b) => {
|
|
1292
1290
|
const terminalOrder = Number(isTerminalLaneState(a.state)) - Number(isTerminalLaneState(b.state));
|
|
@@ -2524,6 +2522,14 @@ export const PICKUP_TERMINAL_REASONS = [
|
|
|
2524
2522
|
"model_unsupported",
|
|
2525
2523
|
];
|
|
2526
2524
|
export const DISPATCH_TERMINAL_REASON = "outpost_dispatch_failed";
|
|
2525
|
+
/** Only explicit non-persistence evidence can prove a failed dispatch has no owner snapshot. */
|
|
2526
|
+
export function isUndispatchedOutpostMirror(lane) {
|
|
2527
|
+
return lane.host.kind === "outpost" &&
|
|
2528
|
+
isTerminalLaneState(lane.state) &&
|
|
2529
|
+
lane.terminal_reason === DISPATCH_TERMINAL_REASON &&
|
|
2530
|
+
lane.terminal_details?.pickup_persisted === false &&
|
|
2531
|
+
lane.last_host_report_at == null;
|
|
2532
|
+
}
|
|
2527
2533
|
function parsePickupTerminalReason(value) {
|
|
2528
2534
|
if (PICKUP_TERMINAL_REASONS.includes(value)) {
|
|
2529
2535
|
return value;
|
|
@@ -3647,6 +3653,9 @@ async function dispatchCreatedOutpostLane(ctx, input) {
|
|
|
3647
3653
|
command_id: dispatched.commandId,
|
|
3648
3654
|
exit_code: dispatched.exitCode,
|
|
3649
3655
|
status: dispatched.status,
|
|
3656
|
+
...(remote?.pickup_persisted === false || ownerError.details.pickup_persisted === false
|
|
3657
|
+
? { pickup_persisted: false }
|
|
3658
|
+
: {}),
|
|
3650
3659
|
owner_error: ownerError,
|
|
3651
3660
|
});
|
|
3652
3661
|
await emitLaneTerminalStateSoft(ctx, failed);
|
|
@@ -5038,13 +5047,17 @@ async function runOutpostIdentityBackfillTarget(ctx, opts) {
|
|
|
5038
5047
|
}
|
|
5039
5048
|
async function refreshOutpostLaneCopiesForShow(ctx, lane, localLanes) {
|
|
5040
5049
|
if (lane.host.kind !== "outpost" || laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
|
|
5041
|
-
return lane;
|
|
5050
|
+
return { lane };
|
|
5051
|
+
}
|
|
5052
|
+
if (isUndispatchedOutpostMirror(lane)) {
|
|
5053
|
+
return { lane, remoteRefresh: "skipped_undispatched" };
|
|
5042
5054
|
}
|
|
5043
5055
|
const outpostId = lane.host.id;
|
|
5044
5056
|
const descendantIds = localLanes
|
|
5045
5057
|
.filter((candidate) => candidate.lane_id !== lane.lane_id &&
|
|
5046
5058
|
candidate.host.kind === "outpost" && candidate.host.id === outpostId &&
|
|
5047
5059
|
walkHitsLane(ctx.hqRoot(), candidate.senior, lane.lane_id, ctx))
|
|
5060
|
+
.filter((candidate) => !isUndispatchedOutpostMirror(candidate))
|
|
5048
5061
|
.map((candidate) => candidate.lane_id);
|
|
5049
5062
|
const refresh = async (laneId) => {
|
|
5050
5063
|
try {
|
|
@@ -5061,13 +5074,14 @@ async function refreshOutpostLaneCopiesForShow(ctx, lane, localLanes) {
|
|
|
5061
5074
|
};
|
|
5062
5075
|
const refreshed = await mapWithConcurrency([lane.lane_id, ...descendantIds], OUTPOST_HIRE_SUMMARY_CONCURRENCY, refresh);
|
|
5063
5076
|
const refreshedLane = refreshed[0] ?? null;
|
|
5064
|
-
return refreshedLane ?? lane;
|
|
5077
|
+
return { lane: refreshedLane ?? lane };
|
|
5065
5078
|
}
|
|
5066
5079
|
async function runShow(ctx, laneId, hasPendingQuestionForLane) {
|
|
5067
5080
|
const hqRoot = ctx.hqRoot();
|
|
5068
5081
|
const initialLane = readLane(hqRoot, laneId);
|
|
5069
5082
|
const initialLanes = loadAllLanes(hqRoot);
|
|
5070
|
-
const
|
|
5083
|
+
const showRefresh = await refreshOutpostLaneCopiesForShow(ctx, initialLane, initialLanes);
|
|
5084
|
+
const storedLane = showRefresh.lane;
|
|
5071
5085
|
const allLanes = loadAllLanes(hqRoot);
|
|
5072
5086
|
const hire = await laneHirePresentation(ctx, storedLane, allLanes);
|
|
5073
5087
|
const remoteQueued = storedLane.state === "queued" &&
|
|
@@ -5152,6 +5166,7 @@ async function runShow(ctx, laneId, hasPendingQuestionForLane) {
|
|
|
5152
5166
|
stalled_reason: lane.stalled_reason ?? null,
|
|
5153
5167
|
ask_delivery_failed: askDeliveryFailed,
|
|
5154
5168
|
delivery,
|
|
5169
|
+
...(showRefresh.remoteRefresh === undefined ? {} : { remote_refresh: showRefresh.remoteRefresh }),
|
|
5155
5170
|
provider_session_id: lane.provider_session_id ?? null,
|
|
5156
5171
|
provider_session_id_absent: lane.provider_session_id_absent ?? null,
|
|
5157
5172
|
envelope_repair_mode: lane.envelope_repair?.mode ?? null,
|
|
@@ -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) {
|
|
@@ -55,7 +55,7 @@ export declare const DEFAULT_PER_CASE_TIMEOUT_MS = 10000;
|
|
|
55
55
|
*/
|
|
56
56
|
export declare const SANDBOX_COPY_RELPATHS: readonly string[];
|
|
57
57
|
/** One gate execution's inputs. */
|
|
58
|
-
|
|
58
|
+
interface GateRunInput {
|
|
59
59
|
/** Absolute path to the sandboxed `hook-gate.sh`. */
|
|
60
60
|
gatePath: string;
|
|
61
61
|
/** Absolute path to the sandboxed hook script the gate delegates to. */
|
|
@@ -72,7 +72,7 @@ export interface GateRunInput {
|
|
|
72
72
|
timeoutMs: number;
|
|
73
73
|
}
|
|
74
74
|
/** One gate execution's observed outcome. */
|
|
75
|
-
|
|
75
|
+
interface GateRunResult {
|
|
76
76
|
/** True when the execution exceeded {@link GateRunInput.timeoutMs}. */
|
|
77
77
|
timedOut: boolean;
|
|
78
78
|
/** Gate exit code, or null when it did not exit normally (timeout/signal). */
|
|
@@ -81,14 +81,7 @@ export interface GateRunResult {
|
|
|
81
81
|
stderr: string;
|
|
82
82
|
}
|
|
83
83
|
/** Executes one gate invocation. Injectable so the aggregation is unit-testable. */
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The real runner: spawn `bash hook-gate.sh <id> <script>` with the payload on
|
|
87
|
-
* stdin, `HQ_HOOK_PROFILE` set, and a hard timeout. A timeout is detected by the
|
|
88
|
-
* `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
|
|
89
|
-
* a hook cannot ignore the bound.
|
|
90
|
-
*/
|
|
91
|
-
export declare const defaultGateRunner: GateRunner;
|
|
84
|
+
type GateRunner = (input: GateRunInput) => GateRunResult;
|
|
92
85
|
/** Options for {@link runDeepGuardTests}. All are injectable for testing. */
|
|
93
86
|
export interface DeepTestOptions {
|
|
94
87
|
/** Per-case time bound. Default: {@link DEFAULT_PER_CASE_TIMEOUT_MS}. */
|
|
@@ -109,4 +102,5 @@ export declare function cleanupAllDeepSandboxes(): void;
|
|
|
109
102
|
* an empty list — creating no sandbox — when there is nothing to run.
|
|
110
103
|
*/
|
|
111
104
|
export declare function runDeepGuardTests(context: CheckContext, options?: DeepTestOptions): Promise<CheckResult[]>;
|
|
105
|
+
export {};
|
|
112
106
|
//# sourceMappingURL=executor.d.ts.map
|
|
@@ -74,7 +74,7 @@ export const SANDBOX_COPY_RELPATHS = [
|
|
|
74
74
|
* `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
|
|
75
75
|
* a hook cannot ignore the bound.
|
|
76
76
|
*/
|
|
77
|
-
|
|
77
|
+
const defaultGateRunner = (input) => {
|
|
78
78
|
const result = spawnSync("bash", [input.gatePath, input.hookId, input.hookScriptPath], {
|
|
79
79
|
input: input.payload,
|
|
80
80
|
cwd: input.cwd,
|
|
@@ -46,14 +46,6 @@ import { type PlatformId } from "../payload-shapes.js";
|
|
|
46
46
|
export declare const PARITY_FAMILY_ID = "deep-parity";
|
|
47
47
|
/** Human title of the parity family. */
|
|
48
48
|
export declare const PARITY_FAMILY_TITLE = "Cross-platform parity replay";
|
|
49
|
-
/** Default per-case, per-platform time bound. */
|
|
50
|
-
export declare const DEFAULT_PARITY_TIMEOUT_MS = 10000;
|
|
51
|
-
/** The gate profile the replay runs each hook under. Strict is the broadest
|
|
52
|
-
* allowlist, so profile gating never suppresses the hook whose LOGIC is being
|
|
53
|
-
* compared across platforms — profile-specific behaviour is US-008's concern. */
|
|
54
|
-
export declare const PARITY_GATE_PROFILE = "strict";
|
|
55
|
-
/** The session id stamped onto every replay payload. */
|
|
56
|
-
export declare const PARITY_SESSION_ID = "hq-doctor-parity-replay";
|
|
57
49
|
/** One platform replay's inputs. */
|
|
58
50
|
export interface PlatformRunInput {
|
|
59
51
|
/** Which platform's adapter path to exercise. */
|
|
@@ -85,27 +77,7 @@ export interface PlatformRunOutput {
|
|
|
85
77
|
/** Executes one platform replay. Injectable so the comparison is unit-testable. */
|
|
86
78
|
export type PlatformRunner = (input: PlatformRunInput) => PlatformRunOutput;
|
|
87
79
|
/** The real runner set, keyed by platform. */
|
|
88
|
-
|
|
89
|
-
/** The real Claude runner: the canonical script through the Claude gate. */
|
|
90
|
-
export declare const defaultClaudeRunner: PlatformRunner;
|
|
91
|
-
/** The real Codex runner: the duplicated mirror through the Codex gate. */
|
|
92
|
-
export declare const defaultCodexRunner: PlatformRunner;
|
|
93
|
-
/**
|
|
94
|
-
* The real Grok runner. Grok shares the canonical `.claude/hooks/` scripts with
|
|
95
|
-
* Claude — it has no mirror of its own — and its adapter's whole job is to read
|
|
96
|
-
* the camelCase payload, map the tool alias back to canonical, build the Claude
|
|
97
|
-
* JSON, and pipe it to the same `hook-gate.sh`. We reproduce that routing here
|
|
98
|
-
* (via {@link toGrokPayload} + {@link grokToSnakePayload}) rather than spawn the
|
|
99
|
-
* adapter directly, for two reasons: the live adapter runs a whole tool
|
|
100
|
-
* *sequence* where the replay needs one isolated hook, and its user-global
|
|
101
|
-
* bridge cannot be simulated from a project-local run (US-005 keeps bridge
|
|
102
|
-
* presence a wiring check). The Grok block protocol still applies — the gate's
|
|
103
|
-
* non-zero exit is what the adapter would translate into a stdout `deny`
|
|
104
|
-
* ({@link interpretGrokBlock} in verdictFor recognises both).
|
|
105
|
-
*/
|
|
106
|
-
export declare const defaultGrokRunner: PlatformRunner;
|
|
107
|
-
/** The default real runner set wired to each platform's real adapter path. */
|
|
108
|
-
export declare const defaultPlatformRunners: PlatformRunners;
|
|
80
|
+
type PlatformRunners = Record<PlatformId, PlatformRunner>;
|
|
109
81
|
/** Options for {@link runParityReplay}. All injectable for testing. */
|
|
110
82
|
export interface ParityOptions {
|
|
111
83
|
/** Per-case, per-platform time bound. Default: {@link DEFAULT_PARITY_TIMEOUT_MS}. */
|
|
@@ -126,4 +98,5 @@ export declare function cleanupAllParitySandboxes(): void;
|
|
|
126
98
|
* empty list — creating no sandbox — when there is nothing to replay.
|
|
127
99
|
*/
|
|
128
100
|
export declare function runParityReplay(context: CheckContext, options?: ParityOptions): Promise<CheckResult[]>;
|
|
101
|
+
export {};
|
|
129
102
|
//# sourceMappingURL=parity.d.ts.map
|
|
@@ -52,13 +52,13 @@ export const PARITY_FAMILY_ID = "deep-parity";
|
|
|
52
52
|
/** Human title of the parity family. */
|
|
53
53
|
export const PARITY_FAMILY_TITLE = "Cross-platform parity replay";
|
|
54
54
|
/** Default per-case, per-platform time bound. */
|
|
55
|
-
|
|
55
|
+
const DEFAULT_PARITY_TIMEOUT_MS = 10_000;
|
|
56
56
|
/** The gate profile the replay runs each hook under. Strict is the broadest
|
|
57
57
|
* allowlist, so profile gating never suppresses the hook whose LOGIC is being
|
|
58
58
|
* compared across platforms — profile-specific behaviour is US-008's concern. */
|
|
59
|
-
|
|
59
|
+
const PARITY_GATE_PROFILE = "strict";
|
|
60
60
|
/** The session id stamped onto every replay payload. */
|
|
61
|
-
|
|
61
|
+
const PARITY_SESSION_ID = "hq-doctor-parity-replay";
|
|
62
62
|
function spawnWithStdin(command, args, stdin, input) {
|
|
63
63
|
const result = spawnSync(command, args, {
|
|
64
64
|
input: stdin,
|
|
@@ -96,12 +96,12 @@ function runThroughGate(input, platformDir, payload) {
|
|
|
96
96
|
return { ...spawned };
|
|
97
97
|
}
|
|
98
98
|
/** The real Claude runner: the canonical script through the Claude gate. */
|
|
99
|
-
|
|
99
|
+
const defaultClaudeRunner = (input) => runThroughGate(input, ".claude", JSON.stringify(toClaudePayload(input.testCase, {
|
|
100
100
|
cwd: input.sandbox,
|
|
101
101
|
sessionId: PARITY_SESSION_ID,
|
|
102
102
|
})));
|
|
103
103
|
/** The real Codex runner: the duplicated mirror through the Codex gate. */
|
|
104
|
-
|
|
104
|
+
const defaultCodexRunner = (input) => runThroughGate(input, ".codex", JSON.stringify(toCodexPayload(input.testCase, {
|
|
105
105
|
cwd: input.sandbox,
|
|
106
106
|
sessionId: PARITY_SESSION_ID,
|
|
107
107
|
})));
|
|
@@ -118,7 +118,7 @@ export const defaultCodexRunner = (input) => runThroughGate(input, ".codex", JSO
|
|
|
118
118
|
* non-zero exit is what the adapter would translate into a stdout `deny`
|
|
119
119
|
* ({@link interpretGrokBlock} in verdictFor recognises both).
|
|
120
120
|
*/
|
|
121
|
-
|
|
121
|
+
const defaultGrokRunner = (input) => {
|
|
122
122
|
const grokPayload = toGrokPayload(input.testCase, {
|
|
123
123
|
cwd: input.sandbox,
|
|
124
124
|
sessionId: PARITY_SESSION_ID,
|
|
@@ -127,7 +127,7 @@ export const defaultGrokRunner = (input) => {
|
|
|
127
127
|
return runThroughGate(input, ".claude", JSON.stringify(snake));
|
|
128
128
|
};
|
|
129
129
|
/** The default real runner set wired to each platform's real adapter path. */
|
|
130
|
-
|
|
130
|
+
const defaultPlatformRunners = {
|
|
131
131
|
claude: defaultClaudeRunner,
|
|
132
132
|
codex: defaultCodexRunner,
|
|
133
133
|
grok: defaultGrokRunner,
|
|
@@ -46,21 +46,10 @@ export declare const DEFAULT_PER_CASE_TIMEOUT_MS = 15000;
|
|
|
46
46
|
* come under sandboxed execution.
|
|
47
47
|
*/
|
|
48
48
|
export type Precondition = "git-repo" | "session-metadata" | "company-scaffold";
|
|
49
|
-
/** Every precondition the default seeder knows how to provide. */
|
|
50
|
-
export declare const KNOWN_PRECONDITIONS: readonly Precondition[];
|
|
51
|
-
/**
|
|
52
|
-
* Tree entries copied from the live tree into every sandbox — the same curated
|
|
53
|
-
* set US-008 uses: the hook scripts and gate, the settings a hook may read, the
|
|
54
|
-
* Codex/Grok mirrors, and `core/` (helpers hooks source). Missing entries are
|
|
55
|
-
* skipped so a partial tree still sandboxes cleanly.
|
|
56
|
-
*/
|
|
57
|
-
export declare const SANDBOX_SEED_RELPATHS: readonly string[];
|
|
58
49
|
/** Remove every side-effect sandbox still on disk. Safe to call repeatedly. */
|
|
59
50
|
export declare function cleanupAllSandboxes(): void;
|
|
60
51
|
/** The sandbox roots this process is still tracking (for tests). */
|
|
61
52
|
export declare function trackedSandboxRoots(): string[];
|
|
62
|
-
/** Remove one sandbox and stop tracking it. Idempotent. */
|
|
63
|
-
export declare function destroySandbox(root: string): void;
|
|
64
53
|
/** The outcome of seeding one sandbox. */
|
|
65
54
|
export interface SeedResult {
|
|
66
55
|
/** Absolute, realpath'd sandbox root under the OS temp dir. */
|
|
@@ -91,7 +80,7 @@ export interface SeedOptions {
|
|
|
91
80
|
*/
|
|
92
81
|
export declare function seedSandbox(hqRoot: string, opts?: SeedOptions): SeedResult;
|
|
93
82
|
/** One hook execution's inputs. */
|
|
94
|
-
|
|
83
|
+
interface HookRunInput {
|
|
95
84
|
/** Absolute path to the sandboxed hook script. */
|
|
96
85
|
hookScriptPath: string;
|
|
97
86
|
/** The JSON payload piped to the hook's stdin. */
|
|
@@ -104,7 +93,7 @@ export interface HookRunInput {
|
|
|
104
93
|
timeoutMs: number;
|
|
105
94
|
}
|
|
106
95
|
/** One hook execution's observed result (exit code is diagnostic only). */
|
|
107
|
-
|
|
96
|
+
interface HookRunResult {
|
|
108
97
|
/** True when the execution exceeded {@link HookRunInput.timeoutMs}. */
|
|
109
98
|
timedOut: boolean;
|
|
110
99
|
/** Exit code, or null when it did not exit normally (timeout/signal). */
|
|
@@ -113,17 +102,9 @@ export interface HookRunResult {
|
|
|
113
102
|
stderr: string;
|
|
114
103
|
}
|
|
115
104
|
/** Runs one hook. Injectable so the tier's aggregation is unit-testable. */
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* The real runner: fire the hook script directly with the payload on stdin, its
|
|
119
|
-
* cwd and session env pointed at the sandbox, and a hard timeout. Unlike a pure
|
|
120
|
-
* guard (US-008), a side-effecting hook is run directly rather than through the
|
|
121
|
-
* gate — its exit code is irrelevant here; what matters is the effect it leaves
|
|
122
|
-
* on the sandbox, which effects.ts reads afterwards.
|
|
123
|
-
*/
|
|
124
|
-
export declare const defaultHookRunner: HookRunner;
|
|
105
|
+
type HookRunner = (input: HookRunInput) => HookRunResult;
|
|
125
106
|
/** One parsed side-effecting fixture case (its own shape, not the US-007 schema). */
|
|
126
|
-
|
|
107
|
+
interface SideEffectCase {
|
|
127
108
|
name: string;
|
|
128
109
|
event: string;
|
|
129
110
|
tool: string;
|
|
@@ -136,13 +117,13 @@ export interface SideEffectCase {
|
|
|
136
117
|
expectedFailure?: ExpectedFailure;
|
|
137
118
|
}
|
|
138
119
|
/** A validated side-effecting fixture. */
|
|
139
|
-
|
|
120
|
+
interface SideEffectFixture {
|
|
140
121
|
hookId: string;
|
|
141
122
|
relpath: string;
|
|
142
123
|
cases: SideEffectCase[];
|
|
143
124
|
}
|
|
144
125
|
/** A side-effecting fixture that could not be parsed. */
|
|
145
|
-
|
|
126
|
+
interface SideEffectFixtureError {
|
|
146
127
|
hookId: string | null;
|
|
147
128
|
relpath: string;
|
|
148
129
|
message: string;
|
|
@@ -187,4 +168,5 @@ export interface SideEffectTestOptions {
|
|
|
187
168
|
* Returns an empty list when there is nothing side-effecting to run.
|
|
188
169
|
*/
|
|
189
170
|
export declare function runSideEffectTests(context: CheckContext, options?: SideEffectTestOptions): Promise<CheckResult[]>;
|
|
171
|
+
export {};
|
|
190
172
|
//# sourceMappingURL=sandbox.d.ts.map
|
|
@@ -46,7 +46,7 @@ import { captureEffectBaseline, describeEffect, defaultGitProbe, evaluateEffects
|
|
|
46
46
|
/** Default per-case time bound. No latency target, but no case may hang. */
|
|
47
47
|
export const DEFAULT_PER_CASE_TIMEOUT_MS = 15_000;
|
|
48
48
|
/** Every precondition the default seeder knows how to provide. */
|
|
49
|
-
|
|
49
|
+
const KNOWN_PRECONDITIONS = [
|
|
50
50
|
"git-repo",
|
|
51
51
|
"session-metadata",
|
|
52
52
|
"company-scaffold",
|
|
@@ -57,7 +57,7 @@ export const KNOWN_PRECONDITIONS = [
|
|
|
57
57
|
* Codex/Grok mirrors, and `core/` (helpers hooks source). Missing entries are
|
|
58
58
|
* skipped so a partial tree still sandboxes cleanly.
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
const SANDBOX_SEED_RELPATHS = [
|
|
61
61
|
".claude/hooks",
|
|
62
62
|
".claude/settings.json",
|
|
63
63
|
".claude/settings.local.json",
|
|
@@ -100,7 +100,7 @@ function removeTree(root) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
/** Remove one sandbox and stop tracking it. Idempotent. */
|
|
103
|
-
|
|
103
|
+
function destroySandbox(root) {
|
|
104
104
|
removeTree(root);
|
|
105
105
|
trackedSandboxes.delete(root);
|
|
106
106
|
}
|
|
@@ -198,7 +198,7 @@ function git(root, args) {
|
|
|
198
198
|
* gate — its exit code is irrelevant here; what matters is the effect it leaves
|
|
199
199
|
* on the sandbox, which effects.ts reads afterwards.
|
|
200
200
|
*/
|
|
201
|
-
|
|
201
|
+
const defaultHookRunner = (input) => {
|
|
202
202
|
const result = spawnSync("bash", [input.hookScriptPath], {
|
|
203
203
|
input: input.payload,
|
|
204
204
|
cwd: input.cwd,
|
|
@@ -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) {
|