@indigoai-us/hq-cli 5.183.0 → 5.185.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 +19 -0
- package/dist/command-catalog.generated.d.ts +3 -0
- package/dist/command-catalog.generated.js +4 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +100 -6
- package/dist/commands/mesh.js +18 -1
- package/dist/lib/bot/progress.d.ts +10 -0
- package/dist/lib/bot/progress.js +55 -0
- package/dist/lib/bot/prompt.js +3 -1
- package/dist/lib/bot/run.d.ts +4 -1
- package/dist/lib/bot/run.js +60 -5
- package/dist/lib/doctor/checks/sync-health.js +13 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +26 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.js +94 -0
- package/dist/lib/doctor/fix/apply.d.ts +5 -0
- package/dist/lib/doctor/fix/apply.js +28 -3
- package/dist/lib/doctor/fix/json-output.d.ts +20 -0
- package/dist/lib/doctor/fix/json-output.js +22 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +12 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.js +25 -0
- package/dist/lib/doctor/json-output.d.ts +7 -1
- package/dist/lib/doctor/json-output.js +6 -2
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/doctor/types.js +15 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +1 -1
- package/dist/lib/mesh/live/daemon/index.js +1 -1
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +25 -3
- package/dist/lib/mesh/live/emit-client.d.ts +6 -0
- package/dist/lib/mesh/live/emit-client.js +8 -0
- package/dist/lib/mesh/live/enqueue.d.ts +5 -0
- package/dist/lib/mesh/live/enqueue.js +19 -0
- package/dist/lib/mesh/live/flush.js +5 -3
- package/dist/lib/mesh/live/format-spool-line.d.ts +5 -2
- package/dist/lib/mesh/live/format-spool-line.js +10 -0
- package/dist/lib/mesh/live/report-summary.d.ts +45 -0
- package/dist/lib/mesh/live/report-summary.js +222 -0
- package/dist/lib/mesh/live/session-event.schema.json +24 -1
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/dist/lib/search-index/collection-path-repair-flag.d.ts +4 -0
- package/dist/lib/search-index/collection-path-repair-flag.js +7 -0
- package/dist/lib/search-index/index.d.ts +4 -0
- package/dist/lib/search-index/index.js +106 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.185.0] — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Local bots no longer post their no-reply signal, narration, blank messages, or raw model errors into rooms. On a room message that does not mention the bot, in-between messages and the working notice are held until the turn ends, and nothing is posted when the reply is the `{"action":"no_reply"}` signal or empty. Any model failure on such a turn (not only a Grok `reasoning_only` empty response, but every model error) is now silent instead of leaking "I didn't finish that — <model> stopped with an error: <raw excerpt>" into the room. When the turn does produce content, the held in-between messages are posted alongside the final answer, so a bot that answers mid-turn and ends with a short sign-off (claude/codex) no longer loses the answer; a Grok final that just concatenates the in-between messages still posts once with no duplication. The signal is never posted as progress anywhere. DMs and @-mentions still stream and still get a reply; a `reasoning_only` failure there now says "the model returned no answer" instead of the raw internal error, and other failures there keep the existing "did not finish" message. The room prompt also tells the model to produce no output rather than explain that it is staying silent.
|
|
10
|
+
|
|
11
|
+
## [5.184.0] — 2026-09-25
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Work Mesh transcript-watch now emits `session_end` for hook-covered sessions that have already been quiet for 10 minutes when first seen (the live skip path that left `skippedHook=411 ended=0`). Held/spool rescans stamp last hook activity from each event's `at`, not the scan clock, so leftover `held.jsonl` lines cannot reset the quiet timer.
|
|
16
|
+
- Quiet `session_end` (and SessionEnd flush) send an optional one-sentence summary of the last assistant reply (~140 chars, tokens and secrets-file paths redacted), plus git repo, branch, and PR URLs when cheap.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `hq doctor --json` includes stable sync `reasonCode` enums. Client-health reports add `checkId: reasonCode` pairs only when the company-scoped `client-health-sync-reason-codes` hq-flags gate is enabled. `hq doctor --fix --json` reports refresh-sync outcomes without raw error details.
|
|
21
|
+
|
|
22
|
+
- Repair known stale QMD collection paths behind the default-off `qmd.collection-path-repair` hq-flags canary while preserving collection metadata.
|
|
23
|
+
|
|
5
24
|
## [5.183.0] — 2026-09-25
|
|
6
25
|
|
|
7
26
|
### Added
|
|
@@ -5495,6 +5495,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5495
5495
|
}, {
|
|
5496
5496
|
readonly flags: "--fix";
|
|
5497
5497
|
readonly description: "Apply the allowlisted safe repairs (backs up first; read-only without this flag).";
|
|
5498
|
+
}, {
|
|
5499
|
+
readonly flags: "--client-health-report";
|
|
5500
|
+
readonly description: "Resolve the opt-in hq-flags gate used by the client-health report hook.";
|
|
5498
5501
|
}, {
|
|
5499
5502
|
readonly flags: "--yes";
|
|
5500
5503
|
readonly description: "Skip the interactive --fix confirmation (non-interactive use).";
|
|
@@ -7089,6 +7089,10 @@ export const COMMAND_CATALOG = [
|
|
|
7089
7089
|
"flags": "--fix",
|
|
7090
7090
|
"description": "Apply the allowlisted safe repairs (backs up first; read-only without this flag)."
|
|
7091
7091
|
},
|
|
7092
|
+
{
|
|
7093
|
+
"flags": "--client-health-report",
|
|
7094
|
+
"description": "Resolve the opt-in hq-flags gate used by the client-health report hook."
|
|
7095
|
+
},
|
|
7092
7096
|
{
|
|
7093
7097
|
"flags": "--yes",
|
|
7094
7098
|
"description": "Skip the interactive --fix confirmation (non-interactive use)."
|
|
@@ -55,6 +55,8 @@ export interface RunDoctorOptions {
|
|
|
55
55
|
stderr?: (chunk: string) => void;
|
|
56
56
|
/** Emit the machine-readable JSON document instead of text. Default: false. */
|
|
57
57
|
json?: boolean;
|
|
58
|
+
/** Resolve hq-flags independently for each company represented in sync results. */
|
|
59
|
+
resolveClientHealthReasonCodesForCompany?: (companySlug: string | null) => Promise<boolean>;
|
|
58
60
|
/** In text mode, also print every PASS result. Default: false. */
|
|
59
61
|
verbose?: boolean;
|
|
60
62
|
/** In text mode, emit ANSI colour. Default: false (no escape sequences). */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import * as fs from "node:fs";
|
|
25
25
|
import * as path from "node:path";
|
|
26
|
+
import { Option } from "commander";
|
|
26
27
|
import { createDefaultRegistry, } from "../lib/doctor/registry.js";
|
|
27
28
|
import { computeExitCode, renderText, UNKNOWN_PLATFORM, } from "../lib/doctor/report.js";
|
|
28
29
|
import { buildDoctorJson, renderJson } from "../lib/doctor/json-output.js";
|
|
@@ -30,6 +31,10 @@ import { detectPlatform } from "../lib/doctor/platform.js";
|
|
|
30
31
|
import { DEEP_FAMILY_ID, DEEP_FAMILY_TITLE, runDeepGuardTests, } from "../lib/doctor/deep/executor.js";
|
|
31
32
|
import { PARITY_FAMILY_ID, PARITY_FAMILY_TITLE, runParityReplay, } from "../lib/doctor/deep/parity.js";
|
|
32
33
|
import { applyFixes } from "../lib/doctor/fix/apply.js";
|
|
34
|
+
import { renderDoctorFixJson } from "../lib/doctor/fix/json-output.js";
|
|
35
|
+
import { clientHealthReasonCodesEnabled as isActiveCompanyClientHealthReasonCodesEnabled, clientHealthReasonCodesEnabledForCompany, } from "../lib/doctor/client-health-reason-codes-flag.js";
|
|
36
|
+
import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
|
|
37
|
+
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
33
38
|
import * as readline from "node:readline";
|
|
34
39
|
import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
|
|
35
40
|
/**
|
|
@@ -157,7 +162,16 @@ export async function runDoctor(options = {}) {
|
|
|
157
162
|
});
|
|
158
163
|
}
|
|
159
164
|
if (options.json) {
|
|
160
|
-
|
|
165
|
+
const clientHealthReasonCodesEnabledCheckIds = options.resolveClientHealthReasonCodesForCompany
|
|
166
|
+
? await resolveClientHealthReasonCodesCheckIds(families, options.resolveClientHealthReasonCodesForCompany)
|
|
167
|
+
: undefined;
|
|
168
|
+
write(renderJson(buildDoctorJson({
|
|
169
|
+
hqRoot,
|
|
170
|
+
families,
|
|
171
|
+
platform,
|
|
172
|
+
scope,
|
|
173
|
+
clientHealthReasonCodesEnabledCheckIds,
|
|
174
|
+
})));
|
|
161
175
|
}
|
|
162
176
|
else {
|
|
163
177
|
write(renderText({
|
|
@@ -172,6 +186,61 @@ export async function runDoctor(options = {}) {
|
|
|
172
186
|
// UNTESTED, NA, and KNOWN-DEFECT are reported but never fail the command.
|
|
173
187
|
return { exitCode: computeExitCode(families), hqRoot, families };
|
|
174
188
|
}
|
|
189
|
+
async function resolveClientHealthReasonCodesCheckIds(families, resolveForCompany) {
|
|
190
|
+
const checksByCompany = new Map();
|
|
191
|
+
for (const run of families) {
|
|
192
|
+
if (run.family.id !== "sync")
|
|
193
|
+
continue;
|
|
194
|
+
for (const result of run.results) {
|
|
195
|
+
if (result.reasonCode === undefined ||
|
|
196
|
+
(result.status !== "FAIL" && result.status !== "WARN")) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const scoped = /^sync\.(?:journal|manifest)\.(.+)$/.exec(result.checkId);
|
|
200
|
+
const companySlug = scoped && isCompanyJournalSlug(scoped[1]) ? scoped[1] : null;
|
|
201
|
+
const checkIds = checksByCompany.get(companySlug) ?? [];
|
|
202
|
+
checkIds.push(result.checkId);
|
|
203
|
+
checksByCompany.set(companySlug, checkIds);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const enabledChecks = new Set();
|
|
207
|
+
const decisions = await Promise.all([...checksByCompany.entries()].map(async ([companySlug, checkIds]) => ({
|
|
208
|
+
checkIds,
|
|
209
|
+
enabled: await resolveForCompany(companySlug),
|
|
210
|
+
})));
|
|
211
|
+
for (const decision of decisions) {
|
|
212
|
+
if (decision.enabled) {
|
|
213
|
+
for (const checkId of decision.checkIds)
|
|
214
|
+
enabledChecks.add(checkId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return enabledChecks;
|
|
218
|
+
}
|
|
219
|
+
function createClientHealthReasonCodeCompanyResolver(hqRoot) {
|
|
220
|
+
const companies = readCompaniesManifestMap(hqRoot);
|
|
221
|
+
return async (companySlug) => {
|
|
222
|
+
if (companySlug === null) {
|
|
223
|
+
return isActiveCompanyClientHealthReasonCodesEnabled();
|
|
224
|
+
}
|
|
225
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(companySlug)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const manifestUid = companies?.[companySlug]?.cloud_uid;
|
|
229
|
+
const activeSlug = process.env.HQ_COMPANY_SLUG?.trim() || "";
|
|
230
|
+
const activeUid = process.env.HQ_COMPANY_UID?.trim() || "";
|
|
231
|
+
const companyUid = typeof manifestUid === "string" && manifestUid.trim()
|
|
232
|
+
? manifestUid.trim()
|
|
233
|
+
: companySlug === activeSlug
|
|
234
|
+
? activeUid
|
|
235
|
+
: "";
|
|
236
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid))
|
|
237
|
+
return false;
|
|
238
|
+
return clientHealthReasonCodesEnabledForCompany({
|
|
239
|
+
companyUid,
|
|
240
|
+
companySlug,
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
}
|
|
175
244
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
176
245
|
export function registerDoctorCommand(program) {
|
|
177
246
|
program
|
|
@@ -186,6 +255,7 @@ export function registerDoctorCommand(program) {
|
|
|
186
255
|
.option("--company <slug>", "Company slug for integrations checks (otherwise resolves your single active company).")
|
|
187
256
|
.option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
|
|
188
257
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
258
|
+
.addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
|
|
189
259
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
190
260
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
191
261
|
.action(async (opts) => {
|
|
@@ -208,8 +278,17 @@ export function registerDoctorCommand(program) {
|
|
|
208
278
|
if (opts.fix === true) {
|
|
209
279
|
const hqRoot = resolveHqRoot();
|
|
210
280
|
if (!hqRoot) {
|
|
211
|
-
|
|
212
|
-
|
|
281
|
+
if (opts.json === true) {
|
|
282
|
+
process.stdout.write(renderDoctorFixJson({
|
|
283
|
+
exitCode: 1,
|
|
284
|
+
refused: "outside-hq-tree",
|
|
285
|
+
applied: [],
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
process.stderr.write(`hq doctor --fix: not inside an HQ tree.\n` +
|
|
290
|
+
` Looking for a directory containing all of: ${HQ_ROOT_MARKERS.join(", ")}\n`);
|
|
291
|
+
}
|
|
213
292
|
process.exitCode = 1;
|
|
214
293
|
return;
|
|
215
294
|
}
|
|
@@ -217,8 +296,14 @@ export function registerDoctorCommand(program) {
|
|
|
217
296
|
hqRoot,
|
|
218
297
|
yes: opts.yes === true,
|
|
219
298
|
force: opts.force === true,
|
|
220
|
-
confirm: promptYesNo,
|
|
299
|
+
confirm: () => promptYesNo(opts.json === true ? process.stderr : process.stdout),
|
|
300
|
+
...(opts.json === true
|
|
301
|
+
? { stdout: () => { }, stderr: () => { } }
|
|
302
|
+
: {}),
|
|
221
303
|
});
|
|
304
|
+
if (opts.json === true) {
|
|
305
|
+
process.stdout.write(renderDoctorFixJson(fixResult));
|
|
306
|
+
}
|
|
222
307
|
process.exitCode = fixResult.exitCode;
|
|
223
308
|
return;
|
|
224
309
|
}
|
|
@@ -237,6 +322,14 @@ export function registerDoctorCommand(program) {
|
|
|
237
322
|
id: detection.platform,
|
|
238
323
|
evidence: detection.evidence,
|
|
239
324
|
};
|
|
325
|
+
const clientHealthReasonCodesResolver = json && opts.clientHealthReport === true
|
|
326
|
+
? (() => {
|
|
327
|
+
const hqRoot = resolveHqRoot();
|
|
328
|
+
return hqRoot
|
|
329
|
+
? createClientHealthReasonCodeCompanyResolver(hqRoot)
|
|
330
|
+
: undefined;
|
|
331
|
+
})()
|
|
332
|
+
: undefined;
|
|
240
333
|
const result = await runDoctor({
|
|
241
334
|
json,
|
|
242
335
|
verbose: opts.verbose === true,
|
|
@@ -247,6 +340,7 @@ export function registerDoctorCommand(program) {
|
|
|
247
340
|
liveRuntimes: opts.liveRuntimes === true,
|
|
248
341
|
company: opts.company,
|
|
249
342
|
only: parseOnly(opts.only),
|
|
343
|
+
resolveClientHealthReasonCodesForCompany: clientHealthReasonCodesResolver,
|
|
250
344
|
});
|
|
251
345
|
// Set the exit code rather than calling process.exit, so the CLI's
|
|
252
346
|
// normal shutdown (telemetry flush) still runs. Non-zero means either an
|
|
@@ -272,12 +366,12 @@ function parseOnly(value) {
|
|
|
272
366
|
* (so a piped run without `--yes` writes nothing) and on anything other than an
|
|
273
367
|
* explicit yes.
|
|
274
368
|
*/
|
|
275
|
-
function promptYesNo() {
|
|
369
|
+
function promptYesNo(output = process.stdout) {
|
|
276
370
|
if (!process.stdin.isTTY)
|
|
277
371
|
return Promise.resolve(false);
|
|
278
372
|
const rl = readline.createInterface({
|
|
279
373
|
input: process.stdin,
|
|
280
|
-
output
|
|
374
|
+
output,
|
|
281
375
|
});
|
|
282
376
|
return new Promise((resolve) => {
|
|
283
377
|
rl.question("Apply these repairs? [y/N] ", (answer) => {
|
package/dist/commands/mesh.js
CHANGED
|
@@ -29,6 +29,7 @@ const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
|
|
|
29
29
|
import { buildEmitEvidence, machineEmitDisposition } from "../lib/mesh/live/emit-evidence.js";
|
|
30
30
|
import { appendHeldLine } from "../lib/mesh/live/spool.js";
|
|
31
31
|
import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
|
|
32
|
+
import { enrichSessionEndEvent } from "../lib/mesh/live/report-summary.js";
|
|
32
33
|
import { emitEvents, formatEmitRejectionLines } from "../lib/mesh/live/emit.js";
|
|
33
34
|
import { readEmitRetry, writeEmitRetry } from "../lib/mesh/live/emit-retry.js";
|
|
34
35
|
import { generateUlid } from "../lib/mesh/live/ulid.js";
|
|
@@ -1533,6 +1534,14 @@ async function runSessionEnqueue(cliKind, opts) {
|
|
|
1533
1534
|
task: opts.task,
|
|
1534
1535
|
env: process.env,
|
|
1535
1536
|
});
|
|
1537
|
+
const reportFields = kind === "session_end"
|
|
1538
|
+
? enrichSessionEndEvent({
|
|
1539
|
+
kind,
|
|
1540
|
+
sessionId: sessionId,
|
|
1541
|
+
cwd: opts.cwd,
|
|
1542
|
+
summary: opts.summary,
|
|
1543
|
+
})
|
|
1544
|
+
: {};
|
|
1536
1545
|
const event = buildMeshEmitEvent({
|
|
1537
1546
|
eventId: opts.eventId?.trim() || generateUlid(Date.now()),
|
|
1538
1547
|
kind: kind,
|
|
@@ -1546,7 +1555,15 @@ async function runSessionEnqueue(cliKind, opts) {
|
|
|
1546
1555
|
taskId: opts.taskId,
|
|
1547
1556
|
status: opts.status,
|
|
1548
1557
|
reason: opts.reason,
|
|
1549
|
-
summary: opts.summary
|
|
1558
|
+
summary: opts.summary ||
|
|
1559
|
+
(typeof reportFields.summary === "string" ? reportFields.summary : undefined),
|
|
1560
|
+
repo: typeof reportFields.repo === "string" ? reportFields.repo : undefined,
|
|
1561
|
+
gitBranch: typeof reportFields.gitBranch === "string"
|
|
1562
|
+
? reportFields.gitBranch
|
|
1563
|
+
: undefined,
|
|
1564
|
+
prUrls: Array.isArray(reportFields.prUrls)
|
|
1565
|
+
? reportFields.prUrls
|
|
1566
|
+
: undefined,
|
|
1550
1567
|
evidence,
|
|
1551
1568
|
});
|
|
1552
1569
|
const meshRoot = workMeshHomeRoot();
|
|
@@ -42,9 +42,18 @@ export interface ProgressPosterOptions {
|
|
|
42
42
|
minGapMs?: number;
|
|
43
43
|
workingNoticeMs?: number;
|
|
44
44
|
workingNoticeText?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Unmentioned room turn: hold every in-between message and the working
|
|
47
|
+
* notice, and post nothing until the turn ends. `finish` then posts the
|
|
48
|
+
* in-between messages followed by the final answer when it adds something
|
|
49
|
+
* (when the turn is not silent); `abandon` posts nothing.
|
|
50
|
+
*/
|
|
51
|
+
buffer?: boolean;
|
|
45
52
|
}
|
|
46
53
|
/** Whitespace-insensitive form used to recognise repeated text. */
|
|
47
54
|
export declare function normalizeForCompare(text: string): string;
|
|
55
|
+
/** Matches {"action":"no_reply"} anywhere in a model reply, with or without preamble text. */
|
|
56
|
+
export declare function containsNoReplySentinel(text: string): boolean;
|
|
48
57
|
export declare class ProgressPoster {
|
|
49
58
|
private readonly send;
|
|
50
59
|
private readonly sendFinalFn?;
|
|
@@ -55,6 +64,7 @@ export declare class ProgressPoster {
|
|
|
55
64
|
private readonly minGapMs;
|
|
56
65
|
private readonly workingNoticeMs;
|
|
57
66
|
private readonly workingNoticeText;
|
|
67
|
+
private readonly buffer;
|
|
58
68
|
private pending;
|
|
59
69
|
/** In-between questions, held until the turn ends (see the header). */
|
|
60
70
|
private heldQuestions;
|
package/dist/lib/bot/progress.js
CHANGED
|
@@ -31,6 +31,10 @@ export const WORKING_NOTICE_TEXT = "Working on it — I'll reply here when it's
|
|
|
31
31
|
export function normalizeForCompare(text) {
|
|
32
32
|
return text.replace(/\s+/g, "");
|
|
33
33
|
}
|
|
34
|
+
/** Matches {"action":"no_reply"} anywhere in a model reply, with or without preamble text. */
|
|
35
|
+
export function containsNoReplySentinel(text) {
|
|
36
|
+
return /\{\s*"action"\s*:\s*"no_reply"\s*\}/.test(text);
|
|
37
|
+
}
|
|
34
38
|
export class ProgressPoster {
|
|
35
39
|
send;
|
|
36
40
|
sendFinalFn;
|
|
@@ -41,6 +45,7 @@ export class ProgressPoster {
|
|
|
41
45
|
minGapMs;
|
|
42
46
|
workingNoticeMs;
|
|
43
47
|
workingNoticeText;
|
|
48
|
+
buffer;
|
|
44
49
|
pending = [];
|
|
45
50
|
/** In-between questions, held until the turn ends (see the header). */
|
|
46
51
|
heldQuestions = [];
|
|
@@ -63,9 +68,12 @@ export class ProgressPoster {
|
|
|
63
68
|
this.minGapMs = opts.minGapMs ?? PROGRESS_MIN_GAP_MS;
|
|
64
69
|
this.workingNoticeMs = opts.workingNoticeMs ?? WORKING_NOTICE_MS;
|
|
65
70
|
this.workingNoticeText = opts.workingNoticeText ?? WORKING_NOTICE_TEXT;
|
|
71
|
+
this.buffer = opts.buffer ?? false;
|
|
66
72
|
}
|
|
67
73
|
/** Arm the one-time "working on it" note. Call when the turn starts. */
|
|
68
74
|
start() {
|
|
75
|
+
if (this.buffer)
|
|
76
|
+
return;
|
|
69
77
|
if (this.finished || this.noticeTimer !== null || this.workingNoticeMs <= 0)
|
|
70
78
|
return;
|
|
71
79
|
this.noticeTimer = this.setTimer(() => {
|
|
@@ -81,8 +89,16 @@ export class ProgressPoster {
|
|
|
81
89
|
const t = text.trim();
|
|
82
90
|
if (this.finished || !t)
|
|
83
91
|
return;
|
|
92
|
+
// Never treat the no_reply sentinel as something the model "said": it is a
|
|
93
|
+
// silence signal, not content, and must never be posted as progress.
|
|
94
|
+
if (containsNoReplySentinel(t))
|
|
95
|
+
return;
|
|
84
96
|
this.cancelNotice();
|
|
85
97
|
this.said.push(t);
|
|
98
|
+
// An unmentioned room turn holds every in-between message: nothing is posted
|
|
99
|
+
// until the turn ends, and then only the final answer.
|
|
100
|
+
if (this.buffer)
|
|
101
|
+
return;
|
|
86
102
|
if (t.includes("?")) {
|
|
87
103
|
this.heldQuestions.push(t);
|
|
88
104
|
return;
|
|
@@ -112,6 +128,34 @@ export class ProgressPoster {
|
|
|
112
128
|
this.cancelNotice();
|
|
113
129
|
this.cancelFlush();
|
|
114
130
|
const final = finalText.trim();
|
|
131
|
+
// Buffered (unmentioned room): nothing was posted during the turn. Post the
|
|
132
|
+
// collected in-between messages (the sentinel is already excluded from
|
|
133
|
+
// `said`), then the final answer when it adds something the in-between
|
|
134
|
+
// messages did not already say — mirroring the streaming path's
|
|
135
|
+
// repeatsSaid logic. claude/codex answer mid-turn and end with a short
|
|
136
|
+
// sign-off, so dropping the in-between messages would lose the real answer;
|
|
137
|
+
// Grok's final is every message concatenated, so it repeats and is skipped.
|
|
138
|
+
// A silent turn (run.ts decides via sentinel/empty) never reaches here.
|
|
139
|
+
if (this.buffer) {
|
|
140
|
+
this.pending = [];
|
|
141
|
+
this.heldQuestions = [];
|
|
142
|
+
for (const s of this.said) {
|
|
143
|
+
if (!containsNoReplySentinel(s))
|
|
144
|
+
this.pending.push(s);
|
|
145
|
+
}
|
|
146
|
+
if (final && !containsNoReplySentinel(final) && !this.repeatsSaid(final)) {
|
|
147
|
+
if (this.sendFinalFn) {
|
|
148
|
+
// Keep the audience split: collected messages go through send, the
|
|
149
|
+
// final reply through sendFinal.
|
|
150
|
+
this.flushNow();
|
|
151
|
+
this.enqueueWith(final, this.sendFinalFn);
|
|
152
|
+
return this.chain;
|
|
153
|
+
}
|
|
154
|
+
this.pending.push(final);
|
|
155
|
+
}
|
|
156
|
+
this.flushNow();
|
|
157
|
+
return this.chain;
|
|
158
|
+
}
|
|
115
159
|
const held = this.heldQuestions;
|
|
116
160
|
this.heldQuestions = [];
|
|
117
161
|
const shouldAddFinal = Boolean(final && !this.repeatsSaid(final));
|
|
@@ -141,6 +185,13 @@ export class ProgressPoster {
|
|
|
141
185
|
this.finished = true;
|
|
142
186
|
this.cancelNotice();
|
|
143
187
|
this.cancelFlush();
|
|
188
|
+
// Buffered turns never post salvage: nothing streamed, so there is nothing
|
|
189
|
+
// to salvage, and the caller decides whether to post anything.
|
|
190
|
+
if (this.buffer) {
|
|
191
|
+
this.pending = [];
|
|
192
|
+
this.heldQuestions = [];
|
|
193
|
+
return this.chain;
|
|
194
|
+
}
|
|
144
195
|
this.pending.push(...this.heldQuestions);
|
|
145
196
|
this.heldQuestions = [];
|
|
146
197
|
this.flushNow();
|
|
@@ -174,6 +225,10 @@ export class ProgressPoster {
|
|
|
174
225
|
this.enqueueWith(body, this.send);
|
|
175
226
|
}
|
|
176
227
|
enqueueWith(body, sendFn) {
|
|
228
|
+
// Defence in depth: never let the silence sentinel reach the chat, whatever
|
|
229
|
+
// path built this body.
|
|
230
|
+
if (containsNoReplySentinel(body))
|
|
231
|
+
return;
|
|
177
232
|
this.lastPostAt = this.now();
|
|
178
233
|
this.chain = this.chain.then(async () => {
|
|
179
234
|
try {
|
package/dist/lib/bot/prompt.js
CHANGED
|
@@ -288,7 +288,9 @@ export function buildSystemPrompt(input) {
|
|
|
288
288
|
`Your owner can also add you to group chats and channels. A message that starts with a \`[#channel · N members · from Name]\` line ` +
|
|
289
289
|
`comes from a room with other people in it (a "Recent messages:" block may follow for context; the last part is the message you are answering). ` +
|
|
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
|
-
`or anything from your DMs to other people
|
|
291
|
+
`or anything from your DMs to other people. ` +
|
|
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
|
+
`Do not narrate your reasoning, do not print JSON, do not explain that you are staying silent. Just produce nothing.`);
|
|
292
294
|
// Only company bots: the cloud's jobs routes refuse a personal bot's per_
|
|
293
295
|
// company claim today, so telling a personal bot it can schedule work makes
|
|
294
296
|
// it promise its owner something that then fails. Drop this condition when
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
import { type OwnerContext } from "./owner-context.js";
|
|
48
48
|
import { type BotApi, type InboxItem } from "./api.js";
|
|
49
49
|
import type { BotConfig, BotKind } from "./config.js";
|
|
50
|
-
import { type ProgressPosterOptions } from "./progress.js";
|
|
50
|
+
import { containsNoReplySentinel, type ProgressPosterOptions } from "./progress.js";
|
|
51
51
|
import { type BotLogger } from "./log.js";
|
|
52
52
|
import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
|
|
53
53
|
import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
|
|
@@ -188,6 +188,9 @@ export declare function turnEnv(base: NodeJS.ProcessEnv, kind: BotKind, agentUid
|
|
|
188
188
|
export declare const SETUP_WORKER_ID = "setup";
|
|
189
189
|
/** The one message a person gets when a turn on their message did not finish. */
|
|
190
190
|
export declare function didNotFinishText(reason: string, progressPosted: boolean): string;
|
|
191
|
+
export { containsNoReplySentinel };
|
|
192
|
+
/** Grok emits is_error with this message when the model produces only reasoning tokens. */
|
|
193
|
+
export declare function isReasoningOnlyError(excerpt: string): boolean;
|
|
191
194
|
/** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
|
|
192
195
|
export declare const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
|
|
193
196
|
export declare const RESTARTED_REASON = "I was restarted while I was working on it";
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -57,7 +57,7 @@ import { ensureBotSessionMeta } from "./company-bind.js";
|
|
|
57
57
|
import { readBotCredsIdentity } from "./creds.js";
|
|
58
58
|
import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
|
|
59
59
|
import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
|
|
60
|
-
import { ProgressPoster } from "./progress.js";
|
|
60
|
+
import { containsNoReplySentinel, ProgressPoster } from "./progress.js";
|
|
61
61
|
import { splitBody } from "./split.js";
|
|
62
62
|
import { createBotLogger } from "./log.js";
|
|
63
63
|
import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
|
|
@@ -178,6 +178,11 @@ export function didNotFinishText(reason, progressPosted) {
|
|
|
178
178
|
const r = reason.trim().replace(/[.\s]+$/, "");
|
|
179
179
|
return `I didn't finish that — ${r}.${progressPosted ? " The messages above show how far I got." : ""}`;
|
|
180
180
|
}
|
|
181
|
+
export { containsNoReplySentinel };
|
|
182
|
+
/** Grok emits is_error with this message when the model produces only reasoning tokens. */
|
|
183
|
+
export function isReasoningOnlyError(excerpt) {
|
|
184
|
+
return excerpt.includes("reasoning_only");
|
|
185
|
+
}
|
|
181
186
|
/** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
|
|
182
187
|
export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
|
|
183
188
|
export const RESTARTED_REASON = "I was restarted while I was working on it";
|
|
@@ -646,6 +651,7 @@ export async function runBot(deps) {
|
|
|
646
651
|
writeInflight(dir, { v: 1, ...args.marker, startedAt: now().toISOString() });
|
|
647
652
|
let running = true;
|
|
648
653
|
let posted = 0;
|
|
654
|
+
const silenceable = args.silenceable === true;
|
|
649
655
|
const makeSendFn = (fn) => async (body) => {
|
|
650
656
|
try {
|
|
651
657
|
await fn(body);
|
|
@@ -662,7 +668,8 @@ export async function runBot(deps) {
|
|
|
662
668
|
};
|
|
663
669
|
const poster = new ProgressPoster({
|
|
664
670
|
...deps.progress,
|
|
665
|
-
...(args.workingNotice === false ? { workingNoticeMs: 0 } : {}),
|
|
671
|
+
...(args.workingNotice === false || silenceable ? { workingNoticeMs: 0 } : {}),
|
|
672
|
+
...(silenceable ? { buffer: true } : {}),
|
|
666
673
|
send: makeSendFn(args.send),
|
|
667
674
|
...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
|
|
668
675
|
});
|
|
@@ -679,8 +686,21 @@ export async function runBot(deps) {
|
|
|
679
686
|
...(args.readDirs?.length ? { readDirs: args.readDirs } : {}),
|
|
680
687
|
});
|
|
681
688
|
running = false;
|
|
682
|
-
|
|
683
|
-
|
|
689
|
+
const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim();
|
|
690
|
+
// 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.
|
|
694
|
+
if (silenceable && sentinelOrEmpty) {
|
|
695
|
+
await poster.abandon();
|
|
696
|
+
return { outcome: { ok: true, reply: "", silent: true }, poster };
|
|
697
|
+
}
|
|
698
|
+
// DM or @-mentioned room: the person asked directly and must get a reply.
|
|
699
|
+
// Strip the silence sentinel so its raw JSON never posts, then let the
|
|
700
|
+
// poster produce its "(no reply)" fallback when there is nothing else.
|
|
701
|
+
const finalText = sentinelOrEmpty ? "" : turn.text;
|
|
702
|
+
await poster.finish(finalText);
|
|
703
|
+
return { outcome: { ok: true, reply: finalText.trim() }, poster };
|
|
684
704
|
}
|
|
685
705
|
catch (err) {
|
|
686
706
|
running = false;
|
|
@@ -984,6 +1004,7 @@ export async function runBot(deps) {
|
|
|
984
1004
|
});
|
|
985
1005
|
await clearThinking();
|
|
986
1006
|
if (outcome.ok) {
|
|
1007
|
+
// The kickoff never runs silenceable, so outcome.silent is never set here.
|
|
987
1008
|
if (poster.postedCount === 0) {
|
|
988
1009
|
patchInflight(dir, KICKOFF_MESSAGE_ID, saved(outcome.reply));
|
|
989
1010
|
log("warn", "kickoff reply failed to send; it is posted on the next start");
|
|
@@ -1334,12 +1355,23 @@ export async function runBot(deps) {
|
|
|
1334
1355
|
send,
|
|
1335
1356
|
sendFinal,
|
|
1336
1357
|
workingNotice: !isSetupBot,
|
|
1358
|
+
silenceable: target.kind === "room" && !target.mentioned,
|
|
1337
1359
|
...(readDirs.length ? { readDirs } : {}),
|
|
1338
1360
|
...(target.kind === "room"
|
|
1339
1361
|
? { afterProgressPost: () => api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId) }
|
|
1340
1362
|
: {}),
|
|
1341
1363
|
});
|
|
1342
1364
|
if (outcome.ok) {
|
|
1365
|
+
// A no_reply sentinel or intentionally empty reply: ack silently, post nothing.
|
|
1366
|
+
if (outcome.silent) {
|
|
1367
|
+
markProcessed(dir, id, now().toISOString());
|
|
1368
|
+
await api.ackInbox(config.agentUid, id);
|
|
1369
|
+
clearInflight(dir, id);
|
|
1370
|
+
attempts.delete(id);
|
|
1371
|
+
await clearThinking();
|
|
1372
|
+
log("info", `no_reply sentinel or empty reply for ${id} (${where}); acked silently`);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1343
1375
|
// Known gap (#614, not changed here): a progress post counts, so a final answer that fails after one is not resent.
|
|
1344
1376
|
if (poster.postedCount === 0) {
|
|
1345
1377
|
// The work finished but nothing reached the chat: keep the answer —
|
|
@@ -1400,8 +1432,31 @@ export async function runBot(deps) {
|
|
|
1400
1432
|
// The model started: it may already have done things, so never re-run.
|
|
1401
1433
|
if (!outcome.aborted)
|
|
1402
1434
|
recordTurnFailure(outcome.error, sessionScope);
|
|
1435
|
+
// reasoning_only means the model produced only reasoning tokens and no text.
|
|
1436
|
+
const reasoningOnly = !outcome.aborted && isReasoningOnlyError(outcome.excerpt);
|
|
1437
|
+
const silenceableTurn = target.kind === "room" && !target.mentioned;
|
|
1438
|
+
// Unmentioned room turn that posted nothing and was not stopped by the
|
|
1439
|
+
// owner: any model failure (reasoning_only or otherwise) is a silent
|
|
1440
|
+
// no-reply. The failure is already recorded above via recordTurnFailure,
|
|
1441
|
+
// so we just ack quietly and never leak the raw error into the room.
|
|
1442
|
+
if (!outcome.aborted && poster.postedCount === 0 && silenceableTurn) {
|
|
1443
|
+
markProcessed(dir, id, now().toISOString());
|
|
1444
|
+
await api.ackInbox(config.agentUid, id);
|
|
1445
|
+
clearInflight(dir, id);
|
|
1446
|
+
attempts.delete(id);
|
|
1447
|
+
await clearThinking();
|
|
1448
|
+
log("info", `unmentioned room turn ${id} failed with no post (${where}); acked silently`);
|
|
1449
|
+
if (failures.exceeded(now().getTime())) {
|
|
1450
|
+
await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
|
|
1451
|
+
}
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1403
1454
|
try {
|
|
1404
|
-
|
|
1455
|
+
// DM or @-mentioned room reasoning_only: the person asked directly, so
|
|
1456
|
+
// still say we did not finish — but with a plain reason, never the raw
|
|
1457
|
+
// internal error string.
|
|
1458
|
+
const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
|
|
1459
|
+
await send(didNotFinishText(reason, poster.postedCount > 0));
|
|
1405
1460
|
}
|
|
1406
1461
|
catch (err) {
|
|
1407
1462
|
// Leave it un-acked with the marker: the next poll says it did not finish.
|