@indigoai-us/hq-cli 5.108.25 → 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 (34) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/commands/integrations-api.d.ts +15 -0
  3. package/dist/commands/integrations-connect.js +84 -3
  4. package/dist/commands/integrations-oauth.js +62 -3
  5. package/dist/commands/mcp-registration.d.ts +17 -7
  6. package/dist/commands/mcp-registration.js +16 -27
  7. package/dist/commands/mesh.js +174 -50
  8. package/dist/commands/pack-install.js +5 -5
  9. package/dist/commands/secrets.d.ts +7 -0
  10. package/dist/commands/secrets.js +26 -2
  11. package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
  12. package/dist/lib/mesh/live/backfill-held.js +95 -13
  13. package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
  14. package/dist/lib/mesh/live/daemon/doctor.js +41 -10
  15. package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
  16. package/dist/lib/mesh/live/daemon/mode.js +88 -0
  17. package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
  18. package/dist/lib/mesh/live/daemon/run.js +39 -28
  19. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  20. package/dist/lib/mesh/live/emit-client.d.ts +99 -0
  21. package/dist/lib/mesh/live/emit-client.js +193 -0
  22. package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
  23. package/dist/lib/mesh/live/emit-evidence.js +77 -0
  24. package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
  25. package/dist/lib/mesh/live/emit-replay.js +157 -0
  26. package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
  27. package/dist/lib/mesh/live/emit-retry.js +79 -0
  28. package/dist/lib/mesh/live/emit.d.ts +54 -0
  29. package/dist/lib/mesh/live/emit.js +153 -0
  30. package/dist/lib/narrow-hint-banner.d.ts +3 -7
  31. package/dist/lib/narrow-hint-banner.js +13 -34
  32. package/dist/lib/plan-limit-nag.d.ts +0 -3
  33. package/dist/lib/plan-limit-nag.js +10 -20
  34. package/package.json +1 -1
@@ -10,6 +10,8 @@ import { workContextRoot } from "../../../work-context/paths.js";
10
10
  import { countJsonlLines, } from "../spool.js";
11
11
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
12
12
  import { daemonDir, daemonPidPath } from "./paths.js";
13
+ import { readEmitState } from "../emit.js";
14
+ import { emitRetryDepth } from "../emit-retry.js";
13
15
  import { pidLockStatus, readPidLock } from "./pid-lock.js";
14
16
  import { readDaemonState } from "./state.js";
15
17
  export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
@@ -79,10 +81,13 @@ export function collectDaemonDoctor(deps = {}) {
79
81
  const outbox = outboxStats(ctxRoot);
80
82
  const mqttState = state?.mqttState ?? (running ? "unknown" : "closed");
81
83
  const companiesOnline = state?.companiesOnline ?? [];
84
+ const emitMode = state?.emitMode ?? "legacy";
85
+ const subscribed = mqttState === "connected";
82
86
  const oldestSpoolAgeMs = oldestJsonlAgeMs(spoolPath, now());
83
87
  const unhealthyReasons = [];
84
88
  const online = mqttState === "connected" && companiesOnline.length > 0;
85
- if (online &&
89
+ if (emitMode === "legacy" &&
90
+ online &&
86
91
  spoolDepth > 0 &&
87
92
  oldestSpoolAgeMs !== undefined &&
88
93
  oldestSpoolAgeMs > UNHEALTHY_SPOOL_AGE_MS) {
@@ -96,6 +101,11 @@ export function collectDaemonDoctor(deps = {}) {
96
101
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
97
102
  }
98
103
  const auth = describeCognitoTokenSource({ home, env });
104
+ const emitState = readEmitState(meshRoot);
105
+ const emitRetry = emitRetryDepth(meshRoot);
106
+ if (emitRetry > 0) {
107
+ unhealthyReasons.push(`direct-emit retry depth=${emitRetry}`);
108
+ }
99
109
  return {
100
110
  running,
101
111
  pid: running ? record?.pid : undefined,
@@ -113,23 +123,44 @@ export function collectDaemonDoctor(deps = {}) {
113
123
  tokenSource: auth.tokenSource,
114
124
  actorKind: auth.actorKind,
115
125
  presenceRefusal,
126
+ emitMode,
127
+ subscribed,
128
+ emitRetryDepth: emitRetry,
129
+ lastPostAt: emitState?.lastPostAt,
130
+ lastEmit: emitState
131
+ ? {
132
+ accepted: emitState.lastAccepted,
133
+ unassigned: emitState.lastUnassigned,
134
+ rejected: emitState.lastRejected,
135
+ error: emitState.lastError,
136
+ }
137
+ : undefined,
116
138
  };
117
139
  }
118
140
  export function formatDaemonDoctor(report) {
119
141
  const lines = [
120
142
  `daemon: ${report.running ? `running pid=${report.pid}` : "not running"}`,
143
+ `mode: ${report.emitMode}`,
121
144
  `mqtt: ${report.mqttState}`,
145
+ `subscription: ${report.subscribed ? "connected" : "disconnected"}`,
122
146
  `companies online: ${report.companiesOnline.length ? report.companiesOnline.join(", ") : "(none)"}`,
123
- `spool depth: ${report.spoolDepth}`,
124
- `held: ${report.heldCount}`,
125
- `dead-letter: ${report.deadLetterCount}`,
126
- `outbox depth: ${report.outboxDepth}`,
127
- `token source: ${report.tokenSource}`,
128
- `actor kind: ${report.actorKind}`,
129
- `last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
130
- ? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
131
- : ""}`,
132
147
  ];
148
+ if (report.emitMode === "legacy") {
149
+ // Legacy emit path only: the spool/held/outbox queues do not exist in
150
+ // receive-only (direct) mode.
151
+ lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `outbox depth: ${report.outboxDepth}`);
152
+ }
153
+ lines.push(`dead-letter: ${report.deadLetterCount}`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
154
+ if (report.emitMode === "legacy") {
155
+ lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
156
+ ? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
157
+ : ""}`);
158
+ }
159
+ lines.push(`emit last post: ${report.lastPostAt ?? "(never)"}${report.lastEmit
160
+ ? ` accepted=${report.lastEmit.accepted ?? 0}` +
161
+ ` unassigned=${report.lastEmit.unassigned ?? 0}` +
162
+ ` rejected=${report.lastEmit.rejected ?? 0}`
163
+ : ""}`, `emit retry depth: ${report.emitRetryDepth}`);
133
164
  if (report.presenceRefusal) {
134
165
  lines.push(`presence refusal: ${report.presenceRefusal.code} (HTTP ${report.presenceRefusal.status}); next retry at ${report.presenceRefusal.nextRetryAt}`);
135
166
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Mesh daemon mode gate (owner decision 2026-09-08 "retire the client daemon";
3
+ * contract direct-emit-v1 §6.3 "deprecated, not deleted").
4
+ *
5
+ * Two modes coexist so this can merge before the server route ships and flip
6
+ * after:
7
+ * - "legacy" — the daemon runs the EMIT path (spool watch + flush + held +
8
+ * outbox replay + transcript-watch fallback) AND the receive
9
+ * subscriber. This is the default until the server route is live.
10
+ * - "direct" — sessions POST events directly (see `hq mesh session`/`emit`);
11
+ * the daemon runs RECEIVE-ONLY: the presence subscriber on
12
+ * hq/{personUid}/mesh keeps the cache the desktop app reads warm.
13
+ * No spool/held/outbox/flush/transcript-watch on this box.
14
+ *
15
+ * The flag is resolved from (highest first): env HQ_MESH_MODE, the work-mesh
16
+ * config file (~/.hq/work-mesh/config.json `emitMode`), else "legacy".
17
+ */
18
+ export type MeshEmitMode = "legacy" | "direct";
19
+ export declare const MESH_MODE_ENV = "HQ_MESH_MODE";
20
+ export declare function meshConfigPath(meshRoot: string): string;
21
+ /** Read `emitMode` from ~/.hq/work-mesh/config.json (best-effort). */
22
+ export declare function readMeshConfigMode(meshRoot: string): MeshEmitMode | null;
23
+ export interface ResolveModeDeps {
24
+ env?: NodeJS.ProcessEnv;
25
+ meshRoot?: string;
26
+ home?: string;
27
+ }
28
+ /**
29
+ * Resolve the daemon's emit mode. Env HQ_MESH_MODE wins, then the config file,
30
+ * else "legacy" (safe default until the server route returns 200).
31
+ */
32
+ export declare function resolveMeshEmitMode(deps?: ResolveModeDeps): MeshEmitMode;
33
+ /** Persist the emit mode to ~/.hq/work-mesh/config.json (`hq mesh mode set`). */
34
+ export declare function writeMeshConfigMode(meshRoot: string, mode: MeshEmitMode): void;
35
+ /** True when the daemon should run the legacy emit path (spool/flush/held). */
36
+ export declare function runsEmitPath(mode: MeshEmitMode): boolean;
37
+ //# sourceMappingURL=mode.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Mesh daemon mode gate (owner decision 2026-09-08 "retire the client daemon";
3
+ * contract direct-emit-v1 §6.3 "deprecated, not deleted").
4
+ *
5
+ * Two modes coexist so this can merge before the server route ships and flip
6
+ * after:
7
+ * - "legacy" — the daemon runs the EMIT path (spool watch + flush + held +
8
+ * outbox replay + transcript-watch fallback) AND the receive
9
+ * subscriber. This is the default until the server route is live.
10
+ * - "direct" — sessions POST events directly (see `hq mesh session`/`emit`);
11
+ * the daemon runs RECEIVE-ONLY: the presence subscriber on
12
+ * hq/{personUid}/mesh keeps the cache the desktop app reads warm.
13
+ * No spool/held/outbox/flush/transcript-watch on this box.
14
+ *
15
+ * The flag is resolved from (highest first): env HQ_MESH_MODE, the work-mesh
16
+ * config file (~/.hq/work-mesh/config.json `emitMode`), else "legacy".
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { workMeshRoot } from "../paths.js";
21
+ export const MESH_MODE_ENV = "HQ_MESH_MODE";
22
+ export function meshConfigPath(meshRoot) {
23
+ return path.join(meshRoot, "config.json");
24
+ }
25
+ function normalizeMode(value) {
26
+ if (typeof value !== "string")
27
+ return null;
28
+ const v = value.trim().toLowerCase();
29
+ if (v === "direct" || v === "direct-emit" || v === "receive-only")
30
+ return "direct";
31
+ if (v === "legacy" || v === "daemon")
32
+ return "legacy";
33
+ return null;
34
+ }
35
+ /** Read `emitMode` from ~/.hq/work-mesh/config.json (best-effort). */
36
+ export function readMeshConfigMode(meshRoot) {
37
+ try {
38
+ const raw = fs.readFileSync(meshConfigPath(meshRoot), "utf8");
39
+ const parsed = JSON.parse(raw);
40
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
41
+ return normalizeMode(parsed.emitMode);
42
+ }
43
+ }
44
+ catch {
45
+ /* absent / malformed → no override */
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * Resolve the daemon's emit mode. Env HQ_MESH_MODE wins, then the config file,
51
+ * else "legacy" (safe default until the server route returns 200).
52
+ */
53
+ export function resolveMeshEmitMode(deps = {}) {
54
+ const env = deps.env ?? process.env;
55
+ const fromEnv = normalizeMode(env[MESH_MODE_ENV]);
56
+ if (fromEnv)
57
+ return fromEnv;
58
+ const meshRoot = deps.meshRoot ?? workMeshRoot(deps.home, env);
59
+ const fromConfig = readMeshConfigMode(meshRoot);
60
+ if (fromConfig)
61
+ return fromConfig;
62
+ return "legacy";
63
+ }
64
+ /** Persist the emit mode to ~/.hq/work-mesh/config.json (`hq mesh mode set`). */
65
+ export function writeMeshConfigMode(meshRoot, mode) {
66
+ const p = meshConfigPath(meshRoot);
67
+ let existing = {};
68
+ try {
69
+ const raw = fs.readFileSync(p, "utf8");
70
+ const parsed = JSON.parse(raw);
71
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
72
+ existing = parsed;
73
+ }
74
+ }
75
+ catch {
76
+ /* new file */
77
+ }
78
+ existing.emitMode = mode;
79
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
80
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
81
+ fs.writeFileSync(tmp, JSON.stringify(existing, null, 2), { mode: 0o600 });
82
+ fs.renameSync(tmp, p);
83
+ }
84
+ /** True when the daemon should run the legacy emit path (spool/flush/held). */
85
+ export function runsEmitPath(mode) {
86
+ return mode === "legacy";
87
+ }
88
+ //# sourceMappingURL=mode.js.map
@@ -13,6 +13,7 @@ import { type CredentialsFetcher, type TimerHost } from "./credentials.js";
13
13
  import { type PidLockDeps } from "./pid-lock.js";
14
14
  import { PresenceClient, type MqttConnectFn } from "./presence.js";
15
15
  import { TranscriptWatcher, type TranscriptFs } from "./transcript-watch.js";
16
+ import { type MeshEmitMode } from "./mode.js";
16
17
  export declare const SPOOL_DEBOUNCE_MS = 2000;
17
18
  export declare const FLUSH_INTERVAL_MS = 10000;
18
19
  /**
@@ -53,6 +54,13 @@ export interface DaemonRunDeps {
53
54
  enablePresence?: boolean;
54
55
  /** When false, skip transcript-watch lane (tests). Default true. */
55
56
  enableTranscriptWatch?: boolean;
57
+ /**
58
+ * Emit mode. "legacy" runs the spool/flush/held/outbox/transcript-watch emit
59
+ * path; "direct" runs receive-only (subscriber + cache) because sessions POST
60
+ * events directly. Default resolves from HQ_MESH_MODE / work-mesh config, else
61
+ * "legacy" (contract direct-emit-v1 §6.3).
62
+ */
63
+ mode?: MeshEmitMode;
56
64
  /** Injectable transcript fs (tests). */
57
65
  transcriptFs?: TranscriptFs;
58
66
  /** HQ tree root for deterministic cwd→company/project mapping. */
@@ -28,6 +28,7 @@ import { PresenceClient, } from "./presence.js";
28
28
  import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state.js";
29
29
  import { workMeshHeldPath } from "../paths.js";
30
30
  import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
31
+ import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
31
32
  export const SPOOL_DEBOUNCE_MS = 2_000;
32
33
  export const FLUSH_INTERVAL_MS = 10_000;
33
34
  /**
@@ -64,6 +65,8 @@ export async function runMeshDaemon(deps = {}) {
64
65
  const ctxRoot = deps.workContextRoot ?? workContextRoot(home, env);
65
66
  const dir = daemonDir(meshRoot, home, env);
66
67
  const timers = deps.timers ?? realTimerHost;
68
+ const mode = deps.mode ?? resolveMeshEmitMode({ env, meshRoot, home });
69
+ const emitPath = runsEmitPath(mode);
67
70
  const now = deps.now ?? (() => new Date());
68
71
  const log = deps.log ??
69
72
  ((d, line) => appendDaemonLog(d, daemonLogLine("info", line, now)));
@@ -77,7 +80,8 @@ export async function runMeshDaemon(deps = {}) {
77
80
  throw new Error(`daemon already running (pid ${lock.owner.pid}); refuse second instance`);
78
81
  }
79
82
  writeDaemonState(dir, defaultDaemonState(lockDeps.pid, now));
80
- log(dir, `daemon started pid=${lockDeps.pid}`);
83
+ log(dir, `daemon started pid=${lockDeps.pid} mode=${mode}`);
84
+ patchDaemonState(dir, { emitMode: mode }, now);
81
85
  // Opt into machine-credential minting when the box has creds (systemd exports
82
86
  // HQ_MACHINE_CREDS_FILE). On a person laptop with no machine creds this falls
83
87
  // through to the person login cache inside ensureCognitoToken.
@@ -199,7 +203,7 @@ export async function runMeshDaemon(deps = {}) {
199
203
  /** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
200
204
  const lastHookEventAt = new Map();
201
205
  let transcriptWatcher = null;
202
- if (deps.enableTranscriptWatch !== false) {
206
+ if (emitPath && deps.enableTranscriptWatch !== false) {
203
207
  const hqRoot = deps.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
204
208
  const getToken = deps.getToken ?? defaultGetToken;
205
209
  transcriptWatcher = new TranscriptWatcher({
@@ -314,34 +318,36 @@ export async function runMeshDaemon(deps = {}) {
314
318
  void doFlushCycle("watch");
315
319
  }, SPOOL_DEBOUNCE_MS);
316
320
  };
317
- // Watch spool directory (and file) for changes.
321
+ // Watch spool directory (and file) for changes — legacy emit path only.
318
322
  const spoolPath = workMeshSpoolPath(meshRoot);
319
- try {
320
- fs.mkdirSync(path.dirname(spoolPath), { recursive: true, mode: 0o700 });
321
- if (!fs.existsSync(spoolPath)) {
322
- fs.writeFileSync(spoolPath, "", { mode: 0o600 });
323
+ if (emitPath) {
324
+ try {
325
+ fs.mkdirSync(path.dirname(spoolPath), { recursive: true, mode: 0o700 });
326
+ if (!fs.existsSync(spoolPath)) {
327
+ fs.writeFileSync(spoolPath, "", { mode: 0o600 });
328
+ }
329
+ watcher = fs.watch(path.dirname(spoolPath), { persistent: true }, (_event, filename) => {
330
+ if (stopped)
331
+ return;
332
+ if (shouldFlushOnSpoolDirEvent(filename)) {
333
+ scheduleDebouncedFlush();
334
+ }
335
+ });
336
+ }
337
+ catch (err) {
338
+ const msg = err instanceof Error ? err.message : String(err);
339
+ log(dir, `fs.watch failed (interval flush still active): ${msg.slice(0, 120)}`);
323
340
  }
324
- watcher = fs.watch(path.dirname(spoolPath), { persistent: true }, (_event, filename) => {
341
+ flushIntervalHandle = timers.setTimeout(function tick() {
325
342
  if (stopped)
326
343
  return;
327
- if (shouldFlushOnSpoolDirEvent(filename)) {
328
- scheduleDebouncedFlush();
329
- }
330
- });
331
- }
332
- catch (err) {
333
- const msg = err instanceof Error ? err.message : String(err);
334
- log(dir, `fs.watch failed (interval flush still active): ${msg.slice(0, 120)}`);
344
+ void doFlushCycle("interval").finally(() => {
345
+ if (stopped)
346
+ return;
347
+ flushIntervalHandle = timers.setTimeout(tick, FLUSH_INTERVAL_MS);
348
+ });
349
+ }, FLUSH_INTERVAL_MS);
335
350
  }
336
- flushIntervalHandle = timers.setTimeout(function tick() {
337
- if (stopped)
338
- return;
339
- void doFlushCycle("interval").finally(() => {
340
- if (stopped)
341
- return;
342
- flushIntervalHandle = timers.setTimeout(tick, FLUSH_INTERVAL_MS);
343
- });
344
- }, FLUSH_INTERVAL_MS);
345
351
  boardIntervalHandle = timers.setTimeout(function boardTick() {
346
352
  if (stopped)
347
353
  return;
@@ -393,10 +399,15 @@ export async function runMeshDaemon(deps = {}) {
393
399
  });
394
400
  }, TRANSCRIPT_WATCH_INTERVAL_MS);
395
401
  }
396
- // Initial flush + board pass + transcript scan.
397
- void doFlushCycle("start");
402
+ // Initial pass. Board refresh runs in both modes; the flush + transcript
403
+ // scan are legacy emit-path only.
404
+ if (emitPath) {
405
+ void doFlushCycle("start");
406
+ }
398
407
  void refreshFn().catch(() => undefined);
399
- void runTranscriptTick();
408
+ if (emitPath) {
409
+ void runTranscriptTick();
410
+ }
400
411
  const stop = async () => {
401
412
  if (stopped)
402
413
  return;
@@ -22,6 +22,8 @@ export interface DaemonStateFile {
22
22
  lastErrorCode?: string;
23
23
  /** Set while credential vend is refused (FEATURE_DISABLED / unsupported / 403). */
24
24
  presenceRefusal?: PresenceRefusalState | null;
25
+ /** Daemon emit mode: "legacy" (spool/flush) or "direct" (receive-only). */
26
+ emitMode?: "legacy" | "direct";
25
27
  updatedAt: string;
26
28
  }
27
29
  export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
@@ -0,0 +1,99 @@
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 type { SessionEventKind, SessionEventHarness, SessionEventSource } from "./format-spool-line.js";
15
+ import type { EmitEvidence } from "./emit-evidence.js";
16
+ export declare const MESH_EVENTS_PATH = "/v1/mesh/events";
17
+ /** Max events per POST (server batches; keep parity with legacy 100). */
18
+ export declare const MESH_EVENTS_BATCH_MAX = 100;
19
+ /** Per-event server disposition. */
20
+ export type EmitEventStatus = "accepted" | "unassigned" | "rejected";
21
+ export interface EmitEventResult {
22
+ eventId: string;
23
+ status: EmitEventStatus;
24
+ /** Present when status != accepted (no_company_evidence | not_a_member | …). */
25
+ reason?: string;
26
+ /** Resolved company (accepted, or unassigned-into-single-company). */
27
+ companyUid?: string;
28
+ }
29
+ export interface EmitPostResult {
30
+ status: number;
31
+ body: unknown;
32
+ ok: boolean;
33
+ retryable: boolean;
34
+ networkError?: boolean;
35
+ }
36
+ export type EmitPoster = (events: Record<string, unknown>[]) => Promise<EmitPostResult>;
37
+ export declare function classifyEmitResponse(status: number, networkError?: boolean): Pick<EmitPostResult, "ok" | "retryable" | "networkError">;
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 declare function parseEmitResults(body: unknown): EmitEventResult[] | null;
45
+ /**
46
+ * Build a poster that uses vaultApiFetch + the caller's bearer token.
47
+ * `post` override is for tests (never hits the network).
48
+ */
49
+ export declare function createEmitPoster(opts: {
50
+ token: string;
51
+ baseUrl?: string;
52
+ post?: (path: string, body: {
53
+ events: Record<string, unknown>[];
54
+ }) => Promise<{
55
+ status: number;
56
+ body: unknown;
57
+ }>;
58
+ }): EmitPoster;
59
+ /** One event posted to /v1/mesh/events: core session-event fields + evidence. */
60
+ export interface MeshEmitEvent {
61
+ v: 1;
62
+ eventId: string;
63
+ kind: SessionEventKind;
64
+ sessionId: string;
65
+ harness: SessionEventHarness;
66
+ adapterVersion: string;
67
+ at: string;
68
+ seq: number;
69
+ runtimeVersion?: string;
70
+ source?: SessionEventSource;
71
+ taskId?: string;
72
+ status?: "queued" | "in_progress" | "review" | "done";
73
+ reason?: string;
74
+ summary?: string;
75
+ evidence?: EmitEvidence;
76
+ }
77
+ export interface BuildMeshEmitEventInput {
78
+ eventId: string;
79
+ kind: SessionEventKind;
80
+ sessionId: string;
81
+ harness: SessionEventHarness;
82
+ adapterVersion: string;
83
+ at: string;
84
+ seq: number;
85
+ runtimeVersion?: string;
86
+ source?: SessionEventSource;
87
+ taskId?: string;
88
+ status?: "queued" | "in_progress" | "review" | "done";
89
+ reason?: string;
90
+ summary?: string;
91
+ evidence?: EmitEvidence;
92
+ }
93
+ /**
94
+ * Build a MeshEmitEvent, omitting empty optionals and dropping the evidence
95
+ * object entirely when it carries nothing. Never includes server-owned fields
96
+ * (companyUid/actorUid/contextStatus) — those would draw a 403 (contract §2.1).
97
+ */
98
+ export declare function buildMeshEmitEvent(input: BuildMeshEmitEventInput): MeshEmitEvent;
99
+ //# sourceMappingURL=emit-client.d.ts.map
@@ -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