@indigoai-us/hq-cli 5.108.24 → 5.108.26

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 (36) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/commands/files.d.ts +11 -0
  3. package/dist/commands/files.js +206 -30
  4. package/dist/commands/integrations-api.d.ts +15 -0
  5. package/dist/commands/integrations-connect.js +84 -3
  6. package/dist/commands/integrations-oauth.js +62 -3
  7. package/dist/commands/mcp-registration.d.ts +17 -7
  8. package/dist/commands/mcp-registration.js +16 -27
  9. package/dist/commands/mesh.js +174 -50
  10. package/dist/commands/pack-install.js +5 -5
  11. package/dist/commands/secrets.d.ts +7 -0
  12. package/dist/commands/secrets.js +26 -2
  13. package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
  14. package/dist/lib/mesh/live/backfill-held.js +95 -13
  15. package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
  16. package/dist/lib/mesh/live/daemon/doctor.js +41 -10
  17. package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
  18. package/dist/lib/mesh/live/daemon/mode.js +88 -0
  19. package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
  20. package/dist/lib/mesh/live/daemon/run.js +39 -28
  21. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  22. package/dist/lib/mesh/live/emit-client.d.ts +99 -0
  23. package/dist/lib/mesh/live/emit-client.js +193 -0
  24. package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
  25. package/dist/lib/mesh/live/emit-evidence.js +77 -0
  26. package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
  27. package/dist/lib/mesh/live/emit-replay.js +157 -0
  28. package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
  29. package/dist/lib/mesh/live/emit-retry.js +79 -0
  30. package/dist/lib/mesh/live/emit.d.ts +54 -0
  31. package/dist/lib/mesh/live/emit.js +153 -0
  32. package/dist/lib/narrow-hint-banner.d.ts +3 -7
  33. package/dist/lib/narrow-hint-banner.js +13 -34
  34. package/dist/lib/plan-limit-nag.d.ts +0 -3
  35. package/dist/lib/plan-limit-nag.js +10 -20
  36. package/package.json +1 -1
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Direct-emit client: POST /v1/mesh/events (owner decision 2026-09-08).
3
+ *
4
+ * Hooks post each Work Mesh event straight to the server over HTTPS with the
5
+ * caller's normal HQ bearer token (person login or fleet agent token). The
6
+ * server persists, attributes (server-side), and fans out over MQTT. There is
7
+ * no local spool, held queue, or per-person STS vend on the emit path.
8
+ *
9
+ * Field names for the endpoint + per-event status vocabulary follow
10
+ * companies/indigo/projects/work-mesh-live/contracts/direct-emit-v1.md; this
11
+ * client tolerates minor shape drift (unknown keys ignored) and treats an
12
+ * unparseable 2xx as "retain and retry" rather than pretend-posted.
13
+ */
14
+ import { vaultApiFetch } from "../../../utils/vault-api.js";
15
+ import { DEFAULT_VAULT_API_URL } from "../../../utils/cognito-session.js";
16
+ export const MESH_EVENTS_PATH = "/v1/mesh/events";
17
+ /** Max events per POST (server batches; keep parity with legacy 100). */
18
+ export const MESH_EVENTS_BATCH_MAX = 100;
19
+ export function classifyEmitResponse(status, networkError = false) {
20
+ if (networkError || status === 0) {
21
+ return { ok: false, retryable: true, networkError: true };
22
+ }
23
+ if (status >= 200 && status < 300)
24
+ return { ok: true, retryable: false };
25
+ // 404/408/425/429 + 5xx are transient (route not yet deployed, throttle,
26
+ // server fault) — RETAIN and retry, never drop. A 404 here matters: replaying
27
+ // the legacy backlog before the server route ships must not discard events.
28
+ if (status === 404 ||
29
+ status === 408 ||
30
+ status === 425 ||
31
+ status === 429 ||
32
+ status >= 500) {
33
+ return { ok: false, retryable: true };
34
+ }
35
+ // Real client/auth errors (400/401/403/413) → terminal (never retried).
36
+ return { ok: false, retryable: false };
37
+ }
38
+ /**
39
+ * Parse the batch response into per-event statuses. Returns null when the body
40
+ * is not an object (caller should retain events rather than treat as posted).
41
+ * Accepts either { results: [{eventId,status,code}] } or
42
+ * { accepted:[ids], unassigned:[ids], rejected:[{eventId,code}] }.
43
+ */
44
+ export function parseEmitResults(body) {
45
+ if (!body || typeof body !== "object" || Array.isArray(body))
46
+ return null;
47
+ const rec = body;
48
+ if (Array.isArray(rec.results)) {
49
+ const out = [];
50
+ for (const row of rec.results) {
51
+ if (!row || typeof row !== "object" || Array.isArray(row))
52
+ continue;
53
+ const r = row;
54
+ const eventId = typeof r.eventId === "string" ? r.eventId.trim() : "";
55
+ if (!eventId)
56
+ continue;
57
+ const status = normalizeStatus(r.status);
58
+ if (!status)
59
+ continue;
60
+ const res = { eventId, status };
61
+ if (typeof r.reason === "string" && r.reason.trim())
62
+ res.reason = r.reason.trim();
63
+ else if (typeof r.code === "string" && r.code.trim())
64
+ res.reason = r.code.trim();
65
+ if (typeof r.companyUid === "string" && r.companyUid.trim()) {
66
+ res.companyUid = r.companyUid.trim();
67
+ }
68
+ out.push(res);
69
+ }
70
+ return out;
71
+ }
72
+ // Fallback shape: parallel arrays.
73
+ const out = [];
74
+ for (const id of asStringArray(rec.accepted))
75
+ out.push({ eventId: id, status: "accepted" });
76
+ for (const id of asStringArray(rec.unassigned))
77
+ out.push({ eventId: id, status: "unassigned" });
78
+ if (Array.isArray(rec.rejected)) {
79
+ for (const row of rec.rejected) {
80
+ if (typeof row === "string" && row.trim()) {
81
+ out.push({ eventId: row.trim(), status: "rejected" });
82
+ }
83
+ else if (row && typeof row === "object" && !Array.isArray(row)) {
84
+ const r = row;
85
+ const eventId = typeof r.eventId === "string" ? r.eventId.trim() : "";
86
+ if (!eventId)
87
+ continue;
88
+ const res = { eventId, status: "rejected" };
89
+ if (typeof r.reason === "string" && r.reason.trim())
90
+ res.reason = r.reason.trim();
91
+ else if (typeof r.code === "string" && r.code.trim())
92
+ res.reason = r.code.trim();
93
+ out.push(res);
94
+ }
95
+ }
96
+ }
97
+ return out.length > 0 ? out : null;
98
+ }
99
+ function normalizeStatus(v) {
100
+ if (typeof v !== "string")
101
+ return null;
102
+ const s = v.trim().toLowerCase();
103
+ if (s === "accepted" || s === "unassigned" || s === "rejected")
104
+ return s;
105
+ return null;
106
+ }
107
+ function asStringArray(v) {
108
+ if (!Array.isArray(v))
109
+ return [];
110
+ return v.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
111
+ }
112
+ /**
113
+ * Build a poster that uses vaultApiFetch + the caller's bearer token.
114
+ * `post` override is for tests (never hits the network).
115
+ */
116
+ export function createEmitPoster(opts) {
117
+ return async (events) => {
118
+ const body = { events };
119
+ try {
120
+ if (opts.post) {
121
+ const res = await opts.post(MESH_EVENTS_PATH, body);
122
+ return { status: res.status, body: res.body, ...classifyEmitResponse(res.status) };
123
+ }
124
+ const res = await vaultApiFetch({
125
+ method: "POST",
126
+ path: MESH_EVENTS_PATH,
127
+ token: opts.token,
128
+ baseUrl: opts.baseUrl ?? DEFAULT_VAULT_API_URL,
129
+ body: body,
130
+ });
131
+ const text = await res.text();
132
+ let parsed = {};
133
+ if (text) {
134
+ try {
135
+ parsed = JSON.parse(text);
136
+ }
137
+ catch {
138
+ parsed = { raw: text };
139
+ }
140
+ }
141
+ return { status: res.status, body: parsed, ...classifyEmitResponse(res.status) };
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ // Auth hard-fail: 401/403 is not retryable (bad token, not a blip).
146
+ if (/^401\b|^403\b|unauthorized|forbidden/i.test(message)) {
147
+ return { status: 401, body: { error: message }, ok: false, retryable: false };
148
+ }
149
+ return { status: 0, body: { error: message }, ...classifyEmitResponse(0, true) };
150
+ }
151
+ };
152
+ }
153
+ function keep(v) {
154
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
155
+ }
156
+ /**
157
+ * Build a MeshEmitEvent, omitting empty optionals and dropping the evidence
158
+ * object entirely when it carries nothing. Never includes server-owned fields
159
+ * (companyUid/actorUid/contextStatus) — those would draw a 403 (contract §2.1).
160
+ */
161
+ export function buildMeshEmitEvent(input) {
162
+ const event = {
163
+ v: 1,
164
+ eventId: input.eventId,
165
+ kind: input.kind,
166
+ sessionId: input.sessionId,
167
+ harness: input.harness,
168
+ adapterVersion: input.adapterVersion,
169
+ at: input.at,
170
+ seq: input.seq,
171
+ };
172
+ const runtimeVersion = keep(input.runtimeVersion);
173
+ if (runtimeVersion)
174
+ event.runtimeVersion = runtimeVersion;
175
+ if (input.source)
176
+ event.source = input.source;
177
+ const taskId = keep(input.taskId);
178
+ if (taskId)
179
+ event.taskId = taskId;
180
+ if (input.status)
181
+ event.status = input.status;
182
+ const reason = keep(input.reason);
183
+ if (reason)
184
+ event.reason = reason;
185
+ const summary = keep(input.summary);
186
+ if (summary)
187
+ event.summary = summary;
188
+ if (input.evidence && Object.keys(input.evidence).length > 0) {
189
+ event.evidence = input.evidence;
190
+ }
191
+ return event;
192
+ }
193
+ //# sourceMappingURL=emit-client.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client-side attribution evidence for direct-emit (owner decision 2026-09-08:
3
+ * "sessions post directly; server attributes"). The hook gathers raw signals of
4
+ * the work — cwd, the files a tool touched, the enclosing repo, the session's
5
+ * bound company_slug, and any reported project/task — and the server decides the
6
+ * company. The client NEVER decides; it only reports evidence.
7
+ *
8
+ * Pure apart from reading the session meta (company_slug) and the enclosing git
9
+ * work-tree off disk. Never reads prompts/tokens; never hits the network.
10
+ */
11
+ /** Evidence fields carried on each emitted event (contract direct-emit-v1). */
12
+ export interface EmitEvidence {
13
+ cwd?: string;
14
+ hqRoot?: string;
15
+ /** The session's explicitly bound company (meta company_slug). */
16
+ companySlug?: string;
17
+ /** Distinct file paths the session's tool calls touched (absolute). */
18
+ touchedPaths?: string[];
19
+ /** Enclosing repo work-tree root of the cwd / touched files. */
20
+ repoPath?: string;
21
+ project?: string;
22
+ task?: string;
23
+ }
24
+ export interface BuildEmitEvidenceInput {
25
+ sessionId: string;
26
+ hqRoot?: string;
27
+ cwd?: string;
28
+ /** Raw touched paths from the tool payload (file_path, edits, notebook_path…). */
29
+ touchedPaths?: Array<string | undefined | null>;
30
+ /** Explicit repo path override (else derived from cwd / touched files). */
31
+ repoPath?: string;
32
+ /** Explicit bound company (else read from the session meta). */
33
+ companySlug?: string;
34
+ project?: string;
35
+ task?: string;
36
+ /** Injectable meta reader (tests). */
37
+ readMeta?: (sessionId: string, hqRoot: string | undefined) => string | undefined;
38
+ /** Injectable work-tree finder (tests). */
39
+ findRepo?: (startDir: string) => string | null;
40
+ }
41
+ /** Dedupe absolute-ish paths, preserving first-seen order. */
42
+ export declare function normalizeTouchedPaths(paths: Array<string | undefined | null>): string[];
43
+ /**
44
+ * Assemble the evidence bag for one event. companySlug falls back to the session
45
+ * meta; repoPath falls back to the enclosing git work-tree of the cwd (or the
46
+ * first touched file). All fields are omitted when empty.
47
+ */
48
+ export declare function buildEmitEvidence(input: BuildEmitEvidenceInput): EmitEvidence;
49
+ //# sourceMappingURL=emit-evidence.d.ts.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Client-side attribution evidence for direct-emit (owner decision 2026-09-08:
3
+ * "sessions post directly; server attributes"). The hook gathers raw signals of
4
+ * the work — cwd, the files a tool touched, the enclosing repo, the session's
5
+ * bound company_slug, and any reported project/task — and the server decides the
6
+ * company. The client NEVER decides; it only reports evidence.
7
+ *
8
+ * Pure apart from reading the session meta (company_slug) and the enclosing git
9
+ * work-tree off disk. Never reads prompts/tokens; never hits the network.
10
+ */
11
+ import * as path from "node:path";
12
+ import { readMetaCompanySlug } from "../../work-context/company.js";
13
+ import { findWorkTreeRoot } from "../../work-context/repo-remote.js";
14
+ function clean(v) {
15
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
16
+ }
17
+ /** Dedupe absolute-ish paths, preserving first-seen order. */
18
+ export function normalizeTouchedPaths(paths) {
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const p of paths) {
22
+ const c = clean(p);
23
+ if (!c)
24
+ continue;
25
+ const abs = path.isAbsolute(c) ? path.normalize(c) : c;
26
+ if (seen.has(abs))
27
+ continue;
28
+ seen.add(abs);
29
+ out.push(abs);
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Assemble the evidence bag for one event. companySlug falls back to the session
35
+ * meta; repoPath falls back to the enclosing git work-tree of the cwd (or the
36
+ * first touched file). All fields are omitted when empty.
37
+ */
38
+ export function buildEmitEvidence(input) {
39
+ const readMeta = input.readMeta ?? readMetaCompanySlug;
40
+ const findRepo = input.findRepo ?? findWorkTreeRoot;
41
+ const cwd = clean(input.cwd);
42
+ const hqRoot = clean(input.hqRoot);
43
+ const touchedPaths = normalizeTouchedPaths(input.touchedPaths ?? []);
44
+ const companySlug = clean(input.companySlug) ?? clean(readMeta(input.sessionId, hqRoot));
45
+ let repoPath = clean(input.repoPath);
46
+ if (!repoPath) {
47
+ // Search from the cwd (a directory) or the directory of the first touched
48
+ // file for the enclosing git work-tree.
49
+ const startDir = cwd ?? firstFileDir(touchedPaths);
50
+ if (startDir)
51
+ repoPath = clean(findRepo(startDir) ?? undefined);
52
+ }
53
+ const evidence = {};
54
+ if (cwd)
55
+ evidence.cwd = cwd;
56
+ if (hqRoot)
57
+ evidence.hqRoot = hqRoot;
58
+ if (companySlug)
59
+ evidence.companySlug = companySlug;
60
+ if (touchedPaths.length > 0)
61
+ evidence.touchedPaths = touchedPaths;
62
+ if (repoPath)
63
+ evidence.repoPath = repoPath;
64
+ const project = clean(input.project);
65
+ if (project)
66
+ evidence.project = project;
67
+ const task = clean(input.task);
68
+ if (task)
69
+ evidence.task = task;
70
+ return evidence;
71
+ }
72
+ /** Directory of the first absolute touched file, for work-tree discovery. */
73
+ function firstFileDir(touchedPaths) {
74
+ const abs = touchedPaths.find((p) => path.isAbsolute(p));
75
+ return abs ? path.dirname(abs) : undefined;
76
+ }
77
+ //# sourceMappingURL=emit-evidence.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Replay the LEGACY local backlog (spool.jsonl + held.jsonl) through the new
3
+ * direct-emit endpoint (owner decision 2026-09-08 + contract direct-emit-v1 §6.3
4
+ * "deprecated, not deleted"). Each legacy line already carries the attribution
5
+ * evidence the daemon used to hold on (cwd / hqRoot / companySlug / project /
6
+ * task); we lift those into the `evidence` object the server now attributes
7
+ * from, and post with the event's original eventId so the server dedupes.
8
+ *
9
+ * Read-only over the legacy files — the caller (emitEvents) owns disposition and
10
+ * the retry file; this module never deletes spool/held (a follow-up removes the
11
+ * legacy queue once replay is confirmed).
12
+ */
13
+ import { type MeshEmitEvent } from "./emit-client.js";
14
+ /** Convert one legacy spool/held event object into a MeshEmitEvent, or null. */
15
+ export declare function legacyEventToEmit(raw: Record<string, unknown>): MeshEmitEvent | null;
16
+ export interface LegacyBacklog {
17
+ events: MeshEmitEvent[];
18
+ scanned: number;
19
+ skipped: number;
20
+ }
21
+ /**
22
+ * Read spool.jsonl + held.jsonl, convert to emit events, dedupe by eventId.
23
+ * The daemon's held retry may duplicate a spool line; first occurrence wins.
24
+ */
25
+ export declare function readLegacyBacklog(workMeshRoot: string): LegacyBacklog;
26
+ //# sourceMappingURL=emit-replay.d.ts.map
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Replay the LEGACY local backlog (spool.jsonl + held.jsonl) through the new
3
+ * direct-emit endpoint (owner decision 2026-09-08 + contract direct-emit-v1 §6.3
4
+ * "deprecated, not deleted"). Each legacy line already carries the attribution
5
+ * evidence the daemon used to hold on (cwd / hqRoot / companySlug / project /
6
+ * task); we lift those into the `evidence` object the server now attributes
7
+ * from, and post with the event's original eventId so the server dedupes.
8
+ *
9
+ * Read-only over the legacy files — the caller (emitEvents) owns disposition and
10
+ * the retry file; this module never deletes spool/held (a follow-up removes the
11
+ * legacy queue once replay is confirmed).
12
+ */
13
+ import * as fs from "node:fs";
14
+ import { workMeshSpoolPath, workMeshHeldPath } from "./paths.js";
15
+ import { buildMeshEmitEvent } from "./emit-client.js";
16
+ const KINDS = new Set([
17
+ "session_start",
18
+ "turn_start",
19
+ "turn_end",
20
+ "session_end",
21
+ "task_status",
22
+ "blocked",
23
+ "note",
24
+ ]);
25
+ const HARNESSES = new Set([
26
+ "claude-code",
27
+ "claude-desktop",
28
+ "codex",
29
+ "grok",
30
+ "hq-sessions",
31
+ "agent-box",
32
+ "agents-v2",
33
+ ]);
34
+ function str(v) {
35
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
36
+ }
37
+ /** Peel a held envelope { event, heldReason, heldAt } → the inner event. */
38
+ function innerEvent(obj) {
39
+ if (obj.event &&
40
+ typeof obj.event === "object" &&
41
+ !Array.isArray(obj.event) &&
42
+ typeof obj.heldReason === "string") {
43
+ return obj.event;
44
+ }
45
+ return obj;
46
+ }
47
+ /** Convert one legacy spool/held event object into a MeshEmitEvent, or null. */
48
+ export function legacyEventToEmit(raw) {
49
+ const event = innerEvent(raw);
50
+ const eventId = str(event.eventId);
51
+ const kind = str(event.kind);
52
+ const sessionId = str(event.sessionId);
53
+ const harness = str(event.harness);
54
+ const adapterVersion = str(event.adapterVersion);
55
+ const at = str(event.at);
56
+ const seq = typeof event.seq === "number" ? event.seq : Number(event.seq);
57
+ if (!eventId ||
58
+ !kind ||
59
+ !KINDS.has(kind) ||
60
+ !sessionId ||
61
+ !harness ||
62
+ !HARNESSES.has(harness) ||
63
+ !adapterVersion ||
64
+ !at ||
65
+ !Number.isInteger(seq) ||
66
+ seq < 1) {
67
+ return null;
68
+ }
69
+ const evidence = {};
70
+ const cwd = str(event.cwd);
71
+ if (cwd)
72
+ evidence.cwd = cwd;
73
+ const hqRoot = str(event.hqRoot);
74
+ if (hqRoot)
75
+ evidence.hqRoot = hqRoot;
76
+ const companySlug = str(event.companySlug);
77
+ if (companySlug)
78
+ evidence.companySlug = companySlug;
79
+ const project = str(event.project);
80
+ if (project)
81
+ evidence.project = project;
82
+ const task = str(event.task);
83
+ if (task)
84
+ evidence.task = task;
85
+ if (Array.isArray(event.touchedPaths)) {
86
+ const tp = event.touchedPaths.filter((p) => typeof p === "string" && p.trim().length > 0);
87
+ if (tp.length > 0)
88
+ evidence.touchedPaths = tp;
89
+ }
90
+ return buildMeshEmitEvent({
91
+ eventId,
92
+ kind,
93
+ sessionId,
94
+ harness,
95
+ adapterVersion,
96
+ at,
97
+ seq,
98
+ runtimeVersion: str(event.runtimeVersion),
99
+ source: event.source === "transcript" ? "transcript" : "hooks",
100
+ taskId: str(event.taskId),
101
+ status: str(event.status),
102
+ reason: str(event.reason),
103
+ summary: str(event.summary),
104
+ evidence,
105
+ });
106
+ }
107
+ function readJsonl(filePath) {
108
+ let raw;
109
+ try {
110
+ raw = fs.readFileSync(filePath, "utf8");
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ const out = [];
116
+ for (const line of raw.split("\n")) {
117
+ const t = line.trim();
118
+ if (!t)
119
+ continue;
120
+ try {
121
+ const v = JSON.parse(t);
122
+ if (v && typeof v === "object" && !Array.isArray(v)) {
123
+ out.push(v);
124
+ }
125
+ }
126
+ catch {
127
+ /* skip */
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Read spool.jsonl + held.jsonl, convert to emit events, dedupe by eventId.
134
+ * The daemon's held retry may duplicate a spool line; first occurrence wins.
135
+ */
136
+ export function readLegacyBacklog(workMeshRoot) {
137
+ const rows = [
138
+ ...readJsonl(workMeshSpoolPath(workMeshRoot)),
139
+ ...readJsonl(workMeshHeldPath(workMeshRoot)),
140
+ ];
141
+ const events = [];
142
+ const seen = new Set();
143
+ let skipped = 0;
144
+ for (const row of rows) {
145
+ const ev = legacyEventToEmit(row);
146
+ if (!ev) {
147
+ skipped += 1;
148
+ continue;
149
+ }
150
+ if (seen.has(ev.eventId))
151
+ continue;
152
+ seen.add(ev.eventId);
153
+ events.push(ev);
154
+ }
155
+ return { events, scanned: rows.length, skipped };
156
+ }
157
+ //# sourceMappingURL=emit-replay.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Tiny local retry file for direct-emit (owner decision 2026-09-08). When a
3
+ * POST /v1/mesh/events fails (network / 5xx / 429), the batch is appended here
4
+ * and the NEXT `hq mesh emit` invocation drains it. No long-lived process; no
5
+ * spool/held queue. Bounded so a long offline stretch cannot grow unbounded —
6
+ * the oldest overflow is dropped with a log (events are best-effort telemetry,
7
+ * never user data).
8
+ */
9
+ /** Default cap on retained retry events (oldest dropped past this). */
10
+ export declare const EMIT_RETRY_MAX = 5000;
11
+ export declare function emitRetryPath(workMeshRoot: string): string;
12
+ /** Read pending retry events (parseable objects only). Missing file → []. */
13
+ export declare function readEmitRetry(workMeshRoot: string): Record<string, unknown>[];
14
+ /**
15
+ * Atomically replace the retry file with `events` (keeping the most recent
16
+ * EMIT_RETRY_MAX). Returns the number of oldest events dropped by the cap.
17
+ * Empty input removes the file.
18
+ */
19
+ export declare function writeEmitRetry(workMeshRoot: string, events: Record<string, unknown>[], max?: number): {
20
+ written: number;
21
+ dropped: number;
22
+ };
23
+ /** Count of pending retry events (doctor retryDepth). */
24
+ export declare function emitRetryDepth(workMeshRoot: string): number;
25
+ //# sourceMappingURL=emit-retry.d.ts.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tiny local retry file for direct-emit (owner decision 2026-09-08). When a
3
+ * POST /v1/mesh/events fails (network / 5xx / 429), the batch is appended here
4
+ * and the NEXT `hq mesh emit` invocation drains it. No long-lived process; no
5
+ * spool/held queue. Bounded so a long offline stretch cannot grow unbounded —
6
+ * the oldest overflow is dropped with a log (events are best-effort telemetry,
7
+ * never user data).
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ /** Default cap on retained retry events (oldest dropped past this). */
12
+ export const EMIT_RETRY_MAX = 5000;
13
+ export function emitRetryPath(workMeshRoot) {
14
+ return path.join(workMeshRoot, "emit-retry.jsonl");
15
+ }
16
+ function ensureFileDir(filePath) {
17
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
18
+ }
19
+ /** Read pending retry events (parseable objects only). Missing file → []. */
20
+ export function readEmitRetry(workMeshRoot) {
21
+ const p = emitRetryPath(workMeshRoot);
22
+ let raw;
23
+ try {
24
+ raw = fs.readFileSync(p, "utf8");
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const out = [];
30
+ for (const line of raw.split("\n")) {
31
+ const t = line.trim();
32
+ if (!t)
33
+ continue;
34
+ try {
35
+ const v = JSON.parse(t);
36
+ if (v && typeof v === "object" && !Array.isArray(v)) {
37
+ out.push(v);
38
+ }
39
+ }
40
+ catch {
41
+ /* skip malformed */
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Atomically replace the retry file with `events` (keeping the most recent
48
+ * EMIT_RETRY_MAX). Returns the number of oldest events dropped by the cap.
49
+ * Empty input removes the file.
50
+ */
51
+ export function writeEmitRetry(workMeshRoot, events, max = EMIT_RETRY_MAX) {
52
+ const p = emitRetryPath(workMeshRoot);
53
+ let dropped = 0;
54
+ let keep = events;
55
+ if (max > 0 && events.length > max) {
56
+ dropped = events.length - max;
57
+ keep = events.slice(events.length - max);
58
+ }
59
+ if (keep.length === 0) {
60
+ try {
61
+ fs.rmSync(p, { force: true });
62
+ }
63
+ catch {
64
+ /* best-effort */
65
+ }
66
+ return { written: 0, dropped };
67
+ }
68
+ ensureFileDir(p);
69
+ const body = keep.map((e) => JSON.stringify(e)).join("\n") + "\n";
70
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
71
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
72
+ fs.renameSync(tmp, p);
73
+ return { written: keep.length, dropped };
74
+ }
75
+ /** Count of pending retry events (doctor retryDepth). */
76
+ export function emitRetryDepth(workMeshRoot) {
77
+ return readEmitRetry(workMeshRoot).length;
78
+ }
79
+ //# sourceMappingURL=emit-retry.js.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Direct-emit orchestration (owner decision 2026-09-08). One invocation:
3
+ * 1. drains the local retry file (prior failures),
4
+ * 2. appends the new event(s),
5
+ * 3. POSTs /v1/mesh/events in batches with a short bounded retry,
6
+ * 4. keeps only network/5xx/429-failed or unaccounted events for next time,
7
+ * 5. records lastPostAt + per-event status counts for `hq mesh doctor`.
8
+ *
9
+ * accepted / unassigned / rejected are all terminal (rejected is dropped with a
10
+ * count — never re-posted). No long-lived process, spool, or held queue.
11
+ */
12
+ import { type EmitPoster, type EmitEventStatus } from "./emit-client.js";
13
+ import { type RandomFn, type SleepFn } from "./backoff.js";
14
+ export interface EmitStateFile {
15
+ lastPostAt?: string;
16
+ lastAttemptAt?: string;
17
+ lastAccepted?: number;
18
+ lastUnassigned?: number;
19
+ lastRejected?: number;
20
+ lastRetryDepth?: number;
21
+ lastError?: string;
22
+ }
23
+ export declare function emitStatePath(workMeshRoot: string): string;
24
+ export declare function readEmitState(workMeshRoot: string): EmitStateFile | null;
25
+ export interface EmitEventsDeps {
26
+ workMeshRoot: string;
27
+ poster: EmitPoster;
28
+ /** New events to emit this invocation (may be empty — just drain retry). */
29
+ newEvents?: Record<string, unknown>[];
30
+ /** Attempts per batch (short: default 3). */
31
+ maxAttempts?: number;
32
+ retryMax?: number;
33
+ now?: () => Date;
34
+ sleep?: SleepFn;
35
+ random?: RandomFn;
36
+ log?: (message: string) => void;
37
+ }
38
+ export interface EmitEventsSummary {
39
+ attempted: number;
40
+ accepted: number;
41
+ unassigned: number;
42
+ rejected: number;
43
+ /** Events kept in the retry file for a later invocation. */
44
+ retained: number;
45
+ /** Oldest events dropped by the retry cap. */
46
+ droppedOverflow: number;
47
+ retryDepth: number;
48
+ batches: number;
49
+ statuses: Record<EmitEventStatus, number>;
50
+ lastPostAt?: string;
51
+ }
52
+ /** Drain the retry file, post pending + new events, persist failures. */
53
+ export declare function emitEvents(deps: EmitEventsDeps): Promise<EmitEventsSummary>;
54
+ //# sourceMappingURL=emit.d.ts.map