@indigoai-us/hq-cli 5.344.1 → 5.345.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,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.0] — 2026-10-05
6
+ - Apply Codex rollout retention, batched stale KPI notices, per-lead error isolation, and autopilot nudge deduplication without rollout gates.
7
+
8
+ - On a Mac with the Claude app or the Codex app installed and used a lot lately (at least 5 of that app's own sessions on 3 different days in the last 14 days; sessions from the `claude` and `codex` commands do not count, and neither do HQ's own bot sessions), the setup bot's welcome now leads straight into "Looks like you use the Claude app. Want to continue setup there?" (or the Codex app), and the HQ desktop app shows a button to open that app in your HQ folder next to a "Keep going here" button. Only a few metadata fields of each session are read: when it was used and which app ran it. Otherwise setup starts as before.
9
+
5
10
  ## [5.344.1] — 2026-10-05
6
11
  - Release the MCP server's package lease after its resident modules preload and record a bounded command purpose on CLI package leases.
7
12
 
@@ -165,6 +165,27 @@ instructions: |
165
165
  short: say what you can do for them and give one concrete example drawn from
166
166
  their HQ. Still no open "what can I help you with".
167
167
 
168
+ ## When the app offered their coding tool first
169
+
170
+ Before your first message, the HQ app checks whether the person has the
171
+ Claude app or the Codex app on this computer and has used it a lot lately.
172
+ When they have, the app sends the first message for you: the opening of
173
+ your welcome, then one line asking whether they want to continue setup in
174
+ that app, with a button that opens the app in their HQ folder with setup
175
+ ready to go, and a "Keep going here" button. You did not write that
176
+ message, and the kickoff waits for their answer.
177
+
178
+ - When they click "Keep going here", your turn starts with a note from the
179
+ app saying so, followed by the kickoff. Do the kickoff exactly as "When
180
+ you receive the kickoff" says, and do not mention the offer.
181
+ - When they write something else instead, your turn starts with a note
182
+ from the app saying the offer was made and setup has not started. If they
183
+ want to continue in that app, tell them in plain words to click
184
+ the button above your message; never tell them to type a command.
185
+ Otherwise open the walkthrough as "Your very first message" says.
186
+ - If they went to that app and come back here later, work out
187
+ where you are as always: some steps may already be done there.
188
+
168
189
  ## Your progress note
169
190
 
170
191
  You are a fresh conversation every turn. The only thing that carries across is
@@ -37,7 +37,7 @@ import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
37
37
  import { runResidentCommandWithPackageLease } from "../utils/resident-package-preload.js";
38
38
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff } from "../lib/lanes/registry-flag.js";
39
39
  import { LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, } from "../lib/lanes/flag-keys.js";
40
- import { CODEX_ROLLOUT_RETENTION_FLAG, configuredCodexRolloutHomes, lowDiskGuardEnabledForLane, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
40
+ import { configuredCodexRolloutHomes, lowDiskGuardEnabledForLane, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
41
41
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
42
42
  import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
43
43
  import { syncOutpostLeadRegistries } from "../lib/lanes/autopilot-registry.js";
@@ -5263,30 +5263,6 @@ async function runCapacity(ctx) {
5263
5263
  async function runGcRollouts(ctx, opts) {
5264
5264
  const hqRoot = ctx.hqRoot();
5265
5265
  const lanes = readAllLanes(hqRoot);
5266
- const companies = new Map();
5267
- for (const lane of lanes) {
5268
- const company = resolveLaneCompany(hqRoot, lane).company;
5269
- if (company)
5270
- companies.set(company.uid ?? company.slug, company);
5271
- }
5272
- if (companies.size === 0) {
5273
- const company = await ctx.resolveCompany();
5274
- companies.set(company.uid ?? company.slug, company);
5275
- }
5276
- let enabled = false;
5277
- for (const company of companies.values()) {
5278
- enabled ||= await readLanesRegistryFlag(CODEX_ROLLOUT_RETENTION_FLAG, {
5279
- company,
5280
- companyUidSource: "resolved_lane_company",
5281
- env: ctx.env,
5282
- });
5283
- if (enabled)
5284
- break;
5285
- }
5286
- if (!enabled) {
5287
- printJson({ ok: true, action: "gc-rollouts", enabled: false, dry_run: Boolean(opts.dryRun), scanned: 0, compressed: [], deleted: [], bytes_freed: 0 });
5288
- return;
5289
- }
5290
5266
  const config = loadLanesConfig(hqRoot);
5291
5267
  const homes = configuredCodexRolloutHomes(hqRoot, config, ctx.hostname());
5292
5268
  const result = await retainCodexRollouts({
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Does the person already use the Claude app or the Codex app a lot on this
3
+ * computer?
4
+ *
5
+ * The setup bot asks this before its first message: someone who works in one
6
+ * of those desktop apps is offered to continue setup there. Only the desktop
7
+ * apps count, never the command-line tools, and only on macOS:
8
+ *
9
+ * 1. The app must be installed: `Claude.app` (bundle id
10
+ * `com.anthropic.claudefordesktop`), or `ChatGPT.app` / `Codex.app`
11
+ * carrying Codex (bundle id `com.openai.codex`), in `/Applications` or
12
+ * `~/Applications`. The bundle id is checked, so another OpenAI app such as
13
+ * ChatGPT Atlas (`com.openai.atlas`) never counts.
14
+ * 2. The app must have recent sessions of its own:
15
+ * - Claude: the Claude app keeps one small record per Code session at
16
+ * `~/Library/Application Support/Claude/claude-code-sessions/<account>/<org>/local_<id>.json`.
17
+ * Sessions started with the `claude` command never get one, so CLI use
18
+ * does not count. Only the record's `lastActivityAt` / `createdAt`
19
+ * numbers and its `cwd` / `cliSessionId` (to skip HQ's bots) are read,
20
+ * from the start of the file; the file's mtime is the fallback.
21
+ * - Codex: the app and the CLI share `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`
22
+ * (`CODEX_HOME` moves `~/.codex`). The first line of each file is a
23
+ * `session_meta` record; a session the app ran says
24
+ * `"originator":"Codex Desktop"` with `"source":"vscode"`. Sessions from
25
+ * `codex` / `codex exec` (other originators, or source `exec`) and
26
+ * sub-agent threads (an object source) do not count. Only those two
27
+ * fields are read, from the start of the first line; the file's mtime
28
+ * is when it was last used.
29
+ *
30
+ * Nothing else in a session is read, and nothing about a session leaves this
31
+ * module except two numbers per app (sessions and distinct days in the window).
32
+ *
33
+ * HQ's own bots are not the person. Any session whose id a bot has on file
34
+ * (`session*.json` in each bot folder) is skipped, and so is a Claude session
35
+ * whose folder is under the bots' own folder.
36
+ */
37
+ export type CodingTool = "claude" | "codex";
38
+ export interface ActivityThreshold {
39
+ windowDays: number;
40
+ minSessions: number;
41
+ minDistinctDays: number;
42
+ }
43
+ /**
44
+ * "Significant recent activity": at least `minSessions` sessions, spread over
45
+ * at least `minDistinctDays` calendar days, within the last `windowDays` days.
46
+ */
47
+ export declare const SIGNIFICANT_CODING_TOOL_ACTIVITY: Readonly<ActivityThreshold>;
48
+ export interface ToolActivity {
49
+ /** Is the desktop app installed? */
50
+ installed: boolean;
51
+ /** App sessions used within the window (0 when the app is not installed). */
52
+ sessions: number;
53
+ /** Distinct local calendar days those sessions fall on. */
54
+ days: number;
55
+ }
56
+ export interface CodingToolActivity {
57
+ claude: ToolActivity;
58
+ codex: ToolActivity;
59
+ /** The app to offer, or null when neither qualifies. */
60
+ pick: CodingTool | null;
61
+ }
62
+ export interface HqBotExclusions {
63
+ /** HQ folders bots run their turns in (from each bot.json `hqRoot`). */
64
+ hqRoots: string[];
65
+ /** Model session ids the bots have on file. */
66
+ sessionIds: string[];
67
+ }
68
+ export interface DetectCodingToolActivityInput {
69
+ now: Date;
70
+ home?: string;
71
+ env?: NodeJS.ProcessEnv;
72
+ /** Defaults to `process.platform`; the apps are only looked for on macOS. */
73
+ platform?: NodeJS.Platform;
74
+ /** Where apps are installed; defaults to `/Applications` and `~/Applications`. */
75
+ applicationsDirs?: string[];
76
+ /** Where the bots live; defaults to `botsRoot()`. */
77
+ botsDir?: string;
78
+ /** Kept for callers; HQ folders are not used to tell app sessions apart. */
79
+ extraHqRoots?: string[];
80
+ threshold?: ActivityThreshold;
81
+ }
82
+ /** The desktop apps, by bundle id, and the bundle names they ship under. */
83
+ export declare const DESKTOP_APPS: Readonly<Record<CodingTool, {
84
+ bundleId: string;
85
+ bundles: readonly string[];
86
+ }>>;
87
+ /** `originator` the Codex app writes into its sessions' first line. */
88
+ export declare const CODEX_APP_ORIGINATOR = "Codex Desktop";
89
+ /** Claude Code's project folder name for a working directory. */
90
+ export declare function claudeProjectDirName(cwd: string): string;
91
+ /**
92
+ * Is `bundleId` this Info.plist's CFBundleIdentifier? An XML plist is read by
93
+ * key. A binary plist has no readable key/value pairing, so it matches when the
94
+ * whole id (not a prefix of a longer one, like `com.openai.codex.skill`)
95
+ * appears in it.
96
+ */
97
+ export declare function plistBundleIdIs(plist: Buffer, bundleId: string): boolean;
98
+ /**
99
+ * Is this desktop app installed? Looks for its bundle names in
100
+ * `/Applications` and `~/Applications` and checks the bundle id in each
101
+ * candidate's Info.plist (XML or binary: the id is stored as plain text in both).
102
+ */
103
+ export declare function desktopAppInstalled(tool: CodingTool, opts: {
104
+ home: string;
105
+ applicationsDirs?: string[];
106
+ }): boolean;
107
+ /**
108
+ * The HQ folders and session ids that belong to HQ's bots on this computer.
109
+ * Reads only the bots' own small config and session files.
110
+ */
111
+ export declare function collectHqBotExclusions(dir?: string): HqBotExclusions;
112
+ /**
113
+ * Is this the Codex app's own session? Reads `originator` and `source` from
114
+ * the start of the rollout's first line (its `session_meta` record).
115
+ */
116
+ export declare function isCodexAppSession(head: string): boolean;
117
+ /** Sessions and distinct days within the window ending at `now`. */
118
+ export declare function summarizeActivity(times: readonly number[], now: Date, windowDays: number): {
119
+ sessions: number;
120
+ days: number;
121
+ };
122
+ export declare function qualifies(activity: {
123
+ installed?: boolean;
124
+ sessions: number;
125
+ days: number;
126
+ }, threshold?: ActivityThreshold): boolean;
127
+ /**
128
+ * The app to offer: the one that qualifies, or, when both do, the one with
129
+ * more sessions in the window (Claude on a tie).
130
+ */
131
+ export declare function pickCodingTool(claude: {
132
+ installed?: boolean;
133
+ sessions: number;
134
+ days: number;
135
+ }, codex: {
136
+ installed?: boolean;
137
+ sessions: number;
138
+ days: number;
139
+ }, threshold?: ActivityThreshold): CodingTool | null;
140
+ /** Look for the Claude and Codex desktop apps and their recent sessions, and pick one to offer, if any. */
141
+ export declare function detectCodingToolActivity(input: DetectCodingToolActivityInput): CodingToolActivity;
142
+ //# sourceMappingURL=coding-tool-activity.d.ts.map
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Does the person already use the Claude app or the Codex app a lot on this
3
+ * computer?
4
+ *
5
+ * The setup bot asks this before its first message: someone who works in one
6
+ * of those desktop apps is offered to continue setup there. Only the desktop
7
+ * apps count, never the command-line tools, and only on macOS:
8
+ *
9
+ * 1. The app must be installed: `Claude.app` (bundle id
10
+ * `com.anthropic.claudefordesktop`), or `ChatGPT.app` / `Codex.app`
11
+ * carrying Codex (bundle id `com.openai.codex`), in `/Applications` or
12
+ * `~/Applications`. The bundle id is checked, so another OpenAI app such as
13
+ * ChatGPT Atlas (`com.openai.atlas`) never counts.
14
+ * 2. The app must have recent sessions of its own:
15
+ * - Claude: the Claude app keeps one small record per Code session at
16
+ * `~/Library/Application Support/Claude/claude-code-sessions/<account>/<org>/local_<id>.json`.
17
+ * Sessions started with the `claude` command never get one, so CLI use
18
+ * does not count. Only the record's `lastActivityAt` / `createdAt`
19
+ * numbers and its `cwd` / `cliSessionId` (to skip HQ's bots) are read,
20
+ * from the start of the file; the file's mtime is the fallback.
21
+ * - Codex: the app and the CLI share `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`
22
+ * (`CODEX_HOME` moves `~/.codex`). The first line of each file is a
23
+ * `session_meta` record; a session the app ran says
24
+ * `"originator":"Codex Desktop"` with `"source":"vscode"`. Sessions from
25
+ * `codex` / `codex exec` (other originators, or source `exec`) and
26
+ * sub-agent threads (an object source) do not count. Only those two
27
+ * fields are read, from the start of the first line; the file's mtime
28
+ * is when it was last used.
29
+ *
30
+ * Nothing else in a session is read, and nothing about a session leaves this
31
+ * module except two numbers per app (sessions and distinct days in the window).
32
+ *
33
+ * HQ's own bots are not the person. Any session whose id a bot has on file
34
+ * (`session*.json` in each bot folder) is skipped, and so is a Claude session
35
+ * whose folder is under the bots' own folder.
36
+ */
37
+ import * as fs from "node:fs";
38
+ import * as os from "node:os";
39
+ import * as path from "node:path";
40
+ import { botsRoot } from "./paths.js";
41
+ /**
42
+ * "Significant recent activity": at least `minSessions` sessions, spread over
43
+ * at least `minDistinctDays` calendar days, within the last `windowDays` days.
44
+ */
45
+ export const SIGNIFICANT_CODING_TOOL_ACTIVITY = Object.freeze({
46
+ windowDays: 14,
47
+ minSessions: 5,
48
+ minDistinctDays: 3,
49
+ });
50
+ /** The desktop apps, by bundle id, and the bundle names they ship under. */
51
+ export const DESKTOP_APPS = Object.freeze({
52
+ claude: { bundleId: "com.anthropic.claudefordesktop", bundles: ["Claude.app"] },
53
+ codex: { bundleId: "com.openai.codex", bundles: ["ChatGPT.app", "Codex.app"] },
54
+ });
55
+ /** `originator` the Codex app writes into its sessions' first line. */
56
+ export const CODEX_APP_ORIGINATOR = "Codex Desktop";
57
+ /** Stop counting after this many session files per app, so a huge history cannot stall a start. */
58
+ const MAX_FILES_PER_TOOL = 20_000;
59
+ /** How much of a file is read to find the few metadata fields used. */
60
+ const HEAD_BYTES = 8 * 1024;
61
+ const DAY_MS = 24 * 60 * 60 * 1000;
62
+ /**
63
+ * A Codex session lives in the folder of the day it started, but a resumed one
64
+ * is still written to later. Dated folders are only skipped when they are this
65
+ * many days older than the window, so a resumed session is still counted.
66
+ */
67
+ const CODEX_FOLDER_MARGIN_DAYS = 31;
68
+ /** Claude Code's project folder name for a working directory. */
69
+ export function claudeProjectDirName(cwd) {
70
+ return path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
71
+ }
72
+ function codexHome(home, env) {
73
+ const override = env.CODEX_HOME?.trim();
74
+ return override ? override : path.join(home, ".codex");
75
+ }
76
+ function readDirSafe(dir) {
77
+ try {
78
+ return fs.readdirSync(dir, { withFileTypes: true });
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ }
84
+ function mtimeMs(file) {
85
+ try {
86
+ return fs.statSync(file).mtimeMs;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ /** The first `HEAD_BYTES` of a file as text, or null. */
93
+ function readHead(file) {
94
+ let fd;
95
+ try {
96
+ fd = fs.openSync(file, "r");
97
+ const buf = Buffer.alloc(HEAD_BYTES);
98
+ const n = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
99
+ return buf.subarray(0, n).toString("utf8");
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ finally {
105
+ if (fd !== undefined) {
106
+ try {
107
+ fs.closeSync(fd);
108
+ }
109
+ catch {
110
+ /* already closed */
111
+ }
112
+ }
113
+ }
114
+ }
115
+ function jsonString(head, key) {
116
+ const m = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(head);
117
+ if (!m)
118
+ return null;
119
+ try {
120
+ return JSON.parse(`"${m[1]}"`);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ function jsonNumber(head, key) {
127
+ const m = new RegExp(`"${key}"\\s*:\\s*(\\d+(?:\\.\\d+)?)`).exec(head);
128
+ return m ? Number(m[1]) : null;
129
+ }
130
+ /**
131
+ * Is `bundleId` this Info.plist's CFBundleIdentifier? An XML plist is read by
132
+ * key. A binary plist has no readable key/value pairing, so it matches when the
133
+ * whole id (not a prefix of a longer one, like `com.openai.codex.skill`)
134
+ * appears in it.
135
+ */
136
+ export function plistBundleIdIs(plist, bundleId) {
137
+ if (plist.subarray(0, 6).toString("latin1") !== "bplist") {
138
+ const m = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<\s]+)\s*<\/string>/.exec(plist.toString("utf8"));
139
+ return m?.[1] === bundleId;
140
+ }
141
+ const needle = Buffer.from(bundleId, "utf8");
142
+ for (let at = plist.indexOf(needle); at !== -1; at = plist.indexOf(needle, at + 1)) {
143
+ const next = plist[at + needle.length];
144
+ if (next === undefined || !/[A-Za-z0-9.-]/.test(String.fromCharCode(next)))
145
+ return true;
146
+ }
147
+ return false;
148
+ }
149
+ /**
150
+ * Is this desktop app installed? Looks for its bundle names in
151
+ * `/Applications` and `~/Applications` and checks the bundle id in each
152
+ * candidate's Info.plist (XML or binary: the id is stored as plain text in both).
153
+ */
154
+ export function desktopAppInstalled(tool, opts) {
155
+ const { bundleId, bundles } = DESKTOP_APPS[tool];
156
+ const dirs = opts.applicationsDirs ?? ["/Applications", path.join(opts.home, "Applications")];
157
+ for (const dir of dirs) {
158
+ for (const bundle of bundles) {
159
+ try {
160
+ const plist = fs.readFileSync(path.join(dir, bundle, "Contents", "Info.plist"));
161
+ if (plistBundleIdIs(plist, bundleId))
162
+ return true;
163
+ }
164
+ catch {
165
+ /* not there */
166
+ }
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+ /**
172
+ * The HQ folders and session ids that belong to HQ's bots on this computer.
173
+ * Reads only the bots' own small config and session files.
174
+ */
175
+ export function collectHqBotExclusions(dir = botsRoot()) {
176
+ const hqRoots = new Set();
177
+ const sessionIds = new Set();
178
+ for (const bot of readDirSafe(dir)) {
179
+ if (!bot.isDirectory())
180
+ continue;
181
+ const botDir = path.join(dir, bot.name);
182
+ try {
183
+ const config = JSON.parse(fs.readFileSync(path.join(botDir, "bot.json"), "utf8"));
184
+ if (typeof config.hqRoot === "string" && config.hqRoot.trim())
185
+ hqRoots.add(config.hqRoot.trim());
186
+ }
187
+ catch {
188
+ /* no usable bot.json */
189
+ }
190
+ for (const entry of readDirSafe(botDir)) {
191
+ if (!entry.isFile() || !/^session.*\.json$/.test(entry.name))
192
+ continue;
193
+ try {
194
+ const session = JSON.parse(fs.readFileSync(path.join(botDir, entry.name), "utf8"));
195
+ if (typeof session.sessionId === "string" && session.sessionId.trim())
196
+ sessionIds.add(session.sessionId.trim());
197
+ }
198
+ catch {
199
+ /* unreadable session file */
200
+ }
201
+ }
202
+ }
203
+ return { hqRoots: [...hqRoots], sessionIds: [...sessionIds] };
204
+ }
205
+ function isBotSession(name, sessionIds) {
206
+ return sessionIds.some((id) => name.includes(id));
207
+ }
208
+ function isUnder(child, parent) {
209
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
210
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
211
+ }
212
+ /** When each of the Claude app's own Code sessions was last used. */
213
+ function claudeAppSessionTimes(home, bots, sessionIds, windowStart) {
214
+ const times = [];
215
+ const root = path.join(home, "Library", "Application Support", "Claude", "claude-code-sessions");
216
+ for (const account of readDirSafe(root)) {
217
+ if (!account.isDirectory())
218
+ continue;
219
+ for (const org of readDirSafe(path.join(root, account.name))) {
220
+ if (!org.isDirectory())
221
+ continue;
222
+ const orgDir = path.join(root, account.name, org.name);
223
+ for (const file of readDirSafe(orgDir)) {
224
+ if (!file.isFile() || !file.name.startsWith("local_") || !file.name.endsWith(".json"))
225
+ continue;
226
+ const full = path.join(orgDir, file.name);
227
+ const mtime = mtimeMs(full);
228
+ // A record is rewritten whenever its session is used, so one untouched
229
+ // since before the window cannot hold a use inside it.
230
+ if (mtime === null || mtime < windowStart)
231
+ continue;
232
+ if (isBotSession(file.name, sessionIds))
233
+ continue;
234
+ const head = readHead(full) ?? "";
235
+ const cliSessionId = jsonString(head, "cliSessionId");
236
+ if (cliSessionId && sessionIds.includes(cliSessionId))
237
+ continue;
238
+ const cwd = jsonString(head, "cwd");
239
+ if (cwd && isUnder(cwd, bots))
240
+ continue;
241
+ const used = jsonNumber(head, "lastActivityAt") ?? jsonNumber(head, "createdAt") ?? mtime;
242
+ times.push(used);
243
+ if (times.length >= MAX_FILES_PER_TOOL)
244
+ return times;
245
+ }
246
+ }
247
+ }
248
+ return times;
249
+ }
250
+ /**
251
+ * Is this the Codex app's own session? Reads `originator` and `source` from
252
+ * the start of the rollout's first line (its `session_meta` record).
253
+ */
254
+ export function isCodexAppSession(head) {
255
+ const firstLine = head.split("\n", 1)[0] ?? "";
256
+ if (!/"type"\s*:\s*"session_meta"/.test(firstLine))
257
+ return false;
258
+ if (jsonString(firstLine, "originator") !== CODEX_APP_ORIGINATOR)
259
+ return false;
260
+ // A sub-agent thread has an object source; `codex exec` says "exec".
261
+ if (/"source"\s*:\s*\{/.test(firstLine))
262
+ return false;
263
+ const source = jsonString(firstLine, "source");
264
+ return source !== "exec";
265
+ }
266
+ /** Last-used times of the Codex app's own sessions. */
267
+ function codexAppSessionTimes(root, sessionIds, oldestDay, windowStart) {
268
+ const times = [];
269
+ const walk = (dir, depth, datePrefix) => {
270
+ for (const entry of readDirSafe(dir)) {
271
+ const full = path.join(dir, entry.name);
272
+ if (entry.isDirectory()) {
273
+ if (depth >= 3)
274
+ continue;
275
+ const dated = /^\d+$/.test(entry.name);
276
+ const prefix = dated ? (datePrefix ? `${datePrefix}-${entry.name}` : entry.name) : datePrefix;
277
+ // sessions/YYYY/MM/DD: a year, month or day wholly before the window is skipped.
278
+ if (dated && prefix < oldestDay.slice(0, prefix.length))
279
+ continue;
280
+ if (!walk(full, depth + 1, prefix))
281
+ return false;
282
+ continue;
283
+ }
284
+ if (!entry.isFile() || !entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl"))
285
+ continue;
286
+ if (isBotSession(entry.name, sessionIds))
287
+ continue;
288
+ const t = mtimeMs(full);
289
+ if (t === null || t < windowStart)
290
+ continue;
291
+ const head = readHead(full);
292
+ if (!head || !isCodexAppSession(head))
293
+ continue;
294
+ times.push(t);
295
+ if (times.length >= MAX_FILES_PER_TOOL)
296
+ return false;
297
+ }
298
+ return true;
299
+ };
300
+ walk(path.join(root, "sessions"), 0, "");
301
+ return times;
302
+ }
303
+ function localDay(ms) {
304
+ const d = new Date(ms);
305
+ const pad = (n) => String(n).padStart(2, "0");
306
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
307
+ }
308
+ /** Sessions and distinct days within the window ending at `now`. */
309
+ export function summarizeActivity(times, now, windowDays) {
310
+ const end = now.getTime();
311
+ const start = end - windowDays * DAY_MS;
312
+ const days = new Set();
313
+ let sessions = 0;
314
+ for (const t of times) {
315
+ if (t < start || t > end)
316
+ continue;
317
+ sessions += 1;
318
+ days.add(localDay(t));
319
+ }
320
+ return { sessions, days: days.size };
321
+ }
322
+ export function qualifies(activity, threshold = SIGNIFICANT_CODING_TOOL_ACTIVITY) {
323
+ if (activity.installed === false)
324
+ return false;
325
+ return activity.sessions >= threshold.minSessions && activity.days >= threshold.minDistinctDays;
326
+ }
327
+ /**
328
+ * The app to offer: the one that qualifies, or, when both do, the one with
329
+ * more sessions in the window (Claude on a tie).
330
+ */
331
+ export function pickCodingTool(claude, codex, threshold = SIGNIFICANT_CODING_TOOL_ACTIVITY) {
332
+ const c = qualifies(claude, threshold);
333
+ const x = qualifies(codex, threshold);
334
+ if (c && x)
335
+ return codex.sessions > claude.sessions ? "codex" : "claude";
336
+ if (c)
337
+ return "claude";
338
+ if (x)
339
+ return "codex";
340
+ return null;
341
+ }
342
+ /** Look for the Claude and Codex desktop apps and their recent sessions, and pick one to offer, if any. */
343
+ export function detectCodingToolActivity(input) {
344
+ const home = input.home ?? os.homedir();
345
+ const env = input.env ?? process.env;
346
+ const platform = input.platform ?? process.platform;
347
+ const threshold = input.threshold ?? SIGNIFICANT_CODING_TOOL_ACTIVITY;
348
+ const none = { installed: false, sessions: 0, days: 0 };
349
+ if (platform !== "darwin")
350
+ return { claude: none, codex: none, pick: null };
351
+ const bots = input.botsDir ?? botsRoot(home, env);
352
+ const exclusions = collectHqBotExclusions(bots);
353
+ const windowStart = input.now.getTime() - threshold.windowDays * DAY_MS;
354
+ const apps = { home, ...(input.applicationsDirs ? { applicationsDirs: input.applicationsDirs } : {}) };
355
+ const claudeInstalled = desktopAppInstalled("claude", apps);
356
+ const codexInstalled = desktopAppInstalled("codex", apps);
357
+ const claude = claudeInstalled
358
+ ? {
359
+ installed: true,
360
+ ...summarizeActivity(claudeAppSessionTimes(home, bots, exclusions.sessionIds, windowStart), input.now, threshold.windowDays),
361
+ }
362
+ : none;
363
+ const oldestDay = localDay(input.now.getTime() - (threshold.windowDays + CODEX_FOLDER_MARGIN_DAYS) * DAY_MS);
364
+ const codex = codexInstalled
365
+ ? {
366
+ installed: true,
367
+ ...summarizeActivity(codexAppSessionTimes(codexHome(home, env), exclusions.sessionIds, oldestDay, windowStart), input.now, threshold.windowDays),
368
+ }
369
+ : none;
370
+ return { claude, codex, pick: pickCodingTool(claude, codex, threshold) };
371
+ }
372
+ //# sourceMappingURL=coding-tool-activity.js.map
@@ -49,6 +49,7 @@ import { type BotApi, type InboxItem } from "./api.js";
49
49
  import type { BotConfig, BotKind } from "./config.js";
50
50
  import { containsNoReplySentinel, type ProgressPosterOptions } from "./progress.js";
51
51
  import { type BotLogger } from "./log.js";
52
+ import { type CodingToolActivity, type DetectCodingToolActivityInput } from "./coding-tool-activity.js";
52
53
  import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
53
54
  import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
54
55
  import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
@@ -166,6 +167,12 @@ export interface BotRunDeps {
166
167
  * exit: the place to start a replacement when no supervisor will.
167
168
  */
168
169
  onCliUpdateRestart?: (update: CliUpdate, log: BotLogger) => void;
170
+ /**
171
+ * The setup bot's look at this computer's Claude Code and Codex use before
172
+ * its first message (coding-tool-activity.ts). Injected in tests so they
173
+ * never read the real home folder.
174
+ */
175
+ detectCodingToolActivity?: (input: DetectCodingToolActivityInput) => CodingToolActivity;
169
176
  }
170
177
  export declare const KICKOFF_MESSAGE_ID = "kickoff";
171
178
  /**
@@ -62,6 +62,8 @@ import { splitBody } from "./split.js";
62
62
  import { createBotLogger } from "./log.js";
63
63
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
64
64
  import { loadSetupExplainer, patchSetupIntroState, readSetupIntroState, readSetupProgressIntro, replyOffersExplain, SETUP_EXPLAINED_NOTE, setupExplainDue, } from "./setup-explain.js";
65
+ import { detectCodingToolActivity, SIGNIFICANT_CODING_TOOL_ACTIVITY, } from "./coding-tool-activity.js";
66
+ import { setupKeptHereNote, setupOfferAnsweredInWordsNote, setupToolOfferAnswer, setupToolOfferText, setupWelcomeOpening, } from "./setup-tool-offer.js";
65
67
  import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
66
68
  import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
67
69
  import { botAttachmentsDir, dmSessionScope } from "./paths.js";
@@ -319,6 +321,57 @@ export async function runBot(deps) {
319
321
  log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
320
322
  }
321
323
  };
324
+ /** The tool the pending "continue setup there?" offer named, while the kickoff waits on it. */
325
+ const pendingToolOffer = () => {
326
+ try {
327
+ const current = readSetupIntroState(dir);
328
+ return current.kickoffDeferred && current.toolOffer ? current.toolOffer.tool : null;
329
+ }
330
+ catch {
331
+ return null;
332
+ }
333
+ };
334
+ /**
335
+ * Setup bot, first message: when the person has the Claude app or the Codex
336
+ * app and uses it a lot (coding-tool-activity.ts), the first message is the
337
+ * fixed "continue setup there?" offer instead of the kickoff turn, and the
338
+ * kickoff waits for their answer (setup-tool-offer.ts). Made once, and only
339
+ * before setup has started. Null (run the kickoff as usual) when nothing
340
+ * qualifies or anything goes wrong.
341
+ */
342
+ const codingToolToOffer = () => {
343
+ if (!isSetupBot || !(config.kickoff ?? "").trim())
344
+ return null;
345
+ try {
346
+ if (readSetupIntroState(dir).toolOffer)
347
+ return null;
348
+ if (readSetupProgressIntro(resolveBotMemoryDir(config)) !== null)
349
+ return null;
350
+ const detect = deps.detectCodingToolActivity ?? detectCodingToolActivity;
351
+ const activity = detect({ now: now(), extraHqRoots: [config.hqRoot] });
352
+ const describe = (a) => a.installed ? `${a.sessions} sessions on ${a.days} days` : "not installed";
353
+ log("info", `desktop app use in the last ${SIGNIFICANT_CODING_TOOL_ACTIVITY.windowDays} days: ` +
354
+ `Claude ${describe(activity.claude)}, Codex ${describe(activity.codex)}`);
355
+ return activity.pick;
356
+ }
357
+ catch (err) {
358
+ log("warn", `coding tool check failed (${err instanceof Error ? err.message : String(err)}); running the kickoff`);
359
+ return null;
360
+ }
361
+ };
362
+ /** The offer went out: the kickoff now waits for the answer. */
363
+ const recordToolOffer = (tool) => {
364
+ try {
365
+ patchSetupIntroState(dir, { toolOffer: { tool, offeredAt: now().toISOString() }, kickoffDeferred: true });
366
+ }
367
+ catch (err) {
368
+ log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
369
+ }
370
+ if (readBotConfig(dir)?.kickoffPendingSignIn)
371
+ patchBotConfig(dir, { kickoffPendingSignIn: false });
372
+ setStatus({ repliesSent: (readStatus()?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
373
+ log("info", `offered to continue setup in the ${tool} app (no model turn); the kickoff waits for the answer`);
374
+ };
322
375
  // Scheduled runs are reported in the owner's own zone: "every weekday at 9am"
323
376
  // was set up here, so "due 13:00 UTC" would read as the wrong time.
324
377
  const timeZone = machineTimeZone();
@@ -455,16 +508,33 @@ export async function runBot(deps) {
455
508
  kickoffAfterIntro = {};
456
509
  }
457
510
  else if (!config.introSentAt) {
458
- try {
459
- const sent = await api.sendDm({ toPersonUid: config.ownerUid, body: introDmText(config.name, config.runtime, config.intro, { kind: effectiveBotKind(config), companies: effectiveBotCompanies(config) }) });
460
- patchBotConfig(dir, { introSentAt: now().toISOString() });
461
- log("info", "intro DM sent to owner");
462
- if (config.kickoff?.trim())
463
- kickoffAfterIntro = { ...(sent?.eventId ? { introEventId: sent.eventId } : {}) };
464
- }
465
- catch (err) {
466
- log("warn", `intro DM failed (will retry next start): ${err instanceof Error ? err.message : String(err)}`);
511
+ // Setup bot with an app to offer: the welcome leads straight into the
512
+ // offer, without its "checking your computer" and Launch-button lines.
513
+ const offerTool = codingToolToOffer();
514
+ let offered = false;
515
+ if (offerTool) {
516
+ try {
517
+ await api.sendDm({ toPersonUid: config.ownerUid, body: setupToolOfferText(offerTool, setupWelcomeOpening(config.intro)) });
518
+ patchBotConfig(dir, { introSentAt: now().toISOString() });
519
+ log("info", "intro DM sent to owner, leading into the app offer");
520
+ recordToolOffer(offerTool);
521
+ offered = true;
522
+ }
523
+ catch (err) {
524
+ log("warn", `coding tool offer could not be sent as the welcome (${err instanceof Error ? err.message : String(err)}); sending the usual welcome`);
525
+ }
467
526
  }
527
+ if (!offered)
528
+ try {
529
+ const sent = await api.sendDm({ toPersonUid: config.ownerUid, body: introDmText(config.name, config.runtime, config.intro, { kind: effectiveBotKind(config), companies: effectiveBotCompanies(config) }) });
530
+ patchBotConfig(dir, { introSentAt: now().toISOString() });
531
+ log("info", "intro DM sent to owner");
532
+ if (config.kickoff?.trim())
533
+ kickoffAfterIntro = { ...(sent?.eventId ? { introEventId: sent.eventId } : {}) };
534
+ }
535
+ catch (err) {
536
+ log("warn", `intro DM failed (will retry next start): ${err instanceof Error ? err.message : String(err)}`);
537
+ }
468
538
  }
469
539
  // ── Heartbeat loop ─────────────────────────────────────────────────────────
470
540
  const failures = new FailureWindow(FAILURE_WINDOW_MS, FAILURE_LIMIT);
@@ -1097,10 +1167,31 @@ export async function runBot(deps) {
1097
1167
  * in-between messages). The thinking reaction goes on the intro message
1098
1168
  * (there is no owner message to mark). A failure never crashes the loop.
1099
1169
  */
1170
+ /**
1171
+ * Setup bot whose welcome already went out (a kickoff held for a sign-in):
1172
+ * the offer as its own message instead of the kickoff turn. False (run the
1173
+ * kickoff) when there is nothing to offer or it cannot be sent.
1174
+ */
1175
+ const offerCodingTool = async () => {
1176
+ const tool = codingToolToOffer();
1177
+ if (!tool)
1178
+ return false;
1179
+ try {
1180
+ await sendToTarget({ kind: "dm", peerUid: config.ownerUid }, setupToolOfferText(tool), undefined, "human");
1181
+ }
1182
+ catch (err) {
1183
+ log("warn", `coding tool offer could not be sent (${err instanceof Error ? err.message : String(err)}); running the kickoff`);
1184
+ return false;
1185
+ }
1186
+ recordToolOffer(tool);
1187
+ return true;
1188
+ };
1100
1189
  const runKickoff = async (introEventId) => {
1101
1190
  const prompt = (config.kickoff ?? "").trim();
1102
1191
  if (!prompt || stopping || state !== "running")
1103
1192
  return;
1193
+ if (isSetupBot && (await offerCodingTool()))
1194
+ return;
1104
1195
  const peerUid = config.ownerUid;
1105
1196
  const tReceived = now().getTime();
1106
1197
  log("info", `kickoff turn starting (${prompt.length} chars)`);
@@ -1530,7 +1621,18 @@ export async function runBot(deps) {
1530
1621
  const sessionScope = target.kind === "room" ? target.sessionScope : dmSessionScope(target.peerUid, config.ownerUid);
1531
1622
  // The turn after the app sent the explanation: tell the model it went out.
1532
1623
  const setupNote = isSetupBot && ownerDm && readSetupIntroState(dir).notePending === true;
1533
- const ownerText = setupNote ? `${SETUP_EXPLAINED_NOTE}\n\n${text}` : text;
1624
+ // The first answer to the coding tool offer: "Keep going here" runs the
1625
+ // waiting kickoff; anything else runs with a note that setup has not started.
1626
+ const toolOffer = isSetupBot && ownerDm ? pendingToolOffer() : null;
1627
+ const toolAnswer = toolOffer ? setupToolOfferAnswer(messageText, attachmentRefs.length) : null;
1628
+ const kickoffPrompt = (config.kickoff ?? "").trim();
1629
+ const ownerText = toolOffer && toolAnswer === "kept_here" && kickoffPrompt
1630
+ ? `${setupKeptHereNote(toolOffer)}\n\n${kickoffPrompt}`
1631
+ : toolOffer
1632
+ ? `${setupOfferAnsweredInWordsNote(toolOffer)}\n\n${text}`
1633
+ : setupNote
1634
+ ? `${SETUP_EXPLAINED_NOTE}\n\n${text}`
1635
+ : text;
1534
1636
  const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? ownerText :
1535
1637
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
1536
1638
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
@@ -1555,7 +1657,7 @@ export async function runBot(deps) {
1555
1657
  });
1556
1658
  // The model saw the note once its turn ran. A turn that never started, or
1557
1659
  // one held for a coding-tool sign-in, runs again and keeps the note for it.
1558
- if (setupNote && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1660
+ if (setupNote && !toolOffer && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1559
1661
  try {
1560
1662
  patchSetupIntroState(dir, { notePending: false });
1561
1663
  }
@@ -1563,6 +1665,16 @@ export async function runBot(deps) {
1563
1665
  log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
1564
1666
  }
1565
1667
  }
1668
+ // Same rule for the offer: answered once the model saw the answer.
1669
+ if (toolOffer && toolAnswer && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1670
+ try {
1671
+ patchSetupIntroState(dir, { kickoffDeferred: false, toolOfferAnswer: toolAnswer });
1672
+ log("info", `coding tool offer answered (${toolAnswer === "kept_here" ? "kept setup here" : "wrote a message"}); setup runs here`);
1673
+ }
1674
+ catch (err) {
1675
+ log("warn", `setup intro state not updated: ${err instanceof Error ? err.message : String(err)}`);
1676
+ }
1677
+ }
1566
1678
  if (outcome.ok && ownerDm)
1567
1679
  noteSetupReply(outcome.reply);
1568
1680
  if (outcome.ok) {
@@ -44,6 +44,18 @@ export interface SetupIntroState {
44
44
  explainedAt?: string;
45
45
  /** The model has not yet been told that the explanation went out. */
46
46
  notePending?: boolean;
47
+ /**
48
+ * The first message offered to continue setup in this coding tool
49
+ * (setup-tool-offer.ts). Set once; the offer is never made again.
50
+ */
51
+ toolOffer?: {
52
+ tool: "claude" | "codex";
53
+ offeredAt: string;
54
+ };
55
+ /** The kickoff turn is waiting for the person's answer to that offer. */
56
+ kickoffDeferred?: boolean;
57
+ /** How the person answered the offer: the "Keep going here" button, or a message of their own. */
58
+ toolOfferAnswer?: "kept_here" | "wrote";
47
59
  }
48
60
  export declare function readSetupIntroState(dir: string): SetupIntroState;
49
61
  export declare function patchSetupIntroState(dir: string, patch: Partial<Omit<SetupIntroState, "v">>): SetupIntroState;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The setup bot's "continue in your coding tool" offer.
3
+ *
4
+ * Before the setup bot's first message, the runner checks whether the person
5
+ * has the Claude app or the Codex app installed and has used it a lot lately
6
+ * (coding-tool-activity.ts). When one qualifies, the first message is not a
7
+ * model turn: in place of the usual welcome, the runner sends the welcome's
8
+ * opening sentence and one fixed line,
9
+ *
10
+ * "Looks like you use the Claude app. Want to continue setup there?"
11
+ *
12
+ * with an hq-block the HQ desktop app draws as two buttons: "Continue in
13
+ * Claude" (the app opens that desktop app in the HQ folder with the setup
14
+ * request ready) and "Keep going here". The kickoff turn waits:
15
+ *
16
+ * - "Keep going here" (exactly that, nothing attached) runs the waiting
17
+ * kickoff as the reply to it, so setup carries on as it always did.
18
+ * - Any other message from the person runs as a normal turn, with a note
19
+ * saying the offer was made and setup has not started yet.
20
+ *
21
+ * The offer is made once per bot, only when setup has not started (no
22
+ * progress note). Anything unexpected falls back to the normal kickoff turn.
23
+ */
24
+ import type { CodingTool } from "./coding-tool-activity.js";
25
+ /** The reply that keeps setup in the HQ app. Exact match only. */
26
+ export declare const KEEP_GOING_HERE_REPLY = "Keep going here";
27
+ /** The hq-block kind the desktop app draws as the two-button card. */
28
+ export declare const CONTINUE_IN_TOOL_BLOCK_KIND = "continueInTool";
29
+ /** The desktop apps' product names. */
30
+ export declare const CODING_TOOL_NAMES: Readonly<Record<CodingTool, string>>;
31
+ /** The offer's one line. */
32
+ export declare function setupToolOfferLine(tool: CodingTool): string;
33
+ /**
34
+ * The opening of the app's welcome, kept when the offer replaces it: its
35
+ * first sentence (who the bot is and what setup covers). The rest of the
36
+ * welcome says the bot is checking the computer and will post its first
37
+ * question, and points at the Launch button; with the offer showing, none of
38
+ * that is true yet, so it is dropped. A first sentence that itself talks about
39
+ * checking or the Launch button is dropped too.
40
+ */
41
+ export declare function setupWelcomeOpening(intro: string | undefined): string | null;
42
+ /**
43
+ * The whole first message. The block also carries "Keep going here" as a
44
+ * suggested reply, so an app that predates the card still shows that button.
45
+ */
46
+ export declare function setupToolOfferText(tool: CodingTool, welcome?: string | null): string;
47
+ /** Put in front of the waiting kickoff when the person clicked "Keep going here". */
48
+ export declare function setupKeptHereNote(tool: CodingTool): string;
49
+ /** Put in front of the person's message when they wrote something instead of clicking. */
50
+ export declare function setupOfferAnsweredInWordsNote(tool: CodingTool): string;
51
+ export type SetupToolOfferAnswer = "kept_here" | "wrote";
52
+ /** How the person answered a pending offer: the button, or anything else. */
53
+ export declare function setupToolOfferAnswer(text: string, attachments: number): SetupToolOfferAnswer;
54
+ //# sourceMappingURL=setup-tool-offer.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The setup bot's "continue in your coding tool" offer.
3
+ *
4
+ * Before the setup bot's first message, the runner checks whether the person
5
+ * has the Claude app or the Codex app installed and has used it a lot lately
6
+ * (coding-tool-activity.ts). When one qualifies, the first message is not a
7
+ * model turn: in place of the usual welcome, the runner sends the welcome's
8
+ * opening sentence and one fixed line,
9
+ *
10
+ * "Looks like you use the Claude app. Want to continue setup there?"
11
+ *
12
+ * with an hq-block the HQ desktop app draws as two buttons: "Continue in
13
+ * Claude" (the app opens that desktop app in the HQ folder with the setup
14
+ * request ready) and "Keep going here". The kickoff turn waits:
15
+ *
16
+ * - "Keep going here" (exactly that, nothing attached) runs the waiting
17
+ * kickoff as the reply to it, so setup carries on as it always did.
18
+ * - Any other message from the person runs as a normal turn, with a note
19
+ * saying the offer was made and setup has not started yet.
20
+ *
21
+ * The offer is made once per bot, only when setup has not started (no
22
+ * progress note). Anything unexpected falls back to the normal kickoff turn.
23
+ */
24
+ /** The reply that keeps setup in the HQ app. Exact match only. */
25
+ export const KEEP_GOING_HERE_REPLY = "Keep going here";
26
+ /** The hq-block kind the desktop app draws as the two-button card. */
27
+ export const CONTINUE_IN_TOOL_BLOCK_KIND = "continueInTool";
28
+ /** The desktop apps' product names. */
29
+ export const CODING_TOOL_NAMES = Object.freeze({
30
+ claude: "Claude",
31
+ codex: "Codex",
32
+ });
33
+ /** The offer's one line. */
34
+ export function setupToolOfferLine(tool) {
35
+ return `Looks like you use the ${CODING_TOOL_NAMES[tool]} app. Want to continue setup there?`;
36
+ }
37
+ /**
38
+ * The opening of the app's welcome, kept when the offer replaces it: its
39
+ * first sentence (who the bot is and what setup covers). The rest of the
40
+ * welcome says the bot is checking the computer and will post its first
41
+ * question, and points at the Launch button; with the offer showing, none of
42
+ * that is true yet, so it is dropped. A first sentence that itself talks about
43
+ * checking or the Launch button is dropped too.
44
+ */
45
+ export function setupWelcomeOpening(intro) {
46
+ const text = (intro ?? "").trim();
47
+ if (!text)
48
+ return null;
49
+ const m = /^[\s\S]*?[.!?](?=\s|$)/.exec(text);
50
+ const first = (m ? m[0] : text).trim();
51
+ if (!first || /\bcheck(ing)?\b|\blaunch\b|first question/i.test(first))
52
+ return null;
53
+ return first;
54
+ }
55
+ /**
56
+ * The whole first message. The block also carries "Keep going here" as a
57
+ * suggested reply, so an app that predates the card still shows that button.
58
+ */
59
+ export function setupToolOfferText(tool, welcome) {
60
+ const envelope = {
61
+ v: 1,
62
+ blocks: [
63
+ { kind: CONTINUE_IN_TOOL_BLOCK_KIND, tool },
64
+ { kind: "suggestions", items: [KEEP_GOING_HERE_REPLY] },
65
+ ],
66
+ };
67
+ return ((welcome ? `${welcome}\n\n` : "") +
68
+ `${setupToolOfferLine(tool)}\n\n` +
69
+ "```hq-block\n" +
70
+ `${JSON.stringify(envelope)}\n` +
71
+ "```");
72
+ }
73
+ /** Put in front of the waiting kickoff when the person clicked "Keep going here". */
74
+ export function setupKeptHereNote(tool) {
75
+ return (`[Note from the HQ app: before setup started, the app offered to continue setup in the ${CODING_TOOL_NAMES[tool]} app, ` +
76
+ `because the person uses that app a lot on this computer. They clicked "${KEEP_GOING_HERE_REPLY}", so setup happens here. ` +
77
+ "Do not mention the offer. The kickoff follows.]");
78
+ }
79
+ /** Put in front of the person's message when they wrote something instead of clicking. */
80
+ export function setupOfferAnsweredInWordsNote(tool) {
81
+ return (`[Note from the HQ app: before setup started, the app said the person seems to use the ${CODING_TOOL_NAMES[tool]} app a lot ` +
82
+ `and offered to continue setup there, with a button that opens the ${CODING_TOOL_NAMES[tool]} app in their HQ folder and a "${KEEP_GOING_HERE_REPLY}" button. ` +
83
+ "They wrote the message below instead. Setup has not started and there was no kickoff. " +
84
+ `If they want to continue in the ${CODING_TOOL_NAMES[tool]} app, tell them in plain words to click the button above to open it. ` +
85
+ "Otherwise begin setup here as your instructions say for a first message.]");
86
+ }
87
+ /** How the person answered a pending offer: the button, or anything else. */
88
+ export function setupToolOfferAnswer(text, attachments) {
89
+ return text === KEEP_GOING_HERE_REPLY && attachments === 0 ? "kept_here" : "wrote";
90
+ }
91
+ //# sourceMappingURL=setup-tool-offer.js.map
@@ -3,7 +3,6 @@ import type { LaneRecord } from "./types.js";
3
3
  export declare const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
4
4
  export declare const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
5
5
  export declare const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_DELAY_MS: number;
6
- export declare const LEAD_AUTOPILOT_NUDGE_DEDUPE_FLAG = "lanes.autopilot-nudge-dedupe";
7
6
  export declare const LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS: number;
8
7
  export declare const LEAD_AUTOPILOT_NUDGE_STATE_RETENTION_MS: number;
9
8
  export declare const LEAD_AUTOPILOT_REVIEW_DELAY_MS: number;
@@ -11,8 +10,6 @@ export declare const LEAD_AUTOPILOT_FILL_DELAY_MS: number;
11
10
  export declare const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
12
11
  export declare const LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES = 55;
13
12
  export declare const LEAD_AUTOPILOT_HEADLINE_STALE_AFTER_MINUTES = 55;
14
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = "lanes.autopilot-per-lead-errors";
15
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
16
13
  export interface RegisteredLead {
17
14
  team: string;
18
15
  lane: string;
@@ -115,7 +112,6 @@ export declare function planLeadAutopilotWakes(input: {
115
112
  managerStatus?: Record<string, unknown>;
116
113
  keepAliveResumed?: boolean;
117
114
  kpiStalenessEnabled?: boolean;
118
- kpiStalenessBatchEnabled?: boolean;
119
115
  headlineStalenessEnabled?: boolean;
120
116
  }): AutopilotWake[];
121
117
  export declare function runLeadAutopilotPass(input: {
@@ -126,7 +122,6 @@ export declare function runLeadAutopilotPass(input: {
126
122
  now: Date;
127
123
  dryRun?: boolean;
128
124
  readFlag?: typeof readLanesRegistryFlag;
129
- readNudgeDedupeFlag?: typeof readLanesRegistryFlag;
130
125
  readDeadlineFlag?: typeof readLanesRegistryFlag;
131
126
  keepAliveResumedLeads?: ReadonlySet<string>;
132
127
  missedDeadlineEscalationDelayMs?: number;
@@ -6,14 +6,13 @@ import { isControllerAliasOutpostId } from "./admission-host.js";
6
6
  import { LEAD_REGISTRY_REL, validateOutpostLeadRegistry } from "./autopilot-registry.js";
7
7
  import { appendDropboxMessage } from "./dropbox.js";
8
8
  import { LanesError } from "./errors.js";
9
- import { LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_FLAG, LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG, LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG, LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG as PER_LEAD_ERRORS_FLAG, } from "./flag-keys.js";
9
+ import { LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, LEAD_AUTOPILOT_KPI_STALENESS_FLAG, LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG, LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG, } from "./flag-keys.js";
10
10
  import { resolveLaneCompany } from "./lane-company.js";
11
11
  import { readLanesRegistryFlag } from "./registry-flag.js";
12
12
  import { readAllLanes } from "./store.js";
13
13
  export const LEAD_AUTOPILOT_FLAG = "lanes.lead-autopilot";
14
14
  export const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG;
15
15
  export const LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_DELAY_MS = 60 * 60_000;
16
- export const LEAD_AUTOPILOT_NUDGE_DEDUPE_FLAG = LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG;
17
16
  export const LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS = 30 * 60_000;
18
17
  export const LEAD_AUTOPILOT_NUDGE_STATE_RETENTION_MS = 24 * 60 * 60_000;
19
18
  const LEAD_AUTOPILOT_DELIVERED_ID_RETENTION_MS = 7 * 24 * 60 * 60_000;
@@ -22,8 +21,6 @@ export const LEAD_AUTOPILOT_FILL_DELAY_MS = 15 * 60_000;
22
21
  export const LEAD_AUTOPILOT_PROMPT_LOOP = "personal/knowledge/team-lead-operating-loop.md";
23
22
  export const LEAD_AUTOPILOT_KPI_STALE_AFTER_MINUTES = 55;
24
23
  export const LEAD_AUTOPILOT_HEADLINE_STALE_AFTER_MINUTES = 55;
25
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = PER_LEAD_ERRORS_FLAG;
26
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
27
24
  const LIVE_STATES = new Set(["running", "queued", "awaiting_input", "blocked"]);
28
25
  const OPEN_STORY_STATES = new Set(["todo", "queued", "in_progress", "review", "blocked"]);
29
26
  function isOpenProjectStory(story) {
@@ -478,7 +475,7 @@ export function planLeadAutopilotWakes(input) {
478
475
  }
479
476
  if (input.kpiStalenessEnabled) {
480
477
  const staleKpis = managerKpiFreshness(status, now).filter((kpi) => kpi.stale);
481
- if (input.kpiStalenessBatchEnabled && staleKpis.length > 0) {
478
+ if (staleKpis.length > 0) {
482
479
  const byOldest = [...staleKpis].sort((a, b) => (b.ageMinutes ?? Number.MAX_SAFE_INTEGER) - (a.ageMinutes ?? Number.MAX_SAFE_INTEGER) ||
483
480
  a.name.localeCompare(b.name));
484
481
  const identity = staleKpis
@@ -688,13 +685,11 @@ function readState(file) {
688
685
  };
689
686
  }
690
687
  function appendAutopilotMessage(input) {
691
- if (input.dedupeEnabled && input.state.delivered_ids?.[input.message.message_id])
688
+ if (input.state.delivered_ids?.[input.message.message_id])
692
689
  return false;
693
690
  appendDropboxMessage(input.hqRoot, input.leadLane, input.message);
694
- if (input.dedupeEnabled) {
695
- input.state.delivered_ids ??= {};
696
- input.state.delivered_ids[input.message.message_id] = input.message.at;
697
- }
691
+ input.state.delivered_ids ??= {};
692
+ input.state.delivered_ids[input.message.message_id] = input.message.at;
698
693
  return true;
699
694
  }
700
695
  function removeDeliveredIdsForFingerprint(state, fingerprint) {
@@ -799,7 +794,6 @@ async function runLeadAutopilotPassUnlocked(input) {
799
794
  continue;
800
795
  }
801
796
  let stateChanged = false;
802
- let perLeadErrorsEnabled = false;
803
797
  let currentInputPath = registered.status;
804
798
  try {
805
799
  const readFlag = input.readFlag ?? readLanesRegistryFlag;
@@ -808,11 +802,9 @@ async function runLeadAutopilotPassUnlocked(input) {
808
802
  result.skipped.push({ lead_lane: lead.lane_id, reason: "flag_off" });
809
803
  continue;
810
804
  }
811
- perLeadErrorsEnabled = await readFlag(PER_LEAD_ERRORS_FLAG, { company });
812
805
  currentInputPath = registered.status;
813
806
  const status = readStatus(hqRoot, registered.status, company.slug);
814
807
  const kpiStalenessEnabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_KPI_STALENESS_FLAG, { company });
815
- const kpiStalenessBatchEnabled = kpiStalenessEnabled && await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG, { company });
816
808
  const headlineStalenessEnabled = await (input.readFlag ?? readLanesRegistryFlag)(LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG, { company });
817
809
  if (kpiStalenessEnabled) {
818
810
  result.kpi_status ??= [];
@@ -834,8 +826,7 @@ async function runLeadAutopilotPassUnlocked(input) {
834
826
  const stories = projectStories(hqRoot, registered.prd, company.slug, lead.project_id);
835
827
  const children = lanes.filter((lane) => lane.senior.kind === "lane" && lane.senior.id === lead.lane_id);
836
828
  const deadlineEscalationEnabled = await (input.readDeadlineFlag ?? readFlag)(LEAD_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG, { company });
837
- const nudgeDedupeEnabled = await (input.readNudgeDedupeFlag ?? readFlag)(LEAD_AUTOPILOT_NUDGE_DEDUPE_FLAG, { company });
838
- if (nudgeDedupeEnabled && pruneDeliveredIds(state, now))
829
+ if (pruneDeliveredIds(state, now))
839
830
  stateChanged = true;
840
831
  currentInputPath = registered.status;
841
832
  let planned = planLeadAutopilotWakes({
@@ -845,11 +836,10 @@ async function runLeadAutopilotPassUnlocked(input) {
845
836
  now,
846
837
  managerStatus: status,
847
838
  kpiStalenessEnabled,
848
- kpiStalenessBatchEnabled,
849
839
  headlineStalenessEnabled,
850
840
  keepAliveResumed: input.keepAliveResumedLeads?.has(lead.lane_id) === true,
851
841
  });
852
- if (kpiStalenessBatchEnabled) {
842
+ if (kpiStalenessEnabled) {
853
843
  const batch = planned.find((wake) => wake.condition === "stale_kpi" && wake.kpi_id === "batch");
854
844
  const headline = planned.find((wake) => wake.condition === "stale_headline");
855
845
  if (batch && headline) {
@@ -872,13 +862,6 @@ async function runLeadAutopilotPassUnlocked(input) {
872
862
  continue;
873
863
  const entry = state.sent[key];
874
864
  const isActive = activeKeys.has(key);
875
- if (!nudgeDedupeEnabled) {
876
- if (!isActive) {
877
- delete state.sent[key];
878
- stateChanged = true;
879
- }
880
- continue;
881
- }
882
865
  if (!isActive && entry.inactive_since === undefined) {
883
866
  state.sent[key] = { ...entry, inactive_since: now.toISOString() };
884
867
  stateChanged = true;
@@ -915,7 +898,7 @@ async function runLeadAutopilotPassUnlocked(input) {
915
898
  if (suppressedKeys.has(key))
916
899
  continue;
917
900
  const prior = state.sent[key];
918
- if (nudgeDedupeEnabled && prior && prior.fingerprint !== wake.fingerprint && !prior.acted) {
901
+ if (prior && prior.fingerprint !== wake.fingerprint && !prior.acted) {
919
902
  const leadActedSincePrior = statusUpdatedAt !== undefined && prior.status_updated_at !== undefined &&
920
903
  Date.parse(statusUpdatedAt) > Date.parse(prior.status_updated_at);
921
904
  if (!leadActedSincePrior) {
@@ -974,11 +957,11 @@ async function runLeadAutopilotPassUnlocked(input) {
974
957
  continue;
975
958
  if (wake.condition === "stale_headline")
976
959
  continue;
977
- const redeliveryDelay = nudgeDedupeEnabled ? LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS : LEAD_AUTOPILOT_FILL_DELAY_MS;
960
+ const redeliveryDelay = LEAD_AUTOPILOT_NUDGE_REDELIVERY_DELAY_MS;
978
961
  if (!current.acted && !current.auto_started && current.escalated_at === undefined && now.getTime() - lastWakeAt >= redeliveryDelay) {
979
962
  const wakeCount = current.wake_count ?? 1;
980
963
  if (wakeCount < 2) {
981
- const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, dedupeEnabled: nudgeDedupeEnabled, message: {
964
+ const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, message: {
982
965
  at: now.toISOString(),
983
966
  message_id: `lead-autopilot:${wake.fingerprint}:wake-2`,
984
967
  kind: "system_notice",
@@ -1002,7 +985,7 @@ async function runLeadAutopilotPassUnlocked(input) {
1002
985
  continue;
1003
986
  }
1004
987
  if (!input.dryRun) {
1005
- const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, dedupeEnabled: nudgeDedupeEnabled, message: {
988
+ const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, message: {
1006
989
  at: now.toISOString(),
1007
990
  message_id: `lead-autopilot:${wake.fingerprint}`,
1008
991
  kind: "system_notice",
@@ -1093,7 +1076,7 @@ async function runLeadAutopilotPassUnlocked(input) {
1093
1076
  fingerprint,
1094
1077
  prompt: `Deadline missed for ${commitment.item} (${commitment.description}), owner ${commitment.owner}, due ${commitment.due}. Deliver it or re-date with a reason. Follow ${LEAD_AUTOPILOT_PROMPT_LOOP}.`,
1095
1078
  };
1096
- const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, dedupeEnabled: nudgeDedupeEnabled, message: {
1079
+ const delivered = appendAutopilotMessage({ hqRoot, leadLane: lead.lane_id, state, message: {
1097
1080
  at: now.toISOString(),
1098
1081
  message_id: `lead-autopilot:${fingerprint}`,
1099
1082
  kind: "system_notice",
@@ -1160,7 +1143,7 @@ async function runLeadAutopilotPassUnlocked(input) {
1160
1143
  catch (error) {
1161
1144
  if (!input.dryRun && stateChanged)
1162
1145
  writeState(stateFile, state);
1163
- if (perLeadErrorsEnabled && error instanceof LanesError && LEAD_AUTOPILOT_PER_LEAD_ERROR_CODES.has(error.error)) {
1146
+ if (error instanceof LanesError && LEAD_AUTOPILOT_PER_LEAD_ERROR_CODES.has(error.error)) {
1164
1147
  const errorPath = typeof error.details.path === "string" ? error.details.path : currentInputPath;
1165
1148
  const messageId = `lead-autopilot-error:${error.error}:${createHash("sha256").update(`${error.error}\0${errorPath}`).digest("hex")}`;
1166
1149
  result.skipped.push({ lane_id: lead.lane_id, error: error.error, path: errorPath, message: error.message });
@@ -2,7 +2,6 @@ import * as fs from "node:fs";
2
2
  import { type ReadLanesFlagOptions } from "./registry-flag.js";
3
3
  import type { LaneRecord, LanesConfig } from "./types.js";
4
4
  export declare const LOW_DISK_GUARD_FLAG = "lanes.low-disk-guard";
5
- export declare const CODEX_ROLLOUT_RETENTION_FLAG = "lanes.codex-rollout-retention";
6
5
  export declare function lowDiskGuardEnabledForLane(hqRoot: string, lane: LaneRecord, readFlag?: (key: string, options: ReadLanesFlagOptions) => Promise<boolean>): Promise<boolean>;
7
6
  interface DiskStats {
8
7
  bsize: number;
@@ -8,7 +8,6 @@ import { resolveLaneCompany } from "./lane-company.js";
8
8
  import { effectiveAccountHome } from "./account-home.js";
9
9
  import { lanesConfigPath } from "./config.js";
10
10
  export const LOW_DISK_GUARD_FLAG = "lanes.low-disk-guard";
11
- export const CODEX_ROLLOUT_RETENTION_FLAG = "lanes.codex-rollout-retention";
12
11
  const DEFAULT_LOW_DISK_FREE_BYTES = 5 * 1024 ** 3;
13
12
  const DEFAULT_LOW_DISK_FRACTION = 0.02;
14
13
  const EXCLUDED_SESSION_FILES = new Set(["auth.json", ".credentials.json", "thread_history.sqlite"]);
@@ -12,17 +12,10 @@ export declare const LANE_HIRE_GRANT_FINISHED_LANES_FLAG = "lanes.hire-grant-cou
12
12
  export declare const LANE_LIST_SKIP_REMOTE_REFRESH_FLAG = "lanes.list-skip-remote-refresh";
13
13
  /** Enables missed-deadline lead nudges and second-miss operator escalation. */
14
14
  export declare const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
15
- export declare const LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG = "lanes.autopilot-nudge-dedupe";
16
15
  /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
17
16
  export declare const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
18
17
  /** Enables stale manager-status KPI reporting and one-time lead nudges. */
19
18
  export declare const LEAD_AUTOPILOT_KPI_STALENESS_FLAG = "lanes.autopilot-kpi-staleness";
20
- /** Skips malformed lead inputs individually instead of aborting the pass. */
21
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = "lanes.autopilot-per-lead-errors";
22
- export declare const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
23
- /** Batches stale manager-status KPI notices into one lead message per pass. */
24
- export declare const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG = "lanes.autopilot-kpi-staleness-batch";
25
- export declare const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_DEFAULT_ENABLED = false;
26
19
  /** Enables staleness notices for a registered lead's dashboard headline. */
27
20
  export declare const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-headline-staleness";
28
21
  /** Lets an Outpost's autopilot wake leads created on that Outpost, which record a local host. */
@@ -12,17 +12,10 @@ export const LANE_HIRE_GRANT_FINISHED_LANES_FLAG = "lanes.hire-grant-count-live-
12
12
  export const LANE_LIST_SKIP_REMOTE_REFRESH_FLAG = "lanes.list-skip-remote-refresh";
13
13
  /** Enables missed-deadline lead nudges and second-miss operator escalation. */
14
14
  export const LANE_AUTOPILOT_MISSED_DEADLINE_ESCALATION_FLAG = "lanes.autopilot-missed-deadline-escalation";
15
- export const LANE_AUTOPILOT_NUDGE_DEDUPE_FLAG = "lanes.autopilot-nudge-dedupe";
16
15
  /** Enables company-scoped lookup of a hostless lane ULID across Outpost indexes. */
17
16
  export const LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG = "lanes.message-route-owner-host";
18
17
  /** Enables stale manager-status KPI reporting and one-time lead nudges. */
19
18
  export const LEAD_AUTOPILOT_KPI_STALENESS_FLAG = "lanes.autopilot-kpi-staleness";
20
- /** Skips malformed lead inputs individually instead of aborting the pass. */
21
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_FLAG = "lanes.autopilot-per-lead-errors";
22
- export const LEAD_AUTOPILOT_PER_LEAD_ERRORS_DEFAULT_ENABLED = false;
23
- /** Batches stale manager-status KPI notices into one lead message per pass. */
24
- export const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_FLAG = "lanes.autopilot-kpi-staleness-batch";
25
- export const LEAD_AUTOPILOT_KPI_STALENESS_BATCH_DEFAULT_ENABLED = false;
26
19
  /** Enables staleness notices for a registered lead's dashboard headline. */
27
20
  export const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-headline-staleness";
28
21
  /** Lets an Outpost's autopilot wake leads created on that Outpost, which record a local host. */
@@ -955,21 +955,8 @@ export async function runPassBody(hqRoot, deps) {
955
955
  }
956
956
  const config = loadLanesConfig(hqRoot);
957
957
  try {
958
- const companyRefs = new Map();
959
- for (const lane of lanes) {
960
- const company = resolveLaneCompany(hqRoot, lane).company;
961
- if (company)
962
- companyRefs.set(company.uid ?? company.slug, company);
963
- }
964
- const readFlag = deps.readRegistryFlag ?? readLanesRegistryFlag;
965
958
  const configuredHomes = configuredCodexRolloutHomes(hqRoot, config, deps.host.hostname);
966
- let retentionEnabled = false;
967
- for (const company of configuredHomes.length > 0 ? companyRefs.values() : []) {
968
- retentionEnabled ||= await readFlag("lanes.codex-rollout-retention", { company });
969
- if (retentionEnabled)
970
- break;
971
- }
972
- if (retentionEnabled) {
959
+ if (configuredHomes.length > 0) {
973
960
  const presentHomes = [];
974
961
  for (const home of configuredHomes) {
975
962
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.344.1",
3
+ "version": "5.345.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {