@indigoai-us/hq-cli 5.249.1 → 5.251.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.251.0] — 2026-09-27
6
+
7
+ ## [5.250.0] — 2026-09-27
8
+
9
+ ### Fixed
10
+
11
+ - A Work Mesh session bound to a project and task keeps the binding episode the
12
+ - An enrolled agent that sets `HQ_AGENT_DIR` now acts as that agent in every
13
+ command, not only under `hq agent`. `hq dm` and `hq secrets` used to look
14
+ for a person's session instead, find none, and open a browser sign-in. On
15
+ the Runneth pilot that hung the first reply for four minutes. Only the
16
+ explicit variable counts, so a bot living beside its owner on the owner's
17
+ computer never takes over the owner's commands.
18
+ - On an agent host (Linux, no terminal, no display), a missing session now
19
+ fails at once with the fix instead of opening a browser sign-in that
20
+ nobody can complete.
21
+ - `hq agent enroll --name` no longer overrides `HQ_AGENT_DIR`. A bot that
22
+ pointed its identity at persistent storage had it written under `$HOME`
23
+ instead, where a rebuild erases it, with a message claiming a person was
24
+ signed in when nobody was.
25
+ - `wake.log` no longer records the wake command's standard output. A wake
26
+ command's normal output (a routine record, an echoed prompt) is not
27
+ diagnostic, and on a share without permissions the log is readable by
28
+ anything with access to the share. Errors are still logged.
29
+ - Bots now reply in the group or channel a message came from. Their inbox
30
+ shows the channel id, `hq dm <channelId>` and the `hq_dm_send` tool post by
31
+ that id (including unnamed group DMs), and the wake and inbox instructions
32
+ say to answer there. Before, a bot answered a group message with a private
33
+ DM to the sender, so the group never heard back. The instructions also tell
34
+ bots not to answer another bot's greetings or sign-offs, so two bots stop
35
+ acknowledging each other in a loop.
36
+ - An agent whose identity files were moved or copied no longer goes silent an
37
+ hour later. The credentials file records where the host key lives; when that
38
+ path is gone and the key sits beside the credentials file, the CLI now points
39
+ at it before running any agent command. `hq agent probe` also fails when the
40
+ host key is missing, instead of passing on a cached token and leaving the
41
+ agent to fail at its next refresh.
42
+
5
43
  ## [5.249.1] — 2026-09-27
6
44
 
7
45
  ## [5.249.0] — 2026-09-27
@@ -204,14 +204,18 @@ function parseEnrollResponse(raw) {
204
204
  */
205
205
  export function resolveEnrollTarget(opts, env = process.env, home = os.homedir()) {
206
206
  const pinned = Boolean(env.HQ_AGENT_DIR?.trim() || env.HQ_MACHINE_CREDS_FILE?.trim());
207
+ // A pinned tree is the environment's explicit choice and wins over --name:
208
+ // a bot that set HQ_AGENT_DIR to its persistent storage must never be
209
+ // redirected to a directory under $HOME, which may be erased on rebuild.
210
+ if (pinned)
211
+ return { paths: agentKitPaths(home, env), local: false };
207
212
  if (opts.name) {
208
213
  const dir = localAgentDir(validateLocalAgentName(opts.name), home);
209
214
  return { paths: agentKitPaths(home, { ...env, HQ_AGENT_DIR: dir }), local: true };
210
215
  }
211
216
  const base = agentKitPaths(home, env);
212
- // A pinned tree is the caller's explicit choice; --replace is an explicit
213
- // choice too. Neither gets silently redirected.
214
- if (pinned || opts.replace)
217
+ // --replace is an explicit choice too; it is not silently redirected.
218
+ if (opts.replace)
215
219
  return { paths: base, local: false };
216
220
  if (detectExistingIdentity(base, env).kind !== "human") {
217
221
  return { paths: base, local: false };
@@ -54,6 +54,8 @@ export interface ProbeDeps {
54
54
  listSecrets: (token: string, companyUid: string) => Promise<number>;
55
55
  postResult: (token: string, result: ProbeResult) => Promise<number>;
56
56
  now?: () => Date;
57
+ /** Test seam; defaults to fs.existsSync. */
58
+ hostKeyExists?: (p: string) => boolean;
57
59
  sleep?: (ms: number) => Promise<void>;
58
60
  nonce?: () => string;
59
61
  dmTimeoutMs?: number;
@@ -63,7 +63,17 @@ export async function runProbe(deps) {
63
63
  const claims = peekIdToken(token);
64
64
  const uid = claims["custom:entityUid"];
65
65
  const type = claims["custom:entityType"];
66
- if (type === "agent" && uid === self) {
66
+ if (type === "agent" && uid === self && !(deps.hostKeyExists ?? fs.existsSync)(deps.creds.hostKeyPath)) {
67
+ // A cached token hides a missing host key for up to an hour: the probe
68
+ // used to pass and the agent then went silent at its next refresh.
69
+ checks.push({
70
+ name: "whoami",
71
+ ok: false,
72
+ detail: `token minted from cache, but there is no host key at ${deps.creds.hostKeyPath}, so the next refresh will fail. Keep host-key beside machine-creds.json, or re-enroll`,
73
+ });
74
+ token = null;
75
+ }
76
+ else if (type === "agent" && uid === self) {
67
77
  checks.push({ name: "whoami", ok: true, detail: `${self} (external, ${deps.creds.companySlug})` });
68
78
  }
69
79
  else {
@@ -84,6 +84,13 @@ export function parseChannelName(recipient) {
84
84
  * unit-testable.
85
85
  */
86
86
  export function matchChannelsByName(channels, name) {
87
+ // A channel id is exact and names nothing else, so it may target any scope,
88
+ // including an unnamed group DM. Bots reply this way: their inbox carries
89
+ // the id of the conversation a message arrived in, not its name.
90
+ const trimmed = name.trim().replace(/^#/, "");
91
+ if (/^chn_[A-Za-z0-9]+$/.test(trimmed)) {
92
+ return channels.filter((c) => c.channelId === trimmed);
93
+ }
87
94
  const targetSlug = channelSlug(name);
88
95
  const targetName = name.trim().toLowerCase();
89
96
  if (!targetSlug && !targetName)
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
+ import { ensureHostKeyPath } from "./creds.js";
21
22
  export const MACHINE_CREDS_FILE_ENV = "HQ_MACHINE_CREDS_FILE";
22
23
  export const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
23
24
  /**
@@ -37,6 +38,14 @@ export function adoptAgentIdentityEnv(paths, env = process.env) {
37
38
  catch {
38
39
  return applied;
39
40
  }
41
+ // A moved or copied identity keeps a stale host-key path until the next
42
+ // token refresh fails; repoint it at the key beside the creds file first.
43
+ try {
44
+ ensureHostKeyPath(paths);
45
+ }
46
+ catch {
47
+ /* never block a command on the repair; probe reports a missing key */
48
+ }
40
49
  applied[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
41
50
  env[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
42
51
  // Per-agent token cache. Two identities on one computer must never share a
@@ -75,4 +75,32 @@ export interface ComponentReading {
75
75
  * older than `staleAfterMs` reads as `error` — silence is never health.
76
76
  */
77
77
  export declare function readComponentStatus(paths: Pick<AgentKitPaths, "stateDir">, component: KitComponent, staleAfterMs: number, now?: () => Date): ComponentReading;
78
+ export type HostKeyPathCheck = {
79
+ status: "ok";
80
+ hostKeyPath: string;
81
+ } | {
82
+ status: "healed";
83
+ hostKeyPath: string;
84
+ previous: string;
85
+ } | {
86
+ status: "missing";
87
+ hostKeyPath: string;
88
+ } | {
89
+ status: "no-creds";
90
+ };
91
+ /**
92
+ * Make the recorded host-key path point at a key that exists.
93
+ *
94
+ * The creds file records the key's absolute path, and hq-cloud signs every
95
+ * token mint with the key at that path. When the identity is moved or copied —
96
+ * the Runneth pilot copied its files from a disposable $HOME to persistent
97
+ * storage — the recorded path goes stale, and nothing fails until the cached
98
+ * token expires up to an hour later. Then every mint fails and the agent goes
99
+ * silent until someone re-enrolls it.
100
+ *
101
+ * The key always travels with the creds file, so when the recorded path is
102
+ * gone and a key sits beside the creds file, point at that one. The key
103
+ * itself is never read or copied here; only the recorded path changes.
104
+ */
105
+ export declare function ensureHostKeyPath(paths: Pick<AgentKitPaths, "machineCredsPath">): HostKeyPathCheck;
78
106
  //# sourceMappingURL=creds.d.ts.map
@@ -26,7 +26,8 @@
26
26
  */
27
27
  import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto";
28
28
  import * as fs from "node:fs";
29
- import { componentStatePath } from "./paths.js";
29
+ import * as path from "node:path";
30
+ import { componentStatePath, HOST_KEY_NAME } from "./paths.js";
30
31
  import { writePrivateFile, } from "./private-file.js";
31
32
  /** Contract fingerprint: sha256(DER SPKI public key) hex, first 16 chars. */
32
33
  export function hostFingerprint(publicPem) {
@@ -130,4 +131,33 @@ export function readComponentStatus(paths, component, staleAfterMs, now = () =>
130
131
  }
131
132
  return { status: "ok", at };
132
133
  }
134
+ /**
135
+ * Make the recorded host-key path point at a key that exists.
136
+ *
137
+ * The creds file records the key's absolute path, and hq-cloud signs every
138
+ * token mint with the key at that path. When the identity is moved or copied —
139
+ * the Runneth pilot copied its files from a disposable $HOME to persistent
140
+ * storage — the recorded path goes stale, and nothing fails until the cached
141
+ * token expires up to an hour later. Then every mint fails and the agent goes
142
+ * silent until someone re-enrolls it.
143
+ *
144
+ * The key always travels with the creds file, so when the recorded path is
145
+ * gone and a key sits beside the creds file, point at that one. The key
146
+ * itself is never read or copied here; only the recorded path changes.
147
+ */
148
+ export function ensureHostKeyPath(paths) {
149
+ const creds = readExternalMachineCreds(paths);
150
+ if (!creds)
151
+ return { status: "no-creds" };
152
+ if (fs.existsSync(creds.hostKeyPath))
153
+ return { status: "ok", hostKeyPath: creds.hostKeyPath };
154
+ const sibling = path.join(path.dirname(paths.machineCredsPath), HOST_KEY_NAME);
155
+ if (sibling === creds.hostKeyPath || !fs.existsSync(sibling)) {
156
+ return { status: "missing", hostKeyPath: creds.hostKeyPath };
157
+ }
158
+ const raw = JSON.parse(fs.readFileSync(paths.machineCredsPath, "utf8"));
159
+ raw.hostKeyPath = sibling;
160
+ writePrivateFile(paths.machineCredsPath, `${JSON.stringify(raw, null, 2)}\n`, 0o600);
161
+ return { status: "healed", hostKeyPath: sibling, previous: creds.hostKeyPath };
162
+ }
133
163
  //# sourceMappingURL=creds.js.map
@@ -50,6 +50,7 @@ export declare function summarizeInboxEntry(e: MirroredInboxEntry, done: boolean
50
50
  attachments?: InboxAttachment[] | undefined;
51
51
  id: string;
52
52
  channel: string | undefined;
53
+ channelId: string | undefined;
53
54
  from: string;
54
55
  fromUid: string | undefined;
55
56
  at: string | undefined;
@@ -118,6 +118,9 @@ export function summarizeInboxEntry(e, done) {
118
118
  return {
119
119
  id: e.id,
120
120
  channel: pick("channel"),
121
+ // Set when the message arrived in a channel or group DM. Reply there
122
+ // (`hq dm <channelId> "…"`), not to the sender privately.
123
+ channelId: pick("channelId"),
121
124
  from: pick("fromDisplayName", "fromEmail", "fromPersonUid") ?? "unknown",
122
125
  fromUid: pick("fromPersonUid"),
123
126
  at: pick("receivedAt", "createdAt", "mirroredAt"),
@@ -213,11 +213,11 @@ 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_…). 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.",
216
+ description: "Send an HQ message as this agent to a person (email or prs_…), an agent (agt_…), or a channel or group DM (chn_…). When replying to an inbox item that has a channelId, pass that channelId as `to` so the answer lands in the conversation it came from. 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
- to: { type: "string", description: "Email, prs_… or agt_… uid" },
220
+ to: { type: "string", description: "Email, prs_… or agt_… uid, or a chn_… channel id" },
221
221
  message: { type: "string" },
222
222
  audience: { type: "string", enum: ["human", "agent", "both"], description: "Who this message is for (default: human)" },
223
223
  },
@@ -231,12 +231,25 @@ export function buildAgentMcpTools(clients) {
231
231
  if (audience !== undefined && !["human", "agent", "both"].includes(audience)) {
232
232
  throw new McpToolInputError('"audience" must be human, agent, or both');
233
233
  }
234
+ if (/^chn_[A-Za-z0-9]+$/.test(to)) {
235
+ // A channel or group DM: post into it, so a reply lands where the
236
+ // message arrived instead of in a private DM to the sender.
237
+ const token = await clients.getToken();
238
+ const res = await clients.apiJson(token, `/v1/notify/channels/${encodeURIComponent(to)}/messages`, {
239
+ method: "POST",
240
+ body: { body: message },
241
+ });
242
+ if (res.status < 200 || res.status >= 300) {
243
+ return errorResult(`Channel send failed (${res.status}): ${describeError(res.body)}`);
244
+ }
245
+ return textResult(`posted in ${to}`);
246
+ }
234
247
  const body = /^[^\s]+@[^\s]+$/.test(to)
235
248
  ? { toEmail: to.toLowerCase(), body: message }
236
249
  : isRecipientUid(to)
237
250
  ? { toPersonUid: to, body: message }
238
251
  : (() => {
239
- throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid');
252
+ throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid, or a chn_… channel id');
240
253
  })();
241
254
  if (audience)
242
255
  body.audience = audience;
@@ -252,7 +265,7 @@ export function buildAgentMcpTools(clients) {
252
265
  name: "hq_inbox_read",
253
266
  description: "Read this agent's HQ inbox (DMs, channel mentions, jobs), oldest first. By default only items not yet " +
254
267
  "marked handled. Items with `attachments` carry files: download each with hq_attachment_fetch (pass its vaultPath " +
255
- "and companyUid) when its vaultPath is under chat/attachments/ before answering. Reply with hq_dm_send, then mark each one handled with hq_inbox_done so it is never answered twice.",
268
+ "and companyUid) when its vaultPath is under chat/attachments/ before answering. Reply with hq_dm_send, passing the item's channelId as `to` when it has one so the reply lands in that channel or group; do not reply to another bot's greeting or sign-off that asks for nothing. Then mark each one handled with hq_inbox_done so it is never answered twice.",
256
269
  inputSchema: {
257
270
  type: "object",
258
271
  properties: {
@@ -31,11 +31,17 @@ The kit's inbox poller mirrors every message addressed to you (DMs, channel
31
31
  mentions, jobs). Work through them like this:
32
32
 
33
33
  \`\`\`bash
34
- hq agent inbox # pending messages, oldest first (id, sender uid, text)
35
- hq dm <sender-uid> "reply" # answer each one
34
+ hq agent inbox # pending messages, oldest first (id, sender uid, channelId, text)
35
+ hq dm <channelId> "reply" # message came from a channel or group: answer in it
36
+ hq dm <sender-uid> "reply" # otherwise: answer the sender privately
36
37
  hq agent inbox done <id> [id…] # mark handled so it is never answered twice
37
38
  \`\`\`
38
39
 
40
+ Reply where the message arrived. A message with a \`channelId\` was posted in a
41
+ channel or group DM, and the answer belongs there, not in a private DM to the
42
+ sender. Do not reply to another bot's greeting, thanks or sign-off that asks
43
+ for nothing: two bots acknowledging each other never stop.
44
+
39
45
  The MCP equivalents are \`hq_inbox_read\`, \`hq_dm_send\` and \`hq_inbox_done\`.
40
46
  If the kit has a wake configured (\`hq agent kit wake show\`), HQ pokes your
41
47
  framework whenever something is pending, so always drain the whole list and
@@ -27,8 +27,10 @@ export const WAKE_STATE_NAME = "wake-state.json";
27
27
  export const DEFAULT_WAKE_RETRY_MS = 10 * 60_000;
28
28
  export const WAKE_URL_TIMEOUT_MS = 15_000;
29
29
  export const DEFAULT_WAKE_COMMAND_TIMEOUT_MS = 10 * 60_000;
30
- export const WAKE_INSTRUCTIONS = "New HQ messages are waiting. Run `hq agent inbox` (or the hq_inbox_read tool), reply to each " +
31
- "with `hq dm <sender-uid> \"…\"` (or hq_dm_send), then mark each handled with `hq agent inbox done <id>` (or hq_inbox_done).";
30
+ export const WAKE_INSTRUCTIONS = "New HQ messages are waiting. Run `hq agent inbox` (or the hq_inbox_read tool). Reply where each message " +
31
+ "arrived: if it has a channelId, post in that channel with `hq dm <channelId> \"…\"`; otherwise reply to the " +
32
+ "sender with `hq dm <sender-uid> \"…\"` (or hq_dm_send). Do not reply to another bot's greeting, thanks or " +
33
+ "sign-off that asks for nothing. Then mark each handled with `hq agent inbox done <id>` (or hq_inbox_done).";
32
34
  export function wakePath(paths) {
33
35
  return path.join(paths.agentDir, WAKE_FILE_NAME);
34
36
  }
@@ -197,7 +199,11 @@ export async function fireWake(config, notice, deps) {
197
199
  let child;
198
200
  try {
199
201
  child = doSpawn(config.command[0], config.command.slice(1), {
200
- stdio: ["pipe", logFd, logFd],
202
+ // stdout is discarded: a wake command's normal output (a routine record,
203
+ // a prompt echo) has no diagnostic value and would land in a log that is
204
+ // world-readable on a share without permissions. stderr is kept so a
205
+ // failing wake can still be diagnosed.
206
+ stdio: ["pipe", "ignore", logFd],
201
207
  env: {
202
208
  ...process.env,
203
209
  HQ_WAKE_EVENT: notice.event,
@@ -8,7 +8,7 @@ import * as path from "node:path";
8
8
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
9
9
  import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
10
10
  import { NotTrackingError } from "./errors.js";
11
- import { readSessionState, writeAcknowledgedRegisterBinding, } from "./state.js";
11
+ import { readSessionState, stripRegisterReceiptEpisodes, updateSessionState, writeAcknowledgedRegisterBinding, } from "./state.js";
12
12
  import { isSafeWorkContextSegment, workContextOutboxDir, workContextOutboxPath, } from "./paths.js";
13
13
  /** Fields allowed on a durable outbox operation (privacy allowlist). */
14
14
  export const OUTBOX_ALLOWLIST = [
@@ -285,7 +285,35 @@ export function writeAckedRegisterState(op, root, receiptId, now = () => new Dat
285
285
  /** Repair older unresolved session projections from acknowledged registrations. */
286
286
  export function repairAckedRegisterStates(root, sessionIds, now = () => new Date()) {
287
287
  let repaired = 0;
288
- for (const op of listOutboxOperations(root)) {
288
+ const operations = listOutboxOperations(root);
289
+ // A stored bindingEpisodeId that is a register receipt makes the server drop
290
+ // every batch. Clear it even when the register operation that produced it is
291
+ // no longer in the outbox: the id shape identifies it. This runs for every
292
+ // requested session, whatever its company or tracking status.
293
+ const receiptsBySession = new Map();
294
+ for (const op of operations) {
295
+ if (op.kind !== "register" || !op.receiptId)
296
+ continue;
297
+ const receipts = receiptsBySession.get(op.sessionId) ?? [];
298
+ receipts.push(op.receiptId);
299
+ receiptsBySession.set(op.sessionId, receipts);
300
+ }
301
+ for (const sessionId of sessionIds ?? receiptsBySession.keys()) {
302
+ if (!isSafeWorkContextSegment(sessionId))
303
+ continue;
304
+ const known = receiptsBySession.get(sessionId) ?? [];
305
+ // Take the lock only for a session that needs the repair.
306
+ const observed = readSessionState(sessionId, root);
307
+ if (!observed || stripRegisterReceiptEpisodes(observed, known) === observed)
308
+ continue;
309
+ updateSessionState(sessionId, root, (current) => {
310
+ if (!current)
311
+ return null;
312
+ const next = stripRegisterReceiptEpisodes(current, known);
313
+ return next === current ? null : next;
314
+ });
315
+ }
316
+ for (const op of operations) {
289
317
  if (op.delivery !== "acked" ||
290
318
  !op.receiptId ||
291
319
  op.kind !== "register" ||
@@ -15,7 +15,7 @@ import { rebindNeedsProjectOnPrompt } from "./prompt-rebind.js";
15
15
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
16
16
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
17
17
  import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
18
- import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
18
+ import { mergeLocalSessionFields, isRegisterReceiptEpisodeId, readSessionState, stripRegisterReceiptEpisodes, writeSessionState, } from "./state.js";
19
19
  function resultOf(parts) {
20
20
  const out = {
21
21
  contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
@@ -174,7 +174,22 @@ export async function reconcileObservation(obs, deps) {
174
174
  }
175
175
  }
176
176
  // Already untracked locally — stay untracked until explicit opt-in (no backfill).
177
- const prior = readSessionState(sessionId, deps.root);
177
+ const observedPrior = readSessionState(sessionId, deps.root);
178
+ // Acked register operations for this session. A register response never
179
+ // carries a binding episode, so an already acknowledged binding is
180
+ // identified by its company / project / task, never by comparing the
181
+ // register receipt with the session's bindingEpisodeId.
182
+ const ackedRegisters = observedPrior
183
+ ? listOutboxOperations(deps.root).filter((operation) => operation.kind === "register" &&
184
+ operation.delivery === "acked" &&
185
+ operation.sessionId === sessionId &&
186
+ Boolean(operation.receiptId))
187
+ : [];
188
+ // Older clients stored a register receipt as the episode. Work from a
189
+ // projection with those cleared so no later write carries one forward.
190
+ const prior = observedPrior
191
+ ? stripRegisterReceiptEpisodes(observedPrior, ackedRegisters.map((operation) => operation.receiptId))
192
+ : observedPrior;
178
193
  if (prior?.contextStatus === "untracked" && prior.untrackedReceipt?.origin === "user") {
179
194
  return {
180
195
  result: resultOf({
@@ -690,15 +705,6 @@ export async function reconcileObservation(obs, deps) {
690
705
  registerClassification = "needs_project";
691
706
  diagnostics.push(`company rebind to ${resolvedCompany.slug ?? resolvedCompany.uid}; cleared project`);
692
707
  }
693
- // Acked register operations for this session. A register that did not come
694
- // back bound yields no binding episode, so its receipt (not the session's
695
- // bindingEpisodeId) is what identifies an already acknowledged binding.
696
- const ackedRegisters = prior
697
- ? listOutboxOperations(deps.root).filter((operation) => operation.kind === "register" &&
698
- operation.delivery === "acked" &&
699
- operation.sessionId === sessionId &&
700
- Boolean(operation.receiptId))
701
- : [];
702
708
  const stateClassification = registerClassification === "unresolved" &&
703
709
  prior?.contextStatus === "needs_project" &&
704
710
  prior.companyUid === resolvedCompany.uid &&
@@ -711,22 +717,26 @@ export async function reconcileObservation(obs, deps) {
711
717
  // into a second semantic register operation: the prior receipt is the stable
712
718
  // identity for this exact company/project/task binding.
713
719
  const priorBindingIsBound = Boolean(projectId && taskId);
720
+ // A state that is not an acknowledged binding (for example one quarantined
721
+ // as company_conflict) must register again even when its fields still match.
722
+ const priorIsAcknowledgedBinding = prior?.contextStatus === "bound" ||
723
+ prior?.contextStatus === "needs_task" ||
724
+ prior?.contextStatus === "needs_project" ||
725
+ prior?.contextStatus === "unresolved";
714
726
  const acknowledgedReceipt = prior &&
727
+ priorIsAcknowledgedBinding &&
715
728
  prior.companyUid === resolvedCompany.uid &&
716
729
  prior.companySlug === resolvedCompany.slug &&
717
730
  prior.projectId === projectId &&
718
731
  prior.taskId === taskId
719
- ? ackedRegisters.find((operation) => (priorBindingIsBound
720
- ? operation.receiptId === prior.bindingEpisodeId
721
- : !prior.bindingEpisodeId || operation.receiptId === prior.bindingEpisodeId) &&
722
- operation.companyUid === resolvedCompany.uid &&
732
+ ? ackedRegisters.find((operation) => operation.companyUid === resolvedCompany.uid &&
723
733
  operation.companySlug === resolvedCompany.slug &&
724
734
  operation.projectId === projectId &&
725
735
  operation.taskId === taskId)?.receiptId
726
736
  : undefined;
727
737
  const acknowledgedPrior = acknowledgedReceipt ? prior : null;
728
738
  if (acknowledgedPrior) {
729
- // A partial (not bound) acknowledged register carries no server episode.
739
+ // Only a bound session holds a server episode (issued by bind / organize).
730
740
  const retainedEpisodeId = priorBindingIsBound
731
741
  ? acknowledgedPrior.bindingEpisodeId
732
742
  : undefined;
@@ -895,10 +905,9 @@ export async function reconcileObservation(obs, deps) {
895
905
  // the true computed classification into durable session state; otherwise
896
906
  // the emitter and mesh presence lose the tenant the server already
897
907
  // acknowledged. projectId / taskId are written only when genuinely
898
- // resolved, and a fresh bindingEpisodeId only when the session is bound —
899
- // never fabricated on a partial path. (writeAckedRegisterState above may
900
- // have already recorded a receipt-derived episode for a resolved partial
901
- // binding; the default merge preserves that when the binding still matches.)
908
+ // resolved. A register ack never yields a bindingEpisodeId, bound or not:
909
+ // the server issues episodes only on bind / organize. The default merge
910
+ // keeps an episode the session already holds when the binding still matches.
902
911
  if (resolvedCompany.uid) {
903
912
  const bound = registerClassification === "bound";
904
913
  // A server ack of the company makes it authoritative: the durable status
@@ -919,7 +928,6 @@ export async function reconcileObservation(obs, deps) {
919
928
  contextStatus: bound ? "bound" : ackedClassification,
920
929
  ...(projectId ? { projectId } : {}),
921
930
  ...(taskId ? { taskId } : {}),
922
- ...(bound ? { bindingEpisodeId: delivered.receiptId } : {}),
923
931
  updatedAt: nowFn().toISOString(),
924
932
  startedAt,
925
933
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
@@ -939,6 +947,12 @@ export async function reconcileObservation(obs, deps) {
939
947
  : registerClassification === "needs_project"
940
948
  ? "needs_project"
941
949
  : "queued";
950
+ // Report the episode the session actually holds, never the receipt.
951
+ const ackedState = readSessionState(sessionId, deps.root);
952
+ const ackedEpisodeId = ackedState &&
953
+ !isRegisterReceiptEpisodeId(ackedState.bindingEpisodeId, [delivered.receiptId])
954
+ ? ackedState.bindingEpisodeId
955
+ : undefined;
942
956
  return {
943
957
  result: resultOf({
944
958
  kind: kind === "queued" ? "queued" : kind,
@@ -950,7 +964,7 @@ export async function reconcileObservation(obs, deps) {
950
964
  companySlug: resolvedCompany.slug,
951
965
  projectId,
952
966
  taskId,
953
- bindingEpisodeId: delivered.receiptId,
967
+ ...(ackedEpisodeId ? { bindingEpisodeId: ackedEpisodeId } : {}),
954
968
  ...bindResultFields,
955
969
  }),
956
970
  exitCode: EXIT_OK,
@@ -158,6 +158,22 @@ export declare function mergeLocalSessionFields(next: SessionStateFile, prior: S
158
158
  preserveBindingEpisode?: boolean;
159
159
  preserveCompanyBinding?: boolean;
160
160
  }): SessionStateFile;
161
+ /**
162
+ * True when a stored bindingEpisodeId is a register receipt, not an episode.
163
+ *
164
+ * The server mints binding episodes as UUIDs, and only on bind / organize. A
165
+ * register response carries no episode: its receipt is the operation id the
166
+ * client generated (`op_...`). An id is treated as a receipt when it has that
167
+ * shape or when it equals a receipt the caller knows about. A UUID is never
168
+ * treated as a receipt by shape.
169
+ */
170
+ export declare function isRegisterReceiptEpisodeId(id: string | undefined, knownReceiptIds?: Iterable<string | undefined>): boolean;
171
+ /**
172
+ * Clear register receipts stored as a binding episode, on the session itself
173
+ * and on its pending presenceLeave. Returns the same object when nothing
174
+ * changed, so callers can detect a repair by identity.
175
+ */
176
+ export declare function stripRegisterReceiptEpisodes(state: SessionStateFile, knownReceiptIds?: Iterable<string | undefined>): SessionStateFile;
161
177
  /**
162
178
  * Upgrade the local projection after the server acknowledges a register.
163
179
  *
@@ -165,6 +181,9 @@ export declare function mergeLocalSessionFields(next: SessionStateFile, prior: S
165
181
  * erase an explicit untracked choice, a conflicting existing company, or a
166
182
  * more specific binding for the same company. This is also used to repair
167
183
  * state files created by older clients before this write-back existed.
184
+ *
185
+ * The read and the write happen under the session state lock, so a concurrent
186
+ * writer (for example bind-project) is never overwritten from a stale snapshot.
168
187
  */
169
188
  export declare function writeAcknowledgedRegisterBinding(binding: AcknowledgedRegisterBinding, root: string, now?: () => Date): SessionStateFile | null;
170
189
  /** True when a durable state file was written by the hook / reconcile path. */
@@ -342,6 +342,47 @@ function bindingStrength(state) {
342
342
  return 1;
343
343
  return 0;
344
344
  }
345
+ const SERVER_EPISODE_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
346
+ /**
347
+ * True when a stored bindingEpisodeId is a register receipt, not an episode.
348
+ *
349
+ * The server mints binding episodes as UUIDs, and only on bind / organize. A
350
+ * register response carries no episode: its receipt is the operation id the
351
+ * client generated (`op_...`). An id is treated as a receipt when it has that
352
+ * shape or when it equals a receipt the caller knows about. A UUID is never
353
+ * treated as a receipt by shape.
354
+ */
355
+ export function isRegisterReceiptEpisodeId(id, knownReceiptIds = []) {
356
+ if (!id)
357
+ return false;
358
+ for (const receiptId of knownReceiptIds) {
359
+ if (receiptId && receiptId === id)
360
+ return true;
361
+ }
362
+ if (SERVER_EPISODE_SHAPE.test(id))
363
+ return false;
364
+ return id.startsWith("op_");
365
+ }
366
+ /**
367
+ * Clear register receipts stored as a binding episode, on the session itself
368
+ * and on its pending presenceLeave. Returns the same object when nothing
369
+ * changed, so callers can detect a repair by identity.
370
+ */
371
+ export function stripRegisterReceiptEpisodes(state, knownReceiptIds = []) {
372
+ const known = [...knownReceiptIds];
373
+ const ownIsReceipt = isRegisterReceiptEpisodeId(state.bindingEpisodeId, known);
374
+ const leaveIsReceipt = isRegisterReceiptEpisodeId(state.presenceLeave?.bindingEpisodeId, known);
375
+ if (!ownIsReceipt && !leaveIsReceipt)
376
+ return state;
377
+ const out = { ...state };
378
+ if (ownIsReceipt)
379
+ out.bindingEpisodeId = undefined;
380
+ if (leaveIsReceipt && state.presenceLeave) {
381
+ const { bindingEpisodeId: _receipt, ...leave } = state.presenceLeave;
382
+ out.presenceLeave = leave;
383
+ }
384
+ return out;
385
+ }
345
386
  /**
346
387
  * Upgrade the local projection after the server acknowledges a register.
347
388
  *
@@ -349,60 +390,68 @@ function bindingStrength(state) {
349
390
  * erase an explicit untracked choice, a conflicting existing company, or a
350
391
  * more specific binding for the same company. This is also used to repair
351
392
  * state files created by older clients before this write-back existed.
393
+ *
394
+ * The read and the write happen under the session state lock, so a concurrent
395
+ * writer (for example bind-project) is never overwritten from a stale snapshot.
352
396
  */
353
397
  export function writeAcknowledgedRegisterBinding(binding, root, now = () => new Date()) {
354
398
  const acknowledged = acknowledgedStatus(binding);
355
399
  if (!acknowledged || !isSafeWorkContextSegment(binding.sessionId))
356
400
  return null;
357
- const prior = readSessionState(binding.sessionId, root);
358
- // Reconcile always writes the local projection before network delivery. Do
359
- // not manufacture session files when replay sees an orphaned legacy outbox
360
- // record with no corresponding tracked session.
361
- if (!prior)
362
- return null;
363
- if (prior.contextStatus === "untracked" ||
364
- (prior.companyUid && prior.companyUid !== binding.companyUid)) {
365
- return prior;
366
- }
367
- const acknowledgementStrength = acknowledged === "bound" ? 3 : acknowledged === "needs_task" ? 2 : 1;
368
- const priorStrength = bindingStrength(prior);
369
- const preservePriorBinding = Boolean(prior.companyUid === binding.companyUid && priorStrength > acknowledgementStrength);
370
- // The server issues no binding episode for a register that did not come
371
- // back bound (unresolved / needs_project / needs_task). Its receipt must
372
- // never stand in for one: session-event batches carry bindingEpisodeId and
373
- // the server drops batches whose episode it does not recognise. Older
374
- // clients stored that receipt here, so also repair a prior value that is
375
- // exactly this partial register's receipt.
376
- const receiptEpisode = acknowledged === "bound" ? binding.receiptId : undefined;
377
- const priorEpisode = acknowledged !== "bound" && prior.bindingEpisodeId === binding.receiptId
378
- ? undefined
379
- : prior.bindingEpisodeId;
380
- const sameBindingAsPrior = prior.companyUid === binding.companyUid &&
381
- prior.projectId === binding.projectId &&
382
- prior.taskId === binding.taskId;
383
- const next = mergeLocalSessionFields({
384
- contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
385
- sessionId: binding.sessionId,
386
- companyUid: binding.companyUid,
387
- companySlug: binding.companySlug ?? prior.companySlug,
388
- contextStatus: preservePriorBinding ? prior.contextStatus : acknowledged,
389
- projectId: preservePriorBinding ? prior.projectId : binding.projectId,
390
- taskId: preservePriorBinding ? prior.taskId : binding.taskId,
391
- bindingEpisodeId: preservePriorBinding
392
- ? priorEpisode ?? receiptEpisode
393
- : receiptEpisode ?? (sameBindingAsPrior ? priorEpisode : undefined),
394
- updatedAt: now().toISOString(),
395
- }, prior, { preserveBindingEpisode: false });
396
- if (prior.companyUid === next.companyUid &&
397
- prior.companySlug === next.companySlug &&
398
- prior.contextStatus === next.contextStatus &&
399
- prior.projectId === next.projectId &&
400
- prior.taskId === next.taskId &&
401
- prior.bindingEpisodeId === next.bindingEpisodeId) {
402
- return prior;
403
- }
404
- writeSessionState(next, root);
405
- return next;
401
+ let result = null;
402
+ updateSessionState(binding.sessionId, root, (current) => {
403
+ // Reconcile always writes the local projection before network delivery. Do
404
+ // not manufacture session files when replay sees an orphaned legacy outbox
405
+ // record with no corresponding tracked session.
406
+ if (!current)
407
+ return null;
408
+ // A register response never carries a server episode, bound or not. The
409
+ // server mints episodes only on bind / organize, and drops session-event
410
+ // batches whose episode it does not recognise. Older clients stored the
411
+ // register receipt as the episode, so clear it wherever it is found. This
412
+ // repair is independent of the binding decision below and therefore also
413
+ // applies to untracked and different-company states.
414
+ const prior = stripRegisterReceiptEpisodes(current, [binding.receiptId]);
415
+ const repaired = prior !== current;
416
+ if (prior.contextStatus === "untracked" ||
417
+ (prior.companyUid && prior.companyUid !== binding.companyUid)) {
418
+ result = prior;
419
+ return repaired ? prior : null;
420
+ }
421
+ const acknowledgementStrength = acknowledged === "bound" ? 3 : acknowledged === "needs_task" ? 2 : 1;
422
+ const priorStrength = bindingStrength(prior);
423
+ const preservePriorBinding = Boolean(prior.companyUid === binding.companyUid && priorStrength > acknowledgementStrength);
424
+ const sameBindingAsPrior = prior.companyUid === binding.companyUid &&
425
+ prior.projectId === binding.projectId &&
426
+ prior.taskId === binding.taskId;
427
+ const next = mergeLocalSessionFields({
428
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
429
+ sessionId: binding.sessionId,
430
+ companyUid: binding.companyUid,
431
+ companySlug: binding.companySlug ?? prior.companySlug,
432
+ contextStatus: preservePriorBinding ? prior.contextStatus : acknowledged,
433
+ projectId: preservePriorBinding ? prior.projectId : binding.projectId,
434
+ taskId: preservePriorBinding ? prior.taskId : binding.taskId,
435
+ // Keep a server-issued episode only while the binding it was issued
436
+ // for is unchanged. Never derive one from the receipt.
437
+ bindingEpisodeId: preservePriorBinding || sameBindingAsPrior
438
+ ? prior.bindingEpisodeId
439
+ : undefined,
440
+ updatedAt: now().toISOString(),
441
+ }, prior, { preserveBindingEpisode: false });
442
+ if (prior.companyUid === next.companyUid &&
443
+ prior.companySlug === next.companySlug &&
444
+ prior.contextStatus === next.contextStatus &&
445
+ prior.projectId === next.projectId &&
446
+ prior.taskId === next.taskId &&
447
+ prior.bindingEpisodeId === next.bindingEpisodeId) {
448
+ result = prior;
449
+ return repaired ? prior : null;
450
+ }
451
+ result = next;
452
+ return next;
453
+ });
454
+ return result;
406
455
  }
407
456
  /** True when a durable state file was written by the hook / reconcile path. */
408
457
  export function isHookWrittenSessionState(state) {
package/dist/main.js CHANGED
@@ -54,6 +54,9 @@ import { installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTree
54
54
  import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
55
55
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
56
56
  import { runGlobalNetworkLifecycle } from "./utils/cli-network-lifecycle.js";
57
+ import * as os from "node:os";
58
+ import { adoptAgentIdentityEnv } from "./lib/agent-kit/adopt-identity.js";
59
+ import { agentKitPaths } from "./lib/agent-kit/paths.js";
57
60
  /**
58
61
  * Total time one CLI invocation may await foreground network work. This spans
59
62
  * the hard version decision, pre-action observability, and release-health
@@ -160,6 +163,16 @@ program
160
163
  .description("HQ management CLI — modules, packages, and cloud sync")
161
164
  .version(CLI_VERSION);
162
165
  program.hook("preAction", async () => {
166
+ // A host that pinned HQ_AGENT_DIR has declared that it IS that agent, so
167
+ // every command — `hq dm`, `hq secrets`, not only `hq agent …` — must
168
+ // mint as the agent. Without this, those commands looked for a person
169
+ // session, found none, and opened a browser sign-in that a headless bot can
170
+ // never complete. Only the explicit variable counts: a bot that merely lives
171
+ // beside its owner on the owner's computer must not take over the owner's
172
+ // commands.
173
+ if (process.env.HQ_AGENT_DIR?.trim()) {
174
+ adoptAgentIdentityEnv(agentKitPaths(os.homedir(), process.env), process.env);
175
+ }
163
176
  // Both are best-effort and fully swallowed: neither can change the command's
164
177
  // result or exit code. They receive only the shared pre-finalization
165
178
  // allowance (up to 1.2s), and that bound is a TIMER, so it only preempts
@@ -290,4 +290,16 @@ export declare function refreshCachedSession(options?: Pick<EnsureCognitoTokenOp
290
290
  refreshed: boolean;
291
291
  reason?: string;
292
292
  }>;
293
+ /**
294
+ * True on a Linux host with no terminal and no display — a container or
295
+ * server, where a browser sign-in can never be completed. macOS and Windows
296
+ * always have a way to open a browser, and a terminal means a person is there.
297
+ */
298
+ export declare function isHeadlessHost(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: boolean): boolean;
299
+ /**
300
+ * True when the environment explicitly names an HQ agent identity. Only the
301
+ * explicit variables count: guessing from the filesystem is unreliable, and a
302
+ * wrong guess would take an ordinary headless run off its sign-in path.
303
+ */
304
+ export declare function hostRunsAnAgent(env?: NodeJS.ProcessEnv): boolean;
293
305
  //# sourceMappingURL=cognito-session.d.ts.map
@@ -943,6 +943,15 @@ async function ensureCognitoTokenUnserialized(options) {
943
943
  if (!interactive) {
944
944
  throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
945
945
  }
946
+ if (isHeadlessHost() && hostRunsAnAgent()) {
947
+ // An agent host with no display has no one to complete a browser sign-in,
948
+ // which then blocks until something kills the process. Fail with the fix
949
+ // instead. Scoped to agent hosts so an ordinary headless run keeps the
950
+ // existing sign-in and its remedies.
951
+ throw new Error("No valid HQ session, and this host has no display for a browser sign-in. " +
952
+ "If this is an enrolled agent, set HQ_AGENT_DIR to its agent directory " +
953
+ "(or HQ_MACHINE_CREDS_FILE to its machine-creds.json). Otherwise run `hq login` on a computer with a browser.");
954
+ }
946
955
  console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
947
956
  const tokens = await browserLogin(DEFAULT_COGNITO);
948
957
  return tokens.accessToken;
@@ -1010,6 +1019,13 @@ async function ensureCognitoIdTokenUnserialized(options) {
1010
1019
  if (!interactive) {
1011
1020
  throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
1012
1021
  }
1022
+ if (isHeadlessHost() && hostRunsAnAgent()) {
1023
+ // A browser sign-in on a server has no one to complete it and blocks
1024
+ // until something kills the process. Fail with the fix instead.
1025
+ throw new Error("No valid HQ session, and this host has no display for a browser sign-in. " +
1026
+ "If this is an enrolled agent, set HQ_AGENT_DIR to its agent directory " +
1027
+ "(or HQ_MACHINE_CREDS_FILE to its machine-creds.json). Otherwise run `hq login` on a computer with a browser.");
1028
+ }
1013
1029
  console.error(chalk.cyan(" No cached HQ session — launching browser sign-in..."));
1014
1030
  const tokens = await browserLogin(DEFAULT_COGNITO);
1015
1031
  return tokens.idToken;
@@ -1121,4 +1137,24 @@ async function refreshCachedSessionUnserialized(options) {
1121
1137
  };
1122
1138
  }
1123
1139
  }
1140
+ /**
1141
+ * True on a Linux host with no terminal and no display — a container or
1142
+ * server, where a browser sign-in can never be completed. macOS and Windows
1143
+ * always have a way to open a browser, and a terminal means a person is there.
1144
+ */
1145
+ export function isHeadlessHost(env = process.env, platform = process.platform, tty = Boolean(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY)) {
1146
+ if (platform !== "linux")
1147
+ return false;
1148
+ if (tty)
1149
+ return false;
1150
+ return !env.DISPLAY?.trim() && !env.WAYLAND_DISPLAY?.trim();
1151
+ }
1152
+ /**
1153
+ * True when the environment explicitly names an HQ agent identity. Only the
1154
+ * explicit variables count: guessing from the filesystem is unreliable, and a
1155
+ * wrong guess would take an ordinary headless run off its sign-in path.
1156
+ */
1157
+ export function hostRunsAnAgent(env = process.env) {
1158
+ return Boolean(env.HQ_AGENT_DIR?.trim() || env.HQ_MACHINE_CREDS_FILE?.trim());
1159
+ }
1124
1160
  //# sourceMappingURL=cognito-session.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.249.1",
3
+ "version": "5.251.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {