@indigoai-us/hq-cli 5.345.199 → 5.345.201

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 (61) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +6 -4
  3. package/dist/command-catalog.generated.d.ts +44 -0
  4. package/dist/command-catalog.generated.js +52 -0
  5. package/dist/commands/bot-runtime.d.ts +35 -0
  6. package/dist/commands/bot-runtime.js +115 -0
  7. package/dist/commands/bot.d.ts +53 -0
  8. package/dist/commands/bot.js +239 -30
  9. package/dist/commands/cloud.d.ts +39 -0
  10. package/dist/commands/cloud.js +173 -82
  11. package/dist/commands/creators.js +2 -1
  12. package/dist/commands/deploy-domains.js +8 -4
  13. package/dist/commands/library-remove.js +2 -1
  14. package/dist/commands/library.js +5 -4
  15. package/dist/commands/mesh.d.ts +0 -7
  16. package/dist/commands/mesh.js +5 -1
  17. package/dist/commands/sync-held.js +2 -2
  18. package/dist/lib/bot/api.d.ts +8 -0
  19. package/dist/lib/bot/api.js +9 -0
  20. package/dist/lib/bot/fleet-download.d.ts +27 -0
  21. package/dist/lib/bot/fleet-download.js +162 -0
  22. package/dist/lib/bot/fleet-local.d.ts +79 -2
  23. package/dist/lib/bot/fleet-local.js +246 -74
  24. package/dist/lib/bot/fleet-python.d.ts +2 -0
  25. package/dist/lib/bot/fleet-python.js +29 -0
  26. package/dist/lib/bot/jobs/source-report.d.ts +31 -0
  27. package/dist/lib/bot/jobs/source-report.js +276 -0
  28. package/dist/lib/bot/legacy-convert.d.ts +107 -4
  29. package/dist/lib/bot/legacy-convert.js +159 -13
  30. package/dist/lib/bot/legacy-provider-login.d.ts +34 -0
  31. package/dist/lib/bot/legacy-provider-login.js +144 -0
  32. package/dist/lib/bot/run.js +20 -1
  33. package/dist/lib/doctor/checks/bots.d.ts +4 -0
  34. package/dist/lib/doctor/checks/bots.js +33 -0
  35. package/dist/lib/lanes/mesh-events.js +3 -1
  36. package/dist/lib/lanes/store.js +7 -0
  37. package/dist/lib/lanes/types.d.ts +2 -0
  38. package/dist/lib/lanes/types.js +1 -0
  39. package/dist/lib/lanes/watcher.d.ts +0 -33
  40. package/dist/lib/lanes/watcher.js +7 -0
  41. package/dist/lib/mesh/api.js +2 -1
  42. package/dist/lib/mesh/live/daemon/run.d.ts +0 -10
  43. package/dist/lib/mesh/live/daemon/run.js +3 -0
  44. package/dist/lib/mesh/live/emit.d.ts +2 -11
  45. package/dist/lib/mesh/live/emit.js +20 -2
  46. package/dist/lib/mesh/live/flush.d.ts +2 -0
  47. package/dist/lib/mesh/live/flush.js +34 -3
  48. package/dist/lib/mesh/live/reporting-privacy.d.ts +7 -0
  49. package/dist/lib/mesh/live/reporting-privacy.js +47 -0
  50. package/dist/lib/work-context/company.d.ts +2 -3
  51. package/dist/lib/work-context/company.js +4 -22
  52. package/dist/secrets-env-action.js +5 -2
  53. package/dist/utils/cognito-callback-port.d.ts +2 -0
  54. package/dist/utils/cognito-callback-port.js +10 -5
  55. package/dist/utils/cognito-config.js +6 -1
  56. package/dist/utils/cognito-session.js +6 -1
  57. package/dist/utils/interactive-caller.d.ts +8 -0
  58. package/dist/utils/interactive-caller.js +10 -0
  59. package/dist/utils/login-browser.js +2 -1
  60. package/dist/utils/resident-package-preload.js +57 -4
  61. package/package.json +7 -7
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Is the provider sign-in a legacy Codex or Grok bot needs readable on this
3
+ * computer, the way HQ Fleet Local imports it (`hq-fleet-local auth login`)?
4
+ *
5
+ * - Codex: the Codex CLI login file `<CODEX_HOME>/auth.json` (else
6
+ * ~/.codex/auth.json) with a refreshable, unexpired token. A bot with its
7
+ * own Codex login (hq bot login) or a capacity account imports from that
8
+ * home instead, so the account it answered with comes along.
9
+ * - Grok: the runtime only copies another Fleet bot's Grok login (its own
10
+ * device-code sign-in needs a browser), so the probe asks the helper of
11
+ * every other Fleet bot on this computer whether it holds one.
12
+ *
13
+ * Nothing here reads, prints or copies a credential value: the Codex check
14
+ * decodes only the token's expiry claim, and the Grok check asks the helper
15
+ * for a yes or no.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import { fleetLocalProviderAuthStatus, listFleetLocalProfiles } from "./fleet-local.js";
21
+ import { botOwnAuthHome, botRuntimeAuth, capacityAccountHome } from "./runtime-auth.js";
22
+ export function codexCliAuthPath(home) {
23
+ return path.join(home, "auth.json");
24
+ }
25
+ /** The `exp` claim of a JWT, in seconds, or null when the token has none that reads. */
26
+ export function jwtExpiry(token) {
27
+ const parts = token.split(".");
28
+ if (parts.length < 2)
29
+ return null;
30
+ try {
31
+ const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
32
+ return typeof claims.exp === "number" && Number.isFinite(claims.exp) ? claims.exp : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Mirrors the runtime's `_import_codex_cli_tokens`: a refreshable, unexpired login in `<home>/auth.json`. */
39
+ export function codexCliLoginReadable(home, now = Date.now()) {
40
+ const file = codexCliAuthPath(home);
41
+ if (!fs.existsSync(file))
42
+ return { readable: false, detail: `no Codex CLI login at ${file}` };
43
+ let tokens;
44
+ try {
45
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
46
+ tokens = parsed.tokens && typeof parsed.tokens === "object" ? parsed.tokens : undefined;
47
+ }
48
+ catch {
49
+ return { readable: false, detail: `${file} could not be read` };
50
+ }
51
+ const access = typeof tokens?.access_token === "string" ? tokens.access_token.trim() : "";
52
+ const refresh = typeof tokens?.refresh_token === "string" ? tokens.refresh_token.trim() : "";
53
+ if (!access || !refresh)
54
+ return { readable: false, detail: `${file} holds no refreshable Codex login` };
55
+ const exp = jwtExpiry(access);
56
+ if (exp !== null && exp * 1000 <= now)
57
+ return { readable: false, detail: `the Codex login at ${file} has expired` };
58
+ return { readable: true, detail: `Codex CLI login at ${file}` };
59
+ }
60
+ /**
61
+ * Where a legacy bot's Codex login lives: its own folder, a capacity account,
62
+ * or the owner's (CODEX_HOME, else ~/.codex). `env` is set when the import
63
+ * must look somewhere other than the owner's default.
64
+ */
65
+ export function legacyCodexHome(dir, config, env = process.env, homedir = os.homedir()) {
66
+ const auth = botRuntimeAuth(config, "codex");
67
+ if (!auth) {
68
+ if (config.runtimeAuth?.codex !== undefined)
69
+ return { error: `${config.name}'s saved codex login setting is not readable` };
70
+ return { home: env.CODEX_HOME?.trim() || path.join(homedir, ".codex") };
71
+ }
72
+ if (auth.mode === "own") {
73
+ const home = botOwnAuthHome(dir, "codex");
74
+ return { home, env: { CODEX_HOME: home } };
75
+ }
76
+ try {
77
+ const home = capacityAccountHome(config.hqRoot, "codex", auth.account);
78
+ return { home, env: { CODEX_HOME: home } };
79
+ }
80
+ catch (err) {
81
+ return { error: err instanceof Error ? err.message : String(err) };
82
+ }
83
+ }
84
+ /** The helper asks the keychain per profile, so one list of bots shares a scan this long. */
85
+ export const GROK_HOLDER_SCAN_TTL_MS = 30_000;
86
+ let grokHolderScan = null;
87
+ export function resetGrokHolderScan() {
88
+ grokHolderScan = null;
89
+ }
90
+ /** Every Fleet bot here with a Grok OAuth login, scanned at most once per TTL in this process (live deps are never cached). */
91
+ function grokHolders(deps, now) {
92
+ const live = !deps.profiles && !deps.status;
93
+ if (live && grokHolderScan && now - grokHolderScan.at < GROK_HOLDER_SCAN_TTL_MS)
94
+ return grokHolderScan;
95
+ const profiles = (deps.profiles ?? listFleetLocalProfiles)();
96
+ const status = deps.status ?? ((agentUid) => fleetLocalProviderAuthStatus(agentUid, "xai-oauth"));
97
+ const holders = [];
98
+ const unanswered = [];
99
+ for (const profile of profiles) {
100
+ const found = status(profile.agentUid);
101
+ if (found === null)
102
+ unanswered.push(profile.agentUid);
103
+ else if (found.authenticated && found.authType === "oauth")
104
+ holders.push(profile.agentUid);
105
+ }
106
+ const scan = { at: now, holders, uids: profiles.map((profile) => profile.agentUid), unanswered };
107
+ // A scan that found nothing or got no answer may be a passing failure: ask again next time.
108
+ if (live && profiles.length > 0 && unanswered.length < profiles.length)
109
+ grokHolderScan = scan;
110
+ return scan;
111
+ }
112
+ /** Mirrors the helper's `_existing_machine_oauth`: another Fleet bot here holds a Grok OAuth login. */
113
+ export function grokLoginReadableHere(exclude, deps = {}, now = Date.now()) {
114
+ const scan = grokHolders(deps, now);
115
+ const other = (agentUid) => agentUid.toLowerCase() !== exclude.toLowerCase();
116
+ const holder = scan.holders.find(other);
117
+ if (holder)
118
+ return { readable: true, detail: `the Grok sign-in of HQ Fleet bot ${holder} on this computer` };
119
+ const others = scan.uids.filter(other);
120
+ const unanswered = scan.unanswered.filter(other);
121
+ if (others.length && unanswered.length === others.length) {
122
+ return { readable: false, detail: `the HQ Fleet helper could not say whether any of the ${others.length} other Fleet bot(s) on this computer is signed in to Grok` };
123
+ }
124
+ return {
125
+ readable: false,
126
+ detail: others.length ? `none of the ${others.length} other HQ Fleet bot(s) on this computer is signed in to Grok` : "no other HQ Fleet bot on this computer is signed in to Grok",
127
+ };
128
+ }
129
+ /** The live probe for a bot: Codex from its login home, Grok from the other Fleet bots here. */
130
+ export function legacyProviderLoginProbe(dir, config) {
131
+ return (runtime) => {
132
+ if (runtime === "grok") {
133
+ if (config.runtimeAuth?.grok !== undefined)
134
+ return { readable: false, detail: "it uses its own Grok login, which HQ Fleet Local cannot import" };
135
+ return grokLoginReadableHere(config.agentUid);
136
+ }
137
+ const home = legacyCodexHome(dir, config);
138
+ if ("error" in home)
139
+ return { readable: false, detail: home.error };
140
+ const login = codexCliLoginReadable(home.home);
141
+ return home.env && login.readable ? { ...login, env: home.env } : login;
142
+ };
143
+ }
144
+ //# sourceMappingURL=legacy-provider-login.js.map
@@ -1,3 +1,4 @@
1
+ import { createSourceReporter, ensureSourceReportJobs, SOURCE_REPORT_JOB_KIND } from "./jobs/source-report.js";
1
2
  import { ensureSignalToLaneJobs, runSignalToLane, SIGNAL_TO_LANE_JOB_KIND } from "./jobs/signal-to-lane.js";
2
3
  /**
3
4
  * The resident bot process: `hq bot run <name>` (local-bots US-003/US-004).
@@ -424,7 +425,13 @@ export async function runBot(deps) {
424
425
  const meetingRider = createMeetingRider(defaultMeetingRiderDeps(config, dir, (message) => log("info", message)));
425
426
  const meetingDetectDeps = defaultMeetingDetectDeps(config, dir, (message) => log("info", message));
426
427
  const meetingDetect = createMeetingDetect(meetingDetectDeps);
428
+ const sourceReporter = createSourceReporter({ config, api, now, botsDir: path.dirname(dir) });
427
429
  const eventHandlers = Object.create(builtinEventHandlers);
430
+ eventHandlers[SOURCE_REPORT_JOB_KIND] = async ({ item }) => {
431
+ const context = typeof item.context === "object" ? item.context : undefined;
432
+ const event = context?.event;
433
+ return sourceReporter.handle(event?.event, context?.payload, context?.company);
434
+ };
428
435
  eventHandlers[MEETING_RIDER_JOB_KIND] = async ({ item }) => {
429
436
  if (!isExecutiveBot(config))
430
437
  return { status: "skipped-precondition", output: "Not the Executive" };
@@ -2060,7 +2067,7 @@ export async function runBot(deps) {
2060
2067
  if (eventHandler) {
2061
2068
  // Recoverable built-ins persist intent before acknowledging the event.
2062
2069
  // Signal lane creation is idempotent, so a pre-ack crash can replay.
2063
- const durableEvent = typeof job.context === "object" && (job.context?.jobKind === MEETING_RIDER_JOB_KIND || job.context?.jobKind === SIGNAL_TO_LANE_JOB_KIND);
2070
+ const durableEvent = typeof job.context === "object" && (job.context?.jobKind === SOURCE_REPORT_JOB_KIND || job.context?.jobKind === MEETING_RIDER_JOB_KIND || job.context?.jobKind === SIGNAL_TO_LANE_JOB_KIND);
2064
2071
  if (!durableEvent)
2065
2072
  markProcessed(dir, id, now().toISOString());
2066
2073
  let completion;
@@ -2884,6 +2891,12 @@ export async function runBot(deps) {
2884
2891
  // up below: end here, before the kickoff can send a "needs a sign-in" DM.
2885
2892
  if (stopping)
2886
2893
  return await stoppedDuringStartup();
2894
+ try {
2895
+ ensureSourceReportJobs(dir, config);
2896
+ }
2897
+ catch {
2898
+ log("warn", "Source report registration failed");
2899
+ }
2887
2900
  try {
2888
2901
  ensureSignalToLaneJobs(dir, config);
2889
2902
  }
@@ -3016,6 +3029,12 @@ export async function runBot(deps) {
3016
3029
  // A failed signals run's automatic retry, once due (no model turn).
3017
3030
  enqueueDueSignalsRetries();
3018
3031
  if (isExecutiveBot(config)) {
3032
+ try {
3033
+ await sourceReporter.recover();
3034
+ }
3035
+ catch {
3036
+ log("warn", "Source report recovery deferred");
3037
+ }
3019
3038
  try {
3020
3039
  meetingDetect.tick();
3021
3040
  }
@@ -1,5 +1,7 @@
1
+ import { type BotConfig } from "../../bot/config.js";
1
2
  import { type ClaudeLoginCheck } from "../../bot/claude-login.js";
2
3
  import { type BotStatusFile } from "../../bot/status.js";
4
+ import { type LegacyMoveState } from "../../bot/legacy-convert.js";
3
5
  import { type NodeBinaryIo } from "../../node-binary.js";
4
6
  import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
5
7
  export declare const BOTS_FAMILY_ID = "bots";
@@ -11,6 +13,8 @@ export interface BotsDoctorDeps {
11
13
  nodeIo?: NodeBinaryIo;
12
14
  /** The Claude Code login read the way the HQ Fleet Runtime reads it (tests). */
13
15
  claudeLogin?: () => ClaudeLoginCheck;
16
+ /** Whether and why a legacy bot moves to the HQ Fleet Runtime at its next start (tests). */
17
+ legacyMove?: (dir: string, config: BotConfig) => LegacyMoveState;
14
18
  }
15
19
  /** The last reported failure, unless a later turn worked. */
16
20
  export declare function unresolvedLastFailure(status: BotStatusFile | null): BotStatusFile["lastFailure"] | null;
@@ -10,10 +10,39 @@ import { botsRoot, botDir, launchdLabel } from "../../bot/paths.js";
10
10
  import { HQ_FLEET_RUNTIME_ENGINE_ID, readBotConfig } from "../../bot/config.js";
11
11
  import { checkClaudeCodeLogin, claudeLoginFix, describeClaudeLoginSources, } from "../../bot/claude-login.js";
12
12
  import { isPidAlive, readBotStatus } from "../../bot/status.js";
13
+ import { legacyMoveState } from "../../bot/legacy-convert.js";
14
+ import { legacyProviderLoginProbe } from "../../bot/legacy-provider-login.js";
15
+ import { runtimeDisplayName } from "../../bot/runtime-sign-in.js";
13
16
  import { nodeBinaryProblem } from "../../node-binary.js";
14
17
  import { launchdPlistProgramArguments, parseLaunchdCommand } from "../../service-manager/launchd.js";
15
18
  export const BOTS_FAMILY_ID = "bots";
16
19
  export const BOTS_FAMILY_TITLE = "Local bots";
20
+ function isLegacyBot(config) {
21
+ return config.engine?.id !== HQ_FLEET_RUNTIME_ENGINE_ID;
22
+ }
23
+ /** A bot still on its legacy runner: one WARN when it moves at its next start, one FAIL when it is held. */
24
+ function legacyRuntimeCheck(name, dir, config, state) {
25
+ const tool = runtimeDisplayName(config.runtime);
26
+ const failure = state.lastFailure;
27
+ const lastMove = failure ? ` Its last move failed at ${failure.at}${failure.step ? ` (could not ${failure.step})` : ""}${failure.error ? `: ${failure.error}` : ""}.` : "";
28
+ if (state.decision.convert) {
29
+ return {
30
+ status: "WARN",
31
+ checkId: `bots.${name}.legacy-runtime`,
32
+ target: dir,
33
+ message: `${name} still runs on its older ${tool} runner and moves to the HQ Fleet Runtime at its next start.${lastMove}`,
34
+ remediation: `hq bot start ${name}`,
35
+ };
36
+ }
37
+ return {
38
+ // The env opt-out is the owner's choice (and read from this shell, not the bot's); a sign-in hold is a bot that cannot work.
39
+ status: state.decision.reason === "kept-by-env" ? "WARN" : "FAIL",
40
+ checkId: `bots.${name}.legacy-runtime`,
41
+ target: dir,
42
+ message: `${name} still runs on its older ${tool} runner (${state.decision.reason}): ${state.notice?.cause ?? state.decision.detail}${lastMove}`,
43
+ remediation: `${state.notice?.fix ?? ""} Details: hq bot status ${name}`.trim(),
44
+ };
45
+ }
17
46
  const DEFAULT_DEPS = {
18
47
  home: () => os.homedir(),
19
48
  root: (home) => botsRoot(home ?? os.homedir()),
@@ -89,6 +118,10 @@ function runChecks(deps) {
89
118
  continue;
90
119
  const status = readBotStatus(dir);
91
120
  const alive = !!status && status.state === "running" && isPidAlive(status.pid);
121
+ if (isLegacyBot(config)) {
122
+ const state = (deps.legacyMove ?? ((d, c) => legacyMoveState(d, c, legacyProviderLoginProbe(d, c))))(dir, config);
123
+ results.push(legacyRuntimeCheck(name, dir, config, state));
124
+ }
92
125
  if (isClaudeFleetBot(config)) {
93
126
  claudeLogin ??= (deps.claudeLogin ?? (() => checkClaudeCodeLogin({ home })))();
94
127
  if (claudeLogin.signedIn) {
@@ -45,6 +45,7 @@
45
45
  import { appendThreadEvent, clamp, ensureLaneMeshThread, ensureProjectThread, getProjectView, patchStoryStatus, putProjectView, requireToken, registerMeshProject, resolveMeshCompany, } from "../mesh/api.js";
46
46
  import { LanesError } from "./errors.js";
47
47
  import { terminalEnvelopeStoryStatus } from "./stories.js";
48
+ import { gatedInteractiveLogin } from "../../utils/interactive-caller.js";
48
49
  export const LANE_MESH_EVENTS = [
49
50
  "lane_started",
50
51
  "lane_progress",
@@ -309,7 +310,8 @@ async function repairProjectView(input) {
309
310
  }
310
311
  export function createLaneMesh(opts = {}) {
311
312
  const interactive = opts.interactive ?? true;
312
- const token = opts.token ?? (() => requireToken({ interactive }));
313
+ // Gated per call: the same mesh can serve a terminal and a detached watcher.
314
+ const token = opts.token ?? (() => requireToken({ interactive: gatedInteractiveLogin(interactive) }));
313
315
  const repair = opts.repairProjectView ?? repairProjectView;
314
316
  return {
315
317
  async ensureProjectThread(company, projectId) {
@@ -495,6 +495,9 @@ export function parseLaneRecord(raw, filePath) {
495
495
  const schema_version = requireString(raw.schema_version, "schema_version");
496
496
  parseMajorSchema(schema_version, "hq-lane", 1);
497
497
  requireString(raw.lane_id, "lane_id");
498
+ if (raw.source_report_pending !== undefined && typeof raw.source_report_pending !== "boolean") {
499
+ throw new LaneSchemaError("source_report_pending must be boolean", { field: "source_report_pending" });
500
+ }
498
501
  if (raw.signal !== undefined) {
499
502
  if (!raw.signal || typeof raw.signal !== "object" || Array.isArray(raw.signal)) {
500
503
  throw new LaneSchemaError("signal must be an object", { field: "signal" });
@@ -1818,6 +1821,10 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
1818
1821
  }
1819
1822
  }
1820
1823
  if (!isTerminalLaneState(current.state) && isTerminalLaneState(updated.state)) {
1824
+ // Couple the report-event intent to the terminal commit. The resident
1825
+ // Executive can publish it even if the watcher dies before appending.
1826
+ if (updated.signal && updated.company)
1827
+ updated.source_report_pending = true;
1821
1828
  const releasedGrant = updated.hire_grant ?? current.hire_grant ?? 0;
1822
1829
  return withoutStaleQueuedDetails({
1823
1830
  ...updated,
@@ -654,6 +654,8 @@ export interface LaneSignal {
654
654
  export interface LaneRecord {
655
655
  signal?: LaneSignal;
656
656
  signal_link?: string;
657
+ /** Event outbox bit, persisted atomically with a signaled lane's terminal transition. */
658
+ source_report_pending?: boolean;
657
659
  schema_version: typeof LANE_SCHEMA_VERSION | string;
658
660
  lane_id: string;
659
661
  /** CLI release that most recently wrote this lane record. Absent on legacy rows. */
@@ -255,6 +255,7 @@ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spaw
255
255
  /** Field ownership at the durable-record mutation boundary (spec §10.1). */
256
256
  export const LANE_FIELD_WRITERS = {
257
257
  signal: ["creator"], signal_link: ["creator"],
258
+ source_report_pending: ["watcher"],
258
259
  schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator", "command"], company_history: ["command"],
259
260
  project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], focus_overrides: ["command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
260
261
  depth: ["command"], senior: ["command"], senior_history: ["command"],
@@ -1,36 +1,3 @@
1
- /**
2
- * Per-lane watcher (spec §5.7).
3
- *
4
- * Armed at spawn, detached from the caller. Emits on progress, terminal, and
5
- * silence (20 minutes with the process still alive). Terminal classification
6
- * follows spec §5: an envelope decides done/ask/blocked; a process that exits
7
- * with partial output and no envelope is `interrupted`; silence is never
8
- * success.
9
- *
10
- * Progress has two sources. Output files (worker.out, worker.err,
11
- * last-message.txt) cover codex and grok, which stream as they work. They do
12
- * not cover Claude: `claude -p --output-format json` writes nothing until it
13
- * exits, so a healthy Claude lane used to hit the silence deadline at exactly
14
- * 20 minutes every time, and a hung one looked identical. The second source
15
- * is logs/tool-activity.json, which the drain hook rewrites on every
16
- * PreToolUse call (dropbox.ts). A worker that is working calls tools; a
17
- * worker that has not called a tool or written output for the whole window
18
- * is `stalled`, and the watcher says so on the lane record.
19
- *
20
- * What this cannot tell apart: a worker hung inside one tool call from a
21
- * worker running one legitimately long tool call (a 25-minute build); a
22
- * worker looping on tool calls without making progress from a working one;
23
- * a model stuck mid-generation with no tool call from one that is writing a
24
- * long answer. It also depends on the drain hook being registered in the
25
- * provider home (hooks-install.ts): with no hook there is no touch, and the
26
- * lane reads as stalled every window, which is the pre-existing behaviour.
27
- * Distinguishing a hung tool from a slow one needs the provider to report
28
- * per-call state, which none of the three CLIs expose today. `stalled` is
29
- * therefore a flag for the senior to look at, never a kill.
30
- *
31
- * Queue admission of the next lane when a slot frees is owned by the
32
- * periodic reconcile pass and is out of scope here.
33
- */
34
1
  import { ENVELOPE_INVALID_REASON, type LaneEnvelope, type LaneCompanyRef, type LaneRecord, type LaneState } from "./types.js";
35
2
  import { type QuotaSignal } from "./quota.js";
36
3
  import { type ProviderRejection } from "./provider-rejection.js";
@@ -1,3 +1,4 @@
1
+ import { emitExecutiveLaneFinished } from "../bot/jobs/source-report.js";
1
2
  /**
2
3
  * Per-lane watcher (spec §5.7).
3
4
  *
@@ -561,6 +562,12 @@ function applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts = {}
561
562
  updated.model) {
562
563
  clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, updated.provider, updated.account, updated.model, new Date(at));
563
564
  }
565
+ try {
566
+ emitExecutiveLaneFinished(hqRoot, updated);
567
+ }
568
+ catch (error) {
569
+ process.stderr.write(`hq lanes watcher: source report event failed: ${error instanceof Error ? error.message : String(error)}\n`);
570
+ }
564
571
  notifySlotFreed({
565
572
  hqRoot,
566
573
  provider: updated.provider,
@@ -9,6 +9,7 @@ import { ensureCognitoToken, isMachineIdentity } from "../../utils/cognito-sessi
9
9
  import { peekIdToken } from "../../utils/id-token.js";
10
10
  import { getCompanyUid, vaultApiFetch } from "../../utils/vault-api.js";
11
11
  import { meshCacheRoot, removeSessionFallbackFiles, writeMeshCacheFile, } from "./cache.js";
12
+ import { gatedInteractiveLogin } from "../../utils/interactive-caller.js";
12
13
  const PRINCIPAL_UID = /^(prs|agt)_/;
13
14
  export const STORY_STATUSES = new Set([
14
15
  "queued",
@@ -183,7 +184,7 @@ export async function requireToken(opts = {}) {
183
184
  // hard policy indigo-agent-caller-resolution-id-token, agent claims ride
184
185
  // the ID token only; the server is correct to require it. Humans keep the
185
186
  // person access token (unchanged).
186
- const interactive = opts.interactive ?? true;
187
+ const interactive = gatedInteractiveLogin(opts.interactive ?? true);
187
188
  if (isMachineIdentity()) {
188
189
  return ensureCognitoToken({ tokenSource: "machine", interactive });
189
190
  }
@@ -1,13 +1,3 @@
1
- /**
2
- * hq mesh daemon run — single-instance resident process.
3
- *
4
- * - pid lock at ~/.hq/work-mesh/daemon/pid
5
- * - fs.watch spool + 2s debounce; flush at least every 10s (US-008 path)
6
- * - replay context outbox
7
- * - board.md refresh for bound sessions ≤ every 60s
8
- * - MQTT presence (contract-3)
9
- * - SIGTERM/SIGINT: flush once more, MQTT DISCONNECT (server derives offline), exit
10
- */
11
1
  import { type DeliverFn } from "../../../work-context/outbox.js";
12
2
  import { type SessionEventsPoster } from "../session-events-client.js";
13
3
  import { type FlushSummary } from "../flush.js";
@@ -1,3 +1,4 @@
1
+ import { reportingRuntimeAllowed } from "../reporting-privacy.js";
1
2
  /**
2
3
  * hq mesh daemon run — single-instance resident process.
3
4
  *
@@ -380,6 +381,8 @@ export async function runMeshDaemon(deps = {}) {
380
381
  baseUrl: resolveVaultApiBase(env),
381
382
  });
382
383
  return flushSessionEvents({
384
+ env,
385
+ reportingAllowed: () => reportingRuntimeAllowed(machineIdentity, env),
383
386
  workMeshRoot: meshRoot,
384
387
  workContextRoot: ctxRoot,
385
388
  poster,
@@ -1,14 +1,3 @@
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
1
  import { type EmitPoster, type EmitEventStatus } from "./emit-client.js";
13
2
  import { type RandomFn, type SleepFn } from "./backoff.js";
14
3
  export interface EmitStateFile {
@@ -23,6 +12,8 @@ export interface EmitStateFile {
23
12
  export declare function emitStatePath(workMeshRoot: string): string;
24
13
  export declare function readEmitState(workMeshRoot: string): EmitStateFile | null;
25
14
  export interface EmitEventsDeps {
15
+ env?: NodeJS.ProcessEnv;
16
+ reportingAllowed?: () => Promise<boolean>;
26
17
  workMeshRoot: string;
27
18
  poster: EmitPoster;
28
19
  /** New events to emit this invocation (may be empty — just drain retry). */
@@ -1,3 +1,4 @@
1
+ import { reportingLocallyDisabled, mayEmitConversation } from "./reporting-privacy.js";
1
2
  /**
2
3
  * Direct-emit orchestration (owner decision 2026-09-08). One invocation:
3
4
  * 1. drains the local retry file (prior failures),
@@ -90,10 +91,27 @@ export async function emitEvents(deps) {
90
91
  statuses: { accepted: 0, unassigned: 0, rejected: 0 },
91
92
  rejections: [],
92
93
  };
94
+ const env = deps.env ?? process.env;
95
+ if (reportingLocallyDisabled(env) || (deps.reportingAllowed && !(await deps.reportingAllowed()))) {
96
+ const written = writeEmitRetry(deps.workMeshRoot, combined, retryMax);
97
+ return { ...summary, attempted: 0, retained: written.written,
98
+ retryDepth: written.written, droppedOverflow: written.dropped };
99
+ }
100
+ const allowed = combined.filter(event => {
101
+ if (mayEmitConversation(event, env))
102
+ return true;
103
+ summary.rejected += 1;
104
+ summary.statuses.rejected += 1;
105
+ const eventId = eventIdOf(event);
106
+ if (eventId)
107
+ summary.rejections.push({ eventId, code: "PERSONAL_OR_UNATTRIBUTED" });
108
+ return false;
109
+ });
110
+ summary.attempted = allowed.length;
93
111
  const keep = [];
94
112
  let anyPosted = false;
95
- for (let i = 0; i < combined.length; i += MESH_EVENTS_BATCH_MAX) {
96
- const chunk = combined.slice(i, i + MESH_EVENTS_BATCH_MAX);
113
+ for (let i = 0; i < allowed.length; i += MESH_EVENTS_BATCH_MAX) {
114
+ const chunk = allowed.slice(i, i + MESH_EVENTS_BATCH_MAX);
97
115
  summary.batches += 1;
98
116
  let result = await deps.poster(chunk);
99
117
  for (let attempt = 1; attempt < maxAttempts && !result.ok && result.retryable; attempt += 1) {
@@ -24,6 +24,8 @@ export declare const HELD_MAX_LINES = 20000;
24
24
  export declare const PROJECT_REBIND_INTERVAL_MS = 60000;
25
25
  export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "NEEDS_PROJECT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
26
26
  export interface FlushDeps {
27
+ /** Production callers re-read the shared person preference before each flush. */
28
+ reportingAllowed?: () => Promise<boolean>;
27
29
  workMeshRoot: string;
28
30
  workContextRoot: string;
29
31
  poster: SessionEventsPoster;
@@ -9,9 +9,10 @@
9
9
  * 2xx responses are accounted per eventId (accepted / duplicates / rejected).
10
10
  */
11
11
  import * as fs from "node:fs";
12
+ import { reportingLocallyDisabled } from "./reporting-privacy.js";
12
13
  import * as path from "node:path";
13
14
  import { readMetaCompanySlug, resolveCompany } from "../../work-context/company.js";
14
- import { companyBindingChanged, reboundSessionProjection, } from "../../work-context/company-rebind.js";
15
+ import { companyBindingChanged, isNonCloudCompanySlug, reboundSessionProjection, } from "../../work-context/company-rebind.js";
15
16
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
16
17
  import { rebindNeedsProjectOnPrompt } from "../../work-context/prompt-rebind.js";
17
18
  import { readSessionState, updateSessionState, writeSessionState } from "../../work-context/state.js";
@@ -172,6 +173,12 @@ async function postBatchWithRetries(poster, body, deps) {
172
173
  * Recovers orphan `*.claimed` files first; restores unprocessed lines on throw.
173
174
  */
174
175
  export async function flushSessionEvents(inputDeps) {
176
+ const env = inputDeps.env ?? process.env;
177
+ if (reportingLocallyDisabled(env) ||
178
+ (inputDeps.reportingAllowed && !(await inputDeps.reportingAllowed()))) {
179
+ return { claimed: 0, posted: 0, held: 0, dropped: 0, deadLettered: 0,
180
+ restored: 0, batches: 0, heldClaimed: false, heldOverflow: 0 };
181
+ }
175
182
  // One resolver per flush: git lookups are cached per cwd for this flush only.
176
183
  const deps = {
177
184
  ...inputDeps,
@@ -268,6 +275,30 @@ export async function flushSessionEvents(inputDeps) {
268
275
  break sessionLoop;
269
276
  let state = readSessionState(sessionId, deps.workContextRoot);
270
277
  state = applySessionMetaCompanyRebind(sessionId, items, state, deps, now);
278
+ // Personal is the documented non-cloud scope, even if an old projection
279
+ // still contains a company UID or a pending presence leave.
280
+ if (isNonCloudCompanySlug(state?.companySlug) || items.some(item => isNonCloudCompanySlug(eventText(item.event, "companySlug")))) {
281
+ summary.dropped += items.length;
282
+ for (const item of items)
283
+ markDisposed(item.event);
284
+ continue;
285
+ }
286
+ if (state?.companyConfidence === "device_default" && deps.authorizeDeviceDefaultTenant) {
287
+ await deps.authorizeDeviceDefaultTenant(state);
288
+ state = readSessionState(sessionId, deps.workContextRoot) ?? state;
289
+ }
290
+ if (state?.companyConfidence === "device_default") {
291
+ summary.held += appendHeldLinesIfAbsent(items.map(item => heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? now().toISOString())), deps.workMeshRoot);
292
+ for (const item of items)
293
+ markDisposed(item.event);
294
+ continue;
295
+ }
296
+ if (isNonCloudCompanySlug(state?.companySlug)) {
297
+ summary.dropped += items.length;
298
+ for (const item of items)
299
+ markDisposed(item.event);
300
+ continue;
301
+ }
271
302
  const leaveResult = await postPresenceLeaveIfNeeded({
272
303
  sessionId,
273
304
  items,
@@ -726,7 +757,7 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now, uidCache)
726
757
  // Stay synchronous unless a uid lookup is needed: the flush loop's
727
758
  // microtask ordering is relied on by retry/back-pressure timing.
728
759
  const company = resolveHeldCompany(sessionId, items, state, deps);
729
- if (!company)
760
+ if (!company || isNonCloudCompanySlug(company.slug))
730
761
  return state;
731
762
  if (company.uid) {
732
763
  return writeHeldCompanyBinding(sessionId, state, { uid: company.uid, slug: company.slug, source: company.source }, deps, now);
@@ -996,7 +1027,7 @@ function classifySession(state) {
996
1027
  state.companyUid === state.ownerMismatch.companyUid) {
997
1028
  return { action: "drop", reason: "owner_mismatch" };
998
1029
  }
999
- if (state.contextStatus === "needs_company") {
1030
+ if (state.companyConfidence === "device_default" || state.contextStatus === "needs_company") {
1000
1031
  return { action: "hold", heldReason: "NEEDS_COMPANY" };
1001
1032
  }
1002
1033
  if (state.contextStatus === "company_conflict") {
@@ -0,0 +1,7 @@
1
+ /** Match the off controls used by the hook and handoff command. */
2
+ export declare function reportingLocallyDisabled(env?: NodeJS.ProcessEnv): boolean;
3
+ /** Legacy emit must not let the server infer a company for conversation text. */
4
+ export declare function mayEmitConversation(event: Record<string, unknown>, env?: NodeJS.ProcessEnv): boolean;
5
+ /** Fleet identities have no person preference; operator and local controls still apply. */
6
+ export declare function reportingRuntimeAllowed(machine: boolean, env?: NodeJS.ProcessEnv): Promise<boolean>;
7
+ //# sourceMappingURL=reporting-privacy.d.ts.map
@@ -0,0 +1,47 @@
1
+ import { anywhereRuntimeAllowed } from "../../anywhere-person-setting.js";
2
+ import { anywhereRuntimeEnabledFromSnapshot } from "../../anywhere-runtime-fast-flag.js";
3
+ import { hqAnywhereRuntimeLocallyDisabled } from "../../anywhere-runtime-flag-key.js";
4
+ import { isNonCloudCompanySlug } from "../../work-context/company-rebind.js";
5
+ import { readMetaCompanySlug, resolveCompany } from "../../work-context/company.js";
6
+ import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "../../work-context/repo-remote.js";
7
+ import { workContextRoot } from "../../work-context/paths.js";
8
+ import { readSessionState } from "../../work-context/state.js";
9
+ /** Match the off controls used by the hook and handoff command. */
10
+ export function reportingLocallyDisabled(env = process.env) {
11
+ const hooks = (env.HQ_DISABLED_HOOKS ?? "").split(",").map(value => value.trim());
12
+ return ["1", "true", "TRUE", "yes", "YES", "on", "ON"].includes(env.HQ_WORK_MESH_DISABLED ?? "") ||
13
+ hooks.some(value => ["*", "work-mesh", "work-mesh-live"].includes(value)) ||
14
+ hqAnywhereRuntimeLocallyDisabled(env);
15
+ }
16
+ /** Legacy emit must not let the server infer a company for conversation text. */
17
+ export function mayEmitConversation(event, env = process.env) {
18
+ if (event.kind !== "session_end" && typeof event.summary !== "string")
19
+ return true;
20
+ if (typeof event.sessionId !== "string" || !event.sessionId.trim())
21
+ return false;
22
+ const evidence = event.evidence && typeof event.evidence === "object"
23
+ ? event.evidence : {};
24
+ const text = (key) => typeof evidence[key] === "string" ? evidence[key] : undefined;
25
+ const root = workContextRoot(undefined, env);
26
+ const state = readSessionState(event.sessionId, root);
27
+ const meta = readMetaCompanySlug(event.sessionId, text("hqRoot"));
28
+ if (isNonCloudCompanySlug(meta) || isNonCloudCompanySlug(state?.companySlug) || isNonCloudCompanySlug(text("companySlug")) ||
29
+ state?.companyConfidence === "device_default" || state?.contextStatus === "untracked")
30
+ return false;
31
+ // The flushing process is not the originating session. Its spawn/company
32
+ // identity cannot authorize historical conversation text.
33
+ const resolved = resolveCompany({ root, sessionId: event.sessionId,
34
+ env: { HQ_AGENT_IDENTITY_FILE: "" },
35
+ cwd: text("cwd"), hqRoot: text("hqRoot"),
36
+ remoteOwnerSlug: deriveRemoteOwnerSlug({ cwd: text("cwd"), hqRoot: text("hqRoot") }) ?? undefined,
37
+ repoIdentityKey: text("cwd") ? deriveRepoIdentityKey({ cwd: text("cwd") }) ?? undefined : undefined,
38
+ trusted: { companySlug: text("companySlug") }, existingState: state });
39
+ return resolved.status === "resolved" && !isNonCloudCompanySlug(resolved.company.slug);
40
+ }
41
+ /** Fleet identities have no person preference; operator and local controls still apply. */
42
+ export async function reportingRuntimeAllowed(machine, env = process.env) {
43
+ if (reportingLocallyDisabled(env))
44
+ return false;
45
+ return machine ? anywhereRuntimeEnabledFromSnapshot(env) : anywhereRuntimeAllowed(env);
46
+ }
47
+ //# sourceMappingURL=reporting-privacy.js.map
@@ -12,11 +12,10 @@
12
12
  * 4b. persisted per-repo company map (gap 4 — person "always ask per repo",
13
13
  * remembered by repoIdentityKey; above the git-remote heuristic + device
14
14
  * default, below the identity file so an agent box always wins)
15
- * 5. enabled device default
16
- * 6. exactly one deterministic mapping (cwd under companies/{slug}/ or repo remote)
15
+ * 5. exactly one deterministic mapping (cwd under companies/{slug}/ or repo remote)
17
16
  *
18
17
  * Prompt-text matching is never used.
19
- * Device default + conflicting deterministic evidence → company_conflict.
18
+ * Device defaults never attribute a session; explicit evidence is required.
20
19
  */
21
20
  import type { TrustedExplicitContext } from "./contract.js";
22
21
  import { type SessionStateFile } from "./state.js";