@indigoai-us/hq-cli 5.147.0 → 5.149.1
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 +29 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +8 -11
- package/dist/command-catalog.generated.d.ts +129 -0
- package/dist/command-catalog.generated.js +168 -0
- package/dist/commands/lanes.js +506 -23
- package/dist/commands/mcp-registration.d.ts +29 -16
- package/dist/commands/mcp-registration.js +254 -17
- package/dist/commands/mesh.js +82 -11
- package/dist/lib/lanes/index.d.ts +3 -2
- package/dist/lib/lanes/index.js +2 -1
- package/dist/lib/lanes/mesh-events.d.ts +8 -0
- package/dist/lib/lanes/mesh-events.js +83 -6
- package/dist/lib/lanes/outpost-dispatch.d.ts +12 -0
- package/dist/lib/lanes/outpost-dispatch.js +17 -0
- package/dist/lib/lanes/reconcile.js +39 -12
- package/dist/lib/lanes/session-relink.d.ts +25 -0
- package/dist/lib/lanes/session-relink.js +216 -0
- package/dist/lib/lanes/spawn.d.ts +1 -0
- package/dist/lib/lanes/spawn.js +48 -5
- package/dist/lib/lanes/store.js +109 -1
- package/dist/lib/lanes/stories.d.ts +17 -0
- package/dist/lib/lanes/stories.js +73 -0
- package/dist/lib/lanes/types.d.ts +20 -0
- package/dist/lib/lanes/types.js +9 -2
- package/dist/lib/lanes/watcher.js +69 -9
- package/dist/lib/mesh/live/daemon/doctor.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/doctor.js +84 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +3 -3
- package/dist/lib/mesh/live/daemon/index.js +2 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +40 -0
- package/dist/lib/mesh/live/daemon/install.js +88 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +12 -1
- package/dist/lib/mesh/live/daemon/state.d.ts +4 -0
- package/dist/lib/mesh/project-board.d.ts +15 -0
- package/dist/lib/mesh/project-board.js +84 -0
- package/dist/lib/work-context/prd-sync.d.ts +5 -1
- package/dist/lib/work-context/prd-sync.js +22 -1
- package/dist/sentry.js +15 -7
- package/dist/utils/sentry-fingerprint.d.ts +11 -0
- package/dist/utils/sentry-fingerprint.js +56 -11
- package/package.json +5 -3
- package/scripts/mesh-daemon-if-stale.mjs +16 -0
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Tests must not actually install services — use a temp HOME/prefix.
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
|
+
import { type RunCommand } from "../../../service-manager/launchd.js";
|
|
15
16
|
export type DaemonInstallPlatform = "darwin" | "linux" | "win32" | "other";
|
|
16
17
|
/** Result of one schtasks.exe invocation (tests inject; never the real binary). */
|
|
17
18
|
export interface SchtasksResult {
|
|
@@ -121,8 +122,47 @@ export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstal
|
|
|
121
122
|
* binary when both exist.
|
|
122
123
|
*/
|
|
123
124
|
export declare function preferStableHqBinary(entry: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* Absolute path of the CLI entry that is executing this process.
|
|
127
|
+
* realpath of argv[1] only — never a PATH lookup of `hq`.
|
|
128
|
+
*/
|
|
129
|
+
export declare function resolveRunningCliEntry(argv?: string[]): string;
|
|
130
|
+
/** Absolute node binary (realpath of process.execPath). No PATH lookup. */
|
|
131
|
+
export declare function resolveAbsoluteNodeBinary(execPath?: string): string;
|
|
124
132
|
/** Resolve the hq binary the current shell uses (argv[1] or PATH). */
|
|
125
133
|
export declare function resolveHqBinary(argv?: string[], env?: NodeJS.ProcessEnv): string;
|
|
134
|
+
/** True when this CLI is newer than the version the daemon recorded, or nothing was recorded. */
|
|
135
|
+
export declare function isNewerCliVersion(current: string, recorded: string | null | undefined): boolean;
|
|
136
|
+
export interface InstallIfStaleResult {
|
|
137
|
+
reinstalled: boolean;
|
|
138
|
+
message: string;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Reinstall and restart the user daemon only when `currentVersion` is newer
|
|
142
|
+
* than daemon/state.json `cliVersion`. A missing record counts as stale.
|
|
143
|
+
* `run` is the launchctl/systemctl seam — tests pass a stub.
|
|
144
|
+
*/
|
|
145
|
+
export declare function installDaemonIfStale(opts: {
|
|
146
|
+
currentVersion: string;
|
|
147
|
+
platform?: DaemonInstallPlatform;
|
|
148
|
+
paths: InstallPaths;
|
|
149
|
+
systemUser?: string;
|
|
150
|
+
run?: RunCommand;
|
|
151
|
+
uid?: number;
|
|
152
|
+
writeFileSync?: typeof fs.writeFileSync;
|
|
153
|
+
mkdirSync?: typeof fs.mkdirSync;
|
|
154
|
+
reloadSystemd?: () => {
|
|
155
|
+
status: number | null;
|
|
156
|
+
};
|
|
157
|
+
schtasks?: SchtasksRunner;
|
|
158
|
+
kill?: (pid: number) => void;
|
|
159
|
+
runCommand?: (cmd: string, args: string[]) => {
|
|
160
|
+
status: number | null;
|
|
161
|
+
stdout: string;
|
|
162
|
+
stderr: string;
|
|
163
|
+
};
|
|
164
|
+
windowsLegacyTaskPresent?: boolean;
|
|
165
|
+
}): InstallIfStaleResult;
|
|
126
166
|
export declare function resolveNodeBinary(execPath?: string, env?: NodeJS.ProcessEnv): string;
|
|
127
167
|
export declare function buildInstallPaths(opts: {
|
|
128
168
|
home?: string;
|
|
@@ -15,11 +15,15 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
+
import semver from "semver";
|
|
18
19
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
19
20
|
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
20
21
|
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
21
22
|
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
22
23
|
import { retireLegacyListen } from "./legacy-listen.js";
|
|
24
|
+
import { readDaemonState } from "./state.js";
|
|
25
|
+
import { activateLaunchd } from "../../../service-manager/launchd.js";
|
|
26
|
+
import { activateSystemd } from "../../../service-manager/systemd.js";
|
|
23
27
|
/**
|
|
24
28
|
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
25
29
|
* launched through `conhost.exe --headless`, so ending the task kills conhost
|
|
@@ -212,6 +216,35 @@ export function preferStableHqBinary(entry) {
|
|
|
212
216
|
return path.resolve(candidate);
|
|
213
217
|
return entry;
|
|
214
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Absolute path of the CLI entry that is executing this process.
|
|
221
|
+
* realpath of argv[1] only — never a PATH lookup of `hq`.
|
|
222
|
+
*/
|
|
223
|
+
export function resolveRunningCliEntry(argv = process.argv) {
|
|
224
|
+
const entry = argv[1];
|
|
225
|
+
if (!entry) {
|
|
226
|
+
throw new Error("Cannot resolve hq binary: process.argv[1] is empty");
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return fs.realpathSync(entry);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new Error(`Cannot resolve hq binary: ${entry} is not a real path`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Absolute node binary (realpath of process.execPath). No PATH lookup. */
|
|
236
|
+
export function resolveAbsoluteNodeBinary(execPath = process.execPath) {
|
|
237
|
+
if (!execPath)
|
|
238
|
+
throw new Error("Cannot resolve node binary: execPath is empty");
|
|
239
|
+
try {
|
|
240
|
+
return fs.realpathSync(execPath);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
if (path.isAbsolute(execPath))
|
|
244
|
+
return execPath;
|
|
245
|
+
throw new Error(`Cannot resolve node binary: ${execPath} is not a real path`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
215
248
|
/** Resolve the hq binary the current shell uses (argv[1] or PATH). */
|
|
216
249
|
export function resolveHqBinary(argv = process.argv, env = process.env) {
|
|
217
250
|
const entry = argv[1];
|
|
@@ -230,6 +263,61 @@ export function resolveHqBinary(argv = process.argv, env = process.env) {
|
|
|
230
263
|
}
|
|
231
264
|
throw new Error("Cannot resolve hq binary for daemon install");
|
|
232
265
|
}
|
|
266
|
+
/** True when this CLI is newer than the version the daemon recorded, or nothing was recorded. */
|
|
267
|
+
export function isNewerCliVersion(current, recorded) {
|
|
268
|
+
const recordedVersion = recorded?.trim() ?? "";
|
|
269
|
+
if (!recordedVersion)
|
|
270
|
+
return true;
|
|
271
|
+
if (semver.valid(current) && semver.valid(recordedVersion)) {
|
|
272
|
+
return semver.gt(current, recordedVersion);
|
|
273
|
+
}
|
|
274
|
+
return current !== recordedVersion;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Reinstall and restart the user daemon only when `currentVersion` is newer
|
|
278
|
+
* than daemon/state.json `cliVersion`. A missing record counts as stale.
|
|
279
|
+
* `run` is the launchctl/systemctl seam — tests pass a stub.
|
|
280
|
+
*/
|
|
281
|
+
export function installDaemonIfStale(opts) {
|
|
282
|
+
const recorded = readDaemonState(opts.paths.daemonDir)?.cliVersion;
|
|
283
|
+
if (!isNewerCliVersion(opts.currentVersion, recorded)) {
|
|
284
|
+
return {
|
|
285
|
+
reinstalled: false,
|
|
286
|
+
message: `daemon already records hq ${recorded}; not reinstalling`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const run = opts.run ?? ((command, args) => {
|
|
290
|
+
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
291
|
+
return {
|
|
292
|
+
status: result.status,
|
|
293
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
294
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
const platform = opts.platform ?? detectPlatform();
|
|
298
|
+
const installed = installDaemonService({
|
|
299
|
+
platform,
|
|
300
|
+
paths: opts.paths,
|
|
301
|
+
systemUser: opts.systemUser,
|
|
302
|
+
writeFileSync: opts.writeFileSync,
|
|
303
|
+
mkdirSync: opts.mkdirSync,
|
|
304
|
+
reloadSystemd: opts.reloadSystemd,
|
|
305
|
+
schtasks: opts.schtasks,
|
|
306
|
+
kill: opts.kill,
|
|
307
|
+
runCommand: opts.runCommand,
|
|
308
|
+
windowsLegacyTaskPresent: opts.windowsLegacyTaskPresent,
|
|
309
|
+
});
|
|
310
|
+
if (platform === "darwin") {
|
|
311
|
+
activateLaunchd(opts.paths.plistPath, run, opts.uid);
|
|
312
|
+
}
|
|
313
|
+
else if (platform === "linux" && !opts.systemUser && !opts.paths.systemUser) {
|
|
314
|
+
activateSystemd(SYSTEMD_UNIT_NAME, run);
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
reinstalled: true,
|
|
318
|
+
message: `${installed.message} Restarted with ${opts.paths.nodeBinary} ${opts.paths.hqBinary}.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
233
321
|
export function resolveNodeBinary(execPath = process.execPath, env = process.env) {
|
|
234
322
|
if (execPath && fs.existsSync(execPath))
|
|
235
323
|
return execPath;
|
|
@@ -103,6 +103,8 @@ export interface DaemonRunDeps {
|
|
|
103
103
|
runForMs?: number;
|
|
104
104
|
/** CLI version loaded by this daemon. Defaults to this package's version. */
|
|
105
105
|
daemonVersion?: string;
|
|
106
|
+
/** realpath of the CLI entry. Defaults to process.argv[1]. */
|
|
107
|
+
cliEntry?: string;
|
|
106
108
|
/**
|
|
107
109
|
* Subscribe to the personal work topic and write live-cache.json.
|
|
108
110
|
* The `hq mesh daemon run` command turns this on. Tests leave it off
|
|
@@ -36,6 +36,7 @@ import { workMeshHeldPath } from "../paths.js";
|
|
|
36
36
|
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
37
37
|
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
38
38
|
import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
|
|
39
|
+
import { resolveRunningCliEntry } from "./install.js";
|
|
39
40
|
import { readDaemonConfig } from "./config.js";
|
|
40
41
|
import { retireLegacyListen } from "./legacy-listen.js";
|
|
41
42
|
import { startWorkCacheSubscriber } from "./work-subscribe.js";
|
|
@@ -140,7 +141,17 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
140
141
|
}
|
|
141
142
|
writeDaemonState(dir, defaultDaemonState(lockDeps.pid, now));
|
|
142
143
|
log(dir, `daemon started pid=${lockDeps.pid} mode=${mode}`);
|
|
143
|
-
|
|
144
|
+
let cliEntry = deps.cliEntry ?? "";
|
|
145
|
+
if (!cliEntry) {
|
|
146
|
+
try {
|
|
147
|
+
cliEntry = resolveRunningCliEntry();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
cliEntry = process.argv[1] ?? "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
log(dir, `daemon version=${daemonVersion} entry=${cliEntry}`);
|
|
154
|
+
patchDaemonState(dir, { emitMode: mode, cliVersion: daemonVersion, cliEntry }, now);
|
|
144
155
|
// Opt into machine-credential minting when the box has creds (systemd exports
|
|
145
156
|
// HQ_MACHINE_CREDS_FILE). On a person laptop with no machine creds this falls
|
|
146
157
|
// through to the person login cache inside ensureCognitoToken.
|
|
@@ -31,6 +31,10 @@ export interface DaemonStateFile {
|
|
|
31
31
|
deviceDefaultAuthorizationDenials?: number;
|
|
32
32
|
/** Transient device-default membership refresh failures; the next cycle retries. */
|
|
33
33
|
deviceDefaultMembershipLookupFailures?: number;
|
|
34
|
+
/** CLI version this daemon process was started with. */
|
|
35
|
+
cliVersion?: string;
|
|
36
|
+
/** realpath of the CLI entry this daemon process was started with. */
|
|
37
|
+
cliEntry?: string;
|
|
34
38
|
updatedAt: string;
|
|
35
39
|
}
|
|
36
40
|
export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { type MeshCompany, type MeshProjectRegistration, type MeshProjectRepo, type MeshProjectStoryInput, type MeshProjectView } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* Add Board stories that a prd-sync emit is about to name, when they are missing.
|
|
4
|
+
* A missing view uses ensureProjectBoard. An existing view is merged so live
|
|
5
|
+
* status stays. Callers that must not block the emit should catch failures.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ensureMissingBoardStories(input: {
|
|
8
|
+
token: string;
|
|
9
|
+
company: MeshCompany;
|
|
10
|
+
projectId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
stories: MeshProjectStoryInput[];
|
|
14
|
+
repos?: MeshProjectRepo[];
|
|
15
|
+
requiredIds: readonly string[];
|
|
16
|
+
}): Promise<void>;
|
|
2
17
|
/**
|
|
3
18
|
* POST /v1/work-mesh/projects/{id}/register always binds the server-derived
|
|
4
19
|
* proj- thread (projectGenesisThreadId). It does not accept a client threadId.
|
|
@@ -1,4 +1,88 @@
|
|
|
1
1
|
import { appendThreadEvent, ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
|
|
2
|
+
function httpStatus(err) {
|
|
3
|
+
if (err && typeof err === "object" && "status" in err && typeof err.status === "number") {
|
|
4
|
+
return err.status;
|
|
5
|
+
}
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Same merge as `hq mesh project ensure`: add missing stories, keep live status. */
|
|
9
|
+
function mergeProjectStories(current, planned) {
|
|
10
|
+
const currentById = new Map(current.map((story) => [story.id, story]));
|
|
11
|
+
const plannedIds = new Set(planned.map((story) => story.id));
|
|
12
|
+
const merged = planned.map((story) => {
|
|
13
|
+
const live = currentById.get(story.id);
|
|
14
|
+
if (!live)
|
|
15
|
+
return story;
|
|
16
|
+
const status = live.status || story.status;
|
|
17
|
+
return {
|
|
18
|
+
...story,
|
|
19
|
+
...(status ? { status } : {}),
|
|
20
|
+
passes: status === "done" ? true : typeof live.passes === "boolean" ? live.passes : story.passes,
|
|
21
|
+
...(live.assigneeUid ? { assigneeUid: live.assigneeUid } : {}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return [...merged, ...current.filter((story) => !plannedIds.has(story.id))];
|
|
25
|
+
}
|
|
26
|
+
function mergeProjectRepos(current, planned) {
|
|
27
|
+
const plannedPaths = new Set(planned.map((repo) => repo.path));
|
|
28
|
+
return [...planned, ...current.filter((repo) => !plannedPaths.has(repo.path))];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Add Board stories that a prd-sync emit is about to name, when they are missing.
|
|
32
|
+
* A missing view uses ensureProjectBoard. An existing view is merged so live
|
|
33
|
+
* status stays. Callers that must not block the emit should catch failures.
|
|
34
|
+
*/
|
|
35
|
+
export async function ensureMissingBoardStories(input) {
|
|
36
|
+
const required = new Set(input.requiredIds);
|
|
37
|
+
if (required.size === 0)
|
|
38
|
+
return;
|
|
39
|
+
let current;
|
|
40
|
+
try {
|
|
41
|
+
current = await getProjectView(input.token, input.company.companyUid, input.projectId);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (httpStatus(err) !== 404)
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if (!current) {
|
|
48
|
+
await ensureProjectBoard({
|
|
49
|
+
token: input.token,
|
|
50
|
+
company: input.company,
|
|
51
|
+
projectId: input.projectId,
|
|
52
|
+
name: input.name || input.projectId,
|
|
53
|
+
description: input.description ?? "",
|
|
54
|
+
stories: input.stories,
|
|
55
|
+
repos: input.repos ?? [],
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if ([...required].every((id) => current.stories.some((story) => story.id === id)))
|
|
60
|
+
return;
|
|
61
|
+
const genesis = await ensureProjectThread(input.token, input.company, input.projectId, {
|
|
62
|
+
summary: input.name || input.projectId,
|
|
63
|
+
});
|
|
64
|
+
if (!genesis.threadId)
|
|
65
|
+
throw new Error("Work mesh did not return a threadId.");
|
|
66
|
+
const stories = mergeProjectStories(current.stories, input.stories);
|
|
67
|
+
const repos = mergeProjectRepos(current.repos, input.repos ?? []);
|
|
68
|
+
const name = input.name || current.name;
|
|
69
|
+
const description = input.description !== undefined ? input.description : current.description;
|
|
70
|
+
await putProjectView(input.token, input.company.companyUid, input.projectId, {
|
|
71
|
+
name,
|
|
72
|
+
description,
|
|
73
|
+
stories,
|
|
74
|
+
repos,
|
|
75
|
+
...(current.version !== undefined ? { expectedVersion: current.version } : {}),
|
|
76
|
+
});
|
|
77
|
+
const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
|
|
78
|
+
await closeSupersededProjectThread({
|
|
79
|
+
token: input.token,
|
|
80
|
+
companyUid: input.company.companyUid,
|
|
81
|
+
openedThreadId: genesis.threadId,
|
|
82
|
+
canonicalThreadId: registration.threadId,
|
|
83
|
+
created: genesis.created,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
2
86
|
/**
|
|
3
87
|
* POST /v1/work-mesh/projects/{id}/register always binds the server-derived
|
|
4
88
|
* proj- thread (projectGenesisThreadId). It does not accept a client threadId.
|
|
@@ -24,7 +24,11 @@ export interface PrdSyncPlan {
|
|
|
24
24
|
/** Story most recently moved to in_progress, if any. */
|
|
25
25
|
taskId?: string;
|
|
26
26
|
seqStart: number;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Write the snapshot. With no argument, every planned story is recorded.
|
|
29
|
+
* With ids, only those stories advance; the rest stay changed for a retry.
|
|
30
|
+
*/
|
|
31
|
+
commit: (acceptedTaskIds?: readonly string[]) => void;
|
|
28
32
|
}
|
|
29
33
|
export declare function planPrdSync(input: {
|
|
30
34
|
sessionId: string;
|
|
@@ -114,7 +114,28 @@ export function planPrdSync(input) {
|
|
|
114
114
|
events,
|
|
115
115
|
...(taskId ? { taskId } : {}),
|
|
116
116
|
seqStart: previous?.seq ?? 0,
|
|
117
|
-
commit: () =>
|
|
117
|
+
commit: (acceptedTaskIds) => {
|
|
118
|
+
if (!acceptedTaskIds) {
|
|
119
|
+
writeSnapshot(input.sessionId, input.root, snapshot);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const accepted = new Set(acceptedTaskIds);
|
|
123
|
+
const stories = { ...(previous?.stories ?? {}) };
|
|
124
|
+
let advanced = 0;
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
if (!accepted.has(event.taskId))
|
|
127
|
+
continue;
|
|
128
|
+
const next = nextStories[event.taskId];
|
|
129
|
+
if (!next)
|
|
130
|
+
continue;
|
|
131
|
+
stories[event.taskId] = next;
|
|
132
|
+
advanced += 1;
|
|
133
|
+
}
|
|
134
|
+
writeSnapshot(input.sessionId, input.root, {
|
|
135
|
+
stories,
|
|
136
|
+
seq: (previous?.seq ?? 0) + advanced,
|
|
137
|
+
});
|
|
138
|
+
},
|
|
118
139
|
};
|
|
119
140
|
}
|
|
120
141
|
//# sourceMappingURL=prd-sync.js.map
|
package/dist/sentry.js
CHANGED
|
@@ -7,7 +7,7 @@ import { getCachedSentryUser } from "./utils/sentry-identity.js";
|
|
|
7
7
|
import { isEpipe } from "./utils/epipe.js";
|
|
8
8
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
9
9
|
import { incompleteInstallCaptureContext, incompleteInstallMessage, } from "./utils/incomplete-install-error.js";
|
|
10
|
-
import { sentryFingerprintFor } from "./utils/sentry-fingerprint.js";
|
|
10
|
+
import { sentryFingerprintFor, sentryMcpCollisionContextFor, } from "./utils/sentry-fingerprint.js";
|
|
11
11
|
/**
|
|
12
12
|
* Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
|
|
13
13
|
* reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
|
|
@@ -70,12 +70,20 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
70
70
|
// before the fleet scrubber — so it covers EVERY capture route (the top-level
|
|
71
71
|
// boundary, the command-level captureException sites, bin/hq-auth-refresh,
|
|
72
72
|
// any uncaughtException), mirroring the EPIPE and environmental-fs drops
|
|
73
|
-
// above. The
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
// above. The typed error classifiers use closed allowlists. The Codex
|
|
74
|
+
// collision branch uses one fixed bucket and adds the validated server name
|
|
75
|
+
// only as a tag; it sends no config values or paths. Unclassifiable
|
|
76
|
+
// events retain Sentry's default grouping.
|
|
77
|
+
const mcpCollision = sentryMcpCollisionContextFor(hint?.originalException);
|
|
78
|
+
if (mcpCollision) {
|
|
79
|
+
event.tags = { ...event.tags, ...mcpCollision.tags };
|
|
80
|
+
event.fingerprint = mcpCollision.fingerprint;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const fingerprint = sentryFingerprintFor(hint?.originalException);
|
|
84
|
+
if (fingerprint)
|
|
85
|
+
event.fingerprint = fingerprint;
|
|
86
|
+
}
|
|
79
87
|
return beforeSend(event, hint);
|
|
80
88
|
}
|
|
81
89
|
/**
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
export interface SentryMcpCollisionContext {
|
|
2
|
+
tags: {
|
|
3
|
+
mcp_client: "codex";
|
|
4
|
+
server_name: string;
|
|
5
|
+
existing_entry_owner: "hq" | "foreign" | "unknown";
|
|
6
|
+
diff_fields: string;
|
|
7
|
+
};
|
|
8
|
+
fingerprint: string[];
|
|
9
|
+
}
|
|
10
|
+
/** Return bounded, value-free Sentry context for an instrumented Codex name collision. */
|
|
11
|
+
export declare function sentryMcpCollisionContextFor(err: unknown): SentryMcpCollisionContext | null;
|
|
1
12
|
/**
|
|
2
13
|
* Return a bounded `event.fingerprint` array for `err`, or `null` when `err`
|
|
3
14
|
* carries no discriminator from the closed allowlist (in which case the caller
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/utils/sentry-fingerprint.ts
|
|
2
2
|
//
|
|
3
|
-
// Compute a BOUNDED Sentry grouping fingerprint from an error, using only
|
|
4
|
-
//
|
|
5
|
-
// user-minted text. Sibling in spirit to qmd-collection-missing-error.ts
|
|
3
|
+
// Compute a BOUNDED Sentry grouping fingerprint from an error, using only
|
|
4
|
+
// closed discriminators and a fixed Codex collision bucket, never arbitrary
|
|
5
|
+
// upstream- or user-minted text. Sibling in spirit to qmd-collection-missing-error.ts
|
|
6
6
|
// (HQ-CLI-S): the same unbounded-fingerprint discipline, applied to grouping
|
|
7
7
|
// instead of to a printed line.
|
|
8
8
|
//
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
// grouping untouched.
|
|
46
46
|
//
|
|
47
47
|
// Two hard rules, mirroring HQ-CLI-S:
|
|
48
|
-
// 1.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
48
|
+
// 1. BOUNDED DISCRIMINATORS. RPC/HTTP values use closed allowlists and
|
|
49
|
+
// fallback buckets. Codex collisions use one fixed client bucket. The
|
|
50
|
+
// server-name tag is restricted to 64 lowercase letters, digits,
|
|
51
|
+
// underscores or hyphens; other names share one fallback value. Owner and
|
|
52
|
+
// field tags also use closed sets.
|
|
53
|
+
// 2. NO FREE TEXT. Error messages, argv, URLs, connection ids, company slugs,
|
|
54
|
+
// config values and paths never reach a fingerprint or tags. The Codex
|
|
55
|
+
// branch uses a fixed fingerprint and bounded diagnostic tags.
|
|
56
56
|
//
|
|
57
57
|
// An error that carries no bounded discriminator returns `null`: the caller
|
|
58
58
|
// leaves `event.fingerprint` unset and Sentry keeps its DEFAULT grouping, so the
|
|
@@ -79,6 +79,48 @@ const KNOWN_HTTP_STATUSES = new Set([
|
|
|
79
79
|
]);
|
|
80
80
|
/** Sentry's placeholder for "keep the normal, stack-based grouping too". */
|
|
81
81
|
const DEFAULT_GROUPING = "{{ default }}";
|
|
82
|
+
const CODEX_COLLISION_NAME_RE = /^[a-z0-9_-]{1,64}$/;
|
|
83
|
+
const CODEX_COLLISION_OWNERS = new Set(["hq", "foreign", "unknown"]);
|
|
84
|
+
const CODEX_COLLISION_FIELDS = new Set(["command", "args", "env", "url", "other"]);
|
|
85
|
+
function isCodexCollisionOwner(value) {
|
|
86
|
+
return typeof value === "string" && CODEX_COLLISION_OWNERS.has(value);
|
|
87
|
+
}
|
|
88
|
+
/** Return bounded, value-free Sentry context for an instrumented Codex name collision. */
|
|
89
|
+
export function sentryMcpCollisionContextFor(err) {
|
|
90
|
+
if (err === null || typeof err !== "object")
|
|
91
|
+
return null;
|
|
92
|
+
const record = err;
|
|
93
|
+
if (record.name !== "McpNameCollisionError" || record.code !== "McpNameCollisionError") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const diagnostics = record.codexDiagnostics;
|
|
97
|
+
if (diagnostics === null || typeof diagnostics !== "object")
|
|
98
|
+
return null;
|
|
99
|
+
const context = diagnostics;
|
|
100
|
+
const owner = context.existingEntryOwner;
|
|
101
|
+
if (context.client !== "codex" ||
|
|
102
|
+
typeof context.serverName !== "string" ||
|
|
103
|
+
!isCodexCollisionOwner(owner) ||
|
|
104
|
+
!Array.isArray(context.diffFields) ||
|
|
105
|
+
context.diffFields.length === 0 ||
|
|
106
|
+
context.diffFields.length > CODEX_COLLISION_FIELDS.size ||
|
|
107
|
+
!context.diffFields.every((field) => typeof field === "string" && CODEX_COLLISION_FIELDS.has(field))) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const fields = [...new Set(context.diffFields)].sort();
|
|
111
|
+
const serverName = CODEX_COLLISION_NAME_RE.test(context.serverName)
|
|
112
|
+
? context.serverName
|
|
113
|
+
: "other";
|
|
114
|
+
return {
|
|
115
|
+
tags: {
|
|
116
|
+
mcp_client: "codex",
|
|
117
|
+
server_name: serverName,
|
|
118
|
+
existing_entry_owner: owner,
|
|
119
|
+
diff_fields: fields.join(","),
|
|
120
|
+
},
|
|
121
|
+
fingerprint: ["mcp-name-collision", "codex"],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
82
124
|
/**
|
|
83
125
|
* The CLOSED set of error class names allowed to appear VERBATIM in a
|
|
84
126
|
* fingerprint. This hook runs on every capture route and accepts `unknown`, so
|
|
@@ -216,6 +258,9 @@ export function sentryFingerprintFor(err) {
|
|
|
216
258
|
const rawName = typeof record.name === "string" && record.name.length > 0 ? record.name : null;
|
|
217
259
|
if (rawName === null)
|
|
218
260
|
return null;
|
|
261
|
+
const mcpCollision = sentryMcpCollisionContextFor(record);
|
|
262
|
+
if (mcpCollision)
|
|
263
|
+
return mcpCollision.fingerprint;
|
|
219
264
|
// qmd process failures carry a PROCESS EXIT CODE in `status` (never an HTTP
|
|
220
265
|
// status) plus a subcommand in args[0]. Fingerprint them on a dedicated,
|
|
221
266
|
// fully-bounded key BEFORE the HTTP-status branch so an exit code is never read
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"description": "HQ by Indigo management CLI
|
|
3
|
+
"version": "5.149.1",
|
|
4
|
+
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"hq": "dist/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"templates",
|
|
19
19
|
"CHANGELOG.md",
|
|
20
20
|
"README.md",
|
|
21
|
-
"contracts"
|
|
21
|
+
"contracts",
|
|
22
|
+
"scripts/mesh-daemon-if-stale.mjs"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
25
|
"build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
|
|
34
35
|
"vitest": "vitest",
|
|
35
36
|
"clean": "rm -rf dist",
|
|
37
|
+
"postinstall": "node scripts/mesh-daemon-if-stale.mjs || true",
|
|
36
38
|
"prepare": "husky || true",
|
|
37
39
|
"check:no-hermes": "bash scripts/check-no-hermes.sh"
|
|
38
40
|
},
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postinstall: point the mesh daemon at this install when it is newer.
|
|
3
|
+
* Best-effort. Never fails the package install. Tests do not run this.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const cli = path.join(root, "dist", "index.js");
|
|
12
|
+
if (!fs.existsSync(cli)) process.exit(0);
|
|
13
|
+
spawnSync(process.execPath, [cli, "mesh", "daemon", "install", "--if-stale"], {
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
});
|
|
16
|
+
process.exit(0);
|