@indigoai-us/hq-cli 5.178.0 → 5.179.1

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.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.179.1] — 2026-09-24
6
+
7
+ ### Added
8
+
9
+ - `hq dm send` now takes `--audience <human|agent|both>` so a bot or operator
10
+ can declare who a DM is for, and the `hq_dm_send` bot tool accepts the same
11
+ optional field. Local bot turns tag in-between progress messages as `agent`
12
+ and the final reply as `human` by default, so status chatter stays out of a
13
+ person's inbox while the answer they need still reaches them. `hq dm inbox`
14
+ and `hq dm thread` mark agent-audience messages with an `[agent]` tag.
15
+
16
+ ## [5.179.0] — 2026-09-24
17
+
18
+ ### Fixed
19
+
20
+ - `hq integrations connect --client-id` with a Google OAuth client (`*.apps.googleusercontent.com`) now warns that the client must be of type "Web application" and prints the redirect URI to register. A "Desktop app" client cannot accept HQ's hosted callback, and Google reports that only after the browser is open.
21
+
5
22
  ## [5.178.0] — 2026-09-24
6
23
 
7
24
  ### Fixed
8
25
 
26
+ - Work Mesh transcript-watch still tracks hook-covered sessions (interactive Claude/Codex) and enqueues `session_end` after 10 minutes of quiet. Hooks keep owning start/turn events; the quiet `session_end` is the channel work-report trigger those sessions were missing. Resume (transcript mtime or a later hook event) resets the timer. A SessionEnd hook within 60s of that emit is not treated as resume, so `/exit` does not double-fire.
9
27
  - Reloading a macOS LaunchAgent (`hq mesh daemon install`, including `--if-stale` and the post-update restart, `hq daemon install`, `hq agent kit` services, and bot agents) now waits for `launchctl bootout` to finish before bootstrapping. Before, bootstrap could race the old job's teardown, fail with "Bootstrap failed: 5: Input/output error", and leave the service unloaded with nothing running. Bootstrap now polls `launchctl print` for up to 10 seconds, retries EIO with backoff (0.5s to 8s), and on final failure either restarts the still-loaded previous job or reports that the service is NOT loaded with the `launchctl bootstrap` command to fix it.
10
28
 
11
29
  ## [5.177.0] — 2026-09-24
@@ -17,7 +35,6 @@
17
35
  show include grant and alive-child counts. Terminal child resume, message, and
18
36
  account-move paths recheck occupancy and remaining grant allocation. The
19
37
  backfill defaults to dry-run.
20
-
21
38
  - When `lanes.hire-grants` is enabled, `hq lanes list` and `hq lanes show` use counts
22
39
  from the owning Outpost for active remote lanes. If the Outpost does not return
23
40
  counts, both commands show local counts and a warning.
@@ -52,7 +69,6 @@
52
69
  - Bot LaunchAgents and systemd units, the Work Mesh daemon unit, the `hq daemon` unit, and the lanes and agent-kit units now name node by its upgrade-stable path (`/opt/homebrew/bin/node` or `/opt/homebrew/opt/<formula>/bin/node`, or nvm's `current` link) instead of the versioned `/opt/homebrew/Cellar/node/<version>/bin/node`. Before, `brew upgrade node` deleted that path and launchd could no longer start the bots. The stable path is used only when it resolves to the same node binary; otherwise the unit keeps the resolved path as before.
53
70
  - Installed bot LaunchAgents that still name a versioned or missing node are repaired: `hq bot start` rewrites and reloads the agent, a bot started by launchd rewrites its own agent and reloads it from a detached shell before it takes any message, and `hq doctor` reports it (`bots.<name>.daemon-node`), with `hq doctor --fix` rewriting only the plist file and not reloading launchd. `hq bot list` and `hq bot status` flag a missing node binary. `hq mesh daemon install --if-stale` also reinstalls a same-version unit whose node path is missing or versioned.
54
71
 
55
-
56
72
  ## [5.172.0] — 2026-09-24
57
73
 
58
74
  ### Changed
@@ -89,7 +105,6 @@
89
105
 
90
106
  - Switching the HQ session company (including `hq-session.sh set company_slug`) clears the Work Mesh project and task, posts `session_end` to the previous company, and holds a personal target as `needs_company`.
91
107
 
92
-
93
108
  ## [5.169.0] — 2026-09-24
94
109
 
95
110
  ### Fixed
@@ -2255,6 +2255,9 @@ export declare const COMMAND_CATALOG: readonly [{
2255
2255
  }, {
2256
2256
  readonly flags: "--in <duration>";
2257
2257
  readonly description: "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)";
2258
+ }, {
2259
+ readonly flags: "--audience <human|agent|both>";
2260
+ readonly description: "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both (1:1 DMs only)";
2258
2261
  }];
2259
2262
  readonly subcommands: readonly [];
2260
2263
  }, {
@@ -2896,6 +2896,10 @@ export const COMMAND_CATALOG = [
2896
2896
  {
2897
2897
  "flags": "--in <duration>",
2898
2898
  "description": "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)"
2899
+ },
2900
+ {
2901
+ "flags": "--audience <human|agent|both>",
2902
+ "description": "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both (1:1 DMs only)"
2899
2903
  }
2900
2904
  ],
2901
2905
  "subcommands": []
@@ -133,6 +133,7 @@ export declare function resolveMentions(tokens: string[], members: ChannelMember
133
133
  * Returns null on anything that doesn't match. Pure → unit-testable.
134
134
  */
135
135
  export declare function parseDuration(input: string): number | null;
136
+ export type DmAudience = "human" | "agent" | "both";
136
137
  export interface DmSendBody {
137
138
  toEmail?: string;
138
139
  toPersonUid?: string;
@@ -140,6 +141,7 @@ export interface DmSendBody {
140
141
  prompt?: string;
141
142
  details?: string;
142
143
  deliverAt?: string;
144
+ audience?: DmAudience;
143
145
  }
144
146
  /**
145
147
  * Build the POST /v1/notify/dm request body from CLI inputs. Pure (no I/O,
@@ -156,6 +158,7 @@ export declare function buildDmBody(args: {
156
158
  at?: string;
157
159
  inDelay?: string;
158
160
  now: number;
161
+ audience?: string;
159
162
  }): DmSendBody;
160
163
  /** A pending incoming connection request, as returned by the backend. */
161
164
  export interface ConnectionRequest {
@@ -204,6 +207,7 @@ export interface DmInboxEvent {
204
207
  details?: string;
205
208
  prompt?: string;
206
209
  acknowledgedAt?: string;
210
+ audience?: DmAudience;
207
211
  /** US-007 file references. Singular `attachment` is the legacy form. */
208
212
  attachments?: DmAttachment[];
209
213
  attachment?: DmAttachment;
@@ -219,6 +223,7 @@ export interface DmThreadMessage {
219
223
  direction: "in" | "out";
220
224
  details?: string;
221
225
  prompt?: string;
226
+ audience?: DmAudience;
222
227
  attachments?: DmAttachment[];
223
228
  attachment?: DmAttachment;
224
229
  }
@@ -276,6 +276,7 @@ export function parseDuration(input) {
276
276
  };
277
277
  return n * mult[m[2]];
278
278
  }
279
+ const DM_AUDIENCE_VALUES = ["human", "agent", "both"];
279
280
  /**
280
281
  * Build the POST /v1/notify/dm request body from CLI inputs. Pure (no I/O,
281
282
  * no clock) so the option-resolution logic is unit-testable; the caller
@@ -312,12 +313,20 @@ export function buildDmBody(args) {
312
313
  }
313
314
  const prompt = args.prompt?.trim();
314
315
  const details = args.details?.trim();
316
+ let audience;
317
+ if (args.audience !== undefined && args.audience !== "") {
318
+ if (!DM_AUDIENCE_VALUES.includes(args.audience)) {
319
+ throw new Error(`Invalid --audience '${args.audience}': must be human, agent, or both.`);
320
+ }
321
+ audience = args.audience;
322
+ }
315
323
  return {
316
324
  ...rcpt,
317
325
  body,
318
326
  ...(prompt ? { prompt } : {}),
319
327
  ...(details ? { details } : {}),
320
328
  ...(deliverAt ? { deliverAt } : {}),
329
+ ...(audience ? { audience } : {}),
321
330
  };
322
331
  }
323
332
  function friendlyDmError(status, code, fallback) {
@@ -670,6 +679,7 @@ async function runDmSend(recipient, message, opts) {
670
679
  at: opts.at,
671
680
  inDelay: opts.in,
672
681
  now: Date.now(),
682
+ audience: opts.audience,
673
683
  });
674
684
  const token = await ensureCognitoToken();
675
685
  const res = await vaultApiFetch({
@@ -831,14 +841,16 @@ export function formatInboxEvent(e, nowMs) {
831
841
  const when = chalk.dim(formatRelativeTime(e.createdAt, nowMs));
832
842
  const who = chalk.bold(senderLabel(e));
833
843
  const email = e.fromEmail && e.fromDisplayName ? chalk.dim(` <${e.fromEmail}>`) : "";
834
- return `${marker} ${when} ${who}${email}\n ${firstLine(e.body)}${formatAttachments(e)}`;
844
+ const audienceTag = e.audience === "agent" ? chalk.dim(" [agent]") : "";
845
+ return `${marker} ${when} ${who}${email}${audienceTag}\n ${firstLine(e.body)}${formatAttachments(e)}`;
835
846
  }
836
847
  /** Render one 1:1 thread line, tagged by direction. */
837
848
  export function formatThreadMessage(m, nowMs) {
838
849
  const arrow = m.direction === "out" ? chalk.dim("→") : chalk.cyan("←");
839
850
  const who = m.direction === "out" ? "you" : senderLabel(m);
840
851
  const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
841
- return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
852
+ const audienceTag = m.audience === "agent" ? chalk.dim(" [agent]") : "";
853
+ return `${arrow} ${chalk.bold(who)} ${when}${audienceTag}\n ${firstLine(m.body)}${formatAttachments(m)}`;
842
854
  }
843
855
  /** Render one channel/group message line. */
844
856
  export function formatChannelMessage(m, nowMs, opts = {}) {
@@ -1280,6 +1292,7 @@ export function registerDmCommand(program) {
1280
1292
  .option("--details-file <path>", "Read the details from a file (1:1 DMs only)")
1281
1293
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
1282
1294
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
1295
+ .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both (1:1 DMs only)")
1283
1296
  .action(async (recipient, message, opts) => {
1284
1297
  await runDmSend(recipient, message, opts);
1285
1298
  });
@@ -47,6 +47,15 @@ export declare function consoleOrigin(vaultApiUrl?: string): string | undefined;
47
47
  * by slug), or undefined when the console for this control plane is unknown.
48
48
  */
49
49
  export declare function consoleIntegrationsUrl(companySlug: string | undefined, origin: string | undefined): string | undefined;
50
+ /**
51
+ * Google issues "Desktop app" and "Web application" OAuth clients with the
52
+ * same client-id shape, but only a Web application client accepts HQ's hosted
53
+ * https callback. A Desktop client fails at Google with `redirect_uri_mismatch`
54
+ * after the user has already opened the browser, so say it up front
55
+ * (Undrdog, 2026-09-24). The callback is read from the authorize URL hq-pro
56
+ * built, so the hint always names the URL Google will actually be sent.
57
+ */
58
+ export declare function googleClientTypeHint(clientId: string, authorizationUrl: string): string | null;
50
59
  export declare function findAlreadyConnected(connections: readonly AdminConnection[], app: string): AdminConnection | undefined;
51
60
  export declare function registerConnectCommands(integrations: Command): void;
52
61
  //# sourceMappingURL=integrations-connect.d.ts.map
@@ -851,12 +851,36 @@ async function userClientSignIn(token, companyUid, target, opts, client, request
851
851
  });
852
852
  console.error(`Open this URL to sign in to ${started.displayName || target.label}:`);
853
853
  console.error(` ${started.authorizationUrl}`);
854
+ const googleHint = googleClientTypeHint(client.clientId, started.authorizationUrl);
855
+ if (googleHint)
856
+ console.error(chalk.yellow(googleHint));
854
857
  if (opts.browser !== false)
855
858
  await open(started.authorizationUrl).catch(() => { });
856
859
  console.error(chalk.dim("The browser finishes on the HQ console. When it says connected, " +
857
860
  "run `hq integrations list` to confirm."));
858
861
  return null;
859
862
  }
863
+ /**
864
+ * Google issues "Desktop app" and "Web application" OAuth clients with the
865
+ * same client-id shape, but only a Web application client accepts HQ's hosted
866
+ * https callback. A Desktop client fails at Google with `redirect_uri_mismatch`
867
+ * after the user has already opened the browser, so say it up front
868
+ * (Undrdog, 2026-09-24). The callback is read from the authorize URL hq-pro
869
+ * built, so the hint always names the URL Google will actually be sent.
870
+ */
871
+ export function googleClientTypeHint(clientId, authorizationUrl) {
872
+ if (!clientId.toLowerCase().endsWith(".apps.googleusercontent.com"))
873
+ return null;
874
+ let redirectUri = null;
875
+ try {
876
+ redirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri");
877
+ }
878
+ catch {
879
+ // An unparseable URL still gets the client-type half of the hint.
880
+ }
881
+ return ("This Google OAuth client must be of type \"Web application\" (a \"Desktop app\" client will not work)" +
882
+ (redirectUri ? `, with this authorized redirect URI:\n ${redirectUri}` : "."));
883
+ }
860
884
  /**
861
885
  * Second chance at a credential when the target's auth mode wasn't known up
862
886
  * front.
@@ -213,12 +213,13 @@ export function buildAgentMcpTools(clients) {
213
213
  },
214
214
  {
215
215
  name: "hq_dm_send",
216
- description: "Send an HQ direct message as this agent to a person (email or prs_…) or agent (agt_…).",
216
+ description: "Send an HQ direct message as this agent to a person (email or prs_…) or agent (agt_…). Use audience='agent' for progress/status messages that only agents need to see; audience='human' for messages a person must read; audience='both' when unsure.",
217
217
  inputSchema: {
218
218
  type: "object",
219
219
  properties: {
220
220
  to: { type: "string", description: "Email, prs_… or agt_… uid" },
221
221
  message: { type: "string" },
222
+ audience: { type: "string", enum: ["human", "agent", "both"], description: "Who this message is for (default: human)" },
222
223
  },
223
224
  required: ["to", "message"],
224
225
  additionalProperties: false,
@@ -226,6 +227,10 @@ export function buildAgentMcpTools(clients) {
226
227
  handler: async (args) => {
227
228
  const to = str(args, "to", { required: true, max: 320 }).trim();
228
229
  const message = str(args, "message", { required: true, max: 20_000 }).trim();
230
+ const audience = str(args, "audience");
231
+ if (audience !== undefined && !["human", "agent", "both"].includes(audience)) {
232
+ throw new McpToolInputError('"audience" must be human, agent, or both');
233
+ }
229
234
  const body = /^[^\s]+@[^\s]+$/.test(to)
230
235
  ? { toEmail: to.toLowerCase(), body: message }
231
236
  : isRecipientUid(to)
@@ -233,6 +238,8 @@ export function buildAgentMcpTools(clients) {
233
238
  : (() => {
234
239
  throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid');
235
240
  })();
241
+ if (audience)
242
+ body.audience = audience;
236
243
  const token = await clients.getToken();
237
244
  const res = await clients.apiJson(token, "/v1/notify/dm", { method: "POST", body });
238
245
  if (res.status < 200 || res.status >= 300) {
@@ -216,6 +216,7 @@ export declare class BotApi {
216
216
  toPersonUid: string;
217
217
  body: string;
218
218
  rootEventId?: string;
219
+ audience?: "human" | "agent" | "both";
219
220
  }): Promise<{
220
221
  eventId?: string;
221
222
  createdAt?: string;
@@ -156,6 +156,8 @@ export class BotApi {
156
156
  const payload = { toPersonUid: recipient, body: input.body };
157
157
  if (input.rootEventId)
158
158
  payload.rootEventId = input.rootEventId;
159
+ if (input.audience)
160
+ payload.audience = input.audience;
159
161
  const { body } = await this.call("/v1/notify/dm", { method: "POST", body: payload });
160
162
  return body;
161
163
  }
@@ -30,6 +30,11 @@ export declare const WORKING_NOTICE_TEXT = "Working on it \u2014 I'll reply here
30
30
  export interface ProgressPosterOptions {
31
31
  /** Post one message; resolve true when it was delivered. */
32
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>;
33
38
  now?: () => number;
34
39
  setTimer?: (fn: () => void, ms: number) => unknown;
35
40
  clearTimer?: (handle: unknown) => void;
@@ -42,6 +47,7 @@ export interface ProgressPosterOptions {
42
47
  export declare function normalizeForCompare(text: string): string;
43
48
  export declare class ProgressPoster {
44
49
  private readonly send;
50
+ private readonly sendFinalFn?;
45
51
  private readonly now;
46
52
  private readonly setTimer;
47
53
  private readonly clearTimer;
@@ -73,6 +79,9 @@ export declare class ProgressPoster {
73
79
  /**
74
80
  * The turn succeeded: post whatever is pending, then the final answer unless
75
81
  * it repeats what was already said. Resolves once everything is sent.
82
+ *
83
+ * When `sendFinal` was provided, progress items flush through `send` and the
84
+ * final reply goes through `sendFinal` — keeping their audience tags distinct.
76
85
  */
77
86
  finish(finalText: string): Promise<void>;
78
87
  /**
@@ -84,6 +93,7 @@ export declare class ProgressPoster {
84
93
  private scheduleFlush;
85
94
  private flushNow;
86
95
  private enqueue;
96
+ private enqueueWith;
87
97
  private cancelFlush;
88
98
  private cancelNotice;
89
99
  }
@@ -33,6 +33,7 @@ export function normalizeForCompare(text) {
33
33
  }
34
34
  export class ProgressPoster {
35
35
  send;
36
+ sendFinalFn;
36
37
  now;
37
38
  setTimer;
38
39
  clearTimer;
@@ -54,6 +55,7 @@ export class ProgressPoster {
54
55
  delivered = 0;
55
56
  constructor(opts) {
56
57
  this.send = opts.send;
58
+ this.sendFinalFn = opts.sendFinal;
57
59
  this.now = opts.now ?? Date.now;
58
60
  this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
59
61
  this.clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
@@ -99,6 +101,9 @@ export class ProgressPoster {
99
101
  /**
100
102
  * The turn succeeded: post whatever is pending, then the final answer unless
101
103
  * it repeats what was already said. Resolves once everything is sent.
104
+ *
105
+ * When `sendFinal` was provided, progress items flush through `send` and the
106
+ * final reply goes through `sendFinal` — keeping their audience tags distinct.
102
107
  */
103
108
  async finish(finalText) {
104
109
  if (this.finished)
@@ -109,13 +114,21 @@ export class ProgressPoster {
109
114
  const final = finalText.trim();
110
115
  const held = this.heldQuestions;
111
116
  this.heldQuestions = [];
112
- if (final && !this.repeatsSaid(final))
113
- this.pending.push(final);
114
- else if (held.length > 0)
117
+ const shouldAddFinal = Boolean(final && !this.repeatsSaid(final));
118
+ if (!shouldAddFinal && held.length > 0)
115
119
  this.pending.push(...held);
116
- if (this.pending.length === 0 && this.delivered === 0 && !final)
120
+ if (this.pending.length === 0 && this.delivered === 0 && !shouldAddFinal)
117
121
  this.pending.push("(no reply)");
118
- this.flushNow();
122
+ if (this.sendFinalFn && shouldAddFinal) {
123
+ // Flush any remaining progress messages first, then the final reply separately.
124
+ this.flushNow();
125
+ this.enqueueWith(final, this.sendFinalFn);
126
+ }
127
+ else {
128
+ if (shouldAddFinal)
129
+ this.pending.push(final);
130
+ this.flushNow();
131
+ }
119
132
  return this.chain;
120
133
  }
121
134
  /**
@@ -158,10 +171,13 @@ export class ProgressPoster {
158
171
  this.enqueue(body);
159
172
  }
160
173
  enqueue(body) {
174
+ this.enqueueWith(body, this.send);
175
+ }
176
+ enqueueWith(body, sendFn) {
161
177
  this.lastPostAt = this.now();
162
178
  this.chain = this.chain.then(async () => {
163
179
  try {
164
- if (await this.send(body))
180
+ if (await sendFn(body))
165
181
  this.delivered += 1;
166
182
  }
167
183
  catch {
@@ -337,6 +337,12 @@ export function buildSystemPrompt(input) {
337
337
  const blocks = input.sources.memory.map((m) => `### ${m.name}\n\n${m.body.trim()}`);
338
338
  parts.push(`## Memory (${memoryDir}/)\n\n${blocks.join("\n\n")}`);
339
339
  }
340
+ parts.push(`## Message audience\n\n` +
341
+ `When sending a DM, tag each message with the right audience using the \`audience\` field:\n` +
342
+ `- \`agent\`: status pings, tool handoffs, and in-progress notes that only another agent needs to read.\n` +
343
+ `- \`human\`: anything a person must read — answers, results, questions, summaries.\n` +
344
+ `- \`both\`: when the content is relevant to both a person and a downstream agent.\n` +
345
+ `When unsure, use \`human\`. Progress messages sent before the final answer should use \`agent\`; the final answer should use \`human\`.`);
340
346
  return `${parts.join("\n\n")}\n`;
341
347
  }
342
348
  /**
@@ -646,23 +646,25 @@ export async function runBot(deps) {
646
646
  writeInflight(dir, { v: 1, ...args.marker, startedAt: now().toISOString() });
647
647
  let running = true;
648
648
  let posted = 0;
649
+ const makeSendFn = (fn) => async (body) => {
650
+ try {
651
+ await fn(body);
652
+ }
653
+ catch (err) {
654
+ log("warn", `post for ${messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
655
+ return false;
656
+ }
657
+ posted += 1;
658
+ patchInflight(dir, messageId, { progressPosted: posted });
659
+ if (running && args.afterProgressPost)
660
+ await args.afterProgressPost().catch(() => undefined);
661
+ return true;
662
+ };
649
663
  const poster = new ProgressPoster({
650
664
  ...deps.progress,
651
665
  ...(args.workingNotice === false ? { workingNoticeMs: 0 } : {}),
652
- send: async (body) => {
653
- try {
654
- await args.send(body);
655
- }
656
- catch (err) {
657
- log("warn", `post for ${messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
658
- return false;
659
- }
660
- posted += 1;
661
- patchInflight(dir, messageId, { progressPosted: posted });
662
- if (running && args.afterProgressPost)
663
- await args.afterProgressPost().catch(() => undefined);
664
- return true;
665
- },
666
+ send: makeSendFn(args.send),
667
+ ...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
666
668
  });
667
669
  const controller = new AbortController();
668
670
  currentTurns.add(controller);
@@ -693,10 +695,10 @@ export async function runBot(deps) {
693
695
  }
694
696
  };
695
697
  const failureReason = (outcome) => outcome.aborted ? STOPPED_BY_OWNER_REASON : `${runtime.id} stopped with an error: ${outcome.excerpt.slice(0, 160)}`;
696
- const postOne = (target, body, rootEventId) => {
698
+ const postOne = (target, body, rootEventId, audience) => {
697
699
  const root = rootEventId ? { rootEventId } : {};
698
700
  return target.kind === "dm"
699
- ? api.sendDm({ toPersonUid: target.peerUid, body, ...root })
701
+ ? api.sendDm({ toPersonUid: target.peerUid, body, ...root, ...(audience ? { audience } : {}) })
700
702
  : api.sendChannelMessage({ channelId: target.channelId, body, ...root });
701
703
  };
702
704
  /**
@@ -704,10 +706,10 @@ export async function runBot(deps) {
704
706
  * PartialPostError naming how many parts got through, so the caller can save
705
707
  * exactly the remainder.
706
708
  */
707
- const sendParts = async (target, parts, from, rootEventId, onDelivered) => {
709
+ const sendParts = async (target, parts, from, rootEventId, onDelivered, audience) => {
708
710
  for (let i = Math.max(0, from); i < parts.length; i += 1) {
709
711
  try {
710
- await postOne(target, parts[i], rootEventId);
712
+ await postOne(target, parts[i], rootEventId, audience);
711
713
  }
712
714
  catch (err) {
713
715
  throw new PartialPostError(parts, i, err);
@@ -716,16 +718,16 @@ export async function runBot(deps) {
716
718
  }
717
719
  };
718
720
  /** Every outbound body goes through here, so nothing over the limit is ever posted whole. */
719
- const sendToTarget = (target, body, rootEventId) => sendParts(target, splitBody(body), 0, rootEventId);
721
+ const sendToTarget = (target, body, rootEventId, audience) => sendParts(target, splitBody(body), 0, rootEventId, undefined, audience);
720
722
  /**
721
723
  * A `send` for one conversation that remembers how much of its last failed
722
724
  * body got through, so the in-flight marker can record the remainder.
723
725
  */
724
726
  const trackingSend = (target, rootEventId) => {
725
727
  const last = { value: null };
726
- const send = async (body) => {
728
+ const makeSend = (audience) => async (body) => {
727
729
  try {
728
- await sendToTarget(target, body, rootEventId);
730
+ await sendToTarget(target, body, rootEventId, audience);
729
731
  last.value = null;
730
732
  }
731
733
  catch (err) {
@@ -733,13 +735,15 @@ export async function runBot(deps) {
733
735
  throw err;
734
736
  }
735
737
  };
738
+ const send = makeSend(target.kind === "dm" ? "agent" : undefined);
739
+ const sendFinal = makeSend(target.kind === "dm" ? "human" : undefined);
736
740
  const saved = (reply) => {
737
741
  const parts = splitBody(reply);
738
742
  const partial = last.value;
739
743
  const sameBody = partial !== null && partial.parts.length === parts.length && partial.parts.every((p, i) => p === parts[i]);
740
744
  return { reply, replyParts: parts, replyPartsDelivered: sameBody ? partial.delivered : 0 };
741
745
  };
742
- return { send, saved };
746
+ return { send, sendFinal, saved };
743
747
  };
744
748
  // ── Scheduled jobs ────────────────────────────────────────────────────────
745
749
  /** Newest pending run key already picked up, per jobId (one catch-up per wake). */
@@ -967,13 +971,14 @@ export async function runBot(deps) {
967
971
  }
968
972
  };
969
973
  const target = { kind: "dm", peerUid };
970
- const { send, saved } = trackingSend(target);
974
+ const { send, sendFinal, saved } = trackingSend(target);
971
975
  const { outcome, poster } = await runTurnWithProgress({
972
976
  marker: { messageId: KICKOFF_MESSAGE_ID, target, ...(thinking && introEventId ? { reactionEventId: introEventId } : {}) },
973
977
  prompt,
974
978
  sessionScope: "dm",
975
979
  tReceived,
976
980
  send,
981
+ sendFinal,
977
982
  // The intro already said the bot is starting, and the app shows it thinking.
978
983
  workingNotice: false,
979
984
  });
@@ -1069,10 +1074,10 @@ export async function runBot(deps) {
1069
1074
  /** The body of the LAST send, when that send failed (the resume point). */
1070
1075
  let lastFailedBody = null;
1071
1076
  const withHeader = (body) => (headerOut ? body : `${header}${body}`);
1072
- const send = async (body) => {
1077
+ const makeSend = (fn) => async (body) => {
1073
1078
  const full = withHeader(body);
1074
1079
  try {
1075
- await tracking.send(full);
1080
+ await fn(full);
1076
1081
  headerOut = true;
1077
1082
  deliveredBodies.push(body);
1078
1083
  lastFailedBody = null;
@@ -1085,6 +1090,8 @@ export async function runBot(deps) {
1085
1090
  throw err;
1086
1091
  }
1087
1092
  };
1093
+ const send = makeSend(tracking.send);
1094
+ const sendFinal = makeSend(tracking.sendFinal);
1088
1095
  const normalize = (text) => text.replace(/\s+/g, " ").trim();
1089
1096
  /**
1090
1097
  * True only when the final report itself reached the chat in full. With no
@@ -1121,6 +1128,7 @@ export async function runBot(deps) {
1121
1128
  sessionScope: target.sessionScope,
1122
1129
  tReceived,
1123
1130
  send,
1131
+ sendFinal,
1124
1132
  // A scheduled run has nobody waiting in a chat: no "working on it" filler.
1125
1133
  workingNotice: false,
1126
1134
  });
@@ -1312,7 +1320,7 @@ export async function runBot(deps) {
1312
1320
  const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? text :
1313
1321
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
1314
1322
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
1315
- const { send, saved } = trackingSend(markerTarget, item.rootEventId);
1323
+ const { send, sendFinal, saved } = trackingSend(markerTarget, item.rootEventId);
1316
1324
  const { outcome, poster } = await runTurnWithProgress({
1317
1325
  marker: {
1318
1326
  messageId: id,
@@ -1324,6 +1332,7 @@ export async function runBot(deps) {
1324
1332
  sessionScope,
1325
1333
  tReceived,
1326
1334
  send,
1335
+ sendFinal,
1327
1336
  workingNotice: !isSetupBot,
1328
1337
  ...(readDirs.length ? { readDirs } : {}),
1329
1338
  ...(target.kind === "room"
@@ -24,6 +24,14 @@
24
24
  * Network: harness claude-desktop | codex, source "transcript" on every event.
25
25
  * Local state keeps transcriptMarker for channel-card / Atlas before network sync;
26
26
  * localHarness is no longer written (harness carries claude-desktop on the wire).
27
+ *
28
+ * Hook-covered sessions (durable hook-written state, or a hook event within
29
+ * TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start / turn_end —
30
+ * hooks own those. The watcher still tracks last activity (transcript mtime or
31
+ * hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
32
+ * quiet so the channel work report has a trigger (US-050b). Resume resets the
33
+ * timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
34
+ * SessionEnd hook catching up and is not treated as resume.
27
35
  */
28
36
  import * as fs from "node:fs";
29
37
  import type { MembershipValidationResult } from "../../../work-context/config.js";
@@ -260,6 +268,14 @@ export declare class TranscriptWatcher {
260
268
  /** Record that a hook path emitted an event for sessionId (daemon flush). */
261
269
  noteHookEvent(sessionId: string, atMs?: number): void;
262
270
  tick(): Promise<TranscriptWatchTickResult>;
271
+ /**
272
+ * Hook-covered sessions already emit start/turns. Still track last activity
273
+ * and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
274
+ * channel work report has a trigger (US-050b). Resume (mtime or a later hook
275
+ * event) resets the timer. A hook event shortly after our own emit is the
276
+ * SessionEnd hook catching up — do not treat it as resume.
277
+ */
278
+ private tickHookCoveredQuietEnd;
263
279
  /**
264
280
  * Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
265
281
  */
@@ -24,6 +24,14 @@
24
24
  * Network: harness claude-desktop | codex, source "transcript" on every event.
25
25
  * Local state keeps transcriptMarker for channel-card / Atlas before network sync;
26
26
  * localHarness is no longer written (harness carries claude-desktop on the wire).
27
+ *
28
+ * Hook-covered sessions (durable hook-written state, or a hook event within
29
+ * TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start / turn_end —
30
+ * hooks own those. The watcher still tracks last activity (transcript mtime or
31
+ * hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
32
+ * quiet so the channel work report has a trigger (US-050b). Resume resets the
33
+ * timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
34
+ * SessionEnd hook catching up and is not treated as resume.
27
35
  */
28
36
  import * as fs from "node:fs";
29
37
  import * as os from "node:os";
@@ -632,8 +640,7 @@ export class TranscriptWatcher {
632
640
  lastHookEventAt: this.deps.lastHookEventAt,
633
641
  readState: this.readState,
634
642
  })) {
635
- result.skippedHookCovered += 1;
636
- this.tracked.delete(d.path);
643
+ this.tickHookCoveredQuietEnd(d, nowMs, result);
637
644
  continue;
638
645
  }
639
646
  let track = this.tracked.get(d.path);
@@ -719,6 +726,63 @@ export class TranscriptWatcher {
719
726
  this.tickInFlight = false;
720
727
  }
721
728
  }
729
+ /**
730
+ * Hook-covered sessions already emit start/turns. Still track last activity
731
+ * and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
732
+ * channel work report has a trigger (US-050b). Resume (mtime or a later hook
733
+ * event) resets the timer. A hook event shortly after our own emit is the
734
+ * SessionEnd hook catching up — do not treat it as resume.
735
+ */
736
+ tickHookCoveredQuietEnd(d, nowMs, result) {
737
+ result.skippedHookCovered += 1;
738
+ const hookAt = this.deps.lastHookEventAt?.get(d.sessionId);
739
+ const lastActivity = Math.max(d.mtimeMs, hookAt ?? 0);
740
+ let track = this.tracked.get(d.path);
741
+ if (!track) {
742
+ if (nowMs - lastActivity > TRANSCRIPT_FRESH_MS)
743
+ return;
744
+ track = {
745
+ path: d.path,
746
+ sessionId: d.sessionId,
747
+ kind: d.kind,
748
+ cwd: d.cwd,
749
+ harness: d.harness,
750
+ lastMtimeMs: d.mtimeMs,
751
+ lastChangeAtMs: lastActivity,
752
+ started: true,
753
+ ended: false,
754
+ pendingTurn: false,
755
+ seq: 0,
756
+ startRetryAttempts: 0,
757
+ nextStartRetryAtMs: 0,
758
+ };
759
+ this.tracked.set(d.path, track);
760
+ }
761
+ if (d.mtimeMs > track.lastMtimeMs) {
762
+ track.lastMtimeMs = d.mtimeMs;
763
+ track.lastChangeAtMs = nowMs;
764
+ if (track.ended)
765
+ track.ended = false;
766
+ }
767
+ if (hookAt !== undefined) {
768
+ const resumeFromHook = !track.ended ||
769
+ track.endedAtMs === undefined ||
770
+ hookAt - track.endedAtMs >= TRANSCRIPT_HOOK_COVER_MS;
771
+ if (resumeFromHook && hookAt > track.lastChangeAtMs) {
772
+ track.lastChangeAtMs = hookAt;
773
+ if (track.ended)
774
+ track.ended = false;
775
+ }
776
+ }
777
+ if (track.started &&
778
+ !track.ended &&
779
+ nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
780
+ this.emitKind(track, "session_end", nowMs);
781
+ track.ended = true;
782
+ track.endedAtMs = nowMs;
783
+ result.ended += 1;
784
+ }
785
+ }
722
786
  /**
723
787
  * Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
724
788
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.178.0",
3
+ "version": "5.179.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {