@indigoai-us/hq-cli 5.125.0 → 5.127.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 +329 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +643 -6
- package/dist/command-catalog.generated.js +836 -6
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +11 -1
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +114 -0
- package/dist/commands/lanes.js +2969 -0
- package/dist/commands/mesh.js +287 -24
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/lanes/accounts.d.ts +103 -0
- package/dist/lib/lanes/accounts.js +318 -0
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +71 -0
- package/dist/lib/lanes/config.js +408 -0
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +49 -0
- package/dist/lib/lanes/index.js +27 -0
- package/dist/lib/lanes/liveness.d.ts +95 -0
- package/dist/lib/lanes/liveness.js +332 -0
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +116 -0
- package/dist/lib/lanes/registry.js +521 -0
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +68 -0
- package/dist/lib/lanes/store.js +745 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +535 -0
- package/dist/lib/lanes/types.js +350 -0
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +18 -13
- package/dist/lib/work-context/config.d.ts +20 -0
- package/dist/lib/work-context/config.js +26 -5
- package/dist/lib/work-context/reconcile.js +28 -7
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- package/package.json +3 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider account resolution (spec §5.5).
|
|
3
|
+
*
|
|
4
|
+
* Naming an account and omitting one mean different things: an explicit
|
|
5
|
+
* disabled id is refused; omitting picks the least-loaded eligible account.
|
|
6
|
+
* Status reports `configured` and `logged_in` separately. logged_in is
|
|
7
|
+
* derived from whether a provider credential file EXISTS — this module
|
|
8
|
+
* never reads, prints, copies, or hashes that file.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { AccountResolutionError } from "./errors.js";
|
|
13
|
+
import { isTerminalLaneState } from "./types.js";
|
|
14
|
+
import { accountMaxRunning } from "./config.js";
|
|
15
|
+
import { listLaneIds, readLane } from "./store.js";
|
|
16
|
+
import { loadQuotaState, quotaBlockFor } from "./quota.js";
|
|
17
|
+
export const PROVIDER_HOME_ENV = {
|
|
18
|
+
claude: "CLAUDE_CONFIG_DIR",
|
|
19
|
+
codex: "CODEX_HOME",
|
|
20
|
+
grok: "GROK_HOME",
|
|
21
|
+
};
|
|
22
|
+
export const AUTH_JSON_NAME = "auth.json";
|
|
23
|
+
export const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
|
|
24
|
+
/**
|
|
25
|
+
* Credential filenames are a property of the provider. Codex and Grok write
|
|
26
|
+
* auth.json. Claude Code writes .credentials.json (never auth.json) inside
|
|
27
|
+
* the config dir, and can also authenticate via the OS keychain with no
|
|
28
|
+
* file here at all.
|
|
29
|
+
*/
|
|
30
|
+
export const PROVIDER_CREDENTIAL_FILES = {
|
|
31
|
+
claude: [CLAUDE_CREDENTIALS_NAME],
|
|
32
|
+
codex: [AUTH_JSON_NAME],
|
|
33
|
+
grok: [AUTH_JSON_NAME],
|
|
34
|
+
};
|
|
35
|
+
function defaultAccountDeps() {
|
|
36
|
+
return { existsSync: (p) => fs.existsSync(p) };
|
|
37
|
+
}
|
|
38
|
+
function enabledIds(provider) {
|
|
39
|
+
return Object.entries(provider.accounts)
|
|
40
|
+
.filter(([, account]) => account.enabled)
|
|
41
|
+
.map(([id]) => id);
|
|
42
|
+
}
|
|
43
|
+
export function credentialPaths(provider, home) {
|
|
44
|
+
return PROVIDER_CREDENTIAL_FILES[provider].map((name) => path.join(home, name));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Existence-only probe. Callers must not read the files this returns paths to.
|
|
48
|
+
*/
|
|
49
|
+
export function hasCredentialFile(provider, home, existsSync) {
|
|
50
|
+
for (const filePath of credentialPaths(provider, home)) {
|
|
51
|
+
if (existsSync(filePath))
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Claude with no file in the config dir is unknown, not logged out: the
|
|
58
|
+
* credential may live in the OS keychain or an API-key helper. Codex and
|
|
59
|
+
* Grok write auth.json when logged in, so absence is logged out.
|
|
60
|
+
*/
|
|
61
|
+
export function authStateFor(provider, present) {
|
|
62
|
+
if (present)
|
|
63
|
+
return "file";
|
|
64
|
+
if (provider === "claude")
|
|
65
|
+
return "unknown";
|
|
66
|
+
return "absent";
|
|
67
|
+
}
|
|
68
|
+
export function loggedInFromAuthState(state) {
|
|
69
|
+
if (state === "file")
|
|
70
|
+
return true;
|
|
71
|
+
if (state === "absent")
|
|
72
|
+
return false;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Admission and listing share this: refuse only a conclusive logged-out. */
|
|
76
|
+
export function isLoggedOut(status) {
|
|
77
|
+
return status.logged_in === false;
|
|
78
|
+
}
|
|
79
|
+
/** Auto-select requires a proven credential file, not Claude-unknown. */
|
|
80
|
+
export function isCredentialProven(status) {
|
|
81
|
+
return status.logged_in === true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Occupying states hold a slot: running / awaiting_input / blocked have a
|
|
85
|
+
* live (or paused-live) worker; queued holds a queue slot that will take a
|
|
86
|
+
* running slot. Terminal states do not count.
|
|
87
|
+
*/
|
|
88
|
+
export function isOccupyingLaneState(state) {
|
|
89
|
+
return !isTerminalLaneState(state);
|
|
90
|
+
}
|
|
91
|
+
export function occupyingLaneCount(lanes, provider, accountId) {
|
|
92
|
+
let n = 0;
|
|
93
|
+
for (const lane of lanes) {
|
|
94
|
+
if (lane.provider !== provider)
|
|
95
|
+
continue;
|
|
96
|
+
if (lane.account !== accountId)
|
|
97
|
+
continue;
|
|
98
|
+
if (!isOccupyingLaneState(lane.state))
|
|
99
|
+
continue;
|
|
100
|
+
n += 1;
|
|
101
|
+
}
|
|
102
|
+
return n;
|
|
103
|
+
}
|
|
104
|
+
export function runningLaneCount(lanes, provider, accountId) {
|
|
105
|
+
let n = 0;
|
|
106
|
+
for (const lane of lanes) {
|
|
107
|
+
if (lane.provider !== provider)
|
|
108
|
+
continue;
|
|
109
|
+
if (lane.account !== accountId)
|
|
110
|
+
continue;
|
|
111
|
+
if (lane.state !== "running")
|
|
112
|
+
continue;
|
|
113
|
+
n += 1;
|
|
114
|
+
}
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
117
|
+
/** Most recent created_at on this account+provider, any state. Null if none. */
|
|
118
|
+
export function lastStartedAt(lanes, provider, accountId) {
|
|
119
|
+
let latest = null;
|
|
120
|
+
for (const lane of lanes) {
|
|
121
|
+
if (lane.provider !== provider)
|
|
122
|
+
continue;
|
|
123
|
+
if (lane.account !== accountId)
|
|
124
|
+
continue;
|
|
125
|
+
if (latest === null || lane.created_at > latest)
|
|
126
|
+
latest = lane.created_at;
|
|
127
|
+
}
|
|
128
|
+
return latest;
|
|
129
|
+
}
|
|
130
|
+
function defaultLoadLanes(hqRoot) {
|
|
131
|
+
return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
|
|
132
|
+
}
|
|
133
|
+
function compareEligible(a, b) {
|
|
134
|
+
if (a.occupying !== b.occupying)
|
|
135
|
+
return a.occupying - b.occupying;
|
|
136
|
+
if (a.lastStarted === null && b.lastStarted === null) {
|
|
137
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
138
|
+
}
|
|
139
|
+
if (a.lastStarted === null)
|
|
140
|
+
return -1;
|
|
141
|
+
if (b.lastStarted === null)
|
|
142
|
+
return 1;
|
|
143
|
+
if (a.lastStarted !== b.lastStarted) {
|
|
144
|
+
return a.lastStarted < b.lastStarted ? -1 : 1;
|
|
145
|
+
}
|
|
146
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
147
|
+
}
|
|
148
|
+
function modelForAccount(provider, accountId, config, ctx) {
|
|
149
|
+
// Same order as resolveLaneModel: an explicit lane model, then --model,
|
|
150
|
+
// then the account default, then the provider default.
|
|
151
|
+
if (ctx.model !== undefined && ctx.model.trim().length > 0)
|
|
152
|
+
return ctx.model.trim();
|
|
153
|
+
if (ctx.cliModel !== undefined && ctx.cliModel.trim().length > 0)
|
|
154
|
+
return ctx.cliModel.trim();
|
|
155
|
+
const accountModel = config.providers[provider].accounts[accountId]?.model;
|
|
156
|
+
if (accountModel !== undefined && accountModel.trim().length > 0)
|
|
157
|
+
return accountModel.trim();
|
|
158
|
+
const providerModel = config.providers[provider].model;
|
|
159
|
+
if (providerModel !== undefined && providerModel.trim().length > 0)
|
|
160
|
+
return providerModel.trim();
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function throwIfQuotaBlocked(block, provider, accountId) {
|
|
164
|
+
if (!block.blocked || !block.reason)
|
|
165
|
+
return;
|
|
166
|
+
const model = block.model ?? "(no model)";
|
|
167
|
+
if (block.reason === "account_model_unavailable") {
|
|
168
|
+
throw new AccountResolutionError("account_model_unavailable", `Account ${JSON.stringify(accountId)} cannot run model ${model}: ${block.marker ?? "needs credits"}`, {
|
|
169
|
+
provider,
|
|
170
|
+
account: accountId,
|
|
171
|
+
model: block.model ?? null,
|
|
172
|
+
marker: block.marker ?? null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
throw new AccountResolutionError("account_quota_exhausted", `Account ${JSON.stringify(accountId)} is out of quota${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""} for model ${model}`, {
|
|
176
|
+
provider,
|
|
177
|
+
account: accountId,
|
|
178
|
+
model: block.model ?? null,
|
|
179
|
+
quota_reset_at: block.quota_reset_at ?? null,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function boundAccount(provider, id, account) {
|
|
183
|
+
return {
|
|
184
|
+
provider,
|
|
185
|
+
id,
|
|
186
|
+
home: account.home,
|
|
187
|
+
enabled: true,
|
|
188
|
+
envVar: PROVIDER_HOME_ENV[provider],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Pick among enabled, credential-proven accounts by occupying count
|
|
193
|
+
* (lowest wins). Ties break to least-recently-started (oldest created_at),
|
|
194
|
+
* then account id ascending. Accounts at max_running are excluded from
|
|
195
|
+
* start-eligible; if every proven account is at cap the pick still returns
|
|
196
|
+
* one so admission can queue rather than overflow.
|
|
197
|
+
*/
|
|
198
|
+
export function selectBalancedAccount(provider, config, ctx = {}) {
|
|
199
|
+
const providerCfg = config.providers[provider];
|
|
200
|
+
const existsSync = ctx.existsSync ?? ((p) => fs.existsSync(p));
|
|
201
|
+
const enabled = enabledIds(providerCfg);
|
|
202
|
+
if (enabled.length === 0) {
|
|
203
|
+
throw new AccountResolutionError("no_enabled_account", `No enabled account for provider ${provider}`, { provider, ineligible: "none_enabled" });
|
|
204
|
+
}
|
|
205
|
+
const proven = [];
|
|
206
|
+
for (const id of enabled) {
|
|
207
|
+
const account = providerCfg.accounts[id];
|
|
208
|
+
if (hasCredentialFile(provider, account.home, existsSync))
|
|
209
|
+
proven.push(id);
|
|
210
|
+
}
|
|
211
|
+
if (proven.length === 0) {
|
|
212
|
+
throw new AccountResolutionError("no_enabled_account", `No logged-in account for provider ${provider}`, { provider, ineligible: "none_logged_in", enabled });
|
|
213
|
+
}
|
|
214
|
+
const hqRoot = ctx.hqRoot;
|
|
215
|
+
const lanes = ctx.loadLanes !== undefined
|
|
216
|
+
? ctx.loadLanes(hqRoot ?? "")
|
|
217
|
+
: hqRoot !== undefined
|
|
218
|
+
? defaultLoadLanes(hqRoot)
|
|
219
|
+
: [];
|
|
220
|
+
const now = ctx.now ?? new Date();
|
|
221
|
+
const quota = hqRoot !== undefined ? loadQuotaState(hqRoot) : null;
|
|
222
|
+
const runnable = quota
|
|
223
|
+
? proven.filter((id) => {
|
|
224
|
+
const block = quotaBlockFor(quota, provider, id, modelForAccount(provider, id, config, ctx), now);
|
|
225
|
+
return !block.blocked;
|
|
226
|
+
})
|
|
227
|
+
: proven;
|
|
228
|
+
if (quota && runnable.length === 0) {
|
|
229
|
+
const sample = proven[0];
|
|
230
|
+
const block = quotaBlockFor(quota, provider, sample, modelForAccount(provider, sample, config, ctx), now);
|
|
231
|
+
throwIfQuotaBlocked(block, provider, sample);
|
|
232
|
+
}
|
|
233
|
+
const underCap = runnable.filter((id) => {
|
|
234
|
+
const cap = accountMaxRunning(providerCfg, id);
|
|
235
|
+
return runningLaneCount(lanes, provider, id) < cap;
|
|
236
|
+
});
|
|
237
|
+
const pool = underCap.length > 0 ? underCap : runnable;
|
|
238
|
+
const ranked = pool
|
|
239
|
+
.map((id) => ({
|
|
240
|
+
id,
|
|
241
|
+
occupying: occupyingLaneCount(lanes, provider, id),
|
|
242
|
+
lastStarted: lastStartedAt(lanes, provider, id),
|
|
243
|
+
}))
|
|
244
|
+
.sort(compareEligible);
|
|
245
|
+
const chosen = ranked[0].id;
|
|
246
|
+
return boundAccount(provider, chosen, providerCfg.accounts[chosen]);
|
|
247
|
+
}
|
|
248
|
+
export function resolveAccount(provider, requestedId, config, ctx = {}) {
|
|
249
|
+
const providerCfg = config.providers[provider];
|
|
250
|
+
const enabled = enabledIds(providerCfg);
|
|
251
|
+
if (requestedId !== undefined && requestedId.length > 0) {
|
|
252
|
+
const account = providerCfg.accounts[requestedId];
|
|
253
|
+
if (account === undefined) {
|
|
254
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(requestedId)} for ${provider}. Enabled: ${enabled.join(", ") || "(none)"}`, {
|
|
255
|
+
provider,
|
|
256
|
+
account: requestedId,
|
|
257
|
+
enabled,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (!account.enabled) {
|
|
261
|
+
throw new AccountResolutionError("account_disabled", `Account ${JSON.stringify(requestedId)} is disabled${account.disabled_reason ? `: ${account.disabled_reason}` : ""}`, {
|
|
262
|
+
account: requestedId,
|
|
263
|
+
reason: account.disabled_reason ?? "",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (ctx.hqRoot !== undefined) {
|
|
267
|
+
const now = ctx.now ?? new Date();
|
|
268
|
+
const block = quotaBlockFor(loadQuotaState(ctx.hqRoot), provider, requestedId, modelForAccount(provider, requestedId, config, ctx), now);
|
|
269
|
+
throwIfQuotaBlocked(block, provider, requestedId);
|
|
270
|
+
}
|
|
271
|
+
return boundAccount(provider, requestedId, account);
|
|
272
|
+
}
|
|
273
|
+
return selectBalancedAccount(provider, config, ctx);
|
|
274
|
+
}
|
|
275
|
+
/** Codex/Grok path. Kept for callers that still name auth.json. */
|
|
276
|
+
export function authJsonPath(home) {
|
|
277
|
+
return path.join(home, AUTH_JSON_NAME);
|
|
278
|
+
}
|
|
279
|
+
export function accountStatus(provider, accountId, config, deps = defaultAccountDeps()) {
|
|
280
|
+
const envVar = PROVIDER_HOME_ENV[provider];
|
|
281
|
+
const account = config.providers[provider].accounts[accountId];
|
|
282
|
+
if (account === undefined) {
|
|
283
|
+
return {
|
|
284
|
+
provider,
|
|
285
|
+
id: accountId,
|
|
286
|
+
home: "",
|
|
287
|
+
enabled: false,
|
|
288
|
+
configured: false,
|
|
289
|
+
logged_in: false,
|
|
290
|
+
auth_state: "absent",
|
|
291
|
+
envVar,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const present = hasCredentialFile(provider, account.home, deps.existsSync);
|
|
295
|
+
const auth_state = authStateFor(provider, present);
|
|
296
|
+
const status = {
|
|
297
|
+
provider,
|
|
298
|
+
id: accountId,
|
|
299
|
+
home: account.home,
|
|
300
|
+
enabled: account.enabled,
|
|
301
|
+
configured: true,
|
|
302
|
+
logged_in: loggedInFromAuthState(auth_state),
|
|
303
|
+
auth_state,
|
|
304
|
+
envVar,
|
|
305
|
+
};
|
|
306
|
+
if (account.disabled_reason !== undefined) {
|
|
307
|
+
status.disabled_reason = account.disabled_reason;
|
|
308
|
+
}
|
|
309
|
+
return status;
|
|
310
|
+
}
|
|
311
|
+
export function listAccountStatuses(provider, config, deps = defaultAccountDeps()) {
|
|
312
|
+
return Object.keys(config.providers[provider].accounts).map((id) => accountStatus(provider, id, config, deps));
|
|
313
|
+
}
|
|
314
|
+
/** Environment assignment the worker process should receive. */
|
|
315
|
+
export function accountEnv(resolved) {
|
|
316
|
+
return { [resolved.envVar]: resolved.home };
|
|
317
|
+
}
|
|
318
|
+
//# sourceMappingURL=accounts.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic compaction of a resumed Claude lane.
|
|
3
|
+
*
|
|
4
|
+
* Owner directives 2026-09-22: "if it's been an hour since last msg, always
|
|
5
|
+
* run compact" and "there should be a way to run compact automatically using
|
|
6
|
+
* code written in hqcli".
|
|
7
|
+
*
|
|
8
|
+
* Every `--resume` replays the whole conversation. When a resume is due
|
|
9
|
+
* (`now - last_message_at >= compact_after_ms`), hq-cli first runs
|
|
10
|
+
* `claude -p --resume <sid> "/compact"` in the lane's account, then starts
|
|
11
|
+
* the resume as usual. The decision is made at resume time only; there is no
|
|
12
|
+
* timer.
|
|
13
|
+
*
|
|
14
|
+
* Measured 2026-09-22 on account d, claude-opus-5, effort low: a session
|
|
15
|
+
* with 71,853 cache-read tokens on a control turn was compacted in print
|
|
16
|
+
* mode. The `/compact` call's own JSON result reports `num_turns: 0` and
|
|
17
|
+
* zero usage, so it cannot be used as evidence. The session transcript
|
|
18
|
+
* gained a `compact_boundary` record (`trigger: manual`,
|
|
19
|
+
* `preTokens: 71899`), the session id was unchanged, and the next turn read
|
|
20
|
+
* 10,383 cache tokens plus 9,136 cache-creation tokens and still recalled a
|
|
21
|
+
* value from before the compaction. The transcript boundary is therefore
|
|
22
|
+
* the success signal this module checks.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here logs the prompt, the summary or a response body. The
|
|
25
|
+
* transcript is read only for `compact_boundary` system records, and only
|
|
26
|
+
* their `compactMetadata.preTokens` is kept.
|
|
27
|
+
*/
|
|
28
|
+
import type { LaneCompactionRecord, LaneRecord, LaneRunUsage } from "./types.js";
|
|
29
|
+
export declare const COMPACTION_MECHANISM: "claude_print_compact";
|
|
30
|
+
/** The whole prompt of the compaction turn. */
|
|
31
|
+
export declare const COMPACTION_PROMPT = "/compact";
|
|
32
|
+
/** Upper bound on the synchronous `/compact` call. */
|
|
33
|
+
export declare const COMPACTION_TIMEOUT_MS: number;
|
|
34
|
+
export interface CompactionDecision {
|
|
35
|
+
due: boolean;
|
|
36
|
+
reference_at: string;
|
|
37
|
+
elapsed_ms: number;
|
|
38
|
+
threshold_ms: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether a resume of this lane must compact first. The reference is the
|
|
42
|
+
* last accepted operator message, else the lane's creation (the brief is the
|
|
43
|
+
* first message). Only Claude lanes resume a session, so only they compact.
|
|
44
|
+
*/
|
|
45
|
+
export declare function compactionDue(lane: Pick<LaneRecord, "provider" | "last_message_at" | "created_at">, nowMs: number, thresholdMs: number): CompactionDecision;
|
|
46
|
+
export interface CompactBoundaryProbe {
|
|
47
|
+
count: number;
|
|
48
|
+
last_pre_tokens: number | null;
|
|
49
|
+
}
|
|
50
|
+
/** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
|
|
51
|
+
export declare function findSessionTranscript(home: string, sessionId: string): string | null;
|
|
52
|
+
/** Count compact boundaries in a transcript. Other lines are never parsed. */
|
|
53
|
+
export declare function probeCompactBoundaries(transcriptPath: string | null): CompactBoundaryProbe;
|
|
54
|
+
export interface CompactionCommandResult {
|
|
55
|
+
status: number | null;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
export type RunCompactionFn = (input: {
|
|
59
|
+
command: string;
|
|
60
|
+
args: string[];
|
|
61
|
+
env: NodeJS.ProcessEnv;
|
|
62
|
+
cwd: string;
|
|
63
|
+
stderrPath: string;
|
|
64
|
+
timeoutMs: number;
|
|
65
|
+
}) => CompactionCommandResult;
|
|
66
|
+
/** Synchronous `/compact`. stdout is discarded; stderr goes to the run dir. */
|
|
67
|
+
export declare const defaultRunCompaction: RunCompactionFn;
|
|
68
|
+
export interface CompactLaneSessionInput {
|
|
69
|
+
command: string;
|
|
70
|
+
/** Full argv of `claude -p --resume <sid> "/compact" …`, built by buildProviderCommand. */
|
|
71
|
+
args: string[];
|
|
72
|
+
sessionId: string;
|
|
73
|
+
accountHome: string;
|
|
74
|
+
env: NodeJS.ProcessEnv;
|
|
75
|
+
cwd: string;
|
|
76
|
+
logsDir: string;
|
|
77
|
+
decision: CompactionDecision;
|
|
78
|
+
before: LaneRunUsage | null;
|
|
79
|
+
now: () => Date;
|
|
80
|
+
run?: RunCompactionFn;
|
|
81
|
+
locateTranscript?: (home: string, sessionId: string) => string | null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Run `/compact` on the recorded session and report what happened. A failed
|
|
85
|
+
* or ineffective compaction is recorded, not thrown: the resume that needed
|
|
86
|
+
* it still has a message to deliver.
|
|
87
|
+
*/
|
|
88
|
+
export declare function compactLaneSession(input: CompactLaneSessionInput): LaneCompactionRecord;
|
|
89
|
+
/**
|
|
90
|
+
* Counters of the first run after the last compaction. Known once that run
|
|
91
|
+
* has exited and no later run has started: the watcher writes
|
|
92
|
+
* `last_run_usage` at exit, and `last_resume.at` still equals the
|
|
93
|
+
* compaction's `resume_at`.
|
|
94
|
+
*/
|
|
95
|
+
export declare function compactionAfterUsage(lane: LaneRecord): LaneRunUsage | null;
|
|
96
|
+
//# sourceMappingURL=compaction.d.ts.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic compaction of a resumed Claude lane.
|
|
3
|
+
*
|
|
4
|
+
* Owner directives 2026-09-22: "if it's been an hour since last msg, always
|
|
5
|
+
* run compact" and "there should be a way to run compact automatically using
|
|
6
|
+
* code written in hqcli".
|
|
7
|
+
*
|
|
8
|
+
* Every `--resume` replays the whole conversation. When a resume is due
|
|
9
|
+
* (`now - last_message_at >= compact_after_ms`), hq-cli first runs
|
|
10
|
+
* `claude -p --resume <sid> "/compact"` in the lane's account, then starts
|
|
11
|
+
* the resume as usual. The decision is made at resume time only; there is no
|
|
12
|
+
* timer.
|
|
13
|
+
*
|
|
14
|
+
* Measured 2026-09-22 on account d, claude-opus-5, effort low: a session
|
|
15
|
+
* with 71,853 cache-read tokens on a control turn was compacted in print
|
|
16
|
+
* mode. The `/compact` call's own JSON result reports `num_turns: 0` and
|
|
17
|
+
* zero usage, so it cannot be used as evidence. The session transcript
|
|
18
|
+
* gained a `compact_boundary` record (`trigger: manual`,
|
|
19
|
+
* `preTokens: 71899`), the session id was unchanged, and the next turn read
|
|
20
|
+
* 10,383 cache tokens plus 9,136 cache-creation tokens and still recalled a
|
|
21
|
+
* value from before the compaction. The transcript boundary is therefore
|
|
22
|
+
* the success signal this module checks.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here logs the prompt, the summary or a response body. The
|
|
25
|
+
* transcript is read only for `compact_boundary` system records, and only
|
|
26
|
+
* their `compactMetadata.preTokens` is kept.
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
export const COMPACTION_MECHANISM = "claude_print_compact";
|
|
32
|
+
/** The whole prompt of the compaction turn. */
|
|
33
|
+
export const COMPACTION_PROMPT = "/compact";
|
|
34
|
+
/** Upper bound on the synchronous `/compact` call. */
|
|
35
|
+
export const COMPACTION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a resume of this lane must compact first. The reference is the
|
|
38
|
+
* last accepted operator message, else the lane's creation (the brief is the
|
|
39
|
+
* first message). Only Claude lanes resume a session, so only they compact.
|
|
40
|
+
*/
|
|
41
|
+
export function compactionDue(lane, nowMs, thresholdMs) {
|
|
42
|
+
const reference_at = lane.last_message_at ?? lane.created_at;
|
|
43
|
+
const refMs = Date.parse(reference_at);
|
|
44
|
+
const elapsed_ms = Number.isFinite(refMs) ? nowMs - refMs : 0;
|
|
45
|
+
return {
|
|
46
|
+
due: lane.provider === "claude" && Number.isFinite(refMs) && elapsed_ms >= thresholdMs,
|
|
47
|
+
reference_at,
|
|
48
|
+
elapsed_ms,
|
|
49
|
+
threshold_ms: thresholdMs,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Locate `<home>/projects/<cwd-slug>/<sid>.jsonl` without deriving the slug. */
|
|
53
|
+
export function findSessionTranscript(home, sessionId) {
|
|
54
|
+
const projects = path.join(home, "projects");
|
|
55
|
+
let dirs;
|
|
56
|
+
try {
|
|
57
|
+
dirs = fs.readdirSync(projects);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const code = err.code;
|
|
61
|
+
if (code === "ENOENT")
|
|
62
|
+
return null;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
for (const dir of dirs) {
|
|
66
|
+
const candidate = path.join(projects, dir, `${sessionId}.jsonl`);
|
|
67
|
+
if (fs.existsSync(candidate))
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** Count compact boundaries in a transcript. Other lines are never parsed. */
|
|
73
|
+
export function probeCompactBoundaries(transcriptPath) {
|
|
74
|
+
if (transcriptPath === null)
|
|
75
|
+
return { count: 0, last_pre_tokens: null };
|
|
76
|
+
let text;
|
|
77
|
+
try {
|
|
78
|
+
text = fs.readFileSync(transcriptPath, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const code = err.code;
|
|
82
|
+
if (code === "ENOENT")
|
|
83
|
+
return { count: 0, last_pre_tokens: null };
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
let count = 0;
|
|
87
|
+
let last = null;
|
|
88
|
+
for (const line of text.split("\n")) {
|
|
89
|
+
if (!line.includes('"compact_boundary"'))
|
|
90
|
+
continue;
|
|
91
|
+
let rec;
|
|
92
|
+
try {
|
|
93
|
+
rec = JSON.parse(line);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
process.stderr.write(`hq lanes: skipped an unparseable line in ${transcriptPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (rec.type !== "system" || rec.subtype !== "compact_boundary")
|
|
100
|
+
continue;
|
|
101
|
+
count += 1;
|
|
102
|
+
const meta = rec.compactMetadata;
|
|
103
|
+
const pre = meta?.preTokens;
|
|
104
|
+
last = typeof pre === "number" && Number.isFinite(pre) ? pre : null;
|
|
105
|
+
}
|
|
106
|
+
return { count, last_pre_tokens: last };
|
|
107
|
+
}
|
|
108
|
+
/** Synchronous `/compact`. stdout is discarded; stderr goes to the run dir. */
|
|
109
|
+
export const defaultRunCompaction = (input) => {
|
|
110
|
+
fs.mkdirSync(path.dirname(input.stderrPath), { recursive: true });
|
|
111
|
+
const errFd = fs.openSync(input.stderrPath, "a");
|
|
112
|
+
try {
|
|
113
|
+
const result = spawnSync(input.command, input.args, {
|
|
114
|
+
cwd: input.cwd,
|
|
115
|
+
env: input.env,
|
|
116
|
+
stdio: ["ignore", "ignore", errFd],
|
|
117
|
+
timeout: input.timeoutMs,
|
|
118
|
+
});
|
|
119
|
+
if (result.error) {
|
|
120
|
+
return { status: result.status, error: result.error.message };
|
|
121
|
+
}
|
|
122
|
+
return { status: result.status };
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
fs.closeSync(errFd);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Run `/compact` on the recorded session and report what happened. A failed
|
|
130
|
+
* or ineffective compaction is recorded, not thrown: the resume that needed
|
|
131
|
+
* it still has a message to deliver.
|
|
132
|
+
*/
|
|
133
|
+
export function compactLaneSession(input) {
|
|
134
|
+
const run = input.run ?? defaultRunCompaction;
|
|
135
|
+
const locate = input.locateTranscript ?? findSessionTranscript;
|
|
136
|
+
const started = input.now();
|
|
137
|
+
const beforeProbe = probeCompactBoundaries(locate(input.accountHome, input.sessionId));
|
|
138
|
+
const result = run({
|
|
139
|
+
command: input.command,
|
|
140
|
+
args: input.args,
|
|
141
|
+
env: input.env,
|
|
142
|
+
cwd: input.cwd,
|
|
143
|
+
stderrPath: path.join(input.logsDir, "compact.err"),
|
|
144
|
+
timeoutMs: COMPACTION_TIMEOUT_MS,
|
|
145
|
+
});
|
|
146
|
+
const afterProbe = probeCompactBoundaries(locate(input.accountHome, input.sessionId));
|
|
147
|
+
const duration_ms = input.now().getTime() - started.getTime();
|
|
148
|
+
let outcome;
|
|
149
|
+
let reason;
|
|
150
|
+
if (afterProbe.count > beforeProbe.count) {
|
|
151
|
+
outcome = "compacted";
|
|
152
|
+
}
|
|
153
|
+
else if (result.status !== 0 || result.error !== undefined) {
|
|
154
|
+
outcome = "failed";
|
|
155
|
+
reason = result.error ?? `exit status ${String(result.status)}`;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
outcome = "no_effect";
|
|
159
|
+
reason = "the session transcript gained no compact_boundary record";
|
|
160
|
+
}
|
|
161
|
+
if (outcome !== "compacted") {
|
|
162
|
+
process.stderr.write(`hq lanes: compaction of session ${input.sessionId} ${outcome}: ${reason ?? ""}\n`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
at: started.toISOString(),
|
|
166
|
+
mechanism: COMPACTION_MECHANISM,
|
|
167
|
+
session_id: input.sessionId,
|
|
168
|
+
outcome,
|
|
169
|
+
...(reason !== undefined ? { reason } : {}),
|
|
170
|
+
duration_ms,
|
|
171
|
+
elapsed_ms: input.decision.elapsed_ms,
|
|
172
|
+
reference_at: input.decision.reference_at,
|
|
173
|
+
pre_tokens: outcome === "compacted" ? afterProbe.last_pre_tokens : null,
|
|
174
|
+
before: input.before,
|
|
175
|
+
after: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Counters of the first run after the last compaction. Known once that run
|
|
180
|
+
* has exited and no later run has started: the watcher writes
|
|
181
|
+
* `last_run_usage` at exit, and `last_resume.at` still equals the
|
|
182
|
+
* compaction's `resume_at`.
|
|
183
|
+
*/
|
|
184
|
+
export function compactionAfterUsage(lane) {
|
|
185
|
+
const c = lane.last_compaction;
|
|
186
|
+
if (c === undefined)
|
|
187
|
+
return null;
|
|
188
|
+
if (c.after !== null)
|
|
189
|
+
return c.after;
|
|
190
|
+
if (c.resume_at === undefined || lane.state === "running" || lane.state === "queued") {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
if (lane.last_resume?.at !== c.resume_at)
|
|
194
|
+
return null;
|
|
195
|
+
return lane.last_run_usage ?? null;
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=compaction.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load and validate personal/lanes/config.json (spec §5.5).
|
|
3
|
+
*
|
|
4
|
+
* A missing file is the documented defaults, not an error. A `providers` key
|
|
5
|
+
* outside {claude, codex, grok} is rejected with the accepted list. Account
|
|
6
|
+
* homes are canonicalised with realpath; two account ids for one provider
|
|
7
|
+
* that resolve to the same home are refused (identity-keyed-cap rule).
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig } from "./types.js";
|
|
11
|
+
export declare const LANES_CONFIG_REL: string;
|
|
12
|
+
export declare const DEFAULT_MAX_QUEUED = 3;
|
|
13
|
+
export declare const DEFAULT_CPU_BUSY_PAUSE = 0.85;
|
|
14
|
+
export declare const DEFAULT_CPU_BUSY_RESUME = 0.7;
|
|
15
|
+
export declare const DEFAULT_MIN_AVAILABLE_MEM_MB = 1024;
|
|
16
|
+
/**
|
|
17
|
+
* Owner directive 2026-09-22: "if it's been an hour since last msg, always
|
|
18
|
+
* run compact". Override per provider with `compact_after_ms`.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_COMPACT_AFTER_MS: number;
|
|
21
|
+
/**
|
|
22
|
+
* Watcher `lane_progress` throttle: at most one thread event per five minutes
|
|
23
|
+
* per lane, and only when the tick saw new activity. Override per provider
|
|
24
|
+
* with `progress_event_min_interval_ms`.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DEFAULT_PROGRESS_EVENT_MIN_INTERVAL_MS: number;
|
|
27
|
+
/** Spec §9: last event within 10 minutes → live. */
|
|
28
|
+
export declare const DEFAULT_LIVE_WITHIN_MS: number;
|
|
29
|
+
/** Spec §9: turn_start with no turn_end after 2 hours → lost. */
|
|
30
|
+
export declare const DEFAULT_TURN_START_LOST_MS: number;
|
|
31
|
+
/** Spec §9: no event for 12 hours (and no session_end) → lost. */
|
|
32
|
+
export declare const DEFAULT_SILENT_LOST_MS: number;
|
|
33
|
+
export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
|
|
34
|
+
export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
|
|
35
|
+
export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
|
|
36
|
+
export interface ConfigLoadDeps {
|
|
37
|
+
homedir: () => string;
|
|
38
|
+
realpathSync: (p: string) => string;
|
|
39
|
+
existsSync: (p: string) => boolean;
|
|
40
|
+
readFileSync: (p: string, encoding: "utf8") => string;
|
|
41
|
+
}
|
|
42
|
+
export declare function expandHome(home: string, homedir: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Canonical principal for an account home: realpath when the path exists,
|
|
45
|
+
* otherwise resolve existing ancestors and rejoin the missing tail so two
|
|
46
|
+
* not-yet-created spellings of the same directory still collide.
|
|
47
|
+
*/
|
|
48
|
+
export declare function canonicalizeHome(home: string, deps?: Pick<ConfigLoadDeps, "homedir" | "realpathSync" | "existsSync">): string;
|
|
49
|
+
export declare function documentedDefaultConfig(deps?: ConfigLoadDeps): LanesConfig;
|
|
50
|
+
export declare function parseLanesConfig(raw: unknown, deps?: ConfigLoadDeps): LanesConfig;
|
|
51
|
+
export declare function lanesConfigPath(hqRoot: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Load operator config. Missing file → documented defaults. Unreadable or
|
|
54
|
+
* invalid JSON is an error (never a silent default).
|
|
55
|
+
*/
|
|
56
|
+
export declare function loadLanesConfig(hqRoot: string, deps?: ConfigLoadDeps): LanesConfig;
|
|
57
|
+
export interface ConfigWriteDeps {
|
|
58
|
+
mkdirSync: typeof fs.mkdirSync;
|
|
59
|
+
writeFileSync: typeof fs.writeFileSync;
|
|
60
|
+
renameSync: typeof fs.renameSync;
|
|
61
|
+
unlinkSync: typeof fs.unlinkSync;
|
|
62
|
+
}
|
|
63
|
+
/** Persist operator config. Homes are stored as already-canonicalised paths. */
|
|
64
|
+
export declare function writeLanesConfig(hqRoot: string, config: LanesConfig, deps?: ConfigWriteDeps): void;
|
|
65
|
+
/** Resume-time compaction threshold for a provider, defaulting to one hour. */
|
|
66
|
+
export declare function providerCompactAfterMs(provider: ProviderConfig): number;
|
|
67
|
+
/** Minimum gap between two `lane_progress` events, defaulting to five minutes. */
|
|
68
|
+
export declare function providerProgressEventMinIntervalMs(provider: ProviderConfig): number;
|
|
69
|
+
/** Account running cap, defaulting to the provider ceiling when unset. */
|
|
70
|
+
export declare function accountMaxRunning(provider: ProviderConfig, accountId: string): number;
|
|
71
|
+
//# sourceMappingURL=config.d.ts.map
|