copillm 0.1.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.
Files changed (38) hide show
  1. package/README.md +52 -0
  2. package/dist/agentconfig/apply.js +53 -0
  3. package/dist/agentconfig/load.js +163 -0
  4. package/dist/agentconfig/markerBlock.js +76 -0
  5. package/dist/agentconfig/render.js +317 -0
  6. package/dist/agentconfig/schema.js +65 -0
  7. package/dist/auth/copilotToken.js +122 -0
  8. package/dist/auth/credentials.js +221 -0
  9. package/dist/auth/deviceFlow.js +89 -0
  10. package/dist/auth/ensureAuthenticated.js +55 -0
  11. package/dist/auth/githubIdentity.js +42 -0
  12. package/dist/auth/interactivePrompt.js +135 -0
  13. package/dist/claude/cache.js +20 -0
  14. package/dist/claude/settingsConflict.js +85 -0
  15. package/dist/cli/agentEnv.js +56 -0
  16. package/dist/cli/configCommands.js +149 -0
  17. package/dist/cli/envBlock.js +43 -0
  18. package/dist/cli/launchAgent.js +59 -0
  19. package/dist/cli/resolveAgent.js +361 -0
  20. package/dist/cli.js +1178 -0
  21. package/dist/codex/init.js +93 -0
  22. package/dist/config/config.js +51 -0
  23. package/dist/config/fsSecurity.js +39 -0
  24. package/dist/config/home.js +62 -0
  25. package/dist/config/logging.js +33 -0
  26. package/dist/config/upstream.js +38 -0
  27. package/dist/models/anthropicDefaults.js +138 -0
  28. package/dist/models/discovery.js +208 -0
  29. package/dist/pi/init.js +174 -0
  30. package/dist/server/anthropicModelsResponse.js +151 -0
  31. package/dist/server/codexSchema.js +100 -0
  32. package/dist/server/debugInfo.js +48 -0
  33. package/dist/server/lock.js +150 -0
  34. package/dist/server/proxy.js +715 -0
  35. package/dist/translation/openaiAnthropic.js +391 -0
  36. package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
  37. package/dist/types/index.js +1 -0
  38. package/package.json +50 -0
@@ -0,0 +1,174 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { CopilotTokenManager } from "../auth/copilotToken.js";
5
+ import { loadStoredCredential } from "../auth/credentials.js";
6
+ import { loadConfig } from "../config/config.js";
7
+ import { listModelsUnion } from "../models/discovery.js";
8
+ import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
9
+ export async function generatePiHome(options) {
10
+ const config = loadConfig();
11
+ const creds = await loadStoredCredential();
12
+ if (!creds) {
13
+ throw new Error("Not authenticated. Run `copillm login` first.");
14
+ }
15
+ const tokenManager = new CopilotTokenManager(creds.token);
16
+ await tokenManager.ensureToken(false);
17
+ const discovery = await listModelsUnion(config.accountType, creds.token, 3);
18
+ const eligible = discovery.models.filter(isPickerEligible);
19
+ // Split the catalog by which upstream endpoint each model supports. Models
20
+ // that advertise `/chat/completions` flow through copillm's Anthropic surface
21
+ // (the proxy translates Anthropic-messages → OpenAI chat completions
22
+ // upstream). Models that only advertise `/responses` (newer GPT-5.x
23
+ // responses-only variants, codex-class models) flow through copillm's
24
+ // `/codex/v1/responses` route and surface in pi as a second provider using
25
+ // pi's `openai-responses` api. A model that supports both endpoints is
26
+ // exposed via the Anthropic-messages path only, so pi's picker doesn't
27
+ // double-list it.
28
+ const anthropicEligible = uniqueByModelId(eligible.filter(supportsChatCompletions));
29
+ const responsesEligible = uniqueByModelId(eligible.filter((m) => !supportsChatCompletions(m) && supportsResponses(m)));
30
+ if (anthropicEligible.length === 0 && responsesEligible.length === 0) {
31
+ throw new Error("No models discovered for pi config.");
32
+ }
33
+ const proxyUrl = `http://127.0.0.1:${options.port}/anthropic`;
34
+ // OpenAI SDK posts to `<baseUrl>/responses`, so the baseUrl must include `/v1`.
35
+ const responsesProxyUrl = `http://127.0.0.1:${options.port}/codex/v1`;
36
+ const providerId = options.providerId.trim().length > 0 ? options.providerId : "copillm";
37
+ const responsesProviderId = `${providerId}-responses`;
38
+ const providers = {};
39
+ if (anthropicEligible.length > 0) {
40
+ providers[providerId] = {
41
+ baseUrl: proxyUrl,
42
+ api: "anthropic-messages",
43
+ apiKey: "copillm-local",
44
+ models: anthropicEligible.map(toPiModelEntry)
45
+ };
46
+ }
47
+ if (responsesEligible.length > 0) {
48
+ providers[responsesProviderId] = {
49
+ baseUrl: responsesProxyUrl,
50
+ api: "openai-responses",
51
+ apiKey: "copillm-local",
52
+ models: responsesEligible.map(toPiModelEntry)
53
+ };
54
+ }
55
+ const payload = { providers };
56
+ const json = `${JSON.stringify(payload, null, 2)}\n`;
57
+ // 1. Write copillm-owned mirror under ~/.copillm/pi/models.json
58
+ const absOutDir = path.resolve(options.outDir);
59
+ ensureSecureDirectory(absOutDir);
60
+ const mirrorPath = path.join(absOutDir, "models.json");
61
+ writeFileSecureAtomic(mirrorPath, json, 0o600);
62
+ // 2. Write the real config pi reads at launch, backing up any pre-existing file.
63
+ const configPath = piModelsJsonPath();
64
+ const backupPath = backupIfMismatch(configPath, json);
65
+ ensureSecureDirectory(path.dirname(configPath));
66
+ writeFileSecureAtomic(configPath, json, 0o600);
67
+ return {
68
+ outDir: absOutDir,
69
+ mirrorPath,
70
+ configPath,
71
+ backupPath,
72
+ modelCount: anthropicEligible.length + responsesEligible.length,
73
+ anthropicModelCount: anthropicEligible.length,
74
+ responsesModelCount: responsesEligible.length,
75
+ proxyUrl,
76
+ responsesProxyUrl
77
+ };
78
+ }
79
+ export function defaultOutputDir(home) {
80
+ return path.join(home, "pi");
81
+ }
82
+ /** Absolute path to `~/.pi/agent/models.json`. Honors $HOME for tests. */
83
+ export function piModelsJsonPath() {
84
+ return path.join(os.homedir(), ".pi", "agent", "models.json");
85
+ }
86
+ /**
87
+ * Eligibility filter shared by both pi providers. Mirrors the gating in
88
+ * `/anthropic/v1/models` and `/codex/v1/models`: a model must be marked
89
+ * picker-enabled by the upstream catalog and its policy must not be disabled.
90
+ * We deliberately do NOT filter by vendor name (so OpenAI- and Gemini-family
91
+ * models surface in pi alongside Anthropic ones); per-endpoint filtering is
92
+ * done by the caller via `supportsChatCompletions` / `supportsResponses`.
93
+ */
94
+ function isPickerEligible(model) {
95
+ if (typeof model.id !== "string" || model.id.length === 0)
96
+ return false;
97
+ if (getNested(model, "model_picker_enabled") !== true)
98
+ return false;
99
+ const policyState = getNested(model, "policy", "state");
100
+ if (typeof policyState === "string" && policyState !== "enabled")
101
+ return false;
102
+ return true;
103
+ }
104
+ function supportsChatCompletions(model) {
105
+ return getEndpointList(model).includes("/chat/completions");
106
+ }
107
+ function supportsResponses(model) {
108
+ return getEndpointList(model).includes("/responses");
109
+ }
110
+ function getEndpointList(model) {
111
+ const raw = model.supported_endpoints;
112
+ return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : [];
113
+ }
114
+ function toPiModelEntry(model) {
115
+ const entry = { id: model.id };
116
+ const contextWindow = getNested(model, "capabilities", "limits", "max_context_window_tokens");
117
+ if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
118
+ entry.contextWindow = contextWindow;
119
+ }
120
+ const maxOutput = getNested(model, "capabilities", "limits", "max_output_tokens");
121
+ if (typeof maxOutput === "number" && Number.isFinite(maxOutput) && maxOutput > 0) {
122
+ entry.maxTokens = maxOutput;
123
+ }
124
+ return entry;
125
+ }
126
+ function uniqueByModelId(models) {
127
+ const seen = new Set();
128
+ const out = [];
129
+ for (const m of models) {
130
+ const id = typeof m.id === "string" ? m.id : "";
131
+ if (id.length === 0 || seen.has(id))
132
+ continue;
133
+ seen.add(id);
134
+ out.push(m);
135
+ }
136
+ return out;
137
+ }
138
+ function getNested(source, ...path) {
139
+ let cur = source;
140
+ for (const key of path) {
141
+ if (cur === null || typeof cur !== "object")
142
+ return undefined;
143
+ cur = cur[key];
144
+ }
145
+ return cur;
146
+ }
147
+ /**
148
+ * If `target` exists and its contents differ from the new payload, copy it
149
+ * aside to a timestamped `.bak`. Returns the backup path, or null when no
150
+ * backup was needed. Best-effort: failures don't block the write — we'd
151
+ * rather configure pi correctly than abort because of a backup error.
152
+ */
153
+ function backupIfMismatch(target, newContent) {
154
+ let existing;
155
+ try {
156
+ existing = fs.readFileSync(target, "utf8");
157
+ }
158
+ catch (error) {
159
+ if (error.code === "ENOENT")
160
+ return null;
161
+ return null;
162
+ }
163
+ if (existing === newContent)
164
+ return null;
165
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
166
+ const backupPath = `${target}.copillm-backup-${stamp}.bak`;
167
+ try {
168
+ fs.copyFileSync(target, backupPath);
169
+ return backupPath;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Claude Code's only client-side marker for a 1M-context-budget model is a
3
+ * literal `[1m]` suffix on the model id (matched in its binary via
4
+ * `id.includes("opus") && id.includes("[1m]")` for opus and
5
+ * `id.includes("sonnet[1m]") || id.includes("sonnet-4-6[1m]")` for sonnet).
6
+ * Without it, Claude Code falls back to its hardcoded 200K per-model cap for
7
+ * unrecognised model ids and auto-compacts conversations well before they
8
+ * approach the model's real conversation budget.
9
+ *
10
+ * Aliasing the advertised id with `[1m]` is the only way to unlock Claude
11
+ * Code's 1M-class autocompact behaviour via the gateway discovery path —
12
+ * the `/anthropic/v1/models` response shape itself has no field for a numeric
13
+ * context window (Claude Code only reads `id` + `display_name`).
14
+ *
15
+ * Request bodies that carry an aliased id are normalised back to the
16
+ * canonical upstream id before being forwarded (see
17
+ * `stripOneMillionAlias` in `src/translation/openaiAnthropic.ts` and the
18
+ * defensive strip in `src/server/proxy.ts`).
19
+ */
20
+ export const ONE_M_ALIAS_SUFFIX = "[1m]";
21
+ const ONE_M_CONTEXT_THRESHOLD_TOKENS = 1_000_000;
22
+ export function buildAnthropicModelsResponse(models) {
23
+ const filtered = models.filter(isAnthropicSurfaceEligible);
24
+ const nowIso = new Date().toISOString();
25
+ const data = filtered.map((model) => ({
26
+ type: "model",
27
+ id: applyOneMillionAlias(model),
28
+ display_name: extractDisplayName(model),
29
+ created_at: extractCreatedAt(model) ?? nowIso
30
+ }));
31
+ return {
32
+ data,
33
+ has_more: false,
34
+ first_id: data.length > 0 ? data[0].id : null,
35
+ last_id: data.length > 0 ? data[data.length - 1].id : null
36
+ };
37
+ }
38
+ /**
39
+ * Append `[1m]` to a model id when (and only when) the upstream catalog
40
+ * reports `capabilities.limits.max_context_window_tokens >= 1_000_000` AND
41
+ * the id contains `opus`.
42
+ *
43
+ * Claude Code's 1M-tier matchers (extracted from its binary) only fire for
44
+ * opus models:
45
+ *
46
+ * dN3(id) = !... && !... && id.toLowerCase().includes("opus") && id.toLowerCase().includes("[1m]")
47
+ *
48
+ * The sonnet matcher (`cN3`) requires the literal substring `sonnet[1m]` or
49
+ * `sonnet-4-6[1m]`, which doesn't fit copillm-aliased ids (an aliased
50
+ * sonnet would end in `[1m]` but its base would not contain a contiguous
51
+ * `sonnet[1m]` substring), so a `[1m]` suffix on a sonnet wouldn't unlock
52
+ * the 1M cap anyway. And no non-Claude vendor (gpt, gemini, ...) has any
53
+ * `[1m]` matcher at all.
54
+ *
55
+ * Restricting the alias to opus avoids showing a misleading `[1m]` next to
56
+ * a non-opus model (e.g. a gpt or gemini variant) in Claude Code's `/model`
57
+ * picker — a label that would imply 1M-class behaviour Claude Code would
58
+ * never deliver for that vendor.
59
+ *
60
+ * Models already carrying the suffix are left alone. Models below the 1M
61
+ * threshold get no alias regardless of name — Claude Code has no
62
+ * client-side marker for the 200K-1M intermediate range, and over-claiming
63
+ * would set the wrong autocompact trigger.
64
+ */
65
+ export function applyOneMillionAlias(model) {
66
+ const baseId = typeof model.id === "string" ? model.id : "";
67
+ if (baseId.endsWith(ONE_M_ALIAS_SUFFIX)) {
68
+ return baseId;
69
+ }
70
+ if (!baseId.toLowerCase().includes("opus")) {
71
+ return baseId;
72
+ }
73
+ const maxContext = getNested(model, "capabilities", "limits", "max_context_window_tokens");
74
+ if (typeof maxContext !== "number" || !Number.isFinite(maxContext)) {
75
+ return baseId;
76
+ }
77
+ if (maxContext < ONE_M_CONTEXT_THRESHOLD_TOKENS) {
78
+ return baseId;
79
+ }
80
+ return `${baseId}${ONE_M_ALIAS_SUFFIX}`;
81
+ }
82
+ /**
83
+ * Decide which upstream Copilot models should appear in `/anthropic/v1/models`.
84
+ *
85
+ * The proxy's Anthropic surface (`/anthropic/v1/messages`) already translates
86
+ * Anthropic-shape requests into OpenAI `/chat/completions` calls upstream and
87
+ * translates responses back — see src/server/proxy.ts line ~234, which sends
88
+ * EVERY Anthropic-surface request to `/chat/completions` regardless of the
89
+ * model's vendor. The historical filter was a regex on model id matching
90
+ * `^(claude|anthropic)` which silently dropped Gemini, gpt-4.1, and the
91
+ * gpt-5.x family from the Claude Code model picker even though the translation
92
+ * pipeline already handled them end-to-end.
93
+ *
94
+ * Gate eligibility on *capability* instead of vendor naming:
95
+ * 1. `model_picker_enabled === true` — upstream marks the model as user-pickable.
96
+ * 2. `policy.state` is "enabled" or absent — upstream policy doesn't disable it.
97
+ * 3. `supported_endpoints` includes `/chat/completions` — the upstream actually
98
+ * speaks the protocol the proxy translates against.
99
+ *
100
+ * Models that fail any one of these don't appear in the picker. Whether a model
101
+ * that DOES pass actually returns 2xx for a translated Anthropic request is a
102
+ * separate concern (some gpt-5.x reasoning models reject the default
103
+ * chat-completions body shape and 400 at runtime); that's surfaced as an
104
+ * upstream error per request, not hidden at the catalog level.
105
+ */
106
+ export function isAnthropicSurfaceEligible(model) {
107
+ if (typeof model.id !== "string" || model.id.length === 0) {
108
+ return false;
109
+ }
110
+ const pickerEnabled = getNested(model, "model_picker_enabled") === true;
111
+ if (!pickerEnabled) {
112
+ return false;
113
+ }
114
+ const policyState = getNested(model, "policy", "state");
115
+ if (policyState !== undefined && policyState !== "enabled") {
116
+ return false;
117
+ }
118
+ const supportedEndpoints = getNested(model, "supported_endpoints");
119
+ if (!Array.isArray(supportedEndpoints) || !supportedEndpoints.includes("/chat/completions")) {
120
+ return false;
121
+ }
122
+ return true;
123
+ }
124
+ function getNested(obj, ...keys) {
125
+ let current = obj;
126
+ for (const key of keys) {
127
+ if (!current || typeof current !== "object") {
128
+ return undefined;
129
+ }
130
+ current = current[key];
131
+ }
132
+ return current;
133
+ }
134
+ function extractDisplayName(model) {
135
+ const candidate = model.name;
136
+ if (typeof candidate === "string" && candidate.length > 0) {
137
+ return candidate;
138
+ }
139
+ const displayCandidate = model.display_name;
140
+ if (typeof displayCandidate === "string" && displayCandidate.length > 0) {
141
+ return displayCandidate;
142
+ }
143
+ return model.id;
144
+ }
145
+ function extractCreatedAt(model) {
146
+ const candidate = model.created_at;
147
+ if (typeof candidate === "string" && candidate.length > 0) {
148
+ return candidate;
149
+ }
150
+ return null;
151
+ }
@@ -0,0 +1,100 @@
1
+ const VALID_REASONING_EFFORTS = new Set([
2
+ "none",
3
+ "minimal",
4
+ "low",
5
+ "medium",
6
+ "high",
7
+ "xhigh"
8
+ ]);
9
+ function toReasoningEffort(value) {
10
+ if (typeof value === "string" && VALID_REASONING_EFFORTS.has(value)) {
11
+ return value;
12
+ }
13
+ return "medium";
14
+ }
15
+ function getNested(obj, ...keys) {
16
+ let current = obj;
17
+ for (const key of keys) {
18
+ if (!current || typeof current !== "object") {
19
+ return undefined;
20
+ }
21
+ current = current[key];
22
+ }
23
+ return current;
24
+ }
25
+ export function isCodexEligible(model) {
26
+ const pickerEnabled = getNested(model, "model_picker_enabled") === true;
27
+ if (!pickerEnabled) {
28
+ return false;
29
+ }
30
+ const policyState = getNested(model, "policy", "state");
31
+ if (policyState !== undefined && policyState !== "enabled") {
32
+ return false;
33
+ }
34
+ const supportedEndpoints = getNested(model, "supported_endpoints");
35
+ if (!Array.isArray(supportedEndpoints) || !supportedEndpoints.includes("/responses")) {
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+ export function mapCopilotModelToCodex(model) {
41
+ const supportsReasoning = getNested(model, "capabilities", "supports", "reasoning_effort");
42
+ const reasoningArray = Array.isArray(supportsReasoning) ? supportsReasoning : ["medium"];
43
+ const supportedLevels = reasoningArray.map((effort) => ({
44
+ effort: toReasoningEffort(effort),
45
+ description: String(effort)
46
+ }));
47
+ const contextWindow = getNested(model, "capabilities", "limits", "max_context_window_tokens") ?? null;
48
+ const parallelTools = getNested(model, "capabilities", "supports", "parallel_tool_calls") === true;
49
+ const pickerEnabled = getNested(model, "model_picker_enabled") === true;
50
+ const vendor = getNested(model, "vendor") ?? "Unknown";
51
+ const displayName = getNested(model, "name") ?? model.id;
52
+ // Derive vision support from the upstream Copilot capability flag rather
53
+ // than advertising image input for every model. Models without
54
+ // `capabilities.supports.vision === true` (e.g. gpt-3.5-turbo, gpt-4-0613,
55
+ // gpt-4o-mini) would 400 on image content if we claimed otherwise.
56
+ // `capabilities.limits.vision` (when present) carries per-model image
57
+ // budgets — image count, byte size, allowed media types. We don't surface
58
+ // those limits in Codex's schema (it has no field for them today), but the
59
+ // boolean gate alone is enough to prevent advertising image_modality on
60
+ // text-only models.
61
+ const supportsVision = getNested(model, "capabilities", "supports", "vision") === true;
62
+ return {
63
+ slug: model.id,
64
+ display_name: displayName,
65
+ description: `${vendor} model: ${model.id}`,
66
+ default_reasoning_level: toReasoningEffort(reasoningArray[0] ?? "medium"),
67
+ supported_reasoning_levels: supportedLevels,
68
+ shell_type: "default",
69
+ visibility: pickerEnabled ? "list" : "hide",
70
+ supported_in_api: true,
71
+ priority: 0,
72
+ additional_speed_tiers: [],
73
+ service_tiers: [],
74
+ availability_nux: null,
75
+ upgrade: null,
76
+ base_instructions: "",
77
+ model_messages: null,
78
+ supports_reasoning_summaries: false,
79
+ default_reasoning_summary: "none",
80
+ support_verbosity: false,
81
+ default_verbosity: null,
82
+ apply_patch_tool_type: null,
83
+ web_search_tool_type: "text",
84
+ truncation_policy: { mode: "bytes", limit: 10_000 },
85
+ supports_parallel_tool_calls: parallelTools,
86
+ supports_image_detail_original: supportsVision,
87
+ context_window: contextWindow,
88
+ max_context_window: contextWindow,
89
+ auto_compact_token_limit: null,
90
+ effective_context_window_percent: 95,
91
+ experimental_supported_tools: [],
92
+ input_modalities: supportsVision ? ["text", "image"] : ["text"],
93
+ supports_search_tool: false
94
+ };
95
+ }
96
+ export function buildCodexCatalog(models) {
97
+ return {
98
+ models: models.filter(isCodexEligible).map(mapCopilotModelToCodex)
99
+ };
100
+ }
@@ -0,0 +1,48 @@
1
+ import { githubUserUrl } from "../config/upstream.js";
2
+ const CACHE_TTL_MS = 5 * 60 * 1_000;
3
+ let cached = null;
4
+ export async function getGithubUserSummary(githubToken, options = {}) {
5
+ const now = Date.now();
6
+ if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
7
+ return cached.summary;
8
+ }
9
+ const response = await fetch(githubUserUrl(), {
10
+ headers: {
11
+ Authorization: `token ${githubToken}`,
12
+ Accept: "application/vnd.github+json",
13
+ "User-Agent": "copillm/0.1.0",
14
+ "X-GitHub-Api-Version": "2022-11-28"
15
+ },
16
+ signal: typeof options.timeoutMs === "number" ? AbortSignal.timeout(options.timeoutMs) : undefined
17
+ });
18
+ if (!response.ok) {
19
+ const detail = await response.text();
20
+ throw new GithubUserFetchError(response.status, detail.slice(0, 256));
21
+ }
22
+ const payload = (await response.json());
23
+ const summary = {
24
+ login: typeof payload.login === "string" ? payload.login : "",
25
+ id: typeof payload.id === "number" ? payload.id : 0,
26
+ name: typeof payload.name === "string" ? payload.name : null,
27
+ email: typeof payload.email === "string" ? payload.email : null,
28
+ type: typeof payload.type === "string" ? payload.type : "User",
29
+ avatar_url: typeof payload.avatar_url === "string" ? payload.avatar_url : null,
30
+ html_url: typeof payload.html_url === "string" ? payload.html_url : null,
31
+ plan_name: typeof payload.plan?.name === "string" ? payload.plan.name : null
32
+ };
33
+ cached = { fetchedAt: now, summary };
34
+ return summary;
35
+ }
36
+ export function clearGithubUserCache() {
37
+ cached = null;
38
+ }
39
+ export class GithubUserFetchError extends Error {
40
+ status;
41
+ bodySnippet;
42
+ constructor(status, bodySnippet) {
43
+ super(`GitHub user lookup failed (${status}).`);
44
+ this.status = status;
45
+ this.bodySnippet = bodySnippet;
46
+ this.name = "GithubUserFetchError";
47
+ }
48
+ }
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs";
2
+ import { readFileSync } from "node:fs";
3
+ import { getCopillmHome, lockPath, lockReadPath } from "../config/home.js";
4
+ import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
5
+ export class LockAlreadyRunningError extends Error {
6
+ lock;
7
+ constructor(lock) {
8
+ super(`copillm is already running (pid ${lock.pid}, port ${lock.port}).`);
9
+ this.lock = lock;
10
+ this.name = "LockAlreadyRunningError";
11
+ }
12
+ }
13
+ export async function acquireLock(port, options) {
14
+ await acquireLockWithRetry(port, options, false);
15
+ }
16
+ async function acquireLockWithRetry(port, options, alreadyRetried) {
17
+ const file = lockPath();
18
+ ensureSecureDirectory(getCopillmHome());
19
+ const data = {
20
+ pid: process.pid,
21
+ port,
22
+ started_at_iso: new Date().toISOString()
23
+ };
24
+ try {
25
+ const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
26
+ fs.closeSync(fd);
27
+ writeFileSecureAtomic(file, JSON.stringify(data, null, 2), 0o600);
28
+ return;
29
+ }
30
+ catch (error) {
31
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
32
+ throw error;
33
+ }
34
+ }
35
+ const inspection = inspectLock();
36
+ if (inspection.state === "running") {
37
+ if (options?.isRunning && (await options.isRunning(inspection.lock))) {
38
+ throw new LockAlreadyRunningError(inspection.lock);
39
+ }
40
+ if (alreadyRetried) {
41
+ throw new Error("Unable to acquire lock after removing stale lock.");
42
+ }
43
+ tryUnlinkLock();
44
+ await acquireLockWithRetry(port, options, true);
45
+ return;
46
+ }
47
+ if (alreadyRetried) {
48
+ const reason = inspection.state === "stale" ? inspection.reason : "lock_exists";
49
+ throw new Error(`Unable to acquire lock: ${reason}`);
50
+ }
51
+ tryUnlinkLock();
52
+ await acquireLockWithRetry(port, options, true);
53
+ }
54
+ export function releaseLock() {
55
+ tryUnlinkLock();
56
+ }
57
+ export function inspectLock() {
58
+ const file = lockReadPath();
59
+ if (!fs.existsSync(file)) {
60
+ return { state: "missing" };
61
+ }
62
+ let raw;
63
+ try {
64
+ raw = readFileSync(file, "utf8");
65
+ }
66
+ catch (error) {
67
+ return { state: "stale", reason: `failed_to_read_lock: ${errorMessage(error)}`, lock: null };
68
+ }
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ return { state: "stale", reason: "lock_json_invalid", lock: null };
75
+ }
76
+ const lock = parseLockFileData(parsed);
77
+ if (!lock) {
78
+ return { state: "stale", reason: "lock_schema_invalid", lock: null };
79
+ }
80
+ const alive = processAlive(lock.pid);
81
+ if (!alive) {
82
+ return { state: "stale", reason: "pid_not_alive", lock };
83
+ }
84
+ return { state: "running", lock };
85
+ }
86
+ export function readLock() {
87
+ const inspection = inspectLock();
88
+ if (inspection.state !== "running") {
89
+ return null;
90
+ }
91
+ return inspection.lock;
92
+ }
93
+ function processAlive(pid) {
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ }
98
+ catch (error) {
99
+ if (!(error instanceof Error) || !("code" in error)) {
100
+ throw error;
101
+ }
102
+ const code = error.code;
103
+ if (code === "ESRCH") {
104
+ return false;
105
+ }
106
+ if (code === "EPERM") {
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ }
112
+ function tryUnlinkLock() {
113
+ const file = lockPath();
114
+ if (!fs.existsSync(file)) {
115
+ return;
116
+ }
117
+ try {
118
+ fs.unlinkSync(file);
119
+ }
120
+ catch (error) {
121
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
122
+ throw error;
123
+ }
124
+ }
125
+ }
126
+ function parseLockFileData(input) {
127
+ if (!input || typeof input !== "object") {
128
+ return null;
129
+ }
130
+ const obj = input;
131
+ if (typeof obj.pid !== "number" ||
132
+ !Number.isInteger(obj.pid) ||
133
+ obj.pid <= 0 ||
134
+ typeof obj.port !== "number" ||
135
+ !Number.isInteger(obj.port) ||
136
+ obj.port < 1 ||
137
+ obj.port > 65535 ||
138
+ typeof obj.started_at_iso !== "string" ||
139
+ obj.started_at_iso.length === 0) {
140
+ return null;
141
+ }
142
+ return {
143
+ pid: obj.pid,
144
+ port: obj.port,
145
+ started_at_iso: obj.started_at_iso
146
+ };
147
+ }
148
+ function errorMessage(error) {
149
+ return error instanceof Error ? error.message : "unknown_error";
150
+ }