@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 +9 -0
- package/dist/commands/lanes.js +1 -1
- package/dist/lib/lanes/config.d.ts +2 -0
- package/dist/lib/lanes/config.js +4 -0
- package/dist/lib/lanes/liveness.d.ts +12 -0
- package/dist/lib/lanes/liveness.js +65 -3
- package/dist/lib/lanes/reconcile.d.ts +1 -1
- package/dist/lib/lanes/reconcile.js +45 -17
- package/dist/lib/lanes/types.d.ts +2 -0
- package/package.json +1 -1
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
|
|
package/dist/commands/lanes.js
CHANGED
|
@@ -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: "
|
|
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>;
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 >=
|
|
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"
|
|
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
|
-
|
|
662
|
-
|
|
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) =>
|
|
719
|
-
|
|
720
|
-
|
|
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;
|