@indigoai-us/hq-cli 5.345.127 → 5.345.129
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 +47 -0
- package/assets/scaffold/core/scripts/handoff-knowledge-commit.sh +123 -0
- package/assets/scaffold/core/scripts/hq-detach.sh +169 -0
- package/dist/command-catalog.generated.d.ts +40 -2
- package/dist/command-catalog.generated.js +48 -2
- package/dist/commands/auth.js +19 -2
- package/dist/commands/core-bucket-c-handoff-knowledge-fast.d.ts +2 -0
- package/dist/commands/core-bucket-c-handoff-knowledge-fast.js +10 -0
- package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +46 -0
- package/dist/commands/core-bucket-c-handoff-knowledge-native.js +284 -0
- package/dist/commands/core-bucket-c-hq-detach-fast.d.ts +2 -0
- package/dist/commands/core-bucket-c-hq-detach-fast.js +10 -0
- package/dist/commands/core-bucket-c-hq-detach-native.d.ts +43 -0
- package/dist/commands/core-bucket-c-hq-detach-native.js +241 -0
- package/dist/commands/core-bucket-c-work-mesh-offer-native.js +8 -2
- package/dist/commands/core.js +12 -0
- package/dist/commands/daemon.js +2 -0
- package/dist/commands/lanes-accounts.js +3 -1
- package/dist/commands/lanes-shared.d.ts +7 -0
- package/dist/commands/lanes.js +139 -17
- package/dist/commands/mesh.js +32 -19
- package/dist/commands/skill.js +9 -4
- package/dist/fast-scaffold.js +28 -0
- package/dist/lib/bot/inbox-poll-backoff-flag.d.ts +3 -5
- package/dist/lib/bot/inbox-poll-backoff-flag.js +20 -12
- package/dist/lib/bot/inbox-wake.d.ts +18 -0
- package/dist/lib/bot/inbox-wake.js +125 -0
- package/dist/lib/bot/run.d.ts +8 -0
- package/dist/lib/bot/run.js +51 -7
- package/dist/lib/bot/runtime/messages-stream.js +31 -3
- package/dist/lib/capacity/lane-login.js +1 -0
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +6 -3
- package/dist/lib/daemon/flag-monitor.d.ts +4 -1
- package/dist/lib/daemon/flag-monitor.js +12 -6
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +11 -5
- package/dist/lib/daemon/hq-child-bootstrap.mjs +47 -7
- package/dist/lib/daemon/kill-switch-flags.d.ts +5 -0
- package/dist/lib/daemon/kill-switch-flags.js +5 -0
- package/dist/lib/daemon/lanes-reconcile-loader.d.ts +7 -0
- package/dist/lib/daemon/lanes-reconcile-loader.js +24 -0
- package/dist/lib/daemon/lanes-reconcile-watcher.d.ts +17 -0
- package/dist/lib/daemon/lanes-reconcile-watcher.js +80 -0
- package/dist/lib/daemon/remote-control.d.ts +8 -5
- package/dist/lib/daemon/remote-control.js +8 -7
- package/dist/lib/daemon/run.d.ts +4 -1
- package/dist/lib/daemon/run.js +133 -20
- package/dist/lib/doctor/json-output.d.ts +15 -3
- package/dist/lib/doctor/json-output.js +13 -1
- package/dist/lib/lanes/accounts.js +2 -2
- package/dist/lib/lanes/daemon-reconcile.d.ts +3 -0
- package/dist/lib/lanes/daemon-reconcile.js +8 -0
- package/dist/lib/lanes/host-capacity.d.ts +4 -0
- package/dist/lib/lanes/host-capacity.js +10 -1
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/loop.d.ts +8 -0
- package/dist/lib/lanes/loop.js +35 -0
- package/dist/lib/lanes/quota-notice.d.ts +11 -11
- package/dist/lib/lanes/quota-notice.js +20 -29
- package/dist/lib/lanes/quota.d.ts +69 -50
- package/dist/lib/lanes/quota.js +146 -127
- package/dist/lib/lanes/reconcile-result.d.ts +10 -0
- package/dist/lib/lanes/reconcile-result.js +11 -0
- package/dist/lib/lanes/reconcile.d.ts +14 -0
- package/dist/lib/lanes/reconcile.js +77 -23
- package/dist/lib/lanes/watcher.d.ts +8 -0
- package/dist/lib/lanes/watcher.js +62 -20
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -1
- package/dist/lib/mesh/live/daemon/work-subscribe.js +8 -1
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +2 -0
- package/dist/lib/mesh/story-status.d.ts +2 -2
- package/dist/lib/mesh/story-status.js +2 -1
- package/dist/utils/resident-package-preload.js +18 -4
- package/package.json +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
export const HANDOFF_KNOWLEDGE_COMMIT_ASSET = "core/scripts/handoff-knowledge-commit.sh";
|
|
5
|
+
const USAGE = "Usage: handoff-knowledge-commit.sh --files-touched-json-file <path> [--root <hq-root>]";
|
|
6
|
+
const fail = (message, code = 1) => ({ code, stdout: "", stderr: message + "\n" });
|
|
7
|
+
const gitDefault = (args, cwd) => spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
8
|
+
const text = (value) => typeof value === "string" ? value : value.toString("utf8");
|
|
9
|
+
function canonicalPath(value) {
|
|
10
|
+
const slashPath = value.replaceAll("\\", "/");
|
|
11
|
+
const withoutExtendedPrefix = slashPath.startsWith("//?/UNC/")
|
|
12
|
+
? `//${slashPath.slice("//?/UNC/".length)}`
|
|
13
|
+
: slashPath.startsWith("//?/") ? slashPath.slice("//?/".length) : slashPath;
|
|
14
|
+
const msysPath = withoutExtendedPrefix.match(/^\/([A-Za-z])\/(.*)$/);
|
|
15
|
+
const nativePath = msysPath ? `${msysPath[1]}:/${msysPath[2]}` : withoutExtendedPrefix;
|
|
16
|
+
if (/^[A-Za-z]:[\\/]/.test(nativePath) || nativePath.startsWith("\\\\")) {
|
|
17
|
+
return path.win32.resolve(nativePath).replaceAll("\\", "/").toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
return path.resolve(nativePath);
|
|
20
|
+
}
|
|
21
|
+
function samePath(left, right) {
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
const resolveWindowsAliases = (value) => {
|
|
24
|
+
try {
|
|
25
|
+
return fs.realpathSync.native(value).replaceAll("\\", "/").toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const windowsPath = (value) => {
|
|
32
|
+
const slashPath = value.replaceAll("\\", "/");
|
|
33
|
+
const withoutExtendedPrefix = slashPath.startsWith("//?/UNC/")
|
|
34
|
+
? `//${slashPath.slice("//?/UNC/".length)}`
|
|
35
|
+
: slashPath.startsWith("//?/") ? slashPath.slice("//?/".length) : slashPath;
|
|
36
|
+
const msysPath = withoutExtendedPrefix.match(/^\/([A-Za-z])\/(.*)$/);
|
|
37
|
+
const nativePath = msysPath ? `${msysPath[1]}:/${msysPath[2]}` : withoutExtendedPrefix;
|
|
38
|
+
return nativePath.replace(/\/$/, "").toLowerCase();
|
|
39
|
+
};
|
|
40
|
+
return windowsPath(resolveWindowsAliases(left)) === windowsPath(resolveWindowsAliases(right));
|
|
41
|
+
}
|
|
42
|
+
return canonicalPath(left) === canonicalPath(right);
|
|
43
|
+
}
|
|
44
|
+
function windowsNativePath(value) {
|
|
45
|
+
const msysPath = value.match(/^\/([A-Za-z])\/(.*)$/);
|
|
46
|
+
return msysPath ? path.win32.normalize(`${msysPath[1].toUpperCase()}:/${msysPath[2]}`) : value;
|
|
47
|
+
}
|
|
48
|
+
function safePath(value) {
|
|
49
|
+
return typeof value === "string" && value.length > 0 && !value.startsWith("/")
|
|
50
|
+
&& !value.includes("\n") && !value.includes("\r")
|
|
51
|
+
&& value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
52
|
+
}
|
|
53
|
+
function validChangeset(value) {
|
|
54
|
+
return Array.isArray(value) && value.every((entry) => safePath(entry) || Boolean(entry && typeof entry === "object" && safePath(entry.path)));
|
|
55
|
+
}
|
|
56
|
+
function readChangeset(file) {
|
|
57
|
+
try {
|
|
58
|
+
return { value: JSON.parse(fs.readFileSync(file, "utf8")) };
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
return { error: String(cause.message) };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function stripTrailingCrFromChangesetPaths(value) {
|
|
65
|
+
if (!Array.isArray(value))
|
|
66
|
+
return value;
|
|
67
|
+
return value.map((entry) => {
|
|
68
|
+
if (typeof entry === "string")
|
|
69
|
+
return entry.replace(/\r$/, "");
|
|
70
|
+
if (entry && typeof entry === "object" && typeof entry.path === "string") {
|
|
71
|
+
return { ...entry, path: entry.path.replace(/\r$/, "") };
|
|
72
|
+
}
|
|
73
|
+
return entry;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function entriesUnder(root) {
|
|
77
|
+
const paths = [];
|
|
78
|
+
for (const parent of ["core/knowledge/public", "core/knowledge/private", "personal/knowledge"]) {
|
|
79
|
+
const dir = path.join(root, parent);
|
|
80
|
+
try {
|
|
81
|
+
for (const name of fs.readdirSync(dir))
|
|
82
|
+
paths.push(path.join(dir, name));
|
|
83
|
+
}
|
|
84
|
+
catch { /* absent subtree */ }
|
|
85
|
+
}
|
|
86
|
+
const companies = path.join(root, "companies");
|
|
87
|
+
try {
|
|
88
|
+
for (const company of fs.readdirSync(companies)) {
|
|
89
|
+
const knowledge = path.join(companies, company, "knowledge");
|
|
90
|
+
try {
|
|
91
|
+
fs.lstatSync(knowledge);
|
|
92
|
+
paths.push(knowledge);
|
|
93
|
+
}
|
|
94
|
+
catch { /* company has no knowledge root */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* no companies tree */ }
|
|
98
|
+
return paths;
|
|
99
|
+
}
|
|
100
|
+
function gitText(git, args, cwd) {
|
|
101
|
+
const result = git(args, cwd);
|
|
102
|
+
return result.status === 0 ? text(result.stdout) : "";
|
|
103
|
+
}
|
|
104
|
+
function gitRoot(git, cwd) {
|
|
105
|
+
const root = gitText(git, ["rev-parse", "--show-toplevel"], cwd).trim();
|
|
106
|
+
return process.platform === "win32" ? windowsNativePath(root) : root;
|
|
107
|
+
}
|
|
108
|
+
function statusPaths(git, repo, relative) {
|
|
109
|
+
const status = git(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", relative], repo);
|
|
110
|
+
if (status.status !== 0)
|
|
111
|
+
return { paths: [], originals: [] };
|
|
112
|
+
const parts = text(status.stdout).split("\0");
|
|
113
|
+
const paths = [];
|
|
114
|
+
const originals = [];
|
|
115
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
116
|
+
const entry = parts[index];
|
|
117
|
+
if (!entry)
|
|
118
|
+
continue;
|
|
119
|
+
const file = entry.slice(3);
|
|
120
|
+
paths.push(file);
|
|
121
|
+
if (/[RC]/.test(entry.slice(0, 2)) && parts[index + 1])
|
|
122
|
+
originals.push(parts[++index]);
|
|
123
|
+
}
|
|
124
|
+
return { paths, originals };
|
|
125
|
+
}
|
|
126
|
+
function expandTouched(git, repo, knowledgeRel, touched) {
|
|
127
|
+
const relative = touched.startsWith(knowledgeRel + "/") ? touched.slice(knowledgeRel.length + 1) : "";
|
|
128
|
+
if (!relative)
|
|
129
|
+
return { paths: [], handled: [] };
|
|
130
|
+
const abs = path.join(repo, relative);
|
|
131
|
+
try {
|
|
132
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
133
|
+
const changed = statusPaths(git, repo, relative);
|
|
134
|
+
const paths = [...changed.paths, ...changed.originals].filter((value) => value.startsWith(relative + "/"));
|
|
135
|
+
return { paths, handled: paths.map((value) => knowledgeRel + "/" + value) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch { /* deleted paths are handled by git ls-files below */ }
|
|
139
|
+
const exists = fs.existsSync(abs);
|
|
140
|
+
if (!exists && git(gitArgs("ls-files", "--error-unmatch", "--", relative), repo).status !== 0)
|
|
141
|
+
return { paths: [], handled: [] };
|
|
142
|
+
return { paths: [relative], handled: [touched] };
|
|
143
|
+
}
|
|
144
|
+
function gitArgs(...args) { return args; }
|
|
145
|
+
function parseNulPaths(status) {
|
|
146
|
+
const parts = status.split("\0");
|
|
147
|
+
const paths = [];
|
|
148
|
+
const originals = [];
|
|
149
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
150
|
+
const entry = parts[index];
|
|
151
|
+
if (!entry)
|
|
152
|
+
continue;
|
|
153
|
+
const name = entry.slice(3);
|
|
154
|
+
paths.push(name);
|
|
155
|
+
if (/[RC]/.test(entry.slice(0, 2)) && parts[index + 1])
|
|
156
|
+
originals.push(parts[++index]);
|
|
157
|
+
}
|
|
158
|
+
return { paths, originals };
|
|
159
|
+
}
|
|
160
|
+
function commitKnowledge(git, root, changes, handled, output) {
|
|
161
|
+
for (const entry of entriesUnder(root)) {
|
|
162
|
+
let stat;
|
|
163
|
+
try {
|
|
164
|
+
stat = fs.lstatSync(entry);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const relative = path.relative(root, entry).split(path.sep).join("/");
|
|
170
|
+
if (stat.isSymbolicLink()) {
|
|
171
|
+
if (relative.startsWith("companies/") && relative.endsWith("/knowledge")) {
|
|
172
|
+
output.stdout.push("INVALID-LINK: " + relative + " is a symlink; company knowledge must be a plain directory synced through the company vault\n");
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const repo = gitRoot(git, entry);
|
|
176
|
+
if (!repo)
|
|
177
|
+
continue;
|
|
178
|
+
const repoReal = fs.realpathSync(repo);
|
|
179
|
+
const hqRepo = gitRoot(git, root);
|
|
180
|
+
if (hqRepo && samePath(repoReal, fs.realpathSync(hqRepo)))
|
|
181
|
+
continue;
|
|
182
|
+
const dirty = gitText(git, ["status", "--porcelain"], repo).trim();
|
|
183
|
+
if (dirty)
|
|
184
|
+
output.stdout.push("INVALID-DIRTY: " + relative + " is a legacy knowledge symlink to " + repo + " with uncommitted changes — NOT auto-committed; run hq reindex to materialize it, then commit, before archiving this session\n");
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (relative.startsWith("companies/") && relative.endsWith("/knowledge"))
|
|
188
|
+
continue;
|
|
189
|
+
if (!fs.existsSync(path.join(entry, ".git")))
|
|
190
|
+
continue;
|
|
191
|
+
const repo = gitRoot(git, entry);
|
|
192
|
+
if (!repo || !samePath(fs.realpathSync(entry), fs.realpathSync(repo)))
|
|
193
|
+
continue;
|
|
194
|
+
const repoPaths = [];
|
|
195
|
+
for (const touched of changes) {
|
|
196
|
+
if (!touched.startsWith(relative + "/"))
|
|
197
|
+
continue;
|
|
198
|
+
const expanded = expandTouched(git, repo, relative, touched);
|
|
199
|
+
repoPaths.push(...expanded.paths);
|
|
200
|
+
expanded.handled.forEach((item) => handled.add(item));
|
|
201
|
+
}
|
|
202
|
+
if (!repoPaths.length)
|
|
203
|
+
continue;
|
|
204
|
+
const changed = git(["status", "--porcelain", "--", ...repoPaths], repo);
|
|
205
|
+
if (changed.status !== 0 || !text(changed.stdout).trim())
|
|
206
|
+
continue;
|
|
207
|
+
const add = git(["add", "--", ...repoPaths], repo);
|
|
208
|
+
if (add.status !== 0) {
|
|
209
|
+
output.stderr.push(text(add.stderr));
|
|
210
|
+
return { code: add.status ?? 1, stdout: output.stdout.join(""), stderr: output.stderr.join("") };
|
|
211
|
+
}
|
|
212
|
+
const commit = git(["commit", "--only", "-m", "checkpoint: save session knowledge changes", "--", ...repoPaths], repo);
|
|
213
|
+
output.stdout.push(text(commit.stdout));
|
|
214
|
+
output.stderr.push(text(commit.stderr));
|
|
215
|
+
if (commit.status !== 0)
|
|
216
|
+
return { code: commit.status ?? 1, stdout: output.stdout.join(""), stderr: output.stderr.join("") };
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
export function runHandoffKnowledgeCommit(args, cwd = process.cwd(), git = gitDefault) {
|
|
221
|
+
let changesFile = "";
|
|
222
|
+
let root = cwd;
|
|
223
|
+
for (let index = 0; index < args.length;) {
|
|
224
|
+
const flag = args[index];
|
|
225
|
+
if (flag === "--files-touched-json-file" || flag === "--root") {
|
|
226
|
+
if (index + 1 >= args.length)
|
|
227
|
+
return fail(USAGE, 2);
|
|
228
|
+
if (flag === "--files-touched-json-file")
|
|
229
|
+
changesFile = args[index + 1];
|
|
230
|
+
else
|
|
231
|
+
root = args[index + 1];
|
|
232
|
+
index += 2;
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
return fail(USAGE, 2);
|
|
236
|
+
}
|
|
237
|
+
if (!changesFile)
|
|
238
|
+
return fail(USAGE, 2);
|
|
239
|
+
const changesPath = path.resolve(cwd, changesFile);
|
|
240
|
+
if (!fs.existsSync(changesPath) || !fs.statSync(changesPath).isFile())
|
|
241
|
+
return fail(USAGE, 2);
|
|
242
|
+
try {
|
|
243
|
+
root = fs.realpathSync(root);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return fail(String((new Error("No such file or directory")).message));
|
|
247
|
+
}
|
|
248
|
+
const rawParsed = readChangeset(changesPath);
|
|
249
|
+
const parsed = rawParsed.value === undefined ? rawParsed : { ...rawParsed, value: stripTrailingCrFromChangesetPaths(rawParsed.value) };
|
|
250
|
+
if (parsed.error || !validChangeset(parsed.value)) {
|
|
251
|
+
const stdout = [];
|
|
252
|
+
if (Array.isArray(parsed.value)) {
|
|
253
|
+
for (const entry of parsed.value) {
|
|
254
|
+
const candidate = typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.path : undefined;
|
|
255
|
+
if (!safePath(candidate))
|
|
256
|
+
stdout.push(" offending entry: " + JSON.stringify(entry) + "\n");
|
|
257
|
+
if (stdout.length >= 5)
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
code: 1,
|
|
263
|
+
stdout: "",
|
|
264
|
+
stderr: "handoff knowledge commit: changeset must be an array of safe relative file paths (files, not folders: no trailing slash, no absolute paths, no .. segments)\n" + stdout.join(""),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const changes = parsed.value.map((entry) => {
|
|
268
|
+
const touched = typeof entry === "string" ? entry : entry.path;
|
|
269
|
+
return touched;
|
|
270
|
+
});
|
|
271
|
+
const output = { stdout: [], stderr: [] };
|
|
272
|
+
const handled = new Set();
|
|
273
|
+
const commitFailure = commitKnowledge(git, root, changes, handled, output);
|
|
274
|
+
if (commitFailure)
|
|
275
|
+
return commitFailure;
|
|
276
|
+
for (const touched of changes) {
|
|
277
|
+
if (/^(core\/knowledge\/public|core\/knowledge\/private|personal\/knowledge)\//.test(touched) && !handled.has(touched)) {
|
|
278
|
+
output.stdout.push("NOT-COMMITTED: " + touched + " is listed in the handoff changeset but no knowledge repository committed it\n");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { code: 0, stdout: output.stdout.join(""), stderr: output.stderr.join("") };
|
|
282
|
+
}
|
|
283
|
+
export const __handoffKnowledgeTest = { safePath, validChangeset, entriesUnder, statusPaths, parseNulPaths, expandTouched, gitRoot, samePath, windowsNativePath };
|
|
284
|
+
//# sourceMappingURL=core-bucket-c-handoff-knowledge-native.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { runHqDetach } from "./core-bucket-c-hq-detach-native.js";
|
|
2
|
+
export async function runHqDetachFast(args) {
|
|
3
|
+
const result = await runHqDetach(args);
|
|
4
|
+
if (result.stdout)
|
|
5
|
+
process.stdout.write(result.stdout);
|
|
6
|
+
if (result.stderr)
|
|
7
|
+
process.stderr.write(result.stderr);
|
|
8
|
+
return result.code;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=core-bucket-c-hq-detach-fast.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
export declare const HQ_DETACH_ASSET = "core/scripts/hq-detach.sh";
|
|
3
|
+
export type HqDetachResult = {
|
|
4
|
+
code: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
};
|
|
8
|
+
type SyncResult = {
|
|
9
|
+
status: number | null;
|
|
10
|
+
stdout?: string | Buffer;
|
|
11
|
+
};
|
|
12
|
+
type Dependencies = {
|
|
13
|
+
spawnSync: (command: string, args: string[], options: Record<string, unknown>) => SyncResult;
|
|
14
|
+
spawn: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
|
15
|
+
platform: NodeJS.Platform;
|
|
16
|
+
cwd: string;
|
|
17
|
+
env: NodeJS.ProcessEnv;
|
|
18
|
+
};
|
|
19
|
+
declare function parseArgs(args: readonly string[]): {
|
|
20
|
+
handoff: boolean;
|
|
21
|
+
pidFile: string;
|
|
22
|
+
ownerPidFile: string;
|
|
23
|
+
logFile: string;
|
|
24
|
+
command: string[];
|
|
25
|
+
} | HqDetachResult;
|
|
26
|
+
declare function findExecutable(name: string, env: NodeJS.ProcessEnv): string | undefined;
|
|
27
|
+
declare function processArgs(deps: Dependencies, pid: string): string;
|
|
28
|
+
declare function isSessionCommand(command: string): boolean;
|
|
29
|
+
declare function writeOwner(deps: Dependencies, ownerFile: string): void;
|
|
30
|
+
declare function isWindowsShell(deps: Dependencies): boolean;
|
|
31
|
+
declare function spawnDetached(deps: Dependencies, command: string, args: string[], logFile: string, detached: boolean): Promise<number | undefined>;
|
|
32
|
+
export declare function runHqDetach(args: readonly string[], deps?: Dependencies): Promise<HqDetachResult>;
|
|
33
|
+
export declare const __hqDetachTest: {
|
|
34
|
+
parseArgs: typeof parseArgs;
|
|
35
|
+
isSessionCommand: typeof isSessionCommand;
|
|
36
|
+
findExecutable: typeof findExecutable;
|
|
37
|
+
processArgs: typeof processArgs;
|
|
38
|
+
writeOwner: typeof writeOwner;
|
|
39
|
+
isWindowsShell: typeof isWindowsShell;
|
|
40
|
+
spawnDetached: typeof spawnDetached;
|
|
41
|
+
};
|
|
42
|
+
export {};
|
|
43
|
+
//# sourceMappingURL=core-bucket-c-hq-detach-native.d.ts.map
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
export const HQ_DETACH_ASSET = "core/scripts/hq-detach.sh";
|
|
5
|
+
const HELP = [
|
|
6
|
+
"hq-core: public",
|
|
7
|
+
"hq-detach.sh — detach a command from the parent turn so its cleanup sweep",
|
|
8
|
+
"cannot reap it. Linux has setsid(1); stock macOS does not.",
|
|
9
|
+
"",
|
|
10
|
+
"Usage:",
|
|
11
|
+
" bash core/scripts/hq-detach.sh [--handoff] [--pidfile PATH] [--owner-pidfile PATH] [--logfile PATH] -- <command>...",
|
|
12
|
+
"",
|
|
13
|
+
"The child leads its own session (sid == pgid == pid of the session leader).",
|
|
14
|
+
"stdin is /dev/null. stdout/stderr go to --logfile or /dev/null.",
|
|
15
|
+
"--handoff uses nohup on Linux/macOS and a hidden Node child on Git Bash/MSYS/Cygwin.",
|
|
16
|
+
"",
|
|
17
|
+
"set -euo pipefail",
|
|
18
|
+
"",
|
|
19
|
+
].join("\n");
|
|
20
|
+
const result = (code, stdout = "", stderr = "") => ({ code, stdout, stderr });
|
|
21
|
+
const fail = (message, code = 1) => result(code, "", message + "\n");
|
|
22
|
+
function parseArgs(args) {
|
|
23
|
+
let handoff = false;
|
|
24
|
+
let pidFile = "";
|
|
25
|
+
let ownerPidFile = "";
|
|
26
|
+
let logFile = "/dev/null";
|
|
27
|
+
for (let index = 0; index < args.length;) {
|
|
28
|
+
const token = args[index];
|
|
29
|
+
if (token === "--handoff") {
|
|
30
|
+
handoff = true;
|
|
31
|
+
index += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (token === "--pidfile" || token === "--owner-pidfile" || token === "--logfile") {
|
|
35
|
+
if (index + 1 >= args.length)
|
|
36
|
+
return result(1);
|
|
37
|
+
const value = args[index + 1];
|
|
38
|
+
if (token === "--pidfile")
|
|
39
|
+
pidFile = value;
|
|
40
|
+
else if (token === "--owner-pidfile")
|
|
41
|
+
ownerPidFile = value;
|
|
42
|
+
else
|
|
43
|
+
logFile = value;
|
|
44
|
+
index += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (token === "--")
|
|
48
|
+
return { handoff, pidFile, ownerPidFile, logFile, command: args.slice(index + 1) };
|
|
49
|
+
if (token === "-h" || token === "--help")
|
|
50
|
+
return result(0, HELP + "\n");
|
|
51
|
+
return fail("hq-detach: unexpected argument: " + token + " (use -- before the command)", 2);
|
|
52
|
+
}
|
|
53
|
+
return result(2, "", "hq-detach: missing command after --\n");
|
|
54
|
+
}
|
|
55
|
+
function findExecutable(name, env) {
|
|
56
|
+
const candidates = path.isAbsolute(name) ? [name] : (env.PATH ?? "").split(path.delimiter).map((dir) => path.join(dir, name));
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
try {
|
|
59
|
+
fs.accessSync(candidate, process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
catch { /* continue through PATH */ }
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
function processArgs(deps, pid) {
|
|
67
|
+
const listing = deps.spawnSync("ps", ["-eww", "-o", "pid=,args="], { cwd: deps.cwd, env: deps.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
68
|
+
if (listing.status !== 0)
|
|
69
|
+
return "";
|
|
70
|
+
const text = typeof listing.stdout === "string" ? listing.stdout : Buffer.from(listing.stdout ?? "").toString("utf8");
|
|
71
|
+
for (const line of text.split(/\r?\n/)) {
|
|
72
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
73
|
+
if (match?.[1] === pid)
|
|
74
|
+
return match[2];
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
function isSessionCommand(command) {
|
|
79
|
+
const [exe = "", script = "", third = ""] = command.trim().split(/\s+/);
|
|
80
|
+
const names = new Set(["claude", "codex", "codex-code-mode-host", "grok", "grok-cli"]);
|
|
81
|
+
const base = (value) => path.basename(value.replaceAll("\\", "/"));
|
|
82
|
+
if (names.has(base(exe)))
|
|
83
|
+
return true;
|
|
84
|
+
if (["bash", "sh", "zsh", "dash", "ksh", "node"].includes(base(exe)) && names.has(base(script)))
|
|
85
|
+
return true;
|
|
86
|
+
if (base(exe) === "env") {
|
|
87
|
+
if (names.has(base(script)))
|
|
88
|
+
return true;
|
|
89
|
+
return ["bash", "sh", "zsh", "dash", "ksh"].includes(base(script)) && names.has(base(third));
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
function writeFile(file, contents) {
|
|
94
|
+
if (!file)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
catch { /* shell ignores mkdir failure */ }
|
|
100
|
+
fs.writeFileSync(file, contents);
|
|
101
|
+
}
|
|
102
|
+
function writeOwner(deps, ownerFile) {
|
|
103
|
+
if (!ownerFile)
|
|
104
|
+
return;
|
|
105
|
+
let candidate = String(process.ppid);
|
|
106
|
+
let hops = 0;
|
|
107
|
+
while (candidate && candidate !== "0" && candidate !== "1" && hops < 24) {
|
|
108
|
+
const args = processArgs(deps, candidate);
|
|
109
|
+
if (isSessionCommand(args)) {
|
|
110
|
+
const started = deps.spawnSync("ps", ["-o", "lstart=", "-p", candidate], { cwd: deps.cwd, env: deps.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
111
|
+
const start = String(started.stdout ?? "").trim();
|
|
112
|
+
if (start)
|
|
113
|
+
writeFile(ownerFile, candidate + "\n" + start + "\n");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const parent = deps.spawnSync("ps", ["-o", "ppid=", "-p", candidate], { cwd: deps.cwd, env: deps.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
117
|
+
candidate = String(parent.stdout ?? "").trim();
|
|
118
|
+
hops += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isWindowsShell(deps) {
|
|
122
|
+
if (deps.platform === "win32")
|
|
123
|
+
return true;
|
|
124
|
+
const uname = deps.spawnSync("uname", ["-s"], { cwd: deps.cwd, env: deps.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
125
|
+
return /^(MINGW|MSYS|CYGWIN)/.test(String(uname.stdout ?? "").trim());
|
|
126
|
+
}
|
|
127
|
+
function spawnDetached(deps, command, args, logFile, detached) {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
let output = "ignore";
|
|
130
|
+
try {
|
|
131
|
+
if (logFile !== "/dev/null")
|
|
132
|
+
output = fs.openSync(logFile, "a");
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
resolve(undefined);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let child;
|
|
139
|
+
try {
|
|
140
|
+
child = deps.spawn(command, args, {
|
|
141
|
+
cwd: deps.cwd, env: deps.env, detached,
|
|
142
|
+
windowsHide: deps.platform === "win32" || deps.env.HQ_DETACH_WINDOWS_SHELL === "1",
|
|
143
|
+
stdio: ["ignore", output, output],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
if (typeof output === "number")
|
|
148
|
+
fs.closeSync(output);
|
|
149
|
+
resolve(undefined);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (typeof output === "number")
|
|
153
|
+
fs.closeSync(output);
|
|
154
|
+
let done = false;
|
|
155
|
+
child.once("spawn", () => {
|
|
156
|
+
if (typeof child.pid !== "number") {
|
|
157
|
+
done = true;
|
|
158
|
+
resolve(undefined);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
child.unref();
|
|
162
|
+
done = true;
|
|
163
|
+
resolve(child.pid);
|
|
164
|
+
});
|
|
165
|
+
child.once("error", () => { if (!done) {
|
|
166
|
+
done = true;
|
|
167
|
+
resolve(undefined);
|
|
168
|
+
} });
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const defaults = () => ({
|
|
172
|
+
spawnSync: (command, args, options) => spawnSync(command, args, options),
|
|
173
|
+
spawn: (command, args, options) => spawn(command, args, options),
|
|
174
|
+
platform: process.platform, cwd: process.cwd(), env: process.env,
|
|
175
|
+
});
|
|
176
|
+
export async function runHqDetach(args, deps = defaults()) {
|
|
177
|
+
const parsed = parseArgs(args);
|
|
178
|
+
if ("code" in parsed)
|
|
179
|
+
return parsed;
|
|
180
|
+
if (!parsed.command.length)
|
|
181
|
+
return fail("hq-detach: missing command after --", 2);
|
|
182
|
+
writeOwner(deps, parsed.ownerPidFile);
|
|
183
|
+
const handoffWindows = parsed.handoff && isWindowsShell(deps);
|
|
184
|
+
if (parsed.handoff && !handoffWindows) {
|
|
185
|
+
const pid = await spawnDetached(deps, "nohup", parsed.command, parsed.logFile, false);
|
|
186
|
+
if (pid === undefined)
|
|
187
|
+
return fail("hq-detach: failed to spawn detached command");
|
|
188
|
+
try {
|
|
189
|
+
writeFile(parsed.pidFile, String(pid) + "\n");
|
|
190
|
+
}
|
|
191
|
+
catch (cause) {
|
|
192
|
+
return fail(String(cause.message));
|
|
193
|
+
}
|
|
194
|
+
return result(0);
|
|
195
|
+
}
|
|
196
|
+
let setsid = "";
|
|
197
|
+
if (deps.env.HQ_DETACH_FORCE_NODE !== "1" && !handoffWindows) {
|
|
198
|
+
for (const candidate of ["setsid", "/opt/homebrew/opt/util-linux/bin/setsid", "/usr/local/opt/util-linux/bin/setsid"]) {
|
|
199
|
+
const found = findExecutable(candidate, deps.env);
|
|
200
|
+
if (found) {
|
|
201
|
+
setsid = found;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (setsid) {
|
|
207
|
+
const pid = await spawnDetached(deps, setsid, parsed.command, parsed.logFile, false);
|
|
208
|
+
if (pid === undefined)
|
|
209
|
+
return fail("hq-detach: failed to spawn detached command");
|
|
210
|
+
try {
|
|
211
|
+
writeFile(parsed.pidFile, String(pid) + "\n");
|
|
212
|
+
}
|
|
213
|
+
catch (cause) {
|
|
214
|
+
return fail(String(cause.message));
|
|
215
|
+
}
|
|
216
|
+
return result(0);
|
|
217
|
+
}
|
|
218
|
+
const node = deps.env.HQ_DETACH_NODE?.trim() || "node";
|
|
219
|
+
if (!findExecutable(node, deps.env))
|
|
220
|
+
return fail("hq-detach: neither setsid(1) nor " + node + " is available");
|
|
221
|
+
let logfile = parsed.logFile;
|
|
222
|
+
if (handoffWindows && logfile !== "/dev/null" && findExecutable("cygpath", deps.env)) {
|
|
223
|
+
const converted = deps.spawnSync("cygpath", ["-m", logfile], { cwd: deps.cwd, env: deps.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
224
|
+
if (converted.status === 0 && String(converted.stdout ?? "").trim())
|
|
225
|
+
logfile = String(converted.stdout).trim();
|
|
226
|
+
}
|
|
227
|
+
const env = { ...deps.env, HQ_DETACH_LOGFILE: logfile, ...(handoffWindows ? { HQ_DETACH_WINDOWS_SHELL: "1" } : {}) };
|
|
228
|
+
const childDeps = { ...deps, env };
|
|
229
|
+
const pid = await spawnDetached(childDeps, parsed.command[0], parsed.command.slice(1), logfile, true);
|
|
230
|
+
if (pid === undefined)
|
|
231
|
+
return fail("hq-detach: failed to spawn detached command");
|
|
232
|
+
try {
|
|
233
|
+
writeFile(parsed.pidFile, String(pid) + "\n");
|
|
234
|
+
}
|
|
235
|
+
catch (cause) {
|
|
236
|
+
return fail(String(cause.message));
|
|
237
|
+
}
|
|
238
|
+
return result(0);
|
|
239
|
+
}
|
|
240
|
+
export const __hqDetachTest = { parseArgs, isSessionCommand, findExecutable, processArgs, writeOwner, isWindowsShell, spawnDetached };
|
|
241
|
+
//# sourceMappingURL=core-bucket-c-hq-detach-native.js.map
|
|
@@ -37,8 +37,14 @@ function cloudUid(root, company) {
|
|
|
37
37
|
}
|
|
38
38
|
if (inCompany && /^ {2}[^\s#]/.test(line))
|
|
39
39
|
break;
|
|
40
|
-
if (inCompany
|
|
41
|
-
|
|
40
|
+
if (inCompany) {
|
|
41
|
+
// Match the shell's awk field contract: `$1 == "cloud_uid:"` and use
|
|
42
|
+
// only `$2`. A value must be separated from the key by whitespace;
|
|
43
|
+
// trailing comments and later fields do not become part of the UID.
|
|
44
|
+
const [key, value] = line.trim().split(/\s+/);
|
|
45
|
+
if (key === "cloud_uid:")
|
|
46
|
+
return (value ?? "").replace(/["']/g, "");
|
|
47
|
+
}
|
|
42
48
|
}
|
|
43
49
|
return "";
|
|
44
50
|
}
|
package/dist/commands/core.js
CHANGED
|
@@ -230,6 +230,18 @@ export const SCAFFOLD_COMMANDS = [
|
|
|
230
230
|
root: "live",
|
|
231
231
|
summary: "Publish the session continuity pointer to the personal vault",
|
|
232
232
|
},
|
|
233
|
+
{
|
|
234
|
+
name: "hq-detach",
|
|
235
|
+
asset: "core/scripts/hq-detach.sh",
|
|
236
|
+
root: "cwd",
|
|
237
|
+
summary: "Detach a command from the parent turn",
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "handoff-knowledge-commit",
|
|
241
|
+
asset: "core/scripts/handoff-knowledge-commit.sh",
|
|
242
|
+
root: "cwd",
|
|
243
|
+
summary: "Commit only knowledge files listed in a handoff changeset",
|
|
244
|
+
},
|
|
233
245
|
{
|
|
234
246
|
name: "hq-task",
|
|
235
247
|
asset: "core/scripts/hq-task.sh",
|
package/dist/commands/daemon.js
CHANGED
|
@@ -907,6 +907,7 @@ async function runTmuxService(d, opts) {
|
|
|
907
907
|
cwd: hqRoot,
|
|
908
908
|
argv: opts.argv,
|
|
909
909
|
label: opts.label,
|
|
910
|
+
restartOnToolUpdate: opts.restartOnToolUpdate,
|
|
910
911
|
// Only a new version of the tool restarts the session: not a same-version
|
|
911
912
|
// reinstall, and not an hq-cli release (the host's own code rarely matters).
|
|
912
913
|
binaryFingerprint: () => toolFingerprint(opts.watch),
|
|
@@ -1094,6 +1095,7 @@ async function runServiceRemoteControlPoolHost(d, accountId) {
|
|
|
1094
1095
|
argv: [claude, ...remoteControlPoolArgs(pool)],
|
|
1095
1096
|
label: `claude remote-control pool ${accountId}`,
|
|
1096
1097
|
watch: claude,
|
|
1098
|
+
restartOnToolUpdate: false,
|
|
1097
1099
|
signal: controller.signal,
|
|
1098
1100
|
sessionEnv: {
|
|
1099
1101
|
HOME: d.home,
|
|
@@ -13,7 +13,7 @@ import { accountQuotaView, clearAccountQuota, loadQuotaState } from "../lib/lane
|
|
|
13
13
|
import { resolveLaneModel } from "../lib/lanes/spawn.js";
|
|
14
14
|
import { leadLaneIds } from "../lib/lanes/admission.js";
|
|
15
15
|
export function createLaneAccountsHandlers(deps) {
|
|
16
|
-
const { automaticBalanceMoveHistoryReason, ensureDir, laneWithRecoveredProviderSession, loadClaudeBalanceHostSnapshots, parseRemoteJson, prepareAccountMove, printJson, reconcileHostFromCtx, resolveCallerPath } = deps;
|
|
16
|
+
const { admitQueuedFromCtx, automaticBalanceMoveHistoryReason, ensureDir, laneWithRecoveredProviderSession, loadClaudeBalanceHostSnapshots, parseRemoteJson, prepareAccountMove, printJson, reconcileHostFromCtx, resolveCallerPath } = deps;
|
|
17
17
|
const VENDOR_LOGIN = {
|
|
18
18
|
claude: { command: "claude", args: ["auth", "login"] },
|
|
19
19
|
codex: { command: "codex", args: ["login"] },
|
|
@@ -539,6 +539,7 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
539
539
|
});
|
|
540
540
|
}
|
|
541
541
|
const cleared = clearAccountQuota(ctx.hqRoot(), provider, account, model === undefined ? {} : { model });
|
|
542
|
+
const admitted = await admitQueuedFromCtx(ctx);
|
|
542
543
|
printJson({
|
|
543
544
|
ok: true,
|
|
544
545
|
action: "accounts.clear-quota",
|
|
@@ -546,6 +547,7 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
546
547
|
account,
|
|
547
548
|
all_models_quota_reset_at: cleared.all_models_quota_reset_at,
|
|
548
549
|
model_quota_resets: cleared.model_quota_resets,
|
|
550
|
+
admitted,
|
|
549
551
|
});
|
|
550
552
|
}
|
|
551
553
|
async function runAccountsEnable(ctx, providerRaw, accountId) {
|