@indigoai-us/hq-cli 5.344.0 → 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,14 @@
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
+
10
+ ## [5.344.1] — 2026-10-05
11
+ - Release the MCP server's package lease after its resident modules preload and record a bounded command purpose on CLI package leases.
12
+
5
13
  ## [5.344.0] — 2026-10-04
6
14
  - Include a bounded reason in sync-failure client-health reports.
7
15
  - Add an explicit opt-in to count usage from HQ sessions with no company toward the default company: `hq mesh context default set` asks in a terminal (default No), takes `--count-usage` / `--no-count-usage`, and writes `usageAttribution` (company and signed-in person) to the work-context config. Changing or clearing the default, logging out, or signing in as another account withdraws it; `get` shows it; an automatic default never opts in.
@@ -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({
@@ -73,11 +73,12 @@ export async function runMcpServe(opts = {}) {
73
73
  process.emitWarning("hq-cli resident package preload completion failed (class=Error, module=unknown)");
74
74
  });
75
75
  };
76
+ // Release before the stdio read can remain open indefinitely. The request
77
+ // handlers and their declared lazy modules are loaded before this point.
78
+ startResidentPreload();
76
79
  const send = (msg) => {
77
80
  const response = `${JSON.stringify({ jsonrpc: "2.0", ...msg })}\n`;
78
- if (residentPreloadStarted)
79
- return output.write(response);
80
- return output.write(response, startResidentPreload);
81
+ return output.write(response);
81
82
  };
82
83
  const reply = (id, result) => send({ id, result });
83
84
  const replyError = (id, code, message) => send({ id, error: { code, message } });
@@ -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
  /**