@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 +8 -0
- package/assets/bot-workers/setup/worker.yaml +21 -0
- package/dist/commands/lanes.js +1 -25
- package/dist/commands/mcp-serve.js +4 -3
- package/dist/lib/bot/coding-tool-activity.d.ts +142 -0
- package/dist/lib/bot/coding-tool-activity.js +372 -0
- package/dist/lib/bot/run.d.ts +7 -0
- package/dist/lib/bot/run.js +123 -11
- package/dist/lib/bot/setup-explain.d.ts +12 -0
- package/dist/lib/bot/setup-tool-offer.d.ts +54 -0
- package/dist/lib/bot/setup-tool-offer.js +91 -0
- package/dist/lib/lanes/autopilot.d.ts +0 -5
- package/dist/lib/lanes/autopilot.js +13 -30
- package/dist/lib/lanes/disk-guard.d.ts +0 -1
- package/dist/lib/lanes/disk-guard.js +0 -1
- package/dist/lib/lanes/flag-keys.d.ts +0 -7
- package/dist/lib/lanes/flag-keys.js +0 -7
- package/dist/lib/lanes/reconcile.js +1 -14
- package/dist/utils/package-use-lease.d.ts +7 -2
- package/dist/utils/package-use-lease.js +39 -3
- package/package.json +1 -1
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
|
package/dist/commands/lanes.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -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
|
/**
|