@indigoai-us/hq-cli 5.194.1 → 5.195.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 CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.195.1] — 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - A checkpoint run from inside an HQ lane no longer passes the lane's identity
10
+ (`HQ_LANE_ID`, `HQ_PROJECT_ID`, `HQ_STORY_ID`, `HQ_POLICY_WORKER_DIR`) to the
11
+ background sibling. Before, the lane drop-box hook delivered the lane's
12
+ operator messages to the sibling instead of the lane.
13
+
14
+ ## [5.195.0] — 2026-09-25
15
+
16
+ ### Fixed
17
+
18
+ - Work Mesh transcript-watch quiet `session_end` for hook-covered sessions now
19
+ uses the shared sequence and counts hook turns (#818).
20
+
5
21
  ## [5.194.1] — 2026-09-25
6
22
 
7
23
  ### Fixed
@@ -81,6 +81,8 @@ export declare function autoBackendPreference(): SpawnableBackend[];
81
81
  * (Claude Code does) agrees with a host that does not.
82
82
  */
83
83
  export declare function siblingEnabledBySetting(liveRoot: string): boolean;
84
+ /** The sibling's environment: the caller's, minus lane identity. */
85
+ export declare function siblingEnv(base: NodeJS.ProcessEnv, backend: SpawnableBackend): NodeJS.ProcessEnv;
84
86
  export declare function siblingArgs(backend: SpawnableBackend, prompt: string): string[];
85
87
  /** Attach the native checkpoint command to the hidden `hq core` group. */
86
88
  export declare function registerCoreCheckpointCommand(core: Command): void;
@@ -780,6 +780,24 @@ function resolveBackend(requested, liveRoot) {
780
780
  // failure is a weaker signal than running no maintenance sibling at all.
781
781
  return available.find((name) => !crashed.has(name)) ?? available[0];
782
782
  }
783
+ /**
784
+ * Lane identity a checkpoint caller may carry. The sibling is HQ maintenance,
785
+ * not the lane: with HQ_LANE_ID set, the lane drop-box hook hands the lane's
786
+ * operator messages to the sibling, and the lane never sees them.
787
+ */
788
+ const LANE_ENV_KEYS = ["HQ_LANE_ID", "HQ_PROJECT_ID", "HQ_STORY_ID", "HQ_POLICY_WORKER_DIR"];
789
+ /** The sibling's environment: the caller's, minus lane identity. */
790
+ export function siblingEnv(base, backend) {
791
+ const vars = { ...base };
792
+ for (const name of LANE_ENV_KEYS)
793
+ delete vars[name];
794
+ // This prevents the scaffold Stop hook from recursively launching a
795
+ // sibling when the background agent itself finishes a turn.
796
+ vars.HQ_CHECKPOINT_SIBLING = "1";
797
+ if (backend === "claude")
798
+ vars.CLAUDE_EFFORT = CLAUDE_SIBLING_EFFORT;
799
+ return vars;
800
+ }
783
801
  export function siblingArgs(backend, prompt) {
784
802
  switch (backend) {
785
803
  case "claude":
@@ -978,13 +996,7 @@ function startSibling(liveRoot, input, threadPath, backend) {
978
996
  detached: true,
979
997
  cwd: liveRoot,
980
998
  stdio: ["ignore", logFd, logFd],
981
- // This prevents the scaffold Stop hook from recursively launching a
982
- // sibling when the background agent itself finishes a turn.
983
- env: {
984
- ...process.env,
985
- HQ_CHECKPOINT_SIBLING: "1",
986
- ...(backend === "claude" ? { CLAUDE_EFFORT: CLAUDE_SIBLING_EFFORT } : {}),
987
- },
999
+ env: siblingEnv(process.env, backend),
988
1000
  });
989
1001
  }
990
1002
  finally {
@@ -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 / turn_end —
30
- * hooks own those. The watcher still tracks last activity (transcript mtime or
31
- * hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
32
- * quiet so the channel work report has a trigger (US-050b). Resume resets the
33
- * timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
34
- * SessionEnd hook catching up and is not treated as resume.
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/turns. Still track last activity
280
- * and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
281
- * channel work report has a trigger (US-050b). Resume (mtime or a later hook
282
- * event) resets the timer. A hook event shortly after our own emit is the
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 / turn_end —
30
- * hooks own those. The watcher still tracks last activity (transcript mtime or
31
- * hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
32
- * quiet so the channel work report has a trigger (US-050b). Resume resets the
33
- * timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
34
- * SessionEnd hook catching up and is not treated as resume.
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
- track.pendingTurn = false;
713
- result.turns += 1;
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
- track.ended = true;
720
- result.ended += 1;
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
- track.ended = true;
732
- result.ended += 1;
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/turns. Still track last activity
749
- * and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
750
- * channel work report has a trigger (US-050b). Resume (mtime or a later hook
751
- * event) resets the timer. A hook event shortly after our own emit is the
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: false,
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
- track.ended = true;
803
- track.endedAtMs = nowMs;
804
- result.ended += 1;
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
- track.seq += 1;
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: track.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
- this.enqueueFn(opts);
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]");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.194.1",
3
+ "version": "5.195.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {