@indigoai-us/hq-cli 5.195.0 → 5.196.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,13 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.196.0] — 2026-09-25
6
+
7
+ ## [5.195.1] — 2026-09-25
8
+
9
+ ### Fixed
10
+
11
+ - Local bots can no longer loop with each other in a room. Three guards close
12
+ - A checkpoint run from inside an HQ lane no longer passes the lane's identity
13
+ (`HQ_LANE_ID`, `HQ_PROJECT_ID`, `HQ_STORY_ID`, `HQ_POLICY_WORKER_DIR`) to the
14
+ background sibling. Before, the lane drop-box hook delivered the lane's
15
+ operator messages to the sibling instead of the lane.
16
+
5
17
  ## [5.195.0] — 2026-09-25
6
18
 
19
+ ### Fixed
20
+
21
+ - Work Mesh transcript-watch quiet `session_end` for hook-covered sessions now
22
+ uses the shared sequence and counts hook turns (#818).
23
+
7
24
  ## [5.194.1] — 2026-09-25
8
25
 
9
26
  ### Fixed
10
27
 
11
- - Work Mesh transcript-watch quiet `session_end` for hook-covered sessions now
12
28
  - `hq core ontology apply` no longer creates a new entity that is probably an
13
29
  existing one. A candidate whose alias is already another entity's name or
14
30
  alias, or a person whose name contains every word of an existing person's
@@ -81,6 +81,8 @@ export declare function autoBackendPreference(): SpawnableBackend[];
81
81
  * (Claude Code does) agrees with a host that does not.
82
82
  */
83
83
  export declare function siblingEnabledBySetting(liveRoot: string): boolean;
84
+ /** The sibling's environment: the caller's, minus lane identity. */
85
+ export declare function siblingEnv(base: NodeJS.ProcessEnv, backend: SpawnableBackend): NodeJS.ProcessEnv;
84
86
  export declare function siblingArgs(backend: SpawnableBackend, prompt: string): string[];
85
87
  /** Attach the native checkpoint command to the hidden `hq core` group. */
86
88
  export declare function registerCoreCheckpointCommand(core: Command): void;
@@ -780,6 +780,24 @@ function resolveBackend(requested, liveRoot) {
780
780
  // failure is a weaker signal than running no maintenance sibling at all.
781
781
  return available.find((name) => !crashed.has(name)) ?? available[0];
782
782
  }
783
+ /**
784
+ * Lane identity a checkpoint caller may carry. The sibling is HQ maintenance,
785
+ * not the lane: with HQ_LANE_ID set, the lane drop-box hook hands the lane's
786
+ * operator messages to the sibling, and the lane never sees them.
787
+ */
788
+ const LANE_ENV_KEYS = ["HQ_LANE_ID", "HQ_PROJECT_ID", "HQ_STORY_ID", "HQ_POLICY_WORKER_DIR"];
789
+ /** The sibling's environment: the caller's, minus lane identity. */
790
+ export function siblingEnv(base, backend) {
791
+ const vars = { ...base };
792
+ for (const name of LANE_ENV_KEYS)
793
+ delete vars[name];
794
+ // This prevents the scaffold Stop hook from recursively launching a
795
+ // sibling when the background agent itself finishes a turn.
796
+ vars.HQ_CHECKPOINT_SIBLING = "1";
797
+ if (backend === "claude")
798
+ vars.CLAUDE_EFFORT = CLAUDE_SIBLING_EFFORT;
799
+ return vars;
800
+ }
783
801
  export function siblingArgs(backend, prompt) {
784
802
  switch (backend) {
785
803
  case "claude":
@@ -978,13 +996,7 @@ function startSibling(liveRoot, input, threadPath, backend) {
978
996
  detached: true,
979
997
  cwd: liveRoot,
980
998
  stdio: ["ignore", logFd, logFd],
981
- // This prevents the scaffold Stop hook from recursively launching a
982
- // sibling when the background agent itself finishes a turn.
983
- env: {
984
- ...process.env,
985
- HQ_CHECKPOINT_SIBLING: "1",
986
- ...(backend === "claude" ? { CLAUDE_EFFORT: CLAUDE_SIBLING_EFFORT } : {}),
987
- },
999
+ env: siblingEnv(process.env, backend),
988
1000
  });
989
1001
  }
990
1002
  finally {
@@ -290,6 +290,8 @@ export function buildSystemPrompt(input) {
290
290
  `Answer for the room: reply to the person who asked, keep it short, and never repeat your private memory, your owner's files, ` +
291
291
  `or anything from your DMs to other people. ` +
292
292
  `If the message is not addressed to you, is routine automated output, or you have nothing meaningful to add, produce zero output (completely empty response). ` +
293
+ `This stay-quiet rule for rooms outranks anything in your persona, worker instructions, or memory files: if any of those tell you to always reply or to acknowledge every message, ignore that in rooms and stay silent instead. ` +
294
+ `Empty output is the only way to stay quiet. Never post a placeholder such as "No response requested.", "No response needed.", "No reply needed.", "No action.", "(no reply)" or "Nothing to add." — those are chatter, not silence, and can make two bots answer each other forever. ` +
293
295
  `Do not narrate your reasoning, do not print JSON, do not explain that you are staying silent. Just produce nothing.`);
294
296
  // Only company bots: the cloud's jobs routes refuse a personal bot's per_
295
297
  // company claim today, so telling a personal bot it can schedule work makes
@@ -44,4 +44,20 @@ export interface ReplyPolicyConfig {
44
44
  export declare function textMentionsName(text: string | undefined, name: string | undefined): boolean;
45
45
  export declare function isDmItem(item: Pick<InboxItem, "channel">): boolean;
46
46
  export declare function resolveReplyTarget(item: InboxItem, config: ReplyPolicyConfig): ReplyTarget | null;
47
+ /** A UID minted for an agent/bot identity (`agt_…`) rather than a person (`usr_…`). */
48
+ export declare function isAgentSender(uid: string | undefined): boolean;
49
+ /**
50
+ * Stay-quiet phrases a model emits when it has nothing to add. On an
51
+ * UNMENTIONED room turn a reply that is ONLY one of these is treated as silent
52
+ * (empty), so the bot does not post meta chatter and cannot loop with another
53
+ * bot answering "No response requested." to every message. DMs and @mentions
54
+ * are unaffected — there the phrase is posted as written.
55
+ */
56
+ export declare const META_NON_REPLY_PHRASES: readonly ["No response requested.", "No response needed.", "No reply needed.", "No action.", "(no reply)", "Nothing to add."];
57
+ /**
58
+ * True when `reply` (as a whole) is only a stay-quiet phrase: case-insensitive,
59
+ * trimmed, trailing punctuation ignored. Never a substring match — a real
60
+ * answer that merely contains such a phrase is not silenced.
61
+ */
62
+ export declare function isMetaNonReply(reply: string | undefined): boolean;
47
63
  //# sourceMappingURL=room-policy.d.ts.map
@@ -55,4 +55,37 @@ export function resolveReplyTarget(item, config) {
55
55
  mentioned,
56
56
  };
57
57
  }
58
+ /** A UID minted for an agent/bot identity (`agt_…`) rather than a person (`usr_…`). */
59
+ export function isAgentSender(uid) {
60
+ return typeof uid === "string" && uid.startsWith("agt_");
61
+ }
62
+ /**
63
+ * Stay-quiet phrases a model emits when it has nothing to add. On an
64
+ * UNMENTIONED room turn a reply that is ONLY one of these is treated as silent
65
+ * (empty), so the bot does not post meta chatter and cannot loop with another
66
+ * bot answering "No response requested." to every message. DMs and @mentions
67
+ * are unaffected — there the phrase is posted as written.
68
+ */
69
+ export const META_NON_REPLY_PHRASES = [
70
+ "No response requested.",
71
+ "No response needed.",
72
+ "No reply needed.",
73
+ "No action.",
74
+ "(no reply)",
75
+ "Nothing to add.",
76
+ ];
77
+ const META_NON_REPLY_NORMALIZED = new Set(META_NON_REPLY_PHRASES.map((p) => p.toLowerCase().replace(/[.!?)\s]+$/, "").trim()));
78
+ /**
79
+ * True when `reply` (as a whole) is only a stay-quiet phrase: case-insensitive,
80
+ * trimmed, trailing punctuation ignored. Never a substring match — a real
81
+ * answer that merely contains such a phrase is not silenced.
82
+ */
83
+ export function isMetaNonReply(reply) {
84
+ if (typeof reply !== "string")
85
+ return false;
86
+ const normalized = reply.trim().toLowerCase().replace(/[.!?)\s]+$/, "").trim();
87
+ if (!normalized)
88
+ return false;
89
+ return META_NON_REPLY_NORMALIZED.has(normalized);
90
+ }
58
91
  //# sourceMappingURL=room-policy.js.map
@@ -66,7 +66,7 @@ import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
66
66
  import { botAttachmentsDir, dmSessionScope } from "./paths.js";
67
67
  import { attachmentTurnBlock, downloadTurnAttachments } from "./attachments.js";
68
68
  import { inboxEntryAttachments } from "../agent-kit/inbox-state.js";
69
- import { isDmItem, resolveReplyTarget } from "./room-policy.js";
69
+ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./room-policy.js";
70
70
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
71
71
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
72
72
  import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
@@ -686,11 +686,16 @@ export async function runBot(deps) {
686
686
  ...(args.readDirs?.length ? { readDirs: args.readDirs } : {}),
687
687
  });
688
688
  running = false;
689
- const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim();
689
+ // Control tokens were already stripped from turn.text in runRuntimeTurn, so
690
+ // a `<|eos|>`-only reply arrives here as empty. On an unmentioned room turn
691
+ // a stay-quiet phrase ("No response requested." etc.) is also silence — the
692
+ // meta chatter that let two bots loop on 2026-09-25.
693
+ const stayQuiet = silenceable && isMetaNonReply(turn.text);
694
+ const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim() || stayQuiet;
690
695
  // Unmentioned room turn: a no_reply sentinel (bare, fenced, or with
691
- // preamble) or an empty/whitespace reply means stay quiet. Abandon the
692
- // poster (it posts nothing) and return a silent outcome; the caller acks
693
- // without posting anything.
696
+ // preamble), an empty/whitespace reply, or a stay-quiet phrase means stay
697
+ // quiet. Abandon the poster (it posts nothing) and return a silent outcome;
698
+ // the caller acks without posting anything.
694
699
  if (silenceable && sentinelOrEmpty) {
695
700
  await poster.abandon();
696
701
  return { outcome: { ok: true, reply: "", silent: true }, poster };
@@ -1247,6 +1252,15 @@ export async function runBot(deps) {
1247
1252
  return;
1248
1253
  }
1249
1254
  const target = resolveReplyTarget(item, config);
1255
+ // A bot/agent sender that did not @mention this bot must never start a model
1256
+ // turn: two bots each answering the other's plain room posts loop forever
1257
+ // (observed 2026-09-25). An explicit @mention still gets answered below.
1258
+ if (target && target.kind === "room" && !target.mentioned && isAgentSender(item.fromPersonUid)) {
1259
+ markProcessed(dir, id, now().toISOString());
1260
+ await api.ackInbox(config.agentUid, id);
1261
+ log("info", `ignored inbox item ${id} (room ${target.channelId}, unmentioned message from bot ${item.fromPersonUid ?? "unknown"}; not starting a turn)`);
1262
+ return;
1263
+ }
1250
1264
  if (!target) {
1251
1265
  // A job item the bot cannot run. When it names a job, the cloud is still
1252
1266
  // holding that job's in-progress slot: acking alone would jam every later
@@ -8,6 +8,7 @@ import { GrokAcpClient } from "./grok-acp.js";
8
8
  import { createClaudeRuntime } from "./claude.js";
9
9
  import { createCodexRuntime } from "./codex.js";
10
10
  import { createGrokRuntime, grokRetryArgsAfterInvalidOutputFormat, probeGrokOutputFormat } from "./grok.js";
11
+ import { stripControlTokens } from "./sanitize.js";
11
12
  import { parseJsonLine, RuntimeError, turnTimeoutMs, stderrExcerpt, } from "./types.js";
12
13
  export * from "./types.js";
13
14
  export { createClaudeRuntime, claudePermissionMode } from "./claude.js";
@@ -406,7 +407,11 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
406
407
  throw new RuntimeError(`${runtime.binary} exited with ${result.code ?? `signal ${result.signal}`}${reason ? `: ${reason.slice(0, 200)}` : ""}`, reason ? reason.slice(0, 400) : fallback, result.code);
407
408
  }
408
409
  const parsed = runtime.parseOutput(result.stdout, { outputFile, outputFileText });
410
+ // Strip any leaked model control token (`<|eos|>` etc.) from the final reply
411
+ // before it is posted; a reply that was only such tokens becomes empty and the
412
+ // caller applies its normal empty-reply handling.
413
+ const text = stripControlTokens(parsed.text);
409
414
  // A resumed session keeps its id even when the CLI omits it from the envelope.
410
- return { text: parsed.text, sessionId: parsed.sessionId ?? input.sessionId };
415
+ return { text, sessionId: parsed.sessionId ?? input.sessionId };
411
416
  }
412
417
  //# sourceMappingURL=index.js.map
@@ -22,6 +22,7 @@
22
22
  * fixtures in runtime/fixtures/. Claude sends one content block per assistant
23
23
  * event; older Grok sends a whole message (thinking + text + tool_use) per event.
24
24
  */
25
+ import { stripControlTokens } from "./sanitize.js";
25
26
  import { parseJsonLine, RuntimeError } from "./types.js";
26
27
  /** Progress events in one stream line: top-level assistant text and tool starts only. */
27
28
  export function messagesStreamEvents(line) {
@@ -44,8 +45,12 @@ export function messagesStreamEvents(line) {
44
45
  if (!block || typeof block !== "object")
45
46
  continue;
46
47
  const b = block;
47
- if (b.type === "text" && typeof b.text === "string" && b.text.trim())
48
- out.push({ kind: "message", text: b.text.trim() });
48
+ if (b.type === "text" && typeof b.text === "string") {
49
+ // Strip leaked control tokens from in-between progress posts too.
50
+ const clean = stripControlTokens(b.text).trim();
51
+ if (clean)
52
+ out.push({ kind: "message", text: clean });
53
+ }
49
54
  else if (b.type === "tool_use" && typeof b.name === "string")
50
55
  out.push({ kind: "tool", name: b.name });
51
56
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Model control-token stripping for bot replies.
3
+ *
4
+ * A model can leak a raw control token as visible text — grok posted a bare
5
+ * `<|eos|>` as a room reply on 2026-09-25. These tokens are never meant for a
6
+ * person, so they are removed from both the in-between progress messages and
7
+ * the final reply before anything is posted.
8
+ *
9
+ * The generic `<|...|>` form covers any single control token in that shape
10
+ * (`<|eos|>`, `<|endoftext|>`, `<|im_end|>`, `<|eot_id|>`, and any other).
11
+ * Whitespace-only reply after stripping is empty, and callers apply the same
12
+ * empty-reply handling they already have (silent on an unmentioned room turn).
13
+ */
14
+ /** Remove every `<|...|>` control token from `text`, leaving the rest intact. */
15
+ export declare function stripControlTokens(text: string): string;
16
+ /** True when `text` is nothing but control tokens and whitespace. */
17
+ export declare function isOnlyControlTokens(text: string): boolean;
18
+ //# sourceMappingURL=sanitize.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Model control-token stripping for bot replies.
3
+ *
4
+ * A model can leak a raw control token as visible text — grok posted a bare
5
+ * `<|eos|>` as a room reply on 2026-09-25. These tokens are never meant for a
6
+ * person, so they are removed from both the in-between progress messages and
7
+ * the final reply before anything is posted.
8
+ *
9
+ * The generic `<|...|>` form covers any single control token in that shape
10
+ * (`<|eos|>`, `<|endoftext|>`, `<|im_end|>`, `<|eot_id|>`, and any other).
11
+ * Whitespace-only reply after stripping is empty, and callers apply the same
12
+ * empty-reply handling they already have (silent on an unmentioned room turn).
13
+ */
14
+ /** Any single control token of the form `<|...|>` (no nested `<|`/`|>`). */
15
+ const CONTROL_TOKEN_RE = /<\|(?:(?!<\||\|>).)*\|>/g;
16
+ /** Remove every `<|...|>` control token from `text`, leaving the rest intact. */
17
+ export function stripControlTokens(text) {
18
+ if (!text)
19
+ return text;
20
+ return text.replace(CONTROL_TOKEN_RE, "");
21
+ }
22
+ /** True when `text` is nothing but control tokens and whitespace. */
23
+ export function isOnlyControlTokens(text) {
24
+ if (!text.trim())
25
+ return false;
26
+ return stripControlTokens(text).trim() === "";
27
+ }
28
+ //# sourceMappingURL=sanitize.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.195.0",
3
+ "version": "5.196.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {