@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 +5 -0
- package/assets/bot-workers/setup/worker.yaml +21 -0
- package/dist/commands/lanes.js +1 -25
- 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/package.json +1 -1
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
|
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({
|
|
@@ -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
|
/**
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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.
|
|
688
|
+
if (input.state.delivered_ids?.[input.message.message_id])
|
|
692
689
|
return false;
|
|
693
690
|
appendDropboxMessage(input.hqRoot, input.leadLane, input.message);
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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 {
|