@indigoai-us/hq-cli 5.282.0 → 5.284.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/assets/scaffold/.claude/hooks/validate-policy-frontmatter.sh +485 -0
  3. package/assets/scaffold/core/scripts/eval-trigger.sh +193 -0
  4. package/assets/scaffold/core/scripts/policy-age-report.sh +156 -0
  5. package/assets/scaffold/core/scripts/policy-retire.sh +199 -0
  6. package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +32 -0
  7. package/dist/command-catalog.generated.d.ts +67 -0
  8. package/dist/command-catalog.generated.js +83 -0
  9. package/dist/commands/core-knowledge-prefs.d.ts +10 -0
  10. package/dist/commands/core-knowledge-prefs.js +125 -0
  11. package/dist/commands/core-native-policy-flag.d.ts +9 -0
  12. package/dist/commands/core-native-policy-flag.js +12 -0
  13. package/dist/commands/core-ontology-native.d.ts +0 -1
  14. package/dist/commands/core-ontology-native.js +2 -30
  15. package/dist/commands/core-ontology.d.ts +4 -0
  16. package/dist/commands/core-ontology.js +8 -3
  17. package/dist/commands/core-policy-frontmatter.d.ts +8 -0
  18. package/dist/commands/core-policy-frontmatter.js +52 -0
  19. package/dist/commands/core-policy.d.ts +15 -0
  20. package/dist/commands/core-policy.js +982 -0
  21. package/dist/commands/core.js +14 -1
  22. package/dist/commands/lanes-shared.d.ts +4 -1
  23. package/dist/commands/lanes-shared.js +2 -1
  24. package/dist/commands/lanes.d.ts +8 -0
  25. package/dist/commands/lanes.js +303 -91
  26. package/dist/fast-lanes-list-route.d.ts +8 -0
  27. package/dist/fast-lanes-list-route.js +30 -0
  28. package/dist/fast-lanes-list.d.ts +3 -0
  29. package/dist/fast-lanes-list.js +252 -0
  30. package/dist/fast-scaffold.d.ts +5 -0
  31. package/dist/fast-scaffold.js +10 -0
  32. package/dist/index.js +10 -1
  33. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +24 -1
  34. package/dist/lib/checkpoint-ontology/merge-copies.js +70 -1
  35. package/dist/lib/lanes/flag-keys.d.ts +3 -0
  36. package/dist/lib/lanes/flag-keys.js +3 -0
  37. package/dist/lib/lanes/index.d.ts +1 -1
  38. package/dist/lib/lanes/index.js +1 -1
  39. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
  40. package/dist/lib/lanes/outpost-dispatch.js +14 -2
  41. package/dist/lib/lanes/outpost-hire-cache.d.ts +3 -0
  42. package/dist/lib/lanes/outpost-hire-cache.js +11 -3
  43. package/dist/lib/lanes/outpost-snapshot-cache.d.ts +26 -0
  44. package/dist/lib/lanes/outpost-snapshot-cache.js +215 -0
  45. package/dist/lib/lanes/store.d.ts +4 -0
  46. package/dist/lib/lanes/store.js +39 -0
  47. package/dist/lib/mesh/live/daemon/credentials.js +8 -0
  48. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
  49. package/dist/lib/mesh/live/daemon/work-subscribe.js +143 -5
  50. package/dist/lib/policy-validator.d.ts +2 -0
  51. package/dist/lib/policy-validator.js +136 -0
  52. package/dist/native-fast-route.d.ts +2 -0
  53. package/dist/native-fast-route.js +6 -2
  54. package/dist/utils/contribution-table.d.ts +1 -1
  55. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  export const OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS = 60_000;
5
+ export const LANE_OUTPOST_SNAPSHOT_CACHE_FLAG = "lanes.outpost-snapshot-cache";
5
6
  const CACHE_RELATIVE_PATH = path.join("workspace", "lanes-runs", ".cache", "outpost-hire-summaries.json");
6
7
  export function outpostHireSummaryCachePath(hqRoot) {
7
8
  return path.join(hqRoot, CACHE_RELATIVE_PATH);
@@ -10,6 +11,13 @@ export function outpostHireSummaryCacheKey(outpostId, laneId) {
10
11
  return JSON.stringify([outpostId, laneId]);
11
12
  }
12
13
  export function readOutpostHireSummaryCache(hqRoot, nowMs) {
14
+ return readCache(hqRoot, nowMs, false);
15
+ }
16
+ /** Reads the last validated summaries even after their freshness window expires. */
17
+ export function readOutpostHireSummaryCacheIncludingStale(hqRoot, nowMs) {
18
+ return readCache(hqRoot, nowMs, true);
19
+ }
20
+ function readCache(hqRoot, nowMs, includeStale) {
13
21
  const filePath = outpostHireSummaryCachePath(hqRoot);
14
22
  let contents;
15
23
  try {
@@ -36,7 +44,7 @@ export function readOutpostHireSummaryCache(hqRoot, nowMs) {
36
44
  const entries = new Map();
37
45
  const duplicates = new Set();
38
46
  for (const value of parsed) {
39
- const entry = parseCacheEntry(value, nowMs);
47
+ const entry = parseCacheEntry(value, nowMs, includeStale);
40
48
  if (!entry)
41
49
  continue;
42
50
  const key = outpostHireSummaryCacheKey(entry.outpost_id, entry.lane_id);
@@ -94,7 +102,7 @@ export function writeOutpostHireSummaryCache(hqRoot, updates, nowMs, existingEnt
94
102
  console.warn(`Could not write Outpost hire summary cache at ${filePath}: ${errorText(err)}`);
95
103
  }
96
104
  }
97
- function parseCacheEntry(value, nowMs) {
105
+ function parseCacheEntry(value, nowMs, includeStale = false) {
98
106
  if (value === null || typeof value !== "object" || Array.isArray(value))
99
107
  return undefined;
100
108
  const entry = value;
@@ -120,7 +128,7 @@ function parseCacheEntry(value, nowMs) {
120
128
  const cachedAt = Date.parse(entry.cached_at);
121
129
  if (!Number.isFinite(cachedAt) ||
122
130
  cachedAt > nowMs ||
123
- nowMs - cachedAt > OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS) {
131
+ (!includeStale && nowMs - cachedAt > OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS)) {
124
132
  return undefined;
125
133
  }
126
134
  const hire = `${entry.hire_alive_children}/${entry.hire_grant}`;
@@ -0,0 +1,26 @@
1
+ export declare const OUTPOST_SNAPSHOT_CACHE_TTL_MS = 3000;
2
+ export declare const OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS = 45000;
3
+ export interface OutpostSnapshotCachePolicy {
4
+ ttlMs?: number;
5
+ staleLockMs?: number;
6
+ lockWaitMs?: number;
7
+ pollMs?: number;
8
+ now?: () => number;
9
+ sleep?: (ms: number) => Promise<void>;
10
+ acquireLock?: (lockPath: string, token: string, nowMs: number) => void;
11
+ }
12
+ /** Deduplicates identical remote snapshot queries within one command invocation. */
13
+ export declare class OutpostSnapshotInvocationMemo {
14
+ private readonly requests;
15
+ getOrCreate<T>(outpostId: string, query: unknown, fetch: () => Promise<T>): Promise<T>;
16
+ }
17
+ export declare function outpostLaneListSnapshotKey(outpostId: string, normalizedQuery: unknown): string;
18
+ export declare function outpostSnapshotCachePath(hqRoot: string, key: string): string;
19
+ export declare function getOrFetchOutpostSnapshot<T>(input: {
20
+ hqRoot: string;
21
+ key: string;
22
+ isValue: (value: unknown) => value is T;
23
+ fetch: () => Promise<T>;
24
+ policy?: OutpostSnapshotCachePolicy;
25
+ }): Promise<T>;
26
+ //# sourceMappingURL=outpost-snapshot-cache.d.ts.map
@@ -0,0 +1,215 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { LanesError } from "./errors.js";
5
+ export const OUTPOST_SNAPSHOT_CACHE_TTL_MS = 3_000;
6
+ export const OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS = 45_000;
7
+ const CACHE_RELATIVE_PATH = path.join("workspace", "lanes-runs", ".cache", "outpost-snapshots");
8
+ const CACHE_SCHEMA_VERSION = "hq-lanes-outpost-snapshot-cache.v1";
9
+ const LOCK_POLL_MS = 20;
10
+ const LOCK_WAIT_MS = 60_000;
11
+ /** Deduplicates identical remote snapshot queries within one command invocation. */
12
+ export class OutpostSnapshotInvocationMemo {
13
+ requests = new Map();
14
+ getOrCreate(outpostId, query, fetch) {
15
+ const key = JSON.stringify([outpostId, query]);
16
+ const existing = this.requests.get(key);
17
+ if (existing)
18
+ return existing;
19
+ const request = fetch();
20
+ this.requests.set(key, request);
21
+ return request;
22
+ }
23
+ }
24
+ export function outpostLaneListSnapshotKey(outpostId, normalizedQuery) {
25
+ return JSON.stringify(["lane-list", outpostId, normalizedQuery]);
26
+ }
27
+ export function outpostSnapshotCachePath(hqRoot, key) {
28
+ const digest = createKeyDigest(key);
29
+ return path.join(hqRoot, CACHE_RELATIVE_PATH, `${digest}.json`);
30
+ }
31
+ export async function getOrFetchOutpostSnapshot(input) {
32
+ const policy = input.policy ?? {};
33
+ const now = policy.now ?? Date.now;
34
+ const ttlMs = policy.ttlMs ?? OUTPOST_SNAPSHOT_CACHE_TTL_MS;
35
+ const staleLockMs = policy.staleLockMs ?? OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS;
36
+ const lockWaitMs = policy.lockWaitMs ?? LOCK_WAIT_MS;
37
+ const pollMs = policy.pollMs ?? LOCK_POLL_MS;
38
+ const sleep = policy.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
39
+ const filePath = outpostSnapshotCachePath(input.hqRoot, input.key);
40
+ const cached = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
41
+ if (cached !== undefined)
42
+ return cached;
43
+ const lockPath = `${filePath}.lock`;
44
+ const token = `${process.pid}:${randomBytes(12).toString("hex")}`;
45
+ const startedAt = now();
46
+ const acquireLock = policy.acquireLock ?? createCacheLock;
47
+ while (true) {
48
+ try {
49
+ acquireLock(lockPath, token, now());
50
+ break;
51
+ }
52
+ catch (err) {
53
+ const code = err.code;
54
+ if (code !== "EEXIST") {
55
+ process.stderr.write(`${JSON.stringify({
56
+ event: "lanes.outpost_snapshot_cache_lock_unavailable",
57
+ error: typeof code === "string" ? code : "UnknownError",
58
+ fallback: "uncached",
59
+ })}\n`);
60
+ const value = await input.fetch();
61
+ if (!input.isValue(value)) {
62
+ throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
63
+ }
64
+ return value;
65
+ }
66
+ if (recoverStaleLock(lockPath, staleLockMs, now()))
67
+ continue;
68
+ const afterWait = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
69
+ if (afterWait !== undefined)
70
+ return afterWait;
71
+ if (now() - startedAt >= lockWaitMs) {
72
+ throw new LanesError("outpost_snapshot_cache_lock_timeout", "Timed out waiting for an Outpost snapshot refresh", { path: lockPath });
73
+ }
74
+ await sleep(pollMs);
75
+ }
76
+ }
77
+ try {
78
+ const afterLock = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
79
+ if (afterLock !== undefined)
80
+ return afterLock;
81
+ const value = await input.fetch();
82
+ if (!input.isValue(value)) {
83
+ throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
84
+ }
85
+ writeCache(filePath, input.key, value, now());
86
+ return value;
87
+ }
88
+ finally {
89
+ releaseLock(lockPath, token);
90
+ }
91
+ }
92
+ function createCacheLock(lockPath, token, nowMs) {
93
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
94
+ const lockFd = fs.openSync(lockPath, "wx", 0o600);
95
+ try {
96
+ fs.writeSync(lockFd, `${token}\n${nowMs}\n`);
97
+ }
98
+ catch (err) {
99
+ try {
100
+ fs.closeSync(lockFd);
101
+ }
102
+ catch (closeErr) {
103
+ process.stderr.write(`lanes snapshot cache: lock close failed: ${errorText(closeErr)}\n`);
104
+ }
105
+ releaseLock(lockPath, token);
106
+ throw err;
107
+ }
108
+ fs.closeSync(lockFd);
109
+ }
110
+ function createKeyDigest(key) {
111
+ // A lane-list query can contain lane ids. Keep raw ids out of cache filenames.
112
+ return createHash("sha256").update(key).digest("hex");
113
+ }
114
+ function readFreshCache(filePath, key, nowMs, ttlMs, isValue) {
115
+ let contents;
116
+ try {
117
+ contents = fs.readFileSync(filePath, "utf8");
118
+ }
119
+ catch (err) {
120
+ if (err.code === "ENOENT")
121
+ return undefined;
122
+ process.stderr.write(`lanes snapshot cache: read failed: ${errorText(err)}\n`);
123
+ return undefined;
124
+ }
125
+ let raw;
126
+ try {
127
+ raw = JSON.parse(contents);
128
+ }
129
+ catch (err) {
130
+ process.stderr.write(`lanes snapshot cache: ignoring malformed cache: ${errorText(err)}\n`);
131
+ return undefined;
132
+ }
133
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
134
+ return undefined;
135
+ const entry = raw;
136
+ if (Object.keys(entry).length !== 4 ||
137
+ entry.schema_version !== CACHE_SCHEMA_VERSION ||
138
+ entry.key !== key ||
139
+ !Number.isSafeInteger(entry.cached_at) ||
140
+ entry.cached_at > nowMs ||
141
+ nowMs - entry.cached_at >= ttlMs ||
142
+ !isValue(entry.value))
143
+ return undefined;
144
+ return entry.value;
145
+ }
146
+ function writeCache(filePath, key, value, nowMs) {
147
+ const temporaryPath = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
148
+ try {
149
+ const fd = fs.openSync(temporaryPath, "wx", 0o600);
150
+ try {
151
+ fs.writeFileSync(fd, `${JSON.stringify({
152
+ schema_version: CACHE_SCHEMA_VERSION,
153
+ key,
154
+ cached_at: nowMs,
155
+ value,
156
+ })}\n`);
157
+ fs.fsyncSync(fd);
158
+ }
159
+ finally {
160
+ fs.closeSync(fd);
161
+ }
162
+ fs.renameSync(temporaryPath, filePath);
163
+ }
164
+ catch (err) {
165
+ try {
166
+ fs.unlinkSync(temporaryPath);
167
+ }
168
+ catch (cleanupErr) {
169
+ if (cleanupErr.code !== "ENOENT") {
170
+ process.stderr.write(`lanes snapshot cache: temp cleanup failed: ${errorText(cleanupErr)}\n`);
171
+ }
172
+ }
173
+ // The cache is an optimization; a successful, validated remote result stays usable.
174
+ process.stderr.write(`lanes snapshot cache: write failed: ${errorText(err)}\n`);
175
+ }
176
+ }
177
+ function recoverStaleLock(lockPath, staleLockMs, nowMs) {
178
+ let stat;
179
+ try {
180
+ stat = fs.statSync(lockPath);
181
+ }
182
+ catch (err) {
183
+ return err.code === "ENOENT";
184
+ }
185
+ if (nowMs - stat.mtimeMs <= staleLockMs)
186
+ return false;
187
+ const stalePath = `${lockPath}.stale.${process.pid}.${randomBytes(6).toString("hex")}`;
188
+ try {
189
+ fs.renameSync(lockPath, stalePath);
190
+ fs.unlinkSync(stalePath);
191
+ return true;
192
+ }
193
+ catch (err) {
194
+ const code = err.code;
195
+ if (code === "ENOENT")
196
+ return true;
197
+ throw new LanesError("outpost_snapshot_cache_lock_recovery_failed", `Could not recover a stale Outpost snapshot lock: ${errorText(err)}`, { path: lockPath });
198
+ }
199
+ }
200
+ function releaseLock(lockPath, token) {
201
+ try {
202
+ const contents = fs.readFileSync(lockPath, "utf8");
203
+ if (contents.split("\n", 1)[0] === token)
204
+ fs.unlinkSync(lockPath);
205
+ }
206
+ catch (err) {
207
+ if (err.code !== "ENOENT") {
208
+ process.stderr.write(`lanes snapshot cache: lock release failed: ${errorText(err)}\n`);
209
+ }
210
+ }
211
+ }
212
+ function errorText(err) {
213
+ return err instanceof Error ? err.message : String(err);
214
+ }
215
+ //# sourceMappingURL=outpost-snapshot-cache.js.map
@@ -22,6 +22,8 @@ export interface StoreDeps {
22
22
  unlinkSync: typeof fs.unlinkSync;
23
23
  existsSync: typeof fs.existsSync;
24
24
  readdirSync: typeof fs.readdirSync;
25
+ /** Bulk readers may provide a pre-indexed conflict-sidecar lookup. */
26
+ conflictSidecarPathsFor?: (recordPath: string) => readonly string[];
25
27
  openSync: typeof fs.openSync;
26
28
  closeSync: typeof fs.closeSync;
27
29
  fsyncSync: typeof fs.fsyncSync;
@@ -50,6 +52,8 @@ export declare function mergeLaneRecords(live: LaneRecord, sidecars: readonly La
50
52
  export declare function mergeQuestionRecords(live: QuestionRecord, sidecars: readonly QuestionRecord[]): QuestionRecord;
51
53
  export declare function tryReadLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord | null;
52
54
  export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
55
+ /** Reads every lane while scanning the lane directory for conflict sidecars once. */
56
+ export declare function readAllLanes(hqRoot: string, deps?: StoreDeps): LaneRecord[];
53
57
  export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
54
58
  export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
55
59
  /** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
@@ -954,6 +954,9 @@ export function isConflictSidecarName(recordBasename, name) {
954
954
  return name.startsWith(`${recordBasename}.conflict-`);
955
955
  }
956
956
  function sidecarPathsFor(recordPath, deps) {
957
+ if (deps.conflictSidecarPathsFor) {
958
+ return [...deps.conflictSidecarPathsFor(recordPath)];
959
+ }
957
960
  const dir = path.dirname(recordPath);
958
961
  const base = path.basename(recordPath);
959
962
  if (!deps.existsSync(dir))
@@ -1157,6 +1160,42 @@ export function readLane(hqRoot, laneId, deps = defaultStoreDeps()) {
1157
1160
  throw new LaneNotFoundError("lane", laneId);
1158
1161
  return record;
1159
1162
  }
1163
+ /** Reads every lane while scanning the lane directory for conflict sidecars once. */
1164
+ export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
1165
+ const laneIds = listLaneIds(hqRoot, deps);
1166
+ if (laneIds.length === 0)
1167
+ return [];
1168
+ const laneDir = path.join(lanesStoreRoot(hqRoot), LANE_RECORDS_DIR);
1169
+ let names;
1170
+ try {
1171
+ names = deps.readdirSync(laneDir);
1172
+ }
1173
+ catch (err) {
1174
+ if (err.code === "ENOENT")
1175
+ names = [];
1176
+ else
1177
+ throw err;
1178
+ }
1179
+ const sidecarsByRecord = new Map();
1180
+ for (const name of names) {
1181
+ if (typeof name !== "string")
1182
+ continue;
1183
+ const marker = name.indexOf(".json.conflict-");
1184
+ if (marker < 0 || name.startsWith("."))
1185
+ continue;
1186
+ const recordBasename = name.slice(0, marker + ".json".length);
1187
+ if (!isConflictSidecarName(recordBasename, name))
1188
+ continue;
1189
+ const sidecars = sidecarsByRecord.get(recordBasename) ?? [];
1190
+ sidecars.push(path.join(laneDir, name));
1191
+ sidecarsByRecord.set(recordBasename, sidecars);
1192
+ }
1193
+ const indexedDeps = {
1194
+ ...deps,
1195
+ conflictSidecarPathsFor: (recordPath) => sidecarsByRecord.get(path.basename(recordPath)) ?? [],
1196
+ };
1197
+ return laneIds.map((laneId) => readLane(hqRoot, laneId, indexedDeps));
1198
+ }
1160
1199
  export function tryReadQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
1161
1200
  const filePath = questionRecordPath(hqRoot, questionId);
1162
1201
  return readMergedRecord(filePath, parseQuestionRecord, mergeQuestionRecords, deps);
@@ -562,13 +562,21 @@ export function normalizePersonalRealtimeBundle(raw) {
562
562
  }
563
563
  if (!topics.dm && typeof r.topic === "string")
564
564
  topics.dm = r.topic;
565
+ const agentInboxTopic = typeof r.agentInboxTopic === "string" ? r.agentInboxTopic.trim() : "";
565
566
  const actorUid = (typeof r.principalUid === "string" && /^(?:prs|agt)_[A-Za-z0-9]+$/.test(r.principalUid)
566
567
  ? r.principalUid
567
568
  : undefined) ||
568
569
  actorUidFromPersonalTopic(topics.dm) ||
570
+ actorUidFromPersonalTopic(agentInboxTopic) ||
569
571
  "";
570
572
  if (!actorUid)
571
573
  throw new Error("contract-2 vend could not derive actorUid");
574
+ // hq-pro advertises the agent's own inbox topic as a top-level capability.
575
+ // Keep it only for the exact actor-owned topic; it reuses this personal MQTT
576
+ // session and never grants the subscriber a topic supplied by another actor.
577
+ if (actorUid.startsWith("agt_") && agentInboxTopic === `hq/${actorUid}/inbox`) {
578
+ topics.inbox = agentInboxTopic;
579
+ }
572
580
  // Only keep topics that belong to the vended principal.
573
581
  for (const [k, v] of Object.entries(topics)) {
574
582
  if (actorUidFromPersonalTopic(v) !== actorUid)
@@ -14,6 +14,7 @@ export interface WorkCacheMqtt {
14
14
  export interface WorkCacheSubscriber {
15
15
  stop: () => Promise<void>;
16
16
  }
17
+ export declare const AGENT_INBOX_PUSH_STATE_BASENAME = "agent-inbox-push.json";
17
18
  export declare function startWorkCacheSubscriber(opts: {
18
19
  meshRoot: string;
19
20
  fetchCredentials?: PersonalCredentialsFetcher;
@@ -2,10 +2,57 @@
2
2
  * Contract-2 subscriber for the personal work topic.
3
3
  * Owns live-cache.json writes. Presence publish stays on PresenceClient.
4
4
  */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
5
7
  import { createPersonalRealtimeFetcher, } from "./credentials.js";
6
8
  import { presignIotWssUrl } from "./presign.js";
7
9
  import { applyProjectViewPush, parseProjectViewPush } from "./live-cache.js";
8
10
  import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
11
+ export const AGENT_INBOX_PUSH_STATE_BASENAME = "agent-inbox-push.json";
12
+ const INBOX_PUSH_HEARTBEAT_MS = 5_000;
13
+ function readInboxWakeSequence(meshRoot, onInfo) {
14
+ const target = path.join(meshRoot, AGENT_INBOX_PUSH_STATE_BASENAME);
15
+ try {
16
+ const info = fs.lstatSync(target);
17
+ if (!info.isFile() || info.isSymbolicLink()) {
18
+ onInfo?.("agent inbox push state read ignored (not a regular file)");
19
+ return 0;
20
+ }
21
+ const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
22
+ if (Number.isSafeInteger(parsed.wakeSequence) && Number(parsed.wakeSequence) >= 0) {
23
+ return Number(parsed.wakeSequence);
24
+ }
25
+ onInfo?.("agent inbox push state read ignored (invalid wake sequence)");
26
+ return 0;
27
+ }
28
+ catch (error) {
29
+ if (error.code !== "ENOENT") {
30
+ const name = error instanceof Error ? error.name : "UnknownError";
31
+ onInfo?.(`agent inbox push state read failed (${name})`);
32
+ }
33
+ return 0;
34
+ }
35
+ }
36
+ function writeInboxPushState(meshRoot, state) {
37
+ fs.mkdirSync(meshRoot, { recursive: true, mode: 0o700 });
38
+ const target = path.join(meshRoot, AGENT_INBOX_PUSH_STATE_BASENAME);
39
+ const temporary = `${target}.${process.pid}.tmp`;
40
+ try {
41
+ fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
42
+ fs.chmodSync(temporary, 0o600);
43
+ fs.renameSync(temporary, target);
44
+ }
45
+ catch (error) {
46
+ try {
47
+ fs.unlinkSync(temporary);
48
+ }
49
+ catch (cleanupError) {
50
+ if (cleanupError.code !== "ENOENT")
51
+ throw cleanupError;
52
+ }
53
+ throw error;
54
+ }
55
+ }
9
56
  export async function startWorkCacheSubscriber(opts) {
10
57
  const fetchCredentials = opts.fetchCredentials ??
11
58
  (opts.token
@@ -28,10 +75,48 @@ export async function startWorkCacheSubscriber(opts) {
28
75
  let reconnectLogged = false;
29
76
  let attempt = 0;
30
77
  let timer = null;
78
+ let heartbeatTimer = null;
31
79
  let generation = 0;
80
+ let inboxSubscribed = false;
81
+ // Carry the sequence forward from the last state marker so a daemon restart
82
+ // cannot reuse a value the watcher has already consumed.
83
+ let wakeSequence = readInboxWakeSequence(opts.meshRoot, opts.onInfo);
84
+ const clearInboxHeartbeat = () => {
85
+ if (heartbeatTimer !== null)
86
+ timers.clearTimeout(heartbeatTimer);
87
+ heartbeatTimer = null;
88
+ };
89
+ const persistInboxState = (connected) => {
90
+ try {
91
+ writeInboxPushState(opts.meshRoot, {
92
+ connected,
93
+ heartbeatAtMs: now().getTime(),
94
+ wakeSequence,
95
+ });
96
+ }
97
+ catch (error) {
98
+ const name = error instanceof Error ? error.name : "UnknownError";
99
+ opts.onInfo?.(`agent inbox push state write failed (${name})`);
100
+ }
101
+ };
102
+ const scheduleInboxHeartbeat = () => {
103
+ if (stopped || !inboxSubscribed)
104
+ return;
105
+ clearInboxHeartbeat();
106
+ heartbeatTimer = timers.setTimeout(() => {
107
+ heartbeatTimer = null;
108
+ if (!stopped && inboxSubscribed) {
109
+ persistInboxState(true);
110
+ scheduleInboxHeartbeat();
111
+ }
112
+ }, INBOX_PUSH_HEARTBEAT_MS);
113
+ };
32
114
  const connect = async () => {
33
115
  if (stopped)
34
116
  return;
117
+ inboxSubscribed = false;
118
+ clearInboxHeartbeat();
119
+ persistInboxState(false);
35
120
  const gen = ++generation;
36
121
  let bundle;
37
122
  try {
@@ -44,8 +129,12 @@ export async function startWorkCacheSubscriber(opts) {
44
129
  if (stopped || gen !== generation)
45
130
  return;
46
131
  const topic = bundle.topics.work;
47
- if (!topic) {
48
- opts.onInfo?.("work cache subscribe skipped: credentials have no work topic");
132
+ const inboxTopic = bundle.actorUid.startsWith("agt_") &&
133
+ bundle.topics.inbox === `hq/${bundle.actorUid}/inbox`
134
+ ? bundle.topics.inbox
135
+ : undefined;
136
+ if (!topic && !inboxTopic) {
137
+ opts.onInfo?.("work cache subscribe skipped: credentials have no supported topic");
49
138
  return;
50
139
  }
51
140
  const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, now());
@@ -60,12 +149,55 @@ export async function startWorkCacheSubscriber(opts) {
60
149
  if (stopped || gen !== generation)
61
150
  return;
62
151
  reconnectLogged = false;
63
- attempt = 0;
64
- next.subscribe(topic, { qos: 1 }, () => undefined);
152
+ let pendingSubscriptions = Number(Boolean(topic)) + Number(Boolean(inboxTopic));
153
+ let subscriptionFailed = false;
154
+ const finishSubscription = (err) => {
155
+ if (err) {
156
+ if (subscriptionFailed)
157
+ return;
158
+ subscriptionFailed = true;
159
+ schedule(err);
160
+ next.end(true);
161
+ return;
162
+ }
163
+ if (subscriptionFailed)
164
+ return;
165
+ pendingSubscriptions -= 1;
166
+ if (pendingSubscriptions === 0)
167
+ attempt = 0;
168
+ };
169
+ if (topic) {
170
+ next.subscribe(topic, { qos: 1 }, (err) => finishSubscription(err));
171
+ }
172
+ if (inboxTopic) {
173
+ next.subscribe(inboxTopic, { qos: 1 }, (err) => {
174
+ if (stopped || gen !== generation)
175
+ return;
176
+ if (err) {
177
+ inboxSubscribed = false;
178
+ clearInboxHeartbeat();
179
+ persistInboxState(false);
180
+ opts.onInfo?.("agent inbox push subscribe failed");
181
+ finishSubscription(err);
182
+ return;
183
+ }
184
+ inboxSubscribed = true;
185
+ persistInboxState(true);
186
+ scheduleInboxHeartbeat();
187
+ finishSubscription();
188
+ });
189
+ }
65
190
  });
66
191
  next.on("message", (_topic, payload) => {
67
192
  if (stopped || gen !== generation)
68
193
  return;
194
+ if (inboxTopic && _topic === inboxTopic && inboxSubscribed) {
195
+ // A topic message is a doorbell only. Never parse, persist, or log its
196
+ // content; the authenticated inbox GET remains the source of truth.
197
+ wakeSequence += 1;
198
+ persistInboxState(true);
199
+ return;
200
+ }
69
201
  if (_topic !== topic)
70
202
  return;
71
203
  const push = parseProjectViewPush(payload);
@@ -79,11 +211,14 @@ export async function startWorkCacheSubscriber(opts) {
79
211
  next.on("close", () => {
80
212
  if (stopped || gen !== generation)
81
213
  return;
214
+ inboxSubscribed = false;
215
+ clearInboxHeartbeat();
216
+ persistInboxState(false);
82
217
  schedule();
83
218
  });
84
219
  };
85
220
  const schedule = (err) => {
86
- if (stopped)
221
+ if (stopped || timer !== null)
87
222
  return;
88
223
  if (!reconnectLogged) {
89
224
  reconnectLogged = true;
@@ -106,6 +241,9 @@ export async function startWorkCacheSubscriber(opts) {
106
241
  generation += 1;
107
242
  if (timer !== null)
108
243
  timers.clearTimeout(timer);
244
+ clearInboxHeartbeat();
245
+ inboxSubscribed = false;
246
+ persistInboxState(false);
109
247
  const current = client;
110
248
  client = null;
111
249
  if (!current)
@@ -0,0 +1,2 @@
1
+ export declare function policyValidatorDiagnostic(content: string, filePath: string, env: NodeJS.ProcessEnv): string | undefined;
2
+ //# sourceMappingURL=policy-validator.d.ts.map