@indigoai-us/hq-cli 5.194.0 → 5.195.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 +13 -0
- package/dist/lib/checkpoint-ontology/apply.d.ts +7 -0
- package/dist/lib/checkpoint-ontology/apply.js +38 -0
- package/dist/lib/lanes/reconcile.js +2 -3
- package/dist/lib/lanes/text-scan.d.ts +11 -0
- package/dist/lib/lanes/text-scan.js +103 -0
- package/dist/lib/mesh/live/daemon/run.js +8 -3
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +29 -12
- package/dist/lib/mesh/live/daemon/transcript-watch.js +96 -30
- package/dist/lib/mesh/live/report-summary.js +1 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.195.0] — 2026-09-25
|
|
6
|
+
|
|
7
|
+
## [5.194.1] — 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Work Mesh transcript-watch quiet `session_end` for hook-covered sessions now
|
|
12
|
+
- `hq core ontology apply` no longer creates a new entity that is probably an
|
|
13
|
+
existing one. A candidate whose alias is already another entity's name or
|
|
14
|
+
alias, or a person whose name contains every word of an existing person's
|
|
15
|
+
full name (for example "Chaudhry Hassaan Saleem" and "Hassaan Saleem"), is
|
|
16
|
+
held back and reported as a suspect duplicate.
|
|
17
|
+
|
|
5
18
|
## [5.194.0] — 2026-09-25
|
|
6
19
|
|
|
7
20
|
### Added
|
|
@@ -108,6 +108,13 @@ type Resolution = {
|
|
|
108
108
|
};
|
|
109
109
|
/** Design section 5.3, tiers 1 to 4. */
|
|
110
110
|
export declare function resolveCandidate(index: EntityIndex, type: string, canonical: string): Resolution;
|
|
111
|
+
/**
|
|
112
|
+
* Existing entities of `type` that a new candidate probably is: one of the
|
|
113
|
+
* candidate's aliases is already their name or alias, or, for people, every
|
|
114
|
+
* word of their name (two words or more) is in the candidate's name
|
|
115
|
+
* (`Chaudhry Hassaan Saleem` and `Hassaan Saleem`).
|
|
116
|
+
*/
|
|
117
|
+
export declare function overlappingEntities(index: EntityIndex, type: string, canonical: string, aliases: string[]): string[];
|
|
111
118
|
export declare function applyOntologyRun(options: ApplyOptions): ApplyReport;
|
|
112
119
|
export {};
|
|
113
120
|
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -81,6 +81,38 @@ export function resolveCandidate(index, type, canonical) {
|
|
|
81
81
|
return { tier: "type_conflict", types: otherTypes };
|
|
82
82
|
return { tier: "new" };
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Existing entities of `type` that a new candidate probably is: one of the
|
|
86
|
+
* candidate's aliases is already their name or alias, or, for people, every
|
|
87
|
+
* word of their name (two words or more) is in the candidate's name
|
|
88
|
+
* (`Chaudhry Hassaan Saleem` and `Hassaan Saleem`).
|
|
89
|
+
*/
|
|
90
|
+
export function overlappingEntities(index, type, canonical, aliases) {
|
|
91
|
+
const paths = new Set();
|
|
92
|
+
for (const alias of aliases) {
|
|
93
|
+
const normalized = normalizeEntityName(alias, type);
|
|
94
|
+
// A generic alias still counts when an existing entity already holds it.
|
|
95
|
+
if (!normalized)
|
|
96
|
+
continue;
|
|
97
|
+
for (const rel of index.canonical.get(key(type, normalized)) ?? [])
|
|
98
|
+
paths.add(rel);
|
|
99
|
+
for (const rel of index.alias.get(key(type, normalized)) ?? [])
|
|
100
|
+
paths.add(rel);
|
|
101
|
+
}
|
|
102
|
+
if (type === "person") {
|
|
103
|
+
const words = new Set(normalizeEntityName(canonical, type).split(" "));
|
|
104
|
+
const prefix = key(type, "");
|
|
105
|
+
for (const [name, rels] of index.canonical) {
|
|
106
|
+
if (!name.startsWith(prefix))
|
|
107
|
+
continue;
|
|
108
|
+
const other = name.slice(prefix.length).split(" ");
|
|
109
|
+
if (other.length >= 2 && other.every((word) => words.has(word)))
|
|
110
|
+
for (const rel of rels)
|
|
111
|
+
paths.add(rel);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...paths].sort();
|
|
115
|
+
}
|
|
84
116
|
/**
|
|
85
117
|
* Aliases worth adding to the entity at `self`: new to it, and held by no
|
|
86
118
|
* other entity of any type, so an alias can never make a later match
|
|
@@ -301,6 +333,12 @@ export function applyOntologyRun(options) {
|
|
|
301
333
|
note(report.rejected, "type_conflict");
|
|
302
334
|
continue;
|
|
303
335
|
}
|
|
336
|
+
const overlap = overlappingEntities(index, candidate.type, canonical, candidate.aliases);
|
|
337
|
+
if (overlap.length > 0) {
|
|
338
|
+
note(report.deferred, "suspect_duplicate");
|
|
339
|
+
report.suspect_duplicates.push({ name: canonical, type: candidate.type, paths: overlap });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
304
342
|
// Tier 5: create.
|
|
305
343
|
if (candidate.confidence < MIN_CREATE_CONFIDENCE) {
|
|
306
344
|
note(report.rejected, "low_confidence");
|
|
@@ -569,7 +569,7 @@ async function withReconcileLock(hqRoot, fn) {
|
|
|
569
569
|
}
|
|
570
570
|
}
|
|
571
571
|
}
|
|
572
|
-
function classifyDeadLocalLane(hqRoot, laneId) {
|
|
572
|
+
function classifyDeadLocalLane(hqRoot, laneId, now) {
|
|
573
573
|
const runDir = laneRunDir(hqRoot, laneId);
|
|
574
574
|
const manifest = readSpawnManifest(runDir);
|
|
575
575
|
const minEnvelopeName = manifest.envelope_path ? path.basename(manifest.envelope_path) : undefined;
|
|
@@ -586,7 +586,6 @@ function classifyDeadLocalLane(hqRoot, laneId) {
|
|
|
586
586
|
// the quota signal, any other becomes provider_rejected, and only a stream
|
|
587
587
|
// with no failure object falls back to the quota reader.
|
|
588
588
|
const workerOutOffset = readSpawnManifest(runDir).worker_out_offset ?? 0;
|
|
589
|
-
const now = new Date();
|
|
590
589
|
const routed = envelope
|
|
591
590
|
? { quota: null, rejection: null }
|
|
592
591
|
: routeProviderFailure(readProviderRejection(runDir, lane.provider, workerOutOffset), () => readQuotaSignal(runDir, lane.model, now, workerOutOffset), lane.model, now);
|
|
@@ -894,7 +893,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
894
893
|
continue;
|
|
895
894
|
}
|
|
896
895
|
try {
|
|
897
|
-
const { classification, latest, quota, providerRejection, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id);
|
|
896
|
+
const { classification, latest, quota, providerRejection, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id, now);
|
|
898
897
|
// Match the watcher's one-repair decision before classifying an
|
|
899
898
|
// unparseable envelope after both processes have died. As in the
|
|
900
899
|
// watcher, a provider rejection or a quota signal skips the repair.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Secret-like values already redacted from lane report summaries. */
|
|
2
|
+
export declare const SECRET_RE: RegExp;
|
|
3
|
+
/** Paths that should not be copied into a report or tracker record. */
|
|
4
|
+
export declare const SECRETS_PATH_RE: RegExp;
|
|
5
|
+
/** Return only the category of a match; never expose matched text to callers. */
|
|
6
|
+
export declare function findSecretText(text: string): {
|
|
7
|
+
kind: string;
|
|
8
|
+
} | null;
|
|
9
|
+
/** Refuse free text containing credentials without copying the matched value into the error. */
|
|
10
|
+
export declare function assertNoSecretText(field: string, text: string): void;
|
|
11
|
+
//# sourceMappingURL=text-scan.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { LanesError } from "./errors.js";
|
|
2
|
+
/** Secret-like values already redacted from lane report summaries. */
|
|
3
|
+
export const SECRET_RE = /(?:Bearer\s+[A-Za-z0-9._\-+=/]+)|(?:sk-[A-Za-z0-9]{8,})|(?:ghp_[A-Za-z0-9]{8,})|(?:github_pat_[A-Za-z0-9_]{8,})|(?:xox[baprs]-[A-Za-z0-9-]{8,})|(?:AKIA[0-9A-Z]{8,})|(?:op:\/\/[^\s"]+)/gi;
|
|
4
|
+
/** Paths that should not be copied into a report or tracker record. */
|
|
5
|
+
export const SECRETS_PATH_RE = /(?:^|[\s"'`])((?:~|\/|\.\/|\.\.\/)?(?:[\w.-]+\/)*(?:\.env(?:\.[A-Za-z0-9._-]+)?|secrets(?:\/[\w.-]+)*|id_rsa|credentials(?:\.json)?|\.netrc|\.npmrc))/gi;
|
|
6
|
+
const TOKEN_SHAPED_SECRET_PATTERNS = [
|
|
7
|
+
// Keep the report-summary short-token thresholds, but require a true left boundary.
|
|
8
|
+
{
|
|
9
|
+
kind: "stripe_api_key",
|
|
10
|
+
pattern: /(?:^|[^a-zA-Z0-9_-])(?:sk|rk)_(?:live|test)_[a-zA-Z0-9]{16,}/i,
|
|
11
|
+
},
|
|
12
|
+
{ kind: "api_key", pattern: /(?:^|[^a-zA-Z0-9_-])sk-[a-zA-Z0-9._-]{8,}/i },
|
|
13
|
+
{ kind: "github_pat", pattern: /(?:^|[^a-zA-Z0-9_-])ghp_[a-zA-Z0-9]{8,}/i },
|
|
14
|
+
{
|
|
15
|
+
kind: "fine_grained_github_pat",
|
|
16
|
+
pattern: /(?:^|[^a-zA-Z0-9_-])github_pat_[a-zA-Z0-9_]{8,}/i,
|
|
17
|
+
},
|
|
18
|
+
{ kind: "slack_token", pattern: /(?:^|[^a-zA-Z0-9_-])xox[baprs]-[a-zA-Z0-9-]{8,}/i },
|
|
19
|
+
{ kind: "aws_access_key", pattern: /(?:^|[^a-zA-Z0-9_-])AKIA[0-9A-Z]{8,}/i },
|
|
20
|
+
{ kind: "onepassword_reference", pattern: /(?:^|[^a-zA-Z0-9_-])op:\/\/[^\s"]+/i },
|
|
21
|
+
// The hook's Bearer rule requires a token-shaped value of at least 20 characters.
|
|
22
|
+
{ kind: "bearer_token", pattern: /(?:^|[^a-zA-Z0-9_-])Bearer\s+[a-zA-Z0-9._-]{20,}/i },
|
|
23
|
+
{
|
|
24
|
+
kind: "private_key",
|
|
25
|
+
pattern: /(?:^|[^a-zA-Z0-9_-])-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
26
|
+
},
|
|
27
|
+
{ kind: "gitlab_pat", pattern: /(?:^|[^a-zA-Z0-9_-])glpat-[a-zA-Z0-9_-]{20,}/ },
|
|
28
|
+
{ kind: "github_oauth_token", pattern: /(?:^|[^a-zA-Z0-9_-])gho_[a-zA-Z0-9]{36,}/ },
|
|
29
|
+
{
|
|
30
|
+
kind: "fine_grained_github_pat",
|
|
31
|
+
pattern: /(?:^|[^a-zA-Z0-9_-])github_pat_[a-zA-Z0-9_]{22,}/,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
const SENSITIVE_URL_PARAMETER_NAMES = new Set([
|
|
35
|
+
"code",
|
|
36
|
+
"token",
|
|
37
|
+
"otp",
|
|
38
|
+
"magic",
|
|
39
|
+
"auth",
|
|
40
|
+
"session",
|
|
41
|
+
"signature",
|
|
42
|
+
]);
|
|
43
|
+
const URL_RE = /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s<>"'`]+/g;
|
|
44
|
+
const ONE_TIME_CODE_RE = /\b(?:(?:one[- ]time|verification|login|sign[- ]in|security|auth)\s+code|otp)\b\s*(?:(?:is|:|=)\s*)?(?:\d{4,8}|\d{3,4}[- ]\d{3,4})\b|\bcode(?:\s*:\s*|\s+is\s+)(?:\d{6}|\d{3,4}[- ]\d{3,4})\b/i;
|
|
45
|
+
function hasSensitiveUrlParameter(urlText) {
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(urlText);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const queryParts = [url.search.slice(1)];
|
|
54
|
+
const fragment = url.hash.slice(1);
|
|
55
|
+
if (fragment) {
|
|
56
|
+
const queryStart = fragment.indexOf("?");
|
|
57
|
+
queryParts.push(queryStart >= 0 ? fragment.slice(queryStart + 1) : fragment);
|
|
58
|
+
}
|
|
59
|
+
for (const part of queryParts) {
|
|
60
|
+
for (const [name, value] of new URLSearchParams(part)) {
|
|
61
|
+
const normalizedName = name
|
|
62
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
const hasSensitiveName = SENSITIVE_URL_PARAMETER_NAMES.has(normalizedName) ||
|
|
65
|
+
/(?:^|[_-])(?:code|token|otp|magic|auth|session|signature)(?:$|[_-])/.test(normalizedName);
|
|
66
|
+
if (hasSensitiveName &&
|
|
67
|
+
value.trim().length > 0) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** Return only the category of a match; never expose matched text to callers. */
|
|
75
|
+
export function findSecretText(text) {
|
|
76
|
+
const secretPathPattern = new RegExp(SECRETS_PATH_RE.source, "gi");
|
|
77
|
+
const pathScanText = text.replace(/(^|[\s"'`])~\//g, "$1./");
|
|
78
|
+
for (const pathMatch of pathScanText.matchAll(secretPathPattern)) {
|
|
79
|
+
const candidate = pathMatch[1] ?? "";
|
|
80
|
+
if (candidate.startsWith("~") || candidate.includes("/")) {
|
|
81
|
+
return { kind: "secrets_path" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const candidate of TOKEN_SHAPED_SECRET_PATTERNS) {
|
|
85
|
+
if (candidate.pattern.test(text))
|
|
86
|
+
return { kind: candidate.kind };
|
|
87
|
+
}
|
|
88
|
+
for (const match of text.matchAll(URL_RE)) {
|
|
89
|
+
if (hasSensitiveUrlParameter(match[0]))
|
|
90
|
+
return { kind: "sign_in_link" };
|
|
91
|
+
}
|
|
92
|
+
if (ONE_TIME_CODE_RE.test(text))
|
|
93
|
+
return { kind: "one_time_code" };
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Refuse free text containing credentials without copying the matched value into the error. */
|
|
97
|
+
export function assertNoSecretText(field, text) {
|
|
98
|
+
const match = findSecretText(text);
|
|
99
|
+
if (!match)
|
|
100
|
+
return;
|
|
101
|
+
throw new LanesError("secret_text_refused", `Sensitive text refused for ${field} (${match.kind})`, { field, kind: match.kind });
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=text-scan.js.map
|
|
@@ -33,7 +33,7 @@ import { daemonDir } from "./paths.js";
|
|
|
33
33
|
import { PresenceClient, defaultMqttConnect, } from "./presence.js";
|
|
34
34
|
import { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
|
|
35
35
|
import { workMeshHeldPath } from "../paths.js";
|
|
36
|
-
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
36
|
+
import { defaultTranscriptFs, 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
39
|
import { resolveRunningCliEntry } from "./install.js";
|
|
@@ -550,6 +550,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
550
550
|
let watcher = null;
|
|
551
551
|
/** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
|
|
552
552
|
const lastHookEventAt = new Map();
|
|
553
|
+
const lastHookTurnEndAt = new Map();
|
|
553
554
|
let transcriptWatcher = null;
|
|
554
555
|
const requestRestartForVersionDrift = () => {
|
|
555
556
|
if (stopped || restartRequested)
|
|
@@ -574,6 +575,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
574
575
|
fs: deps.transcriptFs,
|
|
575
576
|
now: () => now().getTime(),
|
|
576
577
|
lastHookEventAt,
|
|
578
|
+
lastHookTurnEndAt,
|
|
577
579
|
// Transcript watch has no direct state writer: reconcile owns its write
|
|
578
580
|
// and merges local fields. Surface transient lookup failures as throws so
|
|
579
581
|
// reconcile defers without turning them into an authoritative denial.
|
|
@@ -628,8 +630,8 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
628
630
|
}
|
|
629
631
|
// Record hook-emitted session ids from spool/held before claim/rename.
|
|
630
632
|
const at = now().getTime();
|
|
631
|
-
noteHookSessionsFromSpoolFile(workMeshSpoolPath(meshRoot), lastHookEventAt, at);
|
|
632
|
-
noteHookSessionsFromSpoolFile(workMeshHeldPath(meshRoot), lastHookEventAt, at);
|
|
633
|
+
noteHookSessionsFromSpoolFile(workMeshSpoolPath(meshRoot), lastHookEventAt, at, defaultTranscriptFs, lastHookTurnEndAt);
|
|
634
|
+
noteHookSessionsFromSpoolFile(workMeshHeldPath(meshRoot), lastHookEventAt, at, defaultTranscriptFs, lastHookTurnEndAt);
|
|
633
635
|
const summary = await flushFn(shutdownController.signal);
|
|
634
636
|
try {
|
|
635
637
|
const replay = await replayFn();
|
|
@@ -760,6 +762,9 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
760
762
|
summary.turns > 0 ||
|
|
761
763
|
summary.ended > 0) {
|
|
762
764
|
log(dir, `transcript-watch: started=${summary.started} turns=${summary.turns} ended=${summary.ended} skippedHook=${summary.skippedHookCovered}`);
|
|
765
|
+
for (const sessionId of summary.endedSessionIds) {
|
|
766
|
+
log(dir, `transcript-watch quiet session_end sessionId=${sessionId}`);
|
|
767
|
+
}
|
|
763
768
|
scheduleDebouncedFlush();
|
|
764
769
|
}
|
|
765
770
|
}
|
|
@@ -26,12 +26,17 @@
|
|
|
26
26
|
* localHarness is no longer written (harness carries claude-desktop on the wire).
|
|
27
27
|
*
|
|
28
28
|
* Hook-covered sessions (durable hook-written state, or a hook event within
|
|
29
|
-
* TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start — hooks own
|
|
30
|
+
* that. The watcher still tracks last activity (transcript mtime or hook
|
|
31
|
+
* events). It enqueues turn_end when hooks never did (Stop often never fires
|
|
32
|
+
* for a live Claude Code session sitting at the prompt; server turnCount
|
|
33
|
+
* stays 0) and session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
|
|
34
|
+
* channel work report has a trigger (US-050b / US-051c). Both events take
|
|
35
|
+
* seq from the shared ~/.hq/work-mesh/seq/<sid> file the hooks use; an
|
|
36
|
+
* in-memory counter restarting at 1 is dropped by the server as a duplicate
|
|
37
|
+
* of session_start. Resume resets the timer. A hook event within
|
|
38
|
+
* TRANSCRIPT_HOOK_COVER_MS of our own emit is the SessionEnd hook catching
|
|
39
|
+
* up and is not treated as resume.
|
|
35
40
|
*/
|
|
36
41
|
import * as fs from "node:fs";
|
|
37
42
|
import type { MembershipValidationResult } from "../../../work-context/config.js";
|
|
@@ -111,6 +116,11 @@ export interface TranscriptWatchDeps {
|
|
|
111
116
|
* TRANSCRIPT_HOOK_COVER_MS are not synthesized.
|
|
112
117
|
*/
|
|
113
118
|
lastHookEventAt?: Map<string, number>;
|
|
119
|
+
/**
|
|
120
|
+
* Last hook-emitted turn_end per sessionId (epoch ms). Used so we do not
|
|
121
|
+
* synthesize a second turn_end when Stop already landed.
|
|
122
|
+
*/
|
|
123
|
+
lastHookTurnEndAt?: Map<string, number>;
|
|
114
124
|
/** Injectable enqueue (tests). */
|
|
115
125
|
enqueue?: typeof enqueueSessionEvent;
|
|
116
126
|
/** Injectable state read/write (tests). */
|
|
@@ -137,6 +147,8 @@ export interface TranscriptWatchTickResult {
|
|
|
137
147
|
ended: number;
|
|
138
148
|
skippedHookCovered: number;
|
|
139
149
|
skippedStale: number;
|
|
150
|
+
/** sessionIds that received a synthesized session_end this tick (quiet path). */
|
|
151
|
+
endedSessionIds: string[];
|
|
140
152
|
/** Dir stats + readdir children + file stats examined this tick. */
|
|
141
153
|
entriesExamined: number;
|
|
142
154
|
durationMs: number;
|
|
@@ -253,7 +265,7 @@ export declare function resolveTranscriptRegistration(input: {
|
|
|
253
265
|
*/
|
|
254
266
|
export declare function noteHookSessionsFromSpoolFile(spoolPath: string, lastHookEventAt: Map<string, number>, atMs: number, tfs?: Pick<TranscriptFs, "existsSync"> & {
|
|
255
267
|
readFileSync?: (p: string, enc: "utf8") => string;
|
|
256
|
-
}): string[];
|
|
268
|
+
}, lastHookTurnEndAt?: Map<string, number>): string[];
|
|
257
269
|
export declare class TranscriptWatcher {
|
|
258
270
|
private readonly tracked;
|
|
259
271
|
private readonly deps;
|
|
@@ -276,11 +288,11 @@ export declare class TranscriptWatcher {
|
|
|
276
288
|
noteHookEvent(sessionId: string, atMs?: number): void;
|
|
277
289
|
tick(): Promise<TranscriptWatchTickResult>;
|
|
278
290
|
/**
|
|
279
|
-
* Hook-covered sessions already emit start
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* event) resets the timer. A hook event shortly after our own
|
|
283
|
-
* SessionEnd hook catching up — do not treat it as resume.
|
|
291
|
+
* Hook-covered sessions already emit start. Still track last activity,
|
|
292
|
+
* fill in turn_end when Stop never did, and enqueue session_end after
|
|
293
|
+
* TRANSCRIPT_SESSION_END_MS of quiet (US-050b / US-051c). Resume (mtime or a
|
|
294
|
+
* later hook event) resets the timer. A hook event shortly after our own
|
|
295
|
+
* emit is the SessionEnd hook catching up — do not treat it as resume.
|
|
284
296
|
*/
|
|
285
297
|
private tickHookCoveredQuietEnd;
|
|
286
298
|
/**
|
|
@@ -292,6 +304,11 @@ export declare class TranscriptWatcher {
|
|
|
292
304
|
* enqueue session_start. Never overwrites a hook-written state.
|
|
293
305
|
*/
|
|
294
306
|
private emitStart;
|
|
307
|
+
/**
|
|
308
|
+
* Seq comes from the shared per-session file hooks also write. Restarting
|
|
309
|
+
* at 1 here is dropped server-side as a duplicate of session_start (US-051c).
|
|
310
|
+
*/
|
|
311
|
+
private seqEnv;
|
|
295
312
|
private emitKind;
|
|
296
313
|
}
|
|
297
314
|
/**
|
|
@@ -26,12 +26,17 @@
|
|
|
26
26
|
* localHarness is no longer written (harness carries claude-desktop on the wire).
|
|
27
27
|
*
|
|
28
28
|
* Hook-covered sessions (durable hook-written state, or a hook event within
|
|
29
|
-
* TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start — hooks own
|
|
30
|
+
* that. The watcher still tracks last activity (transcript mtime or hook
|
|
31
|
+
* events). It enqueues turn_end when hooks never did (Stop often never fires
|
|
32
|
+
* for a live Claude Code session sitting at the prompt; server turnCount
|
|
33
|
+
* stays 0) and session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
|
|
34
|
+
* channel work report has a trigger (US-050b / US-051c). Both events take
|
|
35
|
+
* seq from the shared ~/.hq/work-mesh/seq/<sid> file the hooks use; an
|
|
36
|
+
* in-memory counter restarting at 1 is dropped by the server as a duplicate
|
|
37
|
+
* of session_start. Resume resets the timer. A hook event within
|
|
38
|
+
* TRANSCRIPT_HOOK_COVER_MS of our own emit is the SessionEnd hook catching
|
|
39
|
+
* up and is not treated as resume.
|
|
35
40
|
*/
|
|
36
41
|
import * as fs from "node:fs";
|
|
37
42
|
import * as os from "node:os";
|
|
@@ -44,6 +49,7 @@ import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../../work-con
|
|
|
44
49
|
import { isHookWrittenSessionState, readSessionState, writeSessionState, } from "../../../work-context/state.js";
|
|
45
50
|
import { enqueueSessionEvent } from "../enqueue.js";
|
|
46
51
|
import { isValidSessionId } from "../session-identity.js";
|
|
52
|
+
import { reserveSessionSeq } from "../session-seq.js";
|
|
47
53
|
export const TRANSCRIPT_WATCH_INTERVAL_MS = 30_000;
|
|
48
54
|
/** Ceiling for adaptive backoff after heavy ticks. */
|
|
49
55
|
export const TRANSCRIPT_WATCH_INTERVAL_MAX_MS = 120_000;
|
|
@@ -512,7 +518,7 @@ export function resolveTranscriptRegistration(input) {
|
|
|
512
518
|
* Scan a spool/held jsonl for hook-emitted session ids (not transcript-watch).
|
|
513
519
|
* Reads spool metadata only — never a transcript file.
|
|
514
520
|
*/
|
|
515
|
-
export function noteHookSessionsFromSpoolFile(spoolPath, lastHookEventAt, atMs, tfs = defaultTranscriptFs) {
|
|
521
|
+
export function noteHookSessionsFromSpoolFile(spoolPath, lastHookEventAt, atMs, tfs = defaultTranscriptFs, lastHookTurnEndAt) {
|
|
516
522
|
if (!tfs.existsSync(spoolPath))
|
|
517
523
|
return [];
|
|
518
524
|
const readFile = tfs.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
|
|
@@ -546,6 +552,12 @@ export function noteHookSessionsFromSpoolFile(spoolPath, lastHookEventAt, atMs,
|
|
|
546
552
|
if (prev === undefined || eventAt > prev) {
|
|
547
553
|
lastHookEventAt.set(ev.sessionId, eventAt);
|
|
548
554
|
}
|
|
555
|
+
if (ev.kind === "turn_end" && lastHookTurnEndAt) {
|
|
556
|
+
const prevEnd = lastHookTurnEndAt.get(ev.sessionId);
|
|
557
|
+
if (prevEnd === undefined || eventAt > prevEnd) {
|
|
558
|
+
lastHookTurnEndAt.set(ev.sessionId, eventAt);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
549
561
|
noted.push(ev.sessionId);
|
|
550
562
|
}
|
|
551
563
|
catch {
|
|
@@ -599,6 +611,7 @@ export class TranscriptWatcher {
|
|
|
599
611
|
ended: 0,
|
|
600
612
|
skippedHookCovered: 0,
|
|
601
613
|
skippedStale: 0,
|
|
614
|
+
endedSessionIds: [],
|
|
602
615
|
entriesExamined: 0,
|
|
603
616
|
durationMs: 0,
|
|
604
617
|
capped: false,
|
|
@@ -624,6 +637,7 @@ export class TranscriptWatcher {
|
|
|
624
637
|
ended: 0,
|
|
625
638
|
skippedHookCovered: 0,
|
|
626
639
|
skippedStale: 0,
|
|
640
|
+
endedSessionIds: [],
|
|
627
641
|
entriesExamined: 0,
|
|
628
642
|
durationMs: 0,
|
|
629
643
|
capped: false,
|
|
@@ -708,16 +722,20 @@ export class TranscriptWatcher {
|
|
|
708
722
|
}
|
|
709
723
|
if (track.pendingTurn &&
|
|
710
724
|
nowMs - track.lastChangeAtMs >= TRANSCRIPT_TURN_QUIET_MS) {
|
|
711
|
-
this.emitKind(track, "turn_end", nowMs)
|
|
712
|
-
|
|
713
|
-
|
|
725
|
+
if (this.emitKind(track, "turn_end", nowMs)) {
|
|
726
|
+
track.pendingTurn = false;
|
|
727
|
+
result.turns += 1;
|
|
728
|
+
}
|
|
714
729
|
}
|
|
715
730
|
if (track.started &&
|
|
716
731
|
!track.ended &&
|
|
717
732
|
nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
|
|
718
|
-
this.emitKind(track, "session_end", nowMs)
|
|
719
|
-
|
|
720
|
-
|
|
733
|
+
if (this.emitKind(track, "session_end", nowMs)) {
|
|
734
|
+
track.ended = true;
|
|
735
|
+
track.endedAtMs = nowMs;
|
|
736
|
+
result.ended += 1;
|
|
737
|
+
result.endedSessionIds.push(track.sessionId);
|
|
738
|
+
}
|
|
721
739
|
}
|
|
722
740
|
}
|
|
723
741
|
// Sessions that disappeared from disk: end if still open. Skip when the
|
|
@@ -727,9 +745,12 @@ export class TranscriptWatcher {
|
|
|
727
745
|
if (seenPaths.has(p))
|
|
728
746
|
continue;
|
|
729
747
|
if (track.started && !track.ended) {
|
|
730
|
-
this.emitKind(track, "session_end", nowMs)
|
|
731
|
-
|
|
732
|
-
|
|
748
|
+
if (this.emitKind(track, "session_end", nowMs)) {
|
|
749
|
+
track.ended = true;
|
|
750
|
+
track.endedAtMs = nowMs;
|
|
751
|
+
result.ended += 1;
|
|
752
|
+
result.endedSessionIds.push(track.sessionId);
|
|
753
|
+
}
|
|
733
754
|
}
|
|
734
755
|
this.tracked.delete(p);
|
|
735
756
|
}
|
|
@@ -745,16 +766,18 @@ export class TranscriptWatcher {
|
|
|
745
766
|
}
|
|
746
767
|
}
|
|
747
768
|
/**
|
|
748
|
-
* Hook-covered sessions already emit start
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
* event) resets the timer. A hook event shortly after our own
|
|
752
|
-
* SessionEnd hook catching up — do not treat it as resume.
|
|
769
|
+
* Hook-covered sessions already emit start. Still track last activity,
|
|
770
|
+
* fill in turn_end when Stop never did, and enqueue session_end after
|
|
771
|
+
* TRANSCRIPT_SESSION_END_MS of quiet (US-050b / US-051c). Resume (mtime or a
|
|
772
|
+
* later hook event) resets the timer. A hook event shortly after our own
|
|
773
|
+
* emit is the SessionEnd hook catching up — do not treat it as resume.
|
|
753
774
|
*/
|
|
754
775
|
tickHookCoveredQuietEnd(d, nowMs, result) {
|
|
755
776
|
result.skippedHookCovered += 1;
|
|
756
777
|
const hookAt = this.deps.lastHookEventAt?.get(d.sessionId);
|
|
778
|
+
const hookTurnEndAt = this.deps.lastHookTurnEndAt?.get(d.sessionId);
|
|
757
779
|
const lastActivity = Math.max(d.mtimeMs, hookAt ?? 0);
|
|
780
|
+
const hookTurnCovers = (mtimeMs) => hookTurnEndAt !== undefined && hookTurnEndAt >= mtimeMs;
|
|
758
781
|
let track = this.tracked.get(d.path);
|
|
759
782
|
if (!track) {
|
|
760
783
|
// Cold transcripts stay silent. Hook-covered sessions that already sat
|
|
@@ -772,7 +795,7 @@ export class TranscriptWatcher {
|
|
|
772
795
|
lastChangeAtMs: lastActivity,
|
|
773
796
|
started: true,
|
|
774
797
|
ended: false,
|
|
775
|
-
pendingTurn:
|
|
798
|
+
pendingTurn: !hookTurnCovers(d.mtimeMs),
|
|
776
799
|
seq: 0,
|
|
777
800
|
startRetryAttempts: 0,
|
|
778
801
|
nextStartRetryAtMs: 0,
|
|
@@ -782,6 +805,7 @@ export class TranscriptWatcher {
|
|
|
782
805
|
if (d.mtimeMs > track.lastMtimeMs) {
|
|
783
806
|
track.lastMtimeMs = d.mtimeMs;
|
|
784
807
|
track.lastChangeAtMs = nowMs;
|
|
808
|
+
track.pendingTurn = !hookTurnCovers(d.mtimeMs);
|
|
785
809
|
if (track.ended)
|
|
786
810
|
track.ended = false;
|
|
787
811
|
}
|
|
@@ -795,13 +819,25 @@ export class TranscriptWatcher {
|
|
|
795
819
|
track.ended = false;
|
|
796
820
|
}
|
|
797
821
|
}
|
|
822
|
+
if (hookTurnCovers(track.lastMtimeMs)) {
|
|
823
|
+
track.pendingTurn = false;
|
|
824
|
+
}
|
|
825
|
+
if (track.pendingTurn &&
|
|
826
|
+
nowMs - track.lastChangeAtMs >= TRANSCRIPT_TURN_QUIET_MS) {
|
|
827
|
+
if (this.emitKind(track, "turn_end", nowMs)) {
|
|
828
|
+
track.pendingTurn = false;
|
|
829
|
+
result.turns += 1;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
798
832
|
if (track.started &&
|
|
799
833
|
!track.ended &&
|
|
800
834
|
nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
|
|
801
|
-
this.emitKind(track, "session_end", nowMs)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
835
|
+
if (this.emitKind(track, "session_end", nowMs)) {
|
|
836
|
+
track.ended = true;
|
|
837
|
+
track.endedAtMs = nowMs;
|
|
838
|
+
result.ended += 1;
|
|
839
|
+
result.endedSessionIds.push(track.sessionId);
|
|
840
|
+
}
|
|
805
841
|
}
|
|
806
842
|
}
|
|
807
843
|
/**
|
|
@@ -857,14 +893,38 @@ export class TranscriptWatcher {
|
|
|
857
893
|
track.ended = true;
|
|
858
894
|
return false;
|
|
859
895
|
}
|
|
860
|
-
this.emitKind(track, "session_start", nowMs)
|
|
896
|
+
if (!this.emitKind(track, "session_start", nowMs))
|
|
897
|
+
return false;
|
|
861
898
|
track.started = true;
|
|
862
899
|
track.startRetryAttempts = 0;
|
|
863
900
|
track.nextStartRetryAtMs = 0;
|
|
864
901
|
return true;
|
|
865
902
|
}
|
|
903
|
+
/**
|
|
904
|
+
* Seq comes from the shared per-session file hooks also write. Restarting
|
|
905
|
+
* at 1 here is dropped server-side as a duplicate of session_start (US-051c).
|
|
906
|
+
*/
|
|
907
|
+
seqEnv() {
|
|
908
|
+
const env = { ...(this.deps.env ?? {}) };
|
|
909
|
+
if (!env.WORK_MESH_SEQ_DIR?.trim()) {
|
|
910
|
+
const root = this.deps.workMeshRoot ??
|
|
911
|
+
(this.deps.home
|
|
912
|
+
? path.join(this.deps.home, ".hq", "work-mesh")
|
|
913
|
+
: undefined);
|
|
914
|
+
if (root)
|
|
915
|
+
env.WORK_MESH_SEQ_DIR = path.join(root, "seq");
|
|
916
|
+
}
|
|
917
|
+
return env;
|
|
918
|
+
}
|
|
866
919
|
emitKind(track, kind, nowMs) {
|
|
867
|
-
|
|
920
|
+
let seq;
|
|
921
|
+
try {
|
|
922
|
+
seq = reserveSessionSeq(track.sessionId, 1, this.seqEnv());
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
track.seq = seq;
|
|
868
928
|
const opts = {
|
|
869
929
|
kind,
|
|
870
930
|
sessionId: track.sessionId,
|
|
@@ -872,7 +932,7 @@ export class TranscriptWatcher {
|
|
|
872
932
|
adapterVersion: TRANSCRIPT_ADAPTER_VERSION,
|
|
873
933
|
source: TRANSCRIPT_EVENT_SOURCE,
|
|
874
934
|
at: new Date(nowMs).toISOString(),
|
|
875
|
-
seq
|
|
935
|
+
seq,
|
|
876
936
|
cwd: track.cwd,
|
|
877
937
|
hqRoot: this.deps.hqRoot,
|
|
878
938
|
root: this.deps.workMeshRoot,
|
|
@@ -880,7 +940,13 @@ export class TranscriptWatcher {
|
|
|
880
940
|
now: () => new Date(nowMs),
|
|
881
941
|
...(kind === "session_end" ? { transcriptPath: track.path } : {}),
|
|
882
942
|
};
|
|
883
|
-
|
|
943
|
+
try {
|
|
944
|
+
this.enqueueFn(opts);
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
884
950
|
}
|
|
885
951
|
}
|
|
886
952
|
/**
|
|
@@ -8,11 +8,10 @@ import { spawnSync } from "node:child_process";
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
+
import { SECRET_RE, SECRETS_PATH_RE } from "../../lanes/text-scan.js";
|
|
11
12
|
export const REPORT_SUMMARY_MAX_CHARS = 140;
|
|
12
13
|
export const REPORT_SUMMARY_SCHEMA_MAX = 280;
|
|
13
14
|
export const REPORT_TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
14
|
-
const SECRET_RE = /(?:Bearer\s+[A-Za-z0-9._\-+=/]+)|(?:sk-[A-Za-z0-9]{8,})|(?:ghp_[A-Za-z0-9]{8,})|(?:github_pat_[A-Za-z0-9_]{8,})|(?:xox[baprs]-[A-Za-z0-9-]{8,})|(?:AKIA[0-9A-Z]{8,})|(?:op:\/\/[^\s"]+)/gi;
|
|
15
|
-
const SECRETS_PATH_RE = /(?:^|[\s"'`])((?:~|\/|\.\/|\.\.\/)?(?:[\w.-]+\/)*(?:\.env(?:\.[A-Za-z0-9._-]+)?|secrets(?:\/[\w.-]+)*|id_rsa|credentials(?:\.json)?|\.netrc|\.npmrc))/gi;
|
|
16
15
|
const PR_URL_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+/g;
|
|
17
16
|
export function redactReportSummary(text) {
|
|
18
17
|
let out = text.replace(SECRET_RE, "[redacted]");
|