@indigoai-us/hq-cli 5.140.0 → 5.142.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 +25 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +77 -2
- package/dist/command-catalog.generated.d.ts +6 -0
- package/dist/command-catalog.generated.js +8 -0
- package/dist/commands/bot-jobs.js +16 -3
- package/dist/commands/lanes.js +125 -26
- package/dist/lib/agent-kit/run/heartbeat.js +2 -2
- package/dist/lib/bot/run.js +2 -2
- package/dist/lib/heartbeat-identity.d.ts +1 -0
- package/dist/lib/heartbeat-identity.js +5 -0
- package/dist/lib/lanes/effort.d.ts +3 -2
- package/dist/lib/lanes/effort.js +5 -1
- package/dist/lib/lanes/errors.d.ts +1 -1
- package/dist/lib/lanes/index.d.ts +2 -0
- package/dist/lib/lanes/index.js +1 -0
- package/dist/lib/lanes/mesh-events.d.ts +4 -5
- package/dist/lib/lanes/mesh-events.js +14 -4
- package/dist/lib/lanes/profile.d.ts +48 -0
- package/dist/lib/lanes/profile.js +405 -0
- package/dist/lib/lanes/reconcile.js +25 -3
- package/dist/lib/lanes/registry.d.ts +3 -0
- package/dist/lib/lanes/registry.js +45 -1
- package/dist/lib/lanes/spawn.d.ts +14 -2
- package/dist/lib/lanes/spawn.js +238 -14
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +11 -1
- package/dist/lib/lanes/types.js +3 -3
- package/package.json +1 -1
package/dist/lib/bot/run.js
CHANGED
|
@@ -69,7 +69,7 @@ import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOut
|
|
|
69
69
|
import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
|
|
70
70
|
import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
|
|
71
71
|
import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
|
|
72
|
-
import { captureHeartbeatIdentityMismatch, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
|
|
72
|
+
import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
|
|
73
73
|
import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
74
74
|
import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
|
|
75
75
|
export const INBOX_POLL_INTERVAL_MS = 2_000;
|
|
@@ -459,7 +459,7 @@ export async function runBot(deps) {
|
|
|
459
459
|
if (err instanceof BotApiError &&
|
|
460
460
|
isHeartbeatIdentityMismatch({ status: err.status, code: err.code, message: err.detail })) {
|
|
461
461
|
const tokenLabel = err.tokenAgentUid ?? "unknown";
|
|
462
|
-
const reason =
|
|
462
|
+
const reason = identityMismatchMessage("heartbeat", config.agentUid, tokenLabel);
|
|
463
463
|
setStatus({
|
|
464
464
|
lastHeartbeatOk: false,
|
|
465
465
|
lastHeartbeatError: reason.slice(0, 400),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CaptureContext } from "@sentry/node";
|
|
2
2
|
export declare const HEARTBEAT_IDENTITY_MISMATCH_CODE = "AGENT_IDENTITY_MISMATCH";
|
|
3
|
+
export declare function identityMismatchMessage(surface: "heartbeat" | "bot jobs", configuredAgentUid: string, tokenAgentUid?: string): string;
|
|
3
4
|
export type HeartbeatRunner = "agent-kit" | "bot";
|
|
4
5
|
export type HeartbeatAgentUidSource = "config_file" | "env" | "flag";
|
|
5
6
|
export type HeartbeatCredsSource = "explicit" | "default";
|
|
@@ -2,6 +2,11 @@ import { CLI_VERSION } from "../cli-version.js";
|
|
|
2
2
|
import { Sentry } from "../sentry.js";
|
|
3
3
|
import { peekIdToken } from "../utils/id-token.js";
|
|
4
4
|
export const HEARTBEAT_IDENTITY_MISMATCH_CODE = "AGENT_IDENTITY_MISMATCH";
|
|
5
|
+
export function identityMismatchMessage(surface, configuredAgentUid, tokenAgentUid) {
|
|
6
|
+
return (`${surface} stopped: identity mismatch configured agentUid=${configuredAgentUid} ` +
|
|
7
|
+
`token agentUid=${tokenAgentUid ?? "unknown"}; fix the bot config to use the token agentUid, ` +
|
|
8
|
+
`or re-mint credentials for the configured agentUid.`);
|
|
9
|
+
}
|
|
5
10
|
const AGENT_UID_RE = /^agt_[A-Za-z0-9_-]{1,100}$/;
|
|
6
11
|
function normalize(value) {
|
|
7
12
|
return (value ?? "").replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { LaneProvider } from "./types.js";
|
|
21
21
|
export declare const PROVIDER_EFFORT_LEVELS: Record<LaneProvider, readonly string[]>;
|
|
22
|
-
export declare const EFFORT_SOURCES: readonly ["cli", "account", "provider", "default_rule", "none"];
|
|
22
|
+
export declare const EFFORT_SOURCES: readonly ["cli", "profile", "account", "provider", "default_rule", "none"];
|
|
23
23
|
export type EffortSource = (typeof EFFORT_SOURCES)[number];
|
|
24
24
|
export interface ResolvedEffort {
|
|
25
25
|
effort?: string;
|
|
@@ -35,9 +35,10 @@ export declare function parseConfigEffort(provider: LaneProvider, raw: unknown,
|
|
|
35
35
|
* Keyed on the model already chosen for the lane; it does not pick a model.
|
|
36
36
|
*/
|
|
37
37
|
export declare function defaultEffortForModel(model: string | undefined): string | undefined;
|
|
38
|
-
/** Precedence: `--effort`,
|
|
38
|
+
/** Precedence: `--effort`, profile, account, provider, then the default rule. */
|
|
39
39
|
export declare function resolveLaneEffort(input: {
|
|
40
40
|
cliEffort?: string;
|
|
41
|
+
profileEffort?: string;
|
|
41
42
|
accountEffort?: string;
|
|
42
43
|
providerEffort?: string;
|
|
43
44
|
model?: string;
|
package/dist/lib/lanes/effort.js
CHANGED
|
@@ -25,6 +25,7 @@ export const PROVIDER_EFFORT_LEVELS = {
|
|
|
25
25
|
};
|
|
26
26
|
export const EFFORT_SOURCES = [
|
|
27
27
|
"cli",
|
|
28
|
+
"profile",
|
|
28
29
|
"account",
|
|
29
30
|
"provider",
|
|
30
31
|
"default_rule",
|
|
@@ -65,10 +66,13 @@ export function defaultEffortForModel(model) {
|
|
|
65
66
|
}
|
|
66
67
|
return undefined;
|
|
67
68
|
}
|
|
68
|
-
/** Precedence: `--effort`,
|
|
69
|
+
/** Precedence: `--effort`, profile, account, provider, then the default rule. */
|
|
69
70
|
export function resolveLaneEffort(input) {
|
|
70
71
|
if (input.cliEffort !== undefined)
|
|
71
72
|
return { effort: input.cliEffort, source: "cli" };
|
|
73
|
+
if (input.profileEffort !== undefined) {
|
|
74
|
+
return { effort: input.profileEffort, source: "profile" };
|
|
75
|
+
}
|
|
72
76
|
if (input.accountEffort !== undefined) {
|
|
73
77
|
return { effort: input.accountEffort, source: "account" };
|
|
74
78
|
}
|
|
@@ -34,6 +34,6 @@ export declare class AccountResolutionError extends LanesError {
|
|
|
34
34
|
constructor(error: "unknown_account" | "account_disabled" | "no_enabled_account" | "account_model_unavailable" | "account_quota_exhausted", message: string, details?: Record<string, unknown>);
|
|
35
35
|
}
|
|
36
36
|
export declare class WorkerResolutionError extends LanesError {
|
|
37
|
-
constructor(error: "worker_not_found" | "worker_tenancy_unresolved" | "worker_not_available" | "worker_not_active" | "worker_id_conflict" | "package_workers_unmounted" | "worker_registry_unreadable", message: string, details?: Record<string, unknown>);
|
|
37
|
+
constructor(error: "worker_not_found" | "worker_tenancy_unresolved" | "worker_not_available" | "worker_not_active" | "worker_id_conflict" | "worker_profile_invalid" | "worker_profile_ambiguous" | "package_workers_unmounted" | "worker_registry_unreadable", message: string, details?: Record<string, unknown>);
|
|
38
38
|
}
|
|
39
39
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -45,6 +45,8 @@ export type { KnownModels, KnownModelsDeps, ProbeCommand, ProbeResult } from "./
|
|
|
45
45
|
export { SESSION_MOVE_REASON, copySessionFiles, encodeClaudeProjectDir, findCodexRollout, planClaudeSessionCopy, planCodexSessionCopy, planSessionCopy, } from "./move-account.js";
|
|
46
46
|
export type { SessionCopyPlan, SessionCopyResult } from "./move-account.js";
|
|
47
47
|
export { WORKER_REGISTRY_REL, personalWorkerDir, personalWorkerExists, readWorkerRegistryFile, registryPath, resolveContextBasePaths, resolveLaneWorker, resolveSkillFiles, tenancyFromRegistryPath, } from "./registry.js";
|
|
48
|
+
export { ignoredWorkerProfileFields, loadWorkerProfile, materializeWorkerProfile, personalWorkerProfileDir, workerProfileMarker, workerProfilePrompt, } from "./profile.js";
|
|
49
|
+
export type { WorkerProfile, WorkerProfileMeta, WorkerProfileRuntime, } from "./profile.js";
|
|
48
50
|
export type { RegistryWorkerEntry, ResolvedLaneWorker, WorkerTenancy, WorkerYaml, } from "./registry.js";
|
|
49
51
|
export { bodyStoreForLane, envelopeQuestionId, parseQuestionBodyStore, questionBodyAbsPath, questionBodyRef, raiseEnvelopeQuestions, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "./questions.js";
|
|
50
52
|
export type { QuestionBody, RaiseLaneQuestionInput } from "./questions.js";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export { PROVIDER_REJECTED_REASON, PROVIDER_REJECTION_MESSAGE_CAP, parseClaudeRe
|
|
|
25
25
|
export { CODEX_MODELS_CACHE_NAME, MODEL_PROBE_TIMEOUT_MS, claudeKnownModels, codexKnownModels, defaultProbe, grokKnownModels, knownModels, modelIsKnown, parseClaudeHelpModels, parseGrokModelsOutput, validateLaneModel, } from "./models.js";
|
|
26
26
|
export { SESSION_MOVE_REASON, copySessionFiles, encodeClaudeProjectDir, findCodexRollout, planClaudeSessionCopy, planCodexSessionCopy, planSessionCopy, } from "./move-account.js";
|
|
27
27
|
export { WORKER_REGISTRY_REL, personalWorkerDir, personalWorkerExists, readWorkerRegistryFile, registryPath, resolveContextBasePaths, resolveLaneWorker, resolveSkillFiles, tenancyFromRegistryPath, } from "./registry.js";
|
|
28
|
+
export { ignoredWorkerProfileFields, loadWorkerProfile, materializeWorkerProfile, personalWorkerProfileDir, workerProfileMarker, workerProfilePrompt, } from "./profile.js";
|
|
28
29
|
export { bodyStoreForLane, envelopeQuestionId, parseQuestionBodyStore, questionBodyAbsPath, questionBodyRef, raiseEnvelopeQuestions, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "./questions.js";
|
|
29
30
|
export { LANE_DELIVERY_MECHANISMS, LAUNCH_TRIGGERS, PROVIDER_SESSION_RESUME, RESUMABLE_LANE_STATES, RESUME_COMMAND_STATES, SESSION_ID_ABSENT_REASONS, claimableStatesFor, deliveryRefusalReason, hasRecordedSessionId, isResumableLaneState, isResumeCommandState, laneDeliveryMechanism, parseClaudeResultOutput, parseCodexEventOutput, parseWorkerRunOutput, planLaneLaunch, providerSupportsSessionResume, readWorkerRunResult, resumeCommandRefusal, } from "./delivery.js";
|
|
30
31
|
export { DROPBOX_DIR, INBOX_NAME, appendDropboxMessage, answerDropboxMessage, drainDropbox, dropboxDir, dropboxInboxPath, formatOperatorInstruction, hookAdditionalContextJson, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneIdForAnswerDelivery, operatorMessageRefusalReason, readToolActivity, recordToolActivity, runDrainHook, toolActivityPath, TOOL_ACTIVITY_NAME, } from "./dropbox.js";
|
|
@@ -80,11 +80,10 @@ export interface CreateLaneMeshOptions {
|
|
|
80
80
|
token?: () => Promise<string>;
|
|
81
81
|
}
|
|
82
82
|
export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
|
|
83
|
-
/**
|
|
84
|
-
export declare function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
};
|
|
83
|
+
/** Resolve the explicit mesh attribution before falling back to the lane tenant. */
|
|
84
|
+
export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
|
|
85
|
+
/** Company attribution + thread present. Unscoped personal lanes emit nothing. */
|
|
86
|
+
export declare function laneCanEmit(lane: Pick<LaneRecord, "company" | "mesh_company" | "thread_id">): boolean;
|
|
88
87
|
/**
|
|
89
88
|
* Spec §8 terminal-envelope events. `done` → lane_done and story → review;
|
|
90
89
|
* `blocked` → lane_blocked; `ask` → lane_ask with the queue ids. The story is
|
|
@@ -186,9 +186,13 @@ export function createLaneMesh(opts = {}) {
|
|
|
186
186
|
},
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
|
-
/**
|
|
189
|
+
/** Resolve the explicit mesh attribution before falling back to the lane tenant. */
|
|
190
|
+
export function laneMeshCompany(lane) {
|
|
191
|
+
return lane.mesh_company ?? lane.company ?? undefined;
|
|
192
|
+
}
|
|
193
|
+
/** Company attribution + thread present. Unscoped personal lanes emit nothing. */
|
|
190
194
|
export function laneCanEmit(lane) {
|
|
191
|
-
return lane
|
|
195
|
+
return laneMeshCompany(lane) !== undefined && !!lane.thread_id;
|
|
192
196
|
}
|
|
193
197
|
function baseIdentity(lane) {
|
|
194
198
|
const lastMove = lane.account_history?.[lane.account_history.length - 1];
|
|
@@ -209,8 +213,10 @@ function baseIdentity(lane) {
|
|
|
209
213
|
export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
|
|
210
214
|
if (!laneCanEmit(lane))
|
|
211
215
|
return;
|
|
212
|
-
const company = lane
|
|
216
|
+
const company = laneMeshCompany(lane);
|
|
213
217
|
const threadId = lane.thread_id;
|
|
218
|
+
if (!company || !threadId)
|
|
219
|
+
return;
|
|
214
220
|
switch (envelope.decision) {
|
|
215
221
|
case "done": {
|
|
216
222
|
const outcomes = await Promise.allSettled([
|
|
@@ -260,7 +266,11 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
|
|
|
260
266
|
export async function emitLaneProgressEvent(mesh, lane, detail, signal) {
|
|
261
267
|
if (!laneCanEmit(lane))
|
|
262
268
|
return;
|
|
263
|
-
|
|
269
|
+
const company = laneMeshCompany(lane);
|
|
270
|
+
const threadId = lane.thread_id;
|
|
271
|
+
if (!company || !threadId)
|
|
272
|
+
return;
|
|
273
|
+
await mesh.appendThreadEvent(company, threadId, "lane_progress", {
|
|
264
274
|
...baseIdentity(lane),
|
|
265
275
|
detail,
|
|
266
276
|
}, signal);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable lane worker profiles stored under personal/workers/<id>.
|
|
3
|
+
*
|
|
4
|
+
* A profile is deliberately independent from the worker registry. The
|
|
5
|
+
* registry describes a worker persona that can be resolved from the company
|
|
6
|
+
* and package trees; this module describes the provider launch contract that
|
|
7
|
+
* must be re-read for every spawn.
|
|
8
|
+
*/
|
|
9
|
+
import { type LaneProvider } from "./types.js";
|
|
10
|
+
export interface WorkerProfileMeta {
|
|
11
|
+
version?: string;
|
|
12
|
+
provider: LaneProvider;
|
|
13
|
+
model: string;
|
|
14
|
+
account: string;
|
|
15
|
+
effort: string;
|
|
16
|
+
fast_mode: boolean;
|
|
17
|
+
max_concurrent?: number;
|
|
18
|
+
source?: string;
|
|
19
|
+
accounts?: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface WorkerProfile {
|
|
22
|
+
id: string;
|
|
23
|
+
dir: string;
|
|
24
|
+
meta: WorkerProfileMeta;
|
|
25
|
+
systemPrompt: string;
|
|
26
|
+
settings?: Record<string, unknown>;
|
|
27
|
+
policyFiles: string[];
|
|
28
|
+
hookFiles: string[];
|
|
29
|
+
contentHash: string;
|
|
30
|
+
}
|
|
31
|
+
export interface WorkerProfileRuntime {
|
|
32
|
+
settingsPath?: string;
|
|
33
|
+
hooksDir?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function personalWorkerProfileDir(hqRoot: string, id: string): string;
|
|
36
|
+
/** A meta.yaml file is the explicit marker for a personal worker profile. */
|
|
37
|
+
export declare function workerProfileMarker(hqRoot: string, id: string): "absent" | "present";
|
|
38
|
+
export declare function loadWorkerProfile(hqRoot: string, id: string): WorkerProfile;
|
|
39
|
+
export declare function workerProfilePrompt(profile: WorkerProfile): string;
|
|
40
|
+
export declare function ignoredWorkerProfileFields(provider: LaneProvider): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Materialize provider settings and hook files in the lane run directory.
|
|
43
|
+
* The path is intentionally run-local so every fresh, resumed, moved, and
|
|
44
|
+
* repair spawn gets the profile currently on disk without mutating an account
|
|
45
|
+
* home or a shared provider configuration.
|
|
46
|
+
*/
|
|
47
|
+
export declare function materializeWorkerProfile(profile: WorkerProfile, runDir: string): WorkerProfileRuntime;
|
|
48
|
+
//# sourceMappingURL=profile.d.ts.map
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable lane worker profiles stored under personal/workers/<id>.
|
|
3
|
+
*
|
|
4
|
+
* A profile is deliberately independent from the worker registry. The
|
|
5
|
+
* registry describes a worker persona that can be resolved from the company
|
|
6
|
+
* and package trees; this module describes the provider launch contract that
|
|
7
|
+
* must be re-read for every spawn.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
13
|
+
import { WorkerResolutionError } from "./errors.js";
|
|
14
|
+
import { PROVIDER_EFFORT_LEVELS } from "./effort.js";
|
|
15
|
+
import { LANE_PROVIDERS, isLaneProvider, } from "./types.js";
|
|
16
|
+
const PROFILE_META = "meta.yaml";
|
|
17
|
+
const PROFILE_SYSTEM_PROMPT = "system-prompt.md";
|
|
18
|
+
const PROFILE_SETTINGS = "settings.json";
|
|
19
|
+
const PROFILE_POLICIES = "policies";
|
|
20
|
+
const PROFILE_HOOKS = "hooks";
|
|
21
|
+
const PROFILE_META_KEYS = new Set([
|
|
22
|
+
"version",
|
|
23
|
+
"provider",
|
|
24
|
+
"model",
|
|
25
|
+
"account",
|
|
26
|
+
"effort",
|
|
27
|
+
"fast_mode",
|
|
28
|
+
"max_concurrent",
|
|
29
|
+
"source",
|
|
30
|
+
"accounts",
|
|
31
|
+
]);
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function profileError(id, dir, message, details = {}) {
|
|
36
|
+
return new WorkerResolutionError("worker_profile_invalid", `Worker profile ${JSON.stringify(id)} at ${dir}: ${message}`, { worker: id, path: dir, ...details });
|
|
37
|
+
}
|
|
38
|
+
function validProfileId(id) {
|
|
39
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id);
|
|
40
|
+
}
|
|
41
|
+
function lstatOrAbsent(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
return fs.lstatSync(filePath);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const code = err.code;
|
|
47
|
+
if (code === "ENOENT")
|
|
48
|
+
return null;
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function requireRegularFile(id, dir, filePath, field) {
|
|
53
|
+
let stat;
|
|
54
|
+
try {
|
|
55
|
+
stat = fs.lstatSync(filePath);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
throw profileError(id, dir, `cannot read ${field}: ${err instanceof Error ? err.message : String(err)}`, { field, file: filePath });
|
|
59
|
+
}
|
|
60
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
61
|
+
throw profileError(id, dir, `${field} must be a regular file`, {
|
|
62
|
+
field,
|
|
63
|
+
file: filePath,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return stat;
|
|
67
|
+
}
|
|
68
|
+
function readRequiredText(id, dir, filePath, field) {
|
|
69
|
+
requireRegularFile(id, dir, filePath, field);
|
|
70
|
+
try {
|
|
71
|
+
return fs.readFileSync(filePath, "utf8");
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
throw profileError(id, dir, `cannot read ${field}: ${err instanceof Error ? err.message : String(err)}`, { field, file: filePath });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function collectFiles(id, dir, relativeDir) {
|
|
78
|
+
const absoluteDir = path.join(dir, relativeDir);
|
|
79
|
+
const stat = lstatOrAbsent(absoluteDir);
|
|
80
|
+
if (stat === null)
|
|
81
|
+
return [];
|
|
82
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
83
|
+
throw profileError(id, dir, `${relativeDir} must be a directory`, {
|
|
84
|
+
field: relativeDir,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const files = [];
|
|
88
|
+
const visit = (current, currentRelative) => {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
throw profileError(id, dir, `cannot read ${currentRelative}: ${err instanceof Error ? err.message : String(err)}`, { field: currentRelative });
|
|
95
|
+
}
|
|
96
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
97
|
+
const entryRelative = path.posix.join(currentRelative, entry.name);
|
|
98
|
+
const entryPath = path.join(dir, entryRelative);
|
|
99
|
+
if (entry.isSymbolicLink()) {
|
|
100
|
+
throw profileError(id, dir, `${entryRelative} must not be a symlink`, {
|
|
101
|
+
field: entryRelative,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
visit(entryPath, entryRelative);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!entry.isFile()) {
|
|
109
|
+
throw profileError(id, dir, `${entryRelative} must be a regular file`, {
|
|
110
|
+
field: entryRelative,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
files.push(entryRelative);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
visit(absoluteDir, relativeDir);
|
|
117
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
118
|
+
}
|
|
119
|
+
function parseMeta(id, dir, raw) {
|
|
120
|
+
if (!isPlainObject(raw)) {
|
|
121
|
+
throw profileError(id, dir, "meta.yaml must contain an object");
|
|
122
|
+
}
|
|
123
|
+
const unknown = Object.keys(raw).filter((key) => !PROFILE_META_KEYS.has(key));
|
|
124
|
+
if (unknown.length > 0) {
|
|
125
|
+
throw profileError(id, dir, `meta.yaml has unknown field(s): ${unknown.join(", ")}`, {
|
|
126
|
+
fields: unknown,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const provider = raw.provider;
|
|
130
|
+
if (typeof provider !== "string" || !isLaneProvider(provider.trim())) {
|
|
131
|
+
throw profileError(id, dir, "meta.yaml provider must be claude, codex, or grok", {
|
|
132
|
+
field: "provider",
|
|
133
|
+
value: provider,
|
|
134
|
+
accepted: [...LANE_PROVIDERS],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const providerValue = provider.trim();
|
|
138
|
+
const requiredString = (field) => {
|
|
139
|
+
const value = raw[field];
|
|
140
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
141
|
+
throw profileError(id, dir, `meta.yaml ${field} must be a non-empty string`, {
|
|
142
|
+
field,
|
|
143
|
+
value,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return value.trim();
|
|
147
|
+
};
|
|
148
|
+
const model = requiredString("model");
|
|
149
|
+
const account = requiredString("account");
|
|
150
|
+
const effort = requiredString("effort");
|
|
151
|
+
if (!PROVIDER_EFFORT_LEVELS[providerValue].includes(effort)) {
|
|
152
|
+
throw profileError(id, dir, `meta.yaml effort ${JSON.stringify(effort)} is invalid for ${providerValue}`, {
|
|
153
|
+
field: "effort",
|
|
154
|
+
provider: providerValue,
|
|
155
|
+
value: effort,
|
|
156
|
+
accepted: [...PROVIDER_EFFORT_LEVELS[providerValue]],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (typeof raw.fast_mode !== "boolean") {
|
|
160
|
+
throw profileError(id, dir, "meta.yaml fast_mode must be a boolean", {
|
|
161
|
+
field: "fast_mode",
|
|
162
|
+
value: raw.fast_mode,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
let version;
|
|
166
|
+
if (raw.version !== undefined) {
|
|
167
|
+
if (typeof raw.version !== "string" || raw.version.trim().length === 0) {
|
|
168
|
+
throw profileError(id, dir, "meta.yaml version must be a non-empty string", {
|
|
169
|
+
field: "version",
|
|
170
|
+
value: raw.version,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
version = raw.version.trim();
|
|
174
|
+
}
|
|
175
|
+
let maxConcurrent;
|
|
176
|
+
if (raw.max_concurrent !== undefined) {
|
|
177
|
+
if (typeof raw.max_concurrent !== "number" || !Number.isInteger(raw.max_concurrent) || raw.max_concurrent < 1) {
|
|
178
|
+
throw profileError(id, dir, "meta.yaml max_concurrent must be a positive integer", {
|
|
179
|
+
field: "max_concurrent",
|
|
180
|
+
value: raw.max_concurrent,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
maxConcurrent = raw.max_concurrent;
|
|
184
|
+
}
|
|
185
|
+
let source;
|
|
186
|
+
if (raw.source !== undefined) {
|
|
187
|
+
if (typeof raw.source !== "string" || raw.source.trim().length === 0) {
|
|
188
|
+
throw profileError(id, dir, "meta.yaml source must be a non-empty string when present", {
|
|
189
|
+
field: "source",
|
|
190
|
+
value: raw.source,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
source = raw.source.trim();
|
|
194
|
+
}
|
|
195
|
+
let accounts;
|
|
196
|
+
if (raw.accounts !== undefined) {
|
|
197
|
+
if (!Array.isArray(raw.accounts) || raw.accounts.length === 0 || raw.accounts.some((item) => typeof item !== "string" || item.trim().length === 0)) {
|
|
198
|
+
throw profileError(id, dir, "meta.yaml accounts must be a non-empty string array when present", {
|
|
199
|
+
field: "accounts",
|
|
200
|
+
value: raw.accounts,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
accounts = raw.accounts.map((item) => item.trim());
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
...(version !== undefined ? { version } : {}),
|
|
207
|
+
provider: providerValue,
|
|
208
|
+
model,
|
|
209
|
+
account,
|
|
210
|
+
effort,
|
|
211
|
+
fast_mode: raw.fast_mode,
|
|
212
|
+
...(maxConcurrent !== undefined ? { max_concurrent: maxConcurrent } : {}),
|
|
213
|
+
...(source !== undefined ? { source } : {}),
|
|
214
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function readSettings(id, dir) {
|
|
218
|
+
const filePath = path.join(dir, PROFILE_SETTINGS);
|
|
219
|
+
const stat = lstatOrAbsent(filePath);
|
|
220
|
+
if (stat === null)
|
|
221
|
+
return {};
|
|
222
|
+
requireRegularFile(id, dir, filePath, PROFILE_SETTINGS);
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
throw profileError(id, dir, `settings.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, {
|
|
229
|
+
field: PROFILE_SETTINGS,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (!isPlainObject(parsed)) {
|
|
233
|
+
throw profileError(id, dir, "settings.json must contain a JSON object", {
|
|
234
|
+
field: PROFILE_SETTINGS,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { settings: parsed, relative: PROFILE_SETTINGS };
|
|
238
|
+
}
|
|
239
|
+
function hashProfileFiles(dir, relativeFiles) {
|
|
240
|
+
const hash = createHash("sha256");
|
|
241
|
+
for (const relative of relativeFiles.sort((a, b) => a.localeCompare(b))) {
|
|
242
|
+
const file = path.join(dir, relative);
|
|
243
|
+
hash.update(relative);
|
|
244
|
+
hash.update("\0");
|
|
245
|
+
hash.update(fs.readFileSync(file));
|
|
246
|
+
hash.update("\0");
|
|
247
|
+
}
|
|
248
|
+
return `sha256:${hash.digest("hex")}`;
|
|
249
|
+
}
|
|
250
|
+
export function personalWorkerProfileDir(hqRoot, id) {
|
|
251
|
+
return path.join(hqRoot, "personal", "workers", id);
|
|
252
|
+
}
|
|
253
|
+
/** A meta.yaml file is the explicit marker for a personal worker profile. */
|
|
254
|
+
export function workerProfileMarker(hqRoot, id) {
|
|
255
|
+
if (!validProfileId(id))
|
|
256
|
+
return "absent";
|
|
257
|
+
const dirStat = lstatOrAbsent(personalWorkerProfileDir(hqRoot, id));
|
|
258
|
+
if (dirStat === null || dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
259
|
+
return "absent";
|
|
260
|
+
}
|
|
261
|
+
const stat = lstatOrAbsent(path.join(personalWorkerProfileDir(hqRoot, id), PROFILE_META));
|
|
262
|
+
if (stat === null)
|
|
263
|
+
return "absent";
|
|
264
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
265
|
+
return "present";
|
|
266
|
+
return "present";
|
|
267
|
+
}
|
|
268
|
+
export function loadWorkerProfile(hqRoot, id) {
|
|
269
|
+
const dir = personalWorkerProfileDir(hqRoot, id);
|
|
270
|
+
if (!validProfileId(id)) {
|
|
271
|
+
throw profileError(id, dir, "profile id must be a simple directory name", {
|
|
272
|
+
field: "worker",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const dirStat = lstatOrAbsent(dir);
|
|
276
|
+
if (dirStat === null) {
|
|
277
|
+
throw profileError(id, dir, "profile directory does not exist", {
|
|
278
|
+
field: "profile",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
282
|
+
throw profileError(id, dir, "profile directory must be a regular directory", {
|
|
283
|
+
field: "profile",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const metaPath = path.join(dir, PROFILE_META);
|
|
287
|
+
const metaStat = lstatOrAbsent(metaPath);
|
|
288
|
+
if (metaStat === null) {
|
|
289
|
+
throw new WorkerResolutionError("worker_profile_invalid", `Worker profile ${JSON.stringify(id)} is missing ${metaPath}`, { worker: id, path: dir, missing: PROFILE_META });
|
|
290
|
+
}
|
|
291
|
+
const metaText = readRequiredText(id, dir, metaPath, PROFILE_META);
|
|
292
|
+
let metaRaw;
|
|
293
|
+
try {
|
|
294
|
+
metaRaw = parseUserYaml(metaText, metaPath);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
throw profileError(id, dir, `meta.yaml is not valid YAML: ${err instanceof Error ? err.message : String(err)}`, {
|
|
298
|
+
field: PROFILE_META,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const meta = parseMeta(id, dir, metaRaw);
|
|
302
|
+
const systemPromptPath = path.join(dir, PROFILE_SYSTEM_PROMPT);
|
|
303
|
+
const systemPrompt = readRequiredText(id, dir, systemPromptPath, PROFILE_SYSTEM_PROMPT);
|
|
304
|
+
if (systemPrompt.trim().length === 0) {
|
|
305
|
+
throw profileError(id, dir, "system-prompt.md must not be empty", {
|
|
306
|
+
field: PROFILE_SYSTEM_PROMPT,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const settingsResult = readSettings(id, dir);
|
|
310
|
+
const policyFiles = collectFiles(id, dir, PROFILE_POLICIES);
|
|
311
|
+
const hookFiles = collectFiles(id, dir, PROFILE_HOOKS);
|
|
312
|
+
const hashFiles = [
|
|
313
|
+
PROFILE_META,
|
|
314
|
+
PROFILE_SYSTEM_PROMPT,
|
|
315
|
+
...(settingsResult.relative ? [settingsResult.relative] : []),
|
|
316
|
+
...policyFiles,
|
|
317
|
+
...hookFiles,
|
|
318
|
+
];
|
|
319
|
+
return {
|
|
320
|
+
id,
|
|
321
|
+
dir,
|
|
322
|
+
meta,
|
|
323
|
+
systemPrompt,
|
|
324
|
+
...(settingsResult.settings !== undefined ? { settings: settingsResult.settings } : {}),
|
|
325
|
+
policyFiles,
|
|
326
|
+
hookFiles,
|
|
327
|
+
contentHash: hashProfileFiles(dir, hashFiles),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
export function workerProfilePrompt(profile) {
|
|
331
|
+
const parts = [profile.systemPrompt.trimEnd()];
|
|
332
|
+
for (const relative of profile.policyFiles) {
|
|
333
|
+
const content = readRequiredText(profile.id, profile.dir, path.join(profile.dir, relative), relative);
|
|
334
|
+
parts.push(`\n## Profile policy: ${relative}\n${content.trimEnd()}`);
|
|
335
|
+
}
|
|
336
|
+
return parts.join("\n");
|
|
337
|
+
}
|
|
338
|
+
export function ignoredWorkerProfileFields(provider) {
|
|
339
|
+
return provider === "claude" ? [] : ["settings", "hooks"];
|
|
340
|
+
}
|
|
341
|
+
function shellQuote(value) {
|
|
342
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Hooks may be flat (`hooks/check.sh`) or grouped by Claude lifecycle event
|
|
346
|
+
* (`hooks/PreToolUse/check.sh`). Flat hooks run at SessionStart.
|
|
347
|
+
*/
|
|
348
|
+
function profileHookEvent(relative) {
|
|
349
|
+
const parts = relative.split("/");
|
|
350
|
+
return parts.length >= 3 && parts[1] ? parts[1] : "SessionStart";
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Materialize provider settings and hook files in the lane run directory.
|
|
354
|
+
* The path is intentionally run-local so every fresh, resumed, moved, and
|
|
355
|
+
* repair spawn gets the profile currently on disk without mutating an account
|
|
356
|
+
* home or a shared provider configuration.
|
|
357
|
+
*/
|
|
358
|
+
export function materializeWorkerProfile(profile, runDir) {
|
|
359
|
+
const runtimeDir = path.join(runDir, "worker-profile");
|
|
360
|
+
fs.rmSync(runtimeDir, { recursive: true, force: true });
|
|
361
|
+
const hooksDir = profile.hookFiles.length > 0 ? path.join(runtimeDir, PROFILE_HOOKS) : undefined;
|
|
362
|
+
if (hooksDir !== undefined)
|
|
363
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
364
|
+
for (const relative of profile.hookFiles) {
|
|
365
|
+
const destination = path.join(runtimeDir, relative);
|
|
366
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
367
|
+
const source = path.join(profile.dir, relative);
|
|
368
|
+
fs.copyFileSync(source, destination);
|
|
369
|
+
fs.chmodSync(destination, fs.statSync(source).mode & 0o777);
|
|
370
|
+
}
|
|
371
|
+
const settingsPath = profile.settings !== undefined || hooksDir !== undefined
|
|
372
|
+
? path.join(runtimeDir, "settings.json")
|
|
373
|
+
: undefined;
|
|
374
|
+
if (settingsPath !== undefined) {
|
|
375
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
376
|
+
const settings = profile.settings === undefined
|
|
377
|
+
? {}
|
|
378
|
+
: JSON.parse(JSON.stringify(profile.settings));
|
|
379
|
+
if (hooksDir !== undefined) {
|
|
380
|
+
const registeredHooks = isPlainObject(settings.hooks)
|
|
381
|
+
? { ...settings.hooks }
|
|
382
|
+
: {};
|
|
383
|
+
for (const relative of profile.hookFiles) {
|
|
384
|
+
const event = profileHookEvent(relative);
|
|
385
|
+
const eventHooks = Array.isArray(registeredHooks[event])
|
|
386
|
+
? [...registeredHooks[event]]
|
|
387
|
+
: [];
|
|
388
|
+
eventHooks.push({
|
|
389
|
+
hooks: [{
|
|
390
|
+
type: "command",
|
|
391
|
+
command: `bash ${shellQuote(path.join(runtimeDir, relative))}`,
|
|
392
|
+
}],
|
|
393
|
+
});
|
|
394
|
+
registeredHooks[event] = eventHooks;
|
|
395
|
+
}
|
|
396
|
+
settings.hooks = registeredHooks;
|
|
397
|
+
}
|
|
398
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
...(settingsPath !== undefined ? { settingsPath } : {}),
|
|
402
|
+
...(hooksDir !== undefined ? { hooksDir } : {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
//# sourceMappingURL=profile.js.map
|