@indigoai-us/hq-cli 5.198.0 → 5.198.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,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.198.1] — 2026-09-25
6
+
5
7
  ## [5.198.0] — 2026-09-25
6
8
 
7
9
  ### Added
@@ -25,6 +27,13 @@
25
27
  `result` / `error.message` / `subtype` — instead of the last characters of
26
28
  the JSON tail (`"costUsdTicks" | } | "numTurns" | }`). Log only; never
27
29
  posted to a person.
30
+ - Session questions stay with live or unknown sessions until their configured
31
+ unanswered timeout; only a positive `session_end` advances immediately. When
32
+ there is no Work Mesh entry, hq-cli checks Claude transcript metadata in
33
+ configured account homes without opening transcript contents, using the
34
+ newest matching mtime. Metadata errors remain visible and do not block the
35
+ independent timeout. The timeout defaults to 600 seconds. A running-lane
36
+ answer reports `awaiting_next_tool_call` after its durable dropbox append.
28
37
 
29
38
  ## [5.197.0] — 2026-09-25
30
39
 
@@ -6771,7 +6771,7 @@ function deliverAnswerToAskingLane(ctx, question, choice, at) {
6771
6771
  }
6772
6772
  assertLaneRunsHereForOperatorMutation(ctx, lane, "deliver an answer to");
6773
6773
  appendDropboxMessage(hqRoot, laneId, answerDropboxMessage(question, choice, at, mintMessageId()));
6774
- return { status: "dropped" };
6774
+ return { status: "awaiting_next_tool_call" };
6775
6775
  }
6776
6776
  catch (err) {
6777
6777
  writeAnswerDeliveryFailed(hqRoot, laneId, question.question_id, err, at);
@@ -37,6 +37,8 @@ export declare const DEFAULT_LIVE_WITHIN_MS: number;
37
37
  export declare const DEFAULT_TURN_START_LOST_MS: number;
38
38
  /** Spec §9: no event for 12 hours (and no session_end) → lost. */
39
39
  export declare const DEFAULT_SILENT_LOST_MS: number;
40
+ /** Unanswered session questions move to the next audience after 10 minutes. */
41
+ export declare const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS: number;
40
42
  export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
41
43
  export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
42
44
  export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
@@ -41,6 +41,8 @@ export const DEFAULT_LIVE_WITHIN_MS = 10 * 60 * 1000;
41
41
  export const DEFAULT_TURN_START_LOST_MS = 2 * 60 * 60 * 1000;
42
42
  /** Spec §9: no event for 12 hours (and no session_end) → lost. */
43
43
  export const DEFAULT_SILENT_LOST_MS = 12 * 60 * 60 * 1000;
44
+ /** Unanswered session questions move to the next audience after 10 minutes. */
45
+ export const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS = 10 * 60;
44
46
  export const DEFAULT_LIVENESS_WINDOWS = {
45
47
  live_within_ms: DEFAULT_LIVE_WITHIN_MS,
46
48
  turn_start_lost_ms: DEFAULT_TURN_START_LOST_MS,
@@ -340,6 +342,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
340
342
  min_available_mem_mb: DEFAULT_MIN_AVAILABLE_MEM_MB,
341
343
  },
342
344
  liveness: { ...DEFAULT_LIVENESS_WINDOWS },
345
+ session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
343
346
  };
344
347
  }
345
348
  export function parseLanesConfig(raw, deps = defaultDeps()) {
@@ -380,6 +383,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
380
383
  providers,
381
384
  throttle: parseThrottle(raw.throttle),
382
385
  liveness: parseLiveness(raw.liveness),
386
+ session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
383
387
  };
384
388
  }
385
389
  export function lanesConfigPath(hqRoot) {
@@ -40,6 +40,10 @@ export interface LivenessDeps {
40
40
  existsSync: (file: string) => boolean;
41
41
  mkdirSync: typeof fs.mkdirSync;
42
42
  statSync: (file: string) => fs.Stats;
43
+ lstatSync: (file: string) => fs.Stats;
44
+ readdirSync: (dir: string, options: {
45
+ withFileTypes: true;
46
+ }) => fs.Dirent[];
43
47
  }
44
48
  export declare function defaultLivenessDeps(): LivenessDeps;
45
49
  export declare function defaultSpoolPath(deps?: LivenessDeps): string;
@@ -53,6 +57,14 @@ export declare function livenessIndexPath(hqRoot: string): string;
53
57
  * `session_end` as the last event is `ended` even when recent (fast path).
54
58
  */
55
59
  export declare function classifySessionLiveness(entry: SessionIndexEntry, nowMs: number, presenceConnected?: boolean, windows?: LivenessWindows): LivenessState;
60
+ /**
61
+ * Use recent Claude transcript file activity when a session has no mesh event
62
+ * entry. This reads file metadata only; transcript contents are never opened.
63
+ * If a session transcript exists in more than one configured home, the newest
64
+ * mtime is the activity signal. A stale or absent transcript remains unknown
65
+ * and is not evidence of an end.
66
+ */
67
+ export declare function unindexedClaudeSessionLiveness(sessionId: string, nowMs: number, windows: LivenessWindows, deps?: LivenessDeps, configuredHomes?: readonly string[]): LivenessState | null;
56
68
  export declare function ingestSpoolLine(line: string, sessions: Record<string, SessionIndexEntry>, lineNo: number): void;
57
69
  export declare function buildLivenessIndexFromSpool(spoolContents: string, meta: {
58
70
  spool_path: string;
@@ -37,6 +37,8 @@ export function defaultLivenessDeps() {
37
37
  existsSync: (file) => fs.existsSync(file),
38
38
  mkdirSync: fs.mkdirSync.bind(fs),
39
39
  statSync: (file) => fs.statSync(file),
40
+ lstatSync: (file) => fs.lstatSync(file),
41
+ readdirSync: fs.readdirSync.bind(fs),
40
42
  };
41
43
  }
42
44
  export function defaultSpoolPath(deps = defaultLivenessDeps()) {
@@ -81,6 +83,64 @@ export function classifySessionLiveness(entry, nowMs, presenceConnected = false,
81
83
  return "idle";
82
84
  return "idle";
83
85
  }
86
+ /**
87
+ * Use recent Claude transcript file activity when a session has no mesh event
88
+ * entry. This reads file metadata only; transcript contents are never opened.
89
+ * If a session transcript exists in more than one configured home, the newest
90
+ * mtime is the activity signal. A stale or absent transcript remains unknown
91
+ * and is not evidence of an end.
92
+ */
93
+ export function unindexedClaudeSessionLiveness(sessionId, nowMs, windows, deps = defaultLivenessDeps(), configuredHomes = []) {
94
+ if (sessionId.length === 0 ||
95
+ sessionId.trim() !== sessionId ||
96
+ sessionId === "." ||
97
+ sessionId === ".." ||
98
+ /[\\/\0]/.test(sessionId)) {
99
+ throw new LanesError("session_activity_invalid_id", "Cannot inspect Claude activity for an invalid session id", { session_id: sessionId });
100
+ }
101
+ const homes = [
102
+ deps.env.CLAUDE_CONFIG_DIR?.trim(),
103
+ path.join(deps.homedir(), ".claude"),
104
+ ...configuredHomes,
105
+ ].filter((home) => typeof home === "string" && home.trim() !== "");
106
+ const projectsDirs = [...new Set(homes.map((home) => path.resolve(home, "projects")))];
107
+ let activityAt = null;
108
+ for (const projectsDir of projectsDirs) {
109
+ let projects;
110
+ try {
111
+ projects = deps.readdirSync(projectsDir, { withFileTypes: true });
112
+ }
113
+ catch (err) {
114
+ if (err.code === "ENOENT")
115
+ continue;
116
+ throw new LanesError("session_activity_unreadable", `Cannot inspect Claude project activity: ${err instanceof Error ? err.message : String(err)}`, { path: projectsDir });
117
+ }
118
+ for (const project of projects) {
119
+ if (!project.isDirectory() || project.isSymbolicLink())
120
+ continue;
121
+ const transcriptPath = path.join(projectsDir, project.name, `${sessionId}.jsonl`);
122
+ let transcript;
123
+ try {
124
+ transcript = deps.lstatSync(transcriptPath);
125
+ }
126
+ catch (err) {
127
+ if (err.code === "ENOENT")
128
+ continue;
129
+ throw new LanesError("session_activity_unreadable", `Cannot inspect Claude session activity: ${err instanceof Error ? err.message : String(err)}`, { path: transcriptPath });
130
+ }
131
+ if (!transcript.isFile() || transcript.isSymbolicLink())
132
+ continue;
133
+ if (!Number.isFinite(transcript.mtimeMs)) {
134
+ throw new LanesError("session_activity_unreadable", `Cannot inspect Claude session activity: invalid modification time`, { path: transcriptPath });
135
+ }
136
+ activityAt = Math.max(activityAt ?? Number.NEGATIVE_INFINITY, transcript.mtimeMs);
137
+ }
138
+ }
139
+ if (activityAt === null)
140
+ return null;
141
+ const ageMs = nowMs - activityAt;
142
+ return ageMs >= 0 && ageMs <= windows.live_within_ms ? "live" : null;
143
+ }
84
144
  export function ingestSpoolLine(line, sessions, lineNo) {
85
145
  const trimmed = line.trim();
86
146
  if (!trimmed)
@@ -310,9 +370,11 @@ export function sessionLiveness(hqRoot, sessionId, opts = {}) {
310
370
  const deps = opts.deps ?? defaultLivenessDeps();
311
371
  const index = loadLivenessIndex(hqRoot, opts.spoolPath, deps);
312
372
  const entry = index.sessions[sessionId];
313
- if (!entry)
314
- return null;
315
- const windows = opts.windows ?? loadLanesConfig(hqRoot).liveness;
373
+ const config = loadLanesConfig(hqRoot);
374
+ const windows = opts.windows ?? config.liveness;
375
+ if (!entry) {
376
+ return unindexedClaudeSessionLiveness(sessionId, opts.nowMs ?? deps.now(), windows, deps, Object.values(config.providers.claude.accounts).map((account) => account.home));
377
+ }
316
378
  const presenceConnected = opts.presenceConnected ?? readDaemonPresenceConnected(deps);
317
379
  return classifySessionLiveness(entry, opts.nowMs ?? deps.now(), presenceConnected, windows);
318
380
  }
@@ -86,7 +86,7 @@ export declare function timeoutPath(hqRoot: string, laneId: string): string;
86
86
  export declare function laneRunsHere(lane: LaneRecord, host: ReconcileHost): boolean;
87
87
  export declare function agedAdvanceCount(question: QuestionRecord): number;
88
88
  export declare function currentHopStartedAtMs(question: QuestionRecord): number;
89
- export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number): boolean;
89
+ export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number, ageWindowMs?: number): boolean;
90
90
  export declare function shouldAdvanceAudienceEnded(audience: SeniorRef, liveness: LivenessState | null): boolean;
91
91
  export declare function readTimeoutDeadline(hqRoot: string, laneId: string): {
92
92
  deadline_at: string;
@@ -14,7 +14,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
14
14
  import { MAX_LANE_DEPTH } from "./constants.js";
15
15
  import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
16
16
  import { loadWorkerProfile } from "./profile.js";
17
- import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, } from "./liveness.js";
17
+ import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
18
18
  import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
19
19
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
20
20
  import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
@@ -106,7 +106,7 @@ export function currentHopStartedAtMs(question) {
106
106
  const ms = Date.parse(raw);
107
107
  return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
108
108
  }
109
- export function shouldAgeQuestion(question, audience, nowMs) {
109
+ export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS) {
110
110
  if (question.status !== "pending")
111
111
  return false;
112
112
  if (audience.kind === "user")
@@ -116,14 +116,14 @@ export function shouldAgeQuestion(question, audience, nowMs) {
116
116
  const started = currentHopStartedAtMs(question);
117
117
  if (started === Number.NEGATIVE_INFINITY)
118
118
  return false;
119
- return nowMs - started >= QUESTION_AGE_MS;
119
+ return nowMs - started >= ageWindowMs;
120
120
  }
121
121
  export function shouldAdvanceAudienceEnded(audience, liveness) {
122
122
  if (audience.kind === "user")
123
123
  return false;
124
124
  if (audience.kind !== "session")
125
125
  return false;
126
- return liveness === "ended" || liveness === "lost" || liveness === null;
126
+ return liveness === "ended";
127
127
  }
128
128
  export function readTimeoutDeadline(hqRoot, laneId) {
129
129
  const filePath = timeoutPath(hqRoot, laneId);
@@ -655,12 +655,25 @@ export async function runPassBody(hqRoot, deps) {
655
655
  };
656
656
  const readSessionSenior = deps.readSessionSenior ??
657
657
  ((sessionId) => defaultReadSessionSenior(hqRoot, sessionId));
658
+ const configuredClaudeHomes = Object.values(config.providers.claude.accounts).map((account) => account.home);
659
+ const sessionStateCache = new Map();
658
660
  const sessionState = (sessionId) => {
661
+ if (sessionStateCache.has(sessionId)) {
662
+ return sessionStateCache.get(sessionId) ?? null;
663
+ }
659
664
  const entry = livenessIndex.sessions[sessionId];
660
- if (!entry)
661
- return null;
662
- return classifySessionLiveness(entry, nowMs, presenceConnected, config.liveness);
665
+ if (!entry) {
666
+ const state = unindexedClaudeSessionLiveness(sessionId, nowMs, config.liveness, livenessDeps, configuredClaudeHomes);
667
+ sessionStateCache.set(sessionId, state);
668
+ return state;
669
+ }
670
+ const state = classifySessionLiveness(entry, nowMs, presenceConnected, config.liveness);
671
+ sessionStateCache.set(sessionId, state);
672
+ return state;
663
673
  };
674
+ const questionAgeWindowMs = (audience) => audience.kind === "session"
675
+ ? config.session_question_timeout_secs * 1000
676
+ : QUESTION_AGE_MS;
664
677
  if (deps.afterLoad)
665
678
  await deps.afterLoad();
666
679
  markOverdueLanes(hqRoot, lanes, summary, deps, now);
@@ -680,12 +693,22 @@ export async function runPassBody(hqRoot, deps) {
680
693
  });
681
694
  continue;
682
695
  }
683
- const liveness = audience.kind === "session" && audience.id
684
- ? sessionState(audience.id)
685
- : audience.kind === "user"
686
- ? "live"
687
- : null;
688
696
  try {
697
+ let liveness;
698
+ try {
699
+ liveness =
700
+ audience.kind === "session" && audience.id
701
+ ? sessionState(audience.id)
702
+ : audience.kind === "user"
703
+ ? "live"
704
+ : null;
705
+ }
706
+ catch (err) {
707
+ // A failed metadata probe cannot prove that the session ended, but it
708
+ // must not suppress the independent unanswered-timeout rule below.
709
+ markError(summary, err);
710
+ liveness = null;
711
+ }
689
712
  if (shouldAdvanceAudienceEnded(audience, liveness)) {
690
713
  const nextHop = question.hop + 1;
691
714
  const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
@@ -712,12 +735,17 @@ export async function runPassBody(hqRoot, deps) {
712
735
  }
713
736
  continue;
714
737
  }
715
- if (shouldAgeQuestion(question, audience, nowMs)) {
738
+ if (shouldAgeQuestion(question, audience, nowMs, questionAgeWindowMs(audience))) {
716
739
  const nextHop = question.hop + 1;
717
740
  const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
718
- const result = casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending" &&
719
- agedAdvanceCount(current) < MAX_AGED_ADVANCES &&
720
- shouldAgeQuestion(current, resolveQuestionAudience(current, readLaneSenior, readSessionSenior), nowMs), (current) => {
741
+ const result = casQuestionIf(hqRoot, question.question_id, (current) => {
742
+ if (current.status !== "pending" ||
743
+ agedAdvanceCount(current) >= MAX_AGED_ADVANCES) {
744
+ return false;
745
+ }
746
+ const currentAudience = resolveQuestionAudience(current, readLaneSenior, readSessionSenior);
747
+ return shouldAgeQuestion(current, currentAudience, nowMs, questionAgeWindowMs(currentAudience));
748
+ }, (current) => {
721
749
  const entry = {
722
750
  hop: current.hop + 1,
723
751
  audience: nextAudience,
@@ -748,7 +776,7 @@ export async function runPassBody(hqRoot, deps) {
748
776
  markDirty(summary, {
749
777
  kind: "question",
750
778
  id: question.question_id,
751
- why: `advance failed: ${err instanceof Error ? err.message : String(err)}`,
779
+ why: `advance failed: ${err instanceof LanesError ? `${err.error}: ` : ""}${err instanceof Error ? err.message : String(err)}`,
752
780
  });
753
781
  }
754
782
  }
@@ -706,6 +706,8 @@ export interface LanesConfig {
706
706
  providers: Record<LaneProvider, ProviderConfig>;
707
707
  throttle: ThrottleConfig;
708
708
  liveness: LivenessWindows;
709
+ /** Seconds an unanswered question waits at a session audience before advancing. */
710
+ session_question_timeout_secs: number;
709
711
  }
710
712
  export declare function parseMajorSchema(schemaVersion: string, expectedPrefix: string, expectedMajor: number): void;
711
713
  export declare function parseLaneEnvelope(raw: unknown): LaneEnvelope;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.198.0",
3
+ "version": "5.198.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {