@indigoai-us/hq-cli 5.343.0 → 5.344.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-catalog.generated.d.ts +15 -4
  3. package/dist/command-catalog.generated.js +19 -4
  4. package/dist/commands/cloud.js +2 -2
  5. package/dist/commands/daemon.js +2 -11
  6. package/dist/commands/dm.js +10 -1
  7. package/dist/commands/lanes-shared.d.ts +1 -0
  8. package/dist/commands/lanes.d.ts +1 -0
  9. package/dist/commands/lanes.js +11 -0
  10. package/dist/commands/login.js +12 -1
  11. package/dist/commands/logout.js +10 -0
  12. package/dist/commands/mesh.d.ts +27 -1
  13. package/dist/commands/mesh.js +123 -12
  14. package/dist/lib/agent-kit/mcp/tools.js +1 -1
  15. package/dist/lib/bot/api.d.ts +1 -0
  16. package/dist/lib/bot/api.js +2 -0
  17. package/dist/lib/bot/progress.d.ts +12 -14
  18. package/dist/lib/bot/progress.js +29 -40
  19. package/dist/lib/bot/run.js +24 -24
  20. package/dist/lib/core-utils/sentry-report.d.ts +1 -1
  21. package/dist/lib/core-utils/sentry-report.js +18 -1
  22. package/dist/lib/daemon/command-call.d.ts +9 -0
  23. package/dist/lib/daemon/command-call.js +18 -0
  24. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  25. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  26. package/dist/lib/work-context/config.d.ts +66 -0
  27. package/dist/lib/work-context/config.js +142 -0
  28. package/dist/lib/work-context/person-identity.d.ts +16 -0
  29. package/dist/lib/work-context/person-identity.js +39 -0
  30. package/dist/utils/cli-telemetry.js +5 -1
  31. package/dist/utils/client-health.d.ts +7 -3
  32. package/dist/utils/client-health.js +30 -6
  33. package/dist/utils/install-tree-torn.d.ts +16 -13
  34. package/dist/utils/install-tree-torn.js +58 -26
  35. package/dist/utils/resident-package-preload.d.ts +1 -1
  36. package/dist/utils/resident-package-preload.js +18 -0
  37. package/package.json +1 -1
@@ -28,13 +28,10 @@ export declare const PROGRESS_MIN_GAP_MS = 4000;
28
28
  export declare const WORKING_NOTICE_MS = 60000;
29
29
  export declare const WORKING_NOTICE_TEXT = "Working on it \u2014 I'll reply here when it's done.";
30
30
  export interface ProgressPosterOptions {
31
- /** Post one message; resolve true when it was delivered. */
32
- send: (body: string) => Promise<boolean>;
33
- /**
34
- * If provided, the final reply is sent through this instead of `send`.
35
- * Use to tag the final reply differently from in-between progress messages.
36
- */
37
- sendFinal?: (body: string) => Promise<boolean>;
31
+ /** Post a live progress beat while the turn is still running. */
32
+ sendProgress: (body: string) => Promise<boolean>;
33
+ /** Post terminal content that the person needs to read. */
34
+ sendToPerson: (body: string) => Promise<boolean>;
38
35
  now?: () => number;
39
36
  setTimer?: (fn: () => void, ms: number) => unknown;
40
37
  clearTimer?: (handle: unknown) => void;
@@ -63,8 +60,8 @@ export declare function normalizeForCompare(text: string): string;
63
60
  /** Matches {"action":"no_reply"} anywhere in a model reply, with or without preamble text. */
64
61
  export declare function containsNoReplySentinel(text: string): boolean;
65
62
  export declare class ProgressPoster {
66
- private readonly send;
67
- private readonly sendFinalFn?;
63
+ private readonly sendProgress;
64
+ private readonly sendToPerson;
68
65
  private readonly now;
69
66
  private readonly setTimer;
70
67
  private readonly clearTimer;
@@ -95,6 +92,8 @@ export declare class ProgressPoster {
95
92
  * deliveries posted the placeholder right after a real message.
96
93
  */
97
94
  private queued;
95
+ /** Posts queued for the person, excluding agent-lane status beats. */
96
+ private personQueued;
98
97
  constructor(opts: ProgressPosterOptions);
99
98
  /** Arm the one-time "working on it" note. Call when the turn starts. */
100
99
  start(): void;
@@ -109,11 +108,9 @@ export declare class ProgressPoster {
109
108
  /** True once any message from the model was said this turn (not counting the notice). */
110
109
  get saidAnything(): boolean;
111
110
  /**
112
- * The turn succeeded: post whatever is pending, then the final answer unless
113
- * it repeats what was already said. Resolves once everything is sent.
114
- *
115
- * When `sendFinal` was provided, progress items flush through `send` and the
116
- * final reply goes through `sendFinal` — keeping their audience tags distinct.
111
+ * The turn succeeded: post pending human-facing text, then a human-lane
112
+ * terminal message when the final adds something. Agent-lane status beats
113
+ * alone still receive a human-lane fallback before the turn closes.
117
114
  */
118
115
  finish(finalText: string): Promise<void>;
119
116
  /**
@@ -125,6 +122,7 @@ export declare class ProgressPoster {
125
122
  private scheduleFlush;
126
123
  private flushNow;
127
124
  private enqueue;
125
+ private enqueueToPerson;
128
126
  private enqueueWith;
129
127
  private cancelFlush;
130
128
  private cancelNotice;
@@ -36,8 +36,8 @@ export function containsNoReplySentinel(text) {
36
36
  return /\{\s*"action"\s*:\s*"no_reply"\s*\}/.test(text);
37
37
  }
38
38
  export class ProgressPoster {
39
- send;
40
- sendFinalFn;
39
+ sendProgress;
40
+ sendToPerson;
41
41
  now;
42
42
  setTimer;
43
43
  clearTimer;
@@ -68,9 +68,11 @@ export class ProgressPoster {
68
68
  * deliveries posted the placeholder right after a real message.
69
69
  */
70
70
  queued = 0;
71
+ /** Posts queued for the person, excluding agent-lane status beats. */
72
+ personQueued = 0;
71
73
  constructor(opts) {
72
- this.send = opts.send;
73
- this.sendFinalFn = opts.sendFinal;
74
+ this.sendProgress = opts.sendProgress;
75
+ this.sendToPerson = opts.sendToPerson;
74
76
  this.now = opts.now ?? Date.now;
75
77
  this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
76
78
  this.clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
@@ -92,7 +94,7 @@ export class ProgressPoster {
92
94
  if (this.finished || this.said.length > 0 || this.noticeSent)
93
95
  return;
94
96
  this.noticeSent = true;
95
- this.enqueue(this.workingNoticeText);
97
+ this.enqueueWith(this.workingNoticeText, this.sendProgress);
96
98
  }, this.workingNoticeMs);
97
99
  }
98
100
  /** One finished assistant message from the model. */
@@ -147,11 +149,9 @@ export class ProgressPoster {
147
149
  return this.said.length > 0;
148
150
  }
149
151
  /**
150
- * The turn succeeded: post whatever is pending, then the final answer unless
151
- * it repeats what was already said. Resolves once everything is sent.
152
- *
153
- * When `sendFinal` was provided, progress items flush through `send` and the
154
- * final reply goes through `sendFinal` — keeping their audience tags distinct.
152
+ * The turn succeeded: post pending human-facing text, then a human-lane
153
+ * terminal message when the final adds something. Agent-lane status beats
154
+ * alone still receive a human-lane fallback before the turn closes.
155
155
  */
156
156
  async finish(finalText) {
157
157
  if (this.finished)
@@ -171,42 +171,25 @@ export class ProgressPoster {
171
171
  // Grok's final is every message concatenated, so it repeats and is skipped.
172
172
  // A silent turn (run.ts decides via sentinel/empty) never reaches here.
173
173
  if (this.buffer) {
174
+ const terminalParts = this.said.filter((s) => !containsNoReplySentinel(s));
174
175
  this.pending = [];
175
176
  this.heldQuestions = [];
176
- for (const s of this.said) {
177
- if (!containsNoReplySentinel(s))
178
- this.pending.push(s);
179
- }
180
177
  if (final && !containsNoReplySentinel(final) && !this.repeatsSaid(final)) {
181
- if (this.sendFinalFn) {
182
- // Keep the audience split: collected messages go through send, the
183
- // final reply through sendFinal.
184
- this.flushNow();
185
- this.enqueueWith(final, this.sendFinalFn);
186
- return this.chain;
187
- }
188
- this.pending.push(final);
178
+ terminalParts.push(final);
189
179
  }
190
- this.flushNow();
180
+ this.enqueueToPerson(terminalParts.join("\n\n") || "(no reply)");
191
181
  return this.chain;
192
182
  }
193
183
  const held = this.heldQuestions;
194
184
  this.heldQuestions = [];
195
- const shouldAddFinal = Boolean(final && !this.repeatsSaid(final));
196
- if (!shouldAddFinal && held.length > 0)
197
- this.pending.push(...held);
198
- if (this.pending.length === 0 && this.queued === 0 && !shouldAddFinal)
199
- this.pending.push("(no reply)");
200
- if (this.sendFinalFn && shouldAddFinal) {
201
- // Flush any remaining progress messages first, then the final reply separately.
202
- this.flushNow();
203
- this.enqueueWith(final, this.sendFinalFn);
204
- }
205
- else {
206
- if (shouldAddFinal)
207
- this.pending.push(final);
208
- this.flushNow();
209
- }
185
+ this.flushNow();
186
+ const shouldAddFinal = Boolean(final && !containsNoReplySentinel(final) && !this.repeatsSaid(final));
187
+ if (shouldAddFinal)
188
+ this.enqueueToPerson(final);
189
+ else if (held.length > 0)
190
+ this.enqueueToPerson(held.join("\n\n"));
191
+ else if (this.personQueued === 0)
192
+ this.enqueueToPerson("(no reply)");
210
193
  return this.chain;
211
194
  }
212
195
  /**
@@ -226,9 +209,11 @@ export class ProgressPoster {
226
209
  this.heldQuestions = [];
227
210
  return this.chain;
228
211
  }
229
- this.pending.push(...this.heldQuestions);
212
+ const held = this.heldQuestions;
230
213
  this.heldQuestions = [];
231
214
  this.flushNow();
215
+ if (held.length > 0)
216
+ this.enqueueToPerson(held.join("\n\n"));
232
217
  return this.chain;
233
218
  }
234
219
  repeatsSaid(final) {
@@ -256,7 +241,11 @@ export class ProgressPoster {
256
241
  this.enqueue(body);
257
242
  }
258
243
  enqueue(body) {
259
- this.enqueueWith(body, this.send);
244
+ this.enqueueToPerson(body);
245
+ }
246
+ enqueueToPerson(body) {
247
+ this.personQueued += 1;
248
+ this.enqueueWith(body, this.sendToPerson);
260
249
  }
261
250
  enqueueWith(body, sendFn) {
262
251
  // Defence in depth: never let the silence sentinel reach the chat, whatever
@@ -766,8 +766,8 @@ export async function runBot(deps) {
766
766
  ...deps.progress,
767
767
  ...(args.workingNotice === false || silenceable || isSetupBot ? { workingNoticeMs: 0 } : {}),
768
768
  ...(silenceable || args.suppressOutput ? { buffer: true } : {}),
769
- send: makeSendFn(args.send),
770
- ...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
769
+ sendProgress: makeSendFn(args.sendProgress),
770
+ sendToPerson: makeSendFn(args.sendToPerson),
771
771
  });
772
772
  const controller = new AbortController();
773
773
  currentTurns.add(controller);
@@ -827,7 +827,7 @@ export async function runBot(deps) {
827
827
  const root = rootEventId ? { rootEventId } : {};
828
828
  return target.kind === "dm"
829
829
  ? api.sendDm({ toPersonUid: target.peerUid, body, ...root, ...(audience ? { audience } : {}) })
830
- : api.sendChannelMessage({ channelId: target.channelId, body, ...root });
830
+ : api.sendChannelMessage({ channelId: target.channelId, body, ...root, ...(audience ? { audience } : {}) });
831
831
  };
832
832
  /**
833
833
  * Post `parts` in order, starting at `from`. A failure throws a
@@ -863,15 +863,15 @@ export async function runBot(deps) {
863
863
  throw err;
864
864
  }
865
865
  };
866
- const send = makeSend(target.kind === "dm" ? "agent" : undefined);
867
- const sendFinal = makeSend(target.kind === "dm" ? "human" : undefined);
866
+ const sendProgress = makeSend("agent");
867
+ const sendToPerson = makeSend("human");
868
868
  const saved = (reply) => {
869
869
  const parts = splitBody(reply);
870
870
  const partial = last.value;
871
871
  const sameBody = partial !== null && partial.parts.length === parts.length && partial.parts.every((p, i) => p === parts[i]);
872
872
  return { reply, replyParts: parts, replyPartsDelivered: sameBody ? partial.delivered : 0 };
873
873
  };
874
- return { send, sendFinal, saved };
874
+ return { sendProgress, sendToPerson, saved };
875
875
  };
876
876
  // ── Scheduled jobs ────────────────────────────────────────────────────────
877
877
  /** Newest pending run key already picked up, per jobId (one catch-up per wake). */
@@ -1113,14 +1113,14 @@ export async function runBot(deps) {
1113
1113
  }
1114
1114
  };
1115
1115
  const target = { kind: "dm", peerUid };
1116
- const { send, sendFinal, saved } = trackingSend(target);
1116
+ const { sendProgress, sendToPerson, saved } = trackingSend(target);
1117
1117
  const { outcome, poster } = await runTurnWithProgress({
1118
1118
  marker: { messageId: KICKOFF_MESSAGE_ID, target, ...(thinking && introEventId ? { reactionEventId: introEventId } : {}) },
1119
1119
  prompt,
1120
1120
  sessionScope: "dm",
1121
1121
  tReceived,
1122
- send,
1123
- sendFinal,
1122
+ sendProgress,
1123
+ sendToPerson,
1124
1124
  // The intro already said the bot is starting, and the app shows it thinking.
1125
1125
  workingNotice: false,
1126
1126
  });
@@ -1146,7 +1146,7 @@ export async function runBot(deps) {
1146
1146
  clearInflight(dir, KICKOFF_MESSAGE_ID);
1147
1147
  patchBotConfig(dir, { kickoffPendingSignIn: true });
1148
1148
  markSignInBroken(outcome.excerpt);
1149
- await tellSignInNeeded("dm", send, true);
1149
+ await tellSignInNeeded("dm", sendToPerson, true);
1150
1150
  return;
1151
1151
  }
1152
1152
  if (readBotConfig(dir)?.kickoffPendingSignIn)
@@ -1158,7 +1158,7 @@ export async function runBot(deps) {
1158
1158
  ? `${didNotFinishText(failureReason(outcome), poster.postedCount > 0)} Send me any message and I'll pick up from there.`
1159
1159
  : `Sorry — I couldn't get started on my own (${runtime.id} failed: ${outcome.excerpt.slice(0, 160)}). Send me any message and I'll pick up from there, or check that ${runtime.id} is signed in on this computer.`;
1160
1160
  try {
1161
- await send(body);
1161
+ await sendToPerson(body);
1162
1162
  clearInflight(dir, KICKOFF_MESSAGE_ID);
1163
1163
  }
1164
1164
  catch {
@@ -1235,8 +1235,8 @@ export async function runBot(deps) {
1235
1235
  throw err;
1236
1236
  }
1237
1237
  };
1238
- const send = makeSend(tracking.send);
1239
- const sendFinal = makeSend(tracking.sendFinal);
1238
+ const sendProgress = makeSend(tracking.sendProgress);
1239
+ const sendToPerson = makeSend(tracking.sendToPerson);
1240
1240
  const normalize = (text) => text.replace(/\s+/g, " ").trim();
1241
1241
  /**
1242
1242
  * True only when the final report itself reached the chat in full. With no
@@ -1272,8 +1272,8 @@ export async function runBot(deps) {
1272
1272
  prompt: buildJobTurnPrompt(job, { now: now(), timeZone }),
1273
1273
  sessionScope: target.sessionScope,
1274
1274
  tReceived,
1275
- send,
1276
- sendFinal,
1275
+ sendProgress,
1276
+ sendToPerson,
1277
1277
  // A scheduled run has nobody waiting in a chat: no "working on it" filler.
1278
1278
  workingNotice: false,
1279
1279
  ...(quietJob ? { suppressOutput: true } : {}),
@@ -1307,7 +1307,7 @@ export async function runBot(deps) {
1307
1307
  clearInflight(dir, id);
1308
1308
  attempts.delete(id);
1309
1309
  markSignInBroken(outcome.excerpt);
1310
- await tellSignInNeeded(`job:${jobId}`, (body) => sendToTarget(markerTarget, body));
1310
+ await tellSignInNeeded(`job:${jobId}`, (body) => sendToTarget(markerTarget, body, undefined, "human"));
1311
1311
  log("warn", `kept scheduled run ${id} of job ${jobId} until ${runtime.id} can sign in again`);
1312
1312
  return; // un-acked, un-reported: run once the sign-in works
1313
1313
  }
@@ -1320,7 +1320,7 @@ export async function runBot(deps) {
1320
1320
  return;
1321
1321
  }
1322
1322
  if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
1323
- await send(`I couldn't run this one — ${runtime.id} kept failing: ${excerpt.slice(0, 160)}.`).catch(() => undefined);
1323
+ await sendToPerson(`I couldn't run this one — ${runtime.id} kept failing: ${excerpt.slice(0, 160)}.`).catch(() => undefined);
1324
1324
  await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(excerpt), disposition: `${runtime.id} could not be started` });
1325
1325
  markReported("failed");
1326
1326
  markProcessed(dir, id, now().toISOString());
@@ -1339,7 +1339,7 @@ export async function runBot(deps) {
1339
1339
  // reports and says this real reason, not "the bot was restarted".
1340
1340
  patchInflight(dir, id, { jobFailure: { reason, ...(outcome.excerpt ? { excerpt: outcome.excerpt } : {}) } });
1341
1341
  try {
1342
- await send(didNotFinishText(reason, poster.postedCount > 0));
1342
+ await sendToPerson(didNotFinishText(reason, poster.postedCount > 0));
1343
1343
  }
1344
1344
  catch (err) {
1345
1345
  // Leave it un-acked with the marker: the next poll reports it.
@@ -1534,7 +1534,7 @@ export async function runBot(deps) {
1534
1534
  const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? ownerText :
1535
1535
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
1536
1536
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
1537
- const { send, sendFinal, saved } = trackingSend(markerTarget, item.rootEventId);
1537
+ const { sendProgress, sendToPerson, saved } = trackingSend(markerTarget, item.rootEventId);
1538
1538
  const { outcome, poster } = await runTurnWithProgress({
1539
1539
  marker: {
1540
1540
  messageId: id,
@@ -1545,8 +1545,8 @@ export async function runBot(deps) {
1545
1545
  prompt,
1546
1546
  sessionScope,
1547
1547
  tReceived,
1548
- send,
1549
- sendFinal,
1548
+ sendProgress,
1549
+ sendToPerson,
1550
1550
  silenceable: target.kind === "room" && !target.mentioned,
1551
1551
  ...(readDirs.length ? { readDirs } : {}),
1552
1552
  ...(target.kind === "room"
@@ -1605,7 +1605,7 @@ export async function runBot(deps) {
1605
1605
  attempts.delete(id);
1606
1606
  markSignInBroken(outcome.excerpt);
1607
1607
  const conversation = markerTarget.kind === "dm" ? sessionScope : `room:${markerTarget.channelId}:${item.rootEventId ?? ""}`;
1608
- await tellSignInNeeded(conversation, send);
1608
+ await tellSignInNeeded(conversation, sendToPerson);
1609
1609
  log("warn", `kept ${id} until ${runtime.id} can sign in again`);
1610
1610
  return; // un-acked: answered once the sign-in works
1611
1611
  }
@@ -1620,7 +1620,7 @@ export async function runBot(deps) {
1620
1620
  }
1621
1621
  if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
1622
1622
  try {
1623
- await send(`Sorry — I couldn't answer that one (${runtime.id} kept failing: ${excerpt.slice(0, 160)}). Try again in a bit, or check that ${runtime.id} is signed in on this computer.`);
1623
+ await sendToPerson(`Sorry — I couldn't answer that one (${runtime.id} kept failing: ${excerpt.slice(0, 160)}). Try again in a bit, or check that ${runtime.id} is signed in on this computer.`);
1624
1624
  }
1625
1625
  catch {
1626
1626
  /* best-effort */
@@ -1660,7 +1660,7 @@ export async function runBot(deps) {
1660
1660
  // still say we did not finish — but with a plain reason, never the raw
1661
1661
  // internal error string.
1662
1662
  const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
1663
- await send(didNotFinishText(reason, poster.postedCount > 0));
1663
+ await sendToPerson(didNotFinishText(reason, poster.postedCount > 0));
1664
1664
  }
1665
1665
  catch (err) {
1666
1666
  // Leave it un-acked with the marker: the next poll says it did not finish.
@@ -26,7 +26,7 @@ export declare const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
26
26
  * is owner-approved telemetry for the exact hook script and may include a
27
27
  * company directory; it is never a substitute for hook input or a transcript.
28
28
  */
29
- export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "hook_script", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average", "spawn_ms", "slow_child", "slow_child_ms", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence", "hook_timeout_debug_context"];
29
+ export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "hook_script", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average", "spawn_ms", "slow_child", "slow_child_ms", "slow_phase", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence", "hook_timeout_debug_context"];
30
30
  export declare const MAX_SENTRY_REPORT_METADATA_KEYS: number;
31
31
  export type HookSequenceEntry = {
32
32
  script: string;
@@ -130,18 +130,25 @@ const DEBUG_CONTEXT_HOOK_EVENTS = new Set([
130
130
  "TaskCompleted",
131
131
  "TeammateIdle",
132
132
  ]);
133
- const DEBUG_CONTEXT_PHASES = new Set([
133
+ const SAFE_SLOW_PHASES = new Set([
134
134
  "startup",
135
135
  "source",
136
136
  "config_load",
137
137
  "policy_load",
138
138
  "external_command",
139
139
  "output_write",
140
+ "output_scan",
141
+ "output_merge",
142
+ "output_stdout",
143
+ "output_abandoned_scan",
144
+ "output_abandoned_merge",
145
+ "output_abandoned_stdout",
140
146
  "wait",
141
147
  "child_wait",
142
148
  "probe",
143
149
  "parse",
144
150
  ]);
151
+ const DEBUG_CONTEXT_PHASES = new Set(SAFE_SLOW_PHASES);
145
152
  /**
146
153
  * The only hook diagnostics that can be attached as metadata. The list omits
147
154
  * command text, prompts, payloads, company names, and repositories. `hook_path`
@@ -166,6 +173,7 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
166
173
  "spawn_ms",
167
174
  "slow_child",
168
175
  "slow_child_ms",
176
+ "slow_phase",
169
177
  "exit_code",
170
178
  "final_elapsed_ms",
171
179
  "bash_env_set",
@@ -255,6 +263,11 @@ function boundedDebugName(value, known) {
255
263
  function boundedDebugPhase(value) {
256
264
  return typeof value === "string" && DEBUG_CONTEXT_PHASES.has(value) ? value : "other";
257
265
  }
266
+ function safeSlowPhase(value) {
267
+ if (typeof value !== "string")
268
+ return "unknown";
269
+ return SAFE_SLOW_PHASES.has(value) ? value : "other";
270
+ }
258
271
  function boundedDebugHookEvent(value) {
259
272
  return typeof value === "string" && DEBUG_CONTEXT_HOOK_EVENTS.has(value) ? value : "other";
260
273
  }
@@ -425,6 +438,9 @@ function parseSentryReportEvent(stdin) {
425
438
  if (typeof metadata.slow_child === "string") {
426
439
  metadata.slow_child = safeSlowChild(metadata);
427
440
  }
441
+ if ("slow_phase" in metadata) {
442
+ metadata.slow_phase = safeSlowPhase(metadata.slow_phase);
443
+ }
428
444
  return {
429
445
  code: 0,
430
446
  event: {
@@ -678,6 +694,7 @@ function hookTimeoutTags(event) {
678
694
  load_bucket: loadAverage === "unavailable" ? "unavailable" : loadBucket(loadAverage),
679
695
  spawn_ms_bucket: spawnMsBucket(metadata),
680
696
  slow_child: safeSlowChild(metadata),
697
+ slow_phase: safeSlowPhase(metadata.slow_phase),
681
698
  slow_child_ms_bucket: typeof slowChildMs === "number" && Number.isFinite(slowChildMs)
682
699
  ? elapsedBucket(slowChildMs)
683
700
  : "unknown",
@@ -0,0 +1,9 @@
1
+ interface DaemonCallDependencies {
2
+ home: string;
3
+ env: NodeJS.ProcessEnv;
4
+ out: (line: string) => void;
5
+ }
6
+ /** Send one CLI request after its protocol module has been preloaded. */
7
+ export declare function runDaemonCall(op: string, args: Record<string, unknown>, timeoutMs: number, deps: DaemonCallDependencies): Promise<void>;
8
+ export {};
9
+ //# sourceMappingURL=command-call.d.ts.map
@@ -0,0 +1,18 @@
1
+ /** Send one CLI request after its protocol module has been preloaded. */
2
+ export async function runDaemonCall(op, args, timeoutMs, deps) {
3
+ const { hqdRequest, hqdSocketPath, HqdClientError } = await import("./protocol.js");
4
+ try {
5
+ const result = await hqdRequest(op, args, {
6
+ socketPath: hqdSocketPath(deps.home, deps.env),
7
+ timeoutMs,
8
+ });
9
+ deps.out(JSON.stringify(result, null, 2));
10
+ }
11
+ catch (error) {
12
+ if (!(error instanceof HqdClientError))
13
+ throw error;
14
+ deps.out(JSON.stringify({ error: { code: error.code, message: error.message } }, null, 2));
15
+ process.exitCode = 1;
16
+ }
17
+ }
18
+ //# sourceMappingURL=command-call.js.map
@@ -146,6 +146,7 @@ export declare function dispatchOutpostPickup(port: OutpostExecPort, input: {
146
146
  command: string;
147
147
  timeoutSeconds?: number;
148
148
  waitMs?: number;
149
+ beforeWait?: () => Promise<void>;
149
150
  }): Promise<OutpostDispatchResult>;
150
151
  export declare function dispatchErrorMessage(err: unknown): string;
151
152
  /** Factory matching `hq outposts exec`'s client wiring. */
@@ -252,6 +252,7 @@ export async function stageOutpostPayload(port, outpostId, bytes, put = fetch) {
252
252
  }
253
253
  export async function dispatchOutpostPickup(port, input) {
254
254
  const submitted = await port.submitExec(input.command, input.outpostId, input.timeoutSeconds ?? PICKUP_EXEC_TIMEOUT_SECONDS);
255
+ await input.beforeWait?.();
255
256
  const ac = new AbortController();
256
257
  const waitMs = input.waitMs ?? PICKUP_WAIT_MS;
257
258
  const timer = setTimeout(() => ac.abort(), waitMs);
@@ -44,10 +44,41 @@ export interface RepoCompanyMapping {
44
44
  updatedAt: string;
45
45
  }
46
46
  type RepoCompanyMap = Record<string, RepoCompanyMapping>;
47
+ /**
48
+ * Explicit opt-in to count usage from HQ sessions with no company toward the
49
+ * device default company. hq-cloud's usage sync reads this field and credits
50
+ * the default company only when mode is exactly "default-company", companyUid
51
+ * starts with "cmp_" and equals the default company's uid, and optedInAt
52
+ * parses as a date. Company owners and admins can see company-attributed
53
+ * usage, so this is never written without the person's explicit consent.
54
+ */
55
+ export interface UsageAttributionOptIn {
56
+ mode: "default-company";
57
+ companyUid: string;
58
+ /**
59
+ * The signed-in person who consented: the `custom:entityUid` claim (prs_...)
60
+ * of the cached Cognito person session. Consent never carries to another
61
+ * account on the same OS user.
62
+ */
63
+ personUid: string;
64
+ optedInAt: string;
65
+ }
66
+ /** A person principal uid, as carried in the `custom:entityUid` token claim. */
67
+ export declare function isPersonUid(value: unknown): value is string;
68
+ /** How `setDefaultCompany` treats the usage-attribution opt-in. */
69
+ export type UsageAttributionChoice =
70
+ /** Write an opt-in for the newly set company (requires its uid). */
71
+ "opt-in"
72
+ /** Remove any opt-in. */
73
+ | "opt-out"
74
+ /** Keep an existing opt-in only when it is for the same company uid. */
75
+ | "keep-if-same";
47
76
  export interface WorkContextDeviceConfig {
48
77
  schemaVersion: typeof DEVICE_CONFIG_SCHEMA_VERSION;
49
78
  /** Explicit device default. Never copied from activeCompany. */
50
79
  defaultCompany?: DeviceDefaultCompany | null;
80
+ /** Opt-in to count no-company HQ session usage toward the default company. */
81
+ usageAttribution?: UsageAttributionOptIn;
51
82
  /** Marks a null default written by the automatic-activation implementation. */
52
83
  defaultCompanyAutomaticEligible?: true;
53
84
  /** Last-known migration capability across memberships (doctor reports this). */
@@ -75,12 +106,31 @@ export interface DeviceConfigDeps {
75
106
  */
76
107
  validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
77
108
  now?: () => Date;
109
+ /**
110
+ * Usage-attribution opt-in handling for setDefaultCompany. Defaults to
111
+ * "keep-if-same": an existing opt-in survives only when it is for the same
112
+ * company uid, so changing the default never carries consent across companies.
113
+ */
114
+ usageAttribution?: UsageAttributionChoice;
115
+ /**
116
+ * The signed-in person (prs_...). Required to write an opt-in; an existing
117
+ * opt-in is kept only when it was given by this person.
118
+ */
119
+ personUid?: string | null;
78
120
  }
79
121
  /** Acquire an interprocess config lock; exported for direct lock regression tests. */
80
122
  export declare function acquireDeviceConfigLock(root: string, options?: {
81
123
  timeoutMs?: number;
82
124
  staleMs?: number;
83
125
  }): () => void;
126
+ /** Parse a raw usageAttribution value; anything malformed is dropped (fail closed). */
127
+ export declare function sanitizeUsageAttribution(raw: unknown): UsageAttributionOptIn | undefined;
128
+ /**
129
+ * The opt-in that is in effect: present, for the uid of the current enabled
130
+ * default company, and given by the signed-in person. Mirrors the hq-cloud
131
+ * reader. No signed-in person means no opt-in.
132
+ */
133
+ export declare function effectiveUsageAttribution(cfg: Pick<WorkContextDeviceConfig, "defaultCompany" | "usageAttribution">, personUid: string | null | undefined): UsageAttributionOptIn | null;
84
134
  /** Injection points for assertSafeConfigPath (tests only). */
85
135
  export interface SafeConfigPathIo {
86
136
  platform?: NodeJS.Platform;
@@ -104,6 +154,22 @@ export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">):
104
154
  /** A clear is a durable opt-out, not an invitation for automatic reactivation. */
105
155
  export declare function isDefaultCompanyDisabled(deps: Pick<DeviceConfigDeps, "root">): boolean;
106
156
  export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps): Promise<WorkContextDeviceConfig>;
157
+ /**
158
+ * Record or remove the usage-attribution opt-in for the current default
159
+ * company. Opting in requires an enabled, explicitly chosen default with a
160
+ * cmp_ uid that equals `companyUid` (the company the person was asked about);
161
+ * an automatic single-company default is never opted in.
162
+ */
163
+ export declare function setUsageAttribution(optIn: {
164
+ companyUid: string;
165
+ personUid: string;
166
+ } | null, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
167
+ /**
168
+ * Drop the usage-attribution opt-in unless it was given by `personUid`. Called
169
+ * on login (identity may have changed) and logout (null: always drop). A
170
+ * config with no opt-in, or one already matching, is left untouched.
171
+ */
172
+ export declare function retainUsageAttributionForPerson(personUid: string | null | undefined, deps: Pick<DeviceConfigDeps, "root" | "now">): boolean;
107
173
  export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
108
174
  /**
109
175
  * Read the persisted company mapping for a repo identity key (gap 4).