@n1creator/openacp-cli 2026.713.1 → 2026.713.2

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/dist/index.js CHANGED
@@ -458,22 +458,22 @@ __export(config_registry_exports, {
458
458
  resolveOptions: () => resolveOptions,
459
459
  setFieldValueAsync: () => setFieldValueAsync
460
460
  });
461
- function getFieldDef(path39) {
462
- return CONFIG_REGISTRY.find((f) => f.path === path39);
461
+ function getFieldDef(path42) {
462
+ return CONFIG_REGISTRY.find((f) => f.path === path42);
463
463
  }
464
464
  function getSafeFields() {
465
465
  return CONFIG_REGISTRY.filter((f) => f.scope === "safe");
466
466
  }
467
- function isHotReloadable(path39) {
468
- const def = getFieldDef(path39);
467
+ function isHotReloadable(path42) {
468
+ const def = getFieldDef(path42);
469
469
  return def?.hotReload ?? false;
470
470
  }
471
471
  function resolveOptions(def, config) {
472
472
  if (!def.options) return void 0;
473
473
  return typeof def.options === "function" ? def.options(config) : def.options;
474
474
  }
475
- function getConfigValue(config, path39) {
476
- const parts = path39.split(".");
475
+ function getConfigValue(config, path42) {
476
+ const parts = path42.split(".");
477
477
  let current = config;
478
478
  for (const part of parts) {
479
479
  if (current && typeof current === "object" && part in current) {
@@ -1624,27 +1624,27 @@ function validateExtractedPaths(destDir) {
1624
1624
  }
1625
1625
  }
1626
1626
  async function extractTarGz(buffer, destDir) {
1627
- const { execFileSync: execFileSync8 } = await import("child_process");
1627
+ const { execFileSync: execFileSync9 } = await import("child_process");
1628
1628
  const tmpFile = path10.join(destDir, "_archive.tar.gz");
1629
1629
  fs11.writeFileSync(tmpFile, buffer);
1630
1630
  try {
1631
- const listing = execFileSync8("tar", ["tf", tmpFile], { stdio: "pipe" }).toString().trim().split("\n").filter(Boolean);
1631
+ const listing = execFileSync9("tar", ["tf", tmpFile], { stdio: "pipe" }).toString().trim().split("\n").filter(Boolean);
1632
1632
  validateArchiveContents(listing, destDir);
1633
- execFileSync8("tar", ["xzf", tmpFile, "-C", destDir], { stdio: "pipe" });
1633
+ execFileSync9("tar", ["xzf", tmpFile, "-C", destDir], { stdio: "pipe" });
1634
1634
  } finally {
1635
1635
  fs11.unlinkSync(tmpFile);
1636
1636
  }
1637
1637
  validateExtractedPaths(destDir);
1638
1638
  }
1639
1639
  async function extractZip(buffer, destDir) {
1640
- const { execFileSync: execFileSync8 } = await import("child_process");
1640
+ const { execFileSync: execFileSync9 } = await import("child_process");
1641
1641
  const tmpFile = path10.join(destDir, "_archive.zip");
1642
1642
  fs11.writeFileSync(tmpFile, buffer);
1643
1643
  try {
1644
- const listing = execFileSync8("unzip", ["-l", tmpFile], { stdio: "pipe" }).toString().trim().split("\n").filter(Boolean);
1644
+ const listing = execFileSync9("unzip", ["-l", tmpFile], { stdio: "pipe" }).toString().trim().split("\n").filter(Boolean);
1645
1645
  const entries = listing.slice(3, -2).map((line) => line.trim().split(/\s+/).slice(3).join(" ")).filter(Boolean);
1646
1646
  validateArchiveContents(entries, destDir);
1647
- execFileSync8("unzip", ["-o", tmpFile, "-d", destDir], { stdio: "pipe" });
1647
+ execFileSync9("unzip", ["-o", tmpFile, "-d", destDir], { stdio: "pipe" });
1648
1648
  } finally {
1649
1649
  fs11.unlinkSync(tmpFile);
1650
1650
  }
@@ -2684,25 +2684,50 @@ function mimeToExt(mimeType) {
2684
2684
  };
2685
2685
  return map[mimeType] || ".bin";
2686
2686
  }
2687
- var GROQ_API_URL, GroqSTT;
2687
+ var GROQ_API_URL, GROQ_MODELS_URL, GroqSTT;
2688
2688
  var init_groq = __esm({
2689
2689
  "src/plugins/speech/providers/groq.ts"() {
2690
2690
  "use strict";
2691
2691
  GROQ_API_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
2692
+ GROQ_MODELS_URL = "https://api.groq.com/openai/v1/models";
2692
2693
  GroqSTT = class {
2693
- constructor(apiKey, defaultModel = "whisper-large-v3-turbo", scopedFetch = globalThis.fetch, getScopedFetch, endpoint = GROQ_API_URL) {
2694
+ constructor(apiKey, defaultModel = "whisper-large-v3-turbo", scopedFetch = globalThis.fetch, getScopedFetch, endpoint = GROQ_API_URL, accessEndpoint = GROQ_MODELS_URL) {
2694
2695
  this.apiKey = apiKey;
2695
2696
  this.defaultModel = defaultModel;
2696
2697
  this.scopedFetch = scopedFetch;
2697
2698
  this.getScopedFetch = getScopedFetch;
2698
2699
  this.endpoint = endpoint;
2700
+ this.accessEndpoint = accessEndpoint;
2699
2701
  }
2700
2702
  apiKey;
2701
2703
  defaultModel;
2702
2704
  scopedFetch;
2703
2705
  getScopedFetch;
2704
2706
  endpoint;
2707
+ accessEndpoint;
2705
2708
  name = "groq";
2709
+ /** Validate credentials without sending audio or exposing a provider response body. */
2710
+ async checkAccess(signal) {
2711
+ let response;
2712
+ try {
2713
+ response = await (this.getScopedFetch?.() ?? this.scopedFetch)(this.accessEndpoint, {
2714
+ method: "GET",
2715
+ headers: { Authorization: `Bearer ${this.apiKey}` },
2716
+ signal
2717
+ });
2718
+ } catch {
2719
+ return { ok: false, message: "Could not reach Groq through the configured speech route." };
2720
+ }
2721
+ try {
2722
+ await response.body?.cancel();
2723
+ } catch {
2724
+ }
2725
+ if (response.ok) return { ok: true, status: response.status, message: "Groq accepted the API key." };
2726
+ if (response.status === 401) return { ok: false, status: 401, message: "Groq rejected the API key." };
2727
+ if (response.status === 403) return { ok: false, status: 403, message: "The API key does not have access to Groq transcription." };
2728
+ if (response.status === 429) return { ok: false, status: 429, message: "Groq temporarily rate-limited the access check. Try again later." };
2729
+ return { ok: false, status: response.status, message: `Groq access check failed (HTTP ${response.status}).` };
2730
+ }
2706
2731
  /**
2707
2732
  * Transcribes audio using the Groq Whisper API.
2708
2733
  *
@@ -2724,7 +2749,10 @@ var init_groq = __esm({
2724
2749
  body: form
2725
2750
  });
2726
2751
  if (!resp.ok) {
2727
- const body = await resp.text();
2752
+ try {
2753
+ await resp.body?.cancel();
2754
+ } catch {
2755
+ }
2728
2756
  if (resp.status === 401) {
2729
2757
  throw new Error("Invalid Groq API key. Check your key at console.groq.com.");
2730
2758
  }
@@ -2734,7 +2762,7 @@ var init_groq = __esm({
2734
2762
  if (resp.status === 429) {
2735
2763
  throw new Error("Groq rate limit exceeded. Free tier: 28,800 seconds/day. Try again later.");
2736
2764
  }
2737
- throw new Error(`Groq STT error (${resp.status}): ${body}`);
2765
+ throw new Error(`Groq transcription failed (HTTP ${resp.status}). Check Groq status and run /speech \u2192 Check setup.`);
2738
2766
  }
2739
2767
  const data = await resp.json();
2740
2768
  return {
@@ -3202,6 +3230,20 @@ function safeError(error) {
3202
3230
  const message = error instanceof Error ? error.message : "Proxy request failed";
3203
3231
  return new Error(redactNetworkSecrets(message));
3204
3232
  }
3233
+ async function approvedTargetPreflight(fetcher) {
3234
+ let response;
3235
+ try {
3236
+ response = await fetcher(PROXY_CONNECTIVITY_TEST_URL, {
3237
+ signal: AbortSignal.timeout(1e4)
3238
+ });
3239
+ if (!response.ok) throw new Error(`Connectivity check returned HTTP ${response.status}`);
3240
+ } finally {
3241
+ try {
3242
+ if (response?.body) await response.arrayBuffer();
3243
+ } catch {
3244
+ }
3245
+ }
3246
+ }
3205
3247
  function normalizeResponse(response) {
3206
3248
  if (response instanceof Response) return response;
3207
3249
  const body = response.body ? Readable.toWeb(response.body) : null;
@@ -3234,7 +3276,7 @@ function copyResponse(response, body) {
3234
3276
  }
3235
3277
  return copy;
3236
3278
  }
3237
- var enabledDebug, PROXY_ENV_KEYS, ProxyValidationError, ProxyUnknownScopeError, ProxyProfileInUseError, ProxyProfileNotFoundError, ProxyProfileExistsError, ProxyRouteTestError, ProxyProfileTestError, ProxyService;
3279
+ var enabledDebug, PROXY_ENV_KEYS, PROXY_CONNECTIVITY_TEST_URL, ProxyValidationError, ProxyUnknownScopeError, ProxyProfileInUseError, ProxyProfileNotFoundError, ProxyProfileExistsError, ProxyRouteTestError, ProxyProfileTestError, ProxyService;
3238
3280
  var init_proxy_service = __esm({
3239
3281
  "src/core/network/proxy-service.ts"() {
3240
3282
  "use strict";
@@ -3255,6 +3297,7 @@ var init_proxy_service = __esm({
3255
3297
  "no_proxy",
3256
3298
  "NODE_USE_ENV_PROXY"
3257
3299
  ];
3300
+ PROXY_CONNECTIVITY_TEST_URL = "https://api.ipify.org?format=json";
3258
3301
  ProxyValidationError = class extends Error {
3259
3302
  constructor(message, code = "PROXY_VALIDATION_ERROR") {
3260
3303
  super(message);
@@ -3300,9 +3343,10 @@ var init_proxy_service = __esm({
3300
3343
  }
3301
3344
  };
3302
3345
  ProxyService = class {
3303
- constructor(instanceRoot, retiredLeaseTimeoutMs = 5 * 6e4, allowedNodeEnvironmentFlags = process.allowedNodeEnvironmentFlags) {
3346
+ constructor(instanceRoot, retiredLeaseTimeoutMs = 5 * 6e4, allowedNodeEnvironmentFlags = process.allowedNodeEnvironmentFlags, connectivityPreflight = approvedTargetPreflight) {
3304
3347
  this.retiredLeaseTimeoutMs = retiredLeaseTimeoutMs;
3305
3348
  this.allowedNodeEnvironmentFlags = allowedNodeEnvironmentFlags;
3349
+ this.connectivityPreflight = connectivityPreflight;
3306
3350
  this.store = new ProxyStore(instanceRoot);
3307
3351
  try {
3308
3352
  const config = this.store.load();
@@ -3312,6 +3356,7 @@ var init_proxy_service = __esm({
3312
3356
  }
3313
3357
  retiredLeaseTimeoutMs;
3314
3358
  allowedNodeEnvironmentFlags;
3359
+ connectivityPreflight;
3315
3360
  store;
3316
3361
  scopes = /* @__PURE__ */ new Set([
3317
3362
  "channels.default",
@@ -3492,16 +3537,15 @@ var init_proxy_service = __esm({
3492
3537
  const { profile: candidate, nextSecrets } = this.buildProfile(input2, config, secrets);
3493
3538
  const affectedScopes = this.scopesUsingProfile(config, input2.id);
3494
3539
  const candidateSecret = nextSecrets[input2.id];
3495
- for (const [scope, tester] of this.testers) {
3496
- if (this.resolve(scope).profile?.id === input2.id) {
3497
- const fetcher = this.createProfileFetch(candidate, candidateSecret);
3498
- try {
3499
- await tester(fetcher);
3500
- } catch (error) {
3501
- throw new ProxyProfileTestError(input2.id, error);
3502
- } finally {
3503
- fetcher.destroy?.();
3504
- }
3540
+ if (affectedScopes.length) {
3541
+ const fetcher = this.createProfileFetch(candidate, candidateSecret);
3542
+ try {
3543
+ await this.connectivityPreflight(fetcher);
3544
+ for (const scope of affectedScopes) await this.testers.get(scope)?.(fetcher);
3545
+ } catch (error) {
3546
+ throw new ProxyProfileTestError(input2.id, error);
3547
+ } finally {
3548
+ fetcher.destroy?.();
3505
3549
  }
3506
3550
  }
3507
3551
  this.invalidatePolicyBeforeCommit(config, input2.id);
@@ -3785,7 +3829,7 @@ var init_proxy_service = __esm({
3785
3829
  env.NO_PROXY = env.no_proxy = noProxy;
3786
3830
  return env;
3787
3831
  }
3788
- async test(scope, targetUrl = "https://api.ipify.org?format=json") {
3832
+ async test(scope, targetUrl = PROXY_CONNECTIVITY_TEST_URL) {
3789
3833
  if (!this.getKnownScopes().includes(scope)) throw new ProxyUnknownScopeError(scope);
3790
3834
  let response;
3791
3835
  try {
@@ -3806,7 +3850,7 @@ var init_proxy_service = __esm({
3806
3850
  let response;
3807
3851
  try {
3808
3852
  fetcher = this.createTransport("services.proxyTest", `profile:${id}`);
3809
- response = await fetcher(targetUrl ?? "https://api.ipify.org?format=json", {
3853
+ response = await fetcher(targetUrl ?? PROXY_CONNECTIVITY_TEST_URL, {
3810
3854
  signal: AbortSignal.timeout(1e4)
3811
3855
  });
3812
3856
  return { ok: response.ok, status: response.status };
@@ -3828,7 +3872,7 @@ var init_proxy_service = __esm({
3828
3872
  const fetcher = this.createProfileFetch(profile, nextSecrets[profile.id]);
3829
3873
  let response;
3830
3874
  try {
3831
- response = await fetcher(targetUrl ?? "https://api.ipify.org?format=json", {
3875
+ response = await fetcher(targetUrl ?? PROXY_CONNECTIVITY_TEST_URL, {
3832
3876
  signal: AbortSignal.timeout(1e4)
3833
3877
  });
3834
3878
  return { ok: response.ok, status: response.status };
@@ -3880,19 +3924,26 @@ var init_proxy_service = __esm({
3880
3924
  if (!id || !this.getProfile(id)) throw new ProxyValidationError(`Proxy profile "${id ?? route}" does not exist`);
3881
3925
  }
3882
3926
  async testCandidateRoutes(current, candidate) {
3883
- for (const [scope, tester] of this.testers) {
3884
- const before = this.resolveFromConfig(scope, current);
3927
+ const groups = /* @__PURE__ */ new Map();
3928
+ for (const scope of this.changedResolutionScopes(current, candidate)) {
3885
3929
  const after = this.resolveFromConfig(scope, candidate);
3886
- if (before.route !== after.route || before.profile?.id !== after.profile?.id) {
3887
- const fetcher = this.createTransport(scope, after.route);
3888
- try {
3889
- await tester(fetcher);
3890
- } finally {
3891
- fetcher.destroy?.();
3892
- }
3930
+ const transportKey = this.candidateTransportIdentity(after);
3931
+ groups.set(transportKey, [...groups.get(transportKey) ?? [], { scope, route: after.route }]);
3932
+ }
3933
+ for (const group of groups.values()) {
3934
+ const first = group[0];
3935
+ const fetcher = this.createTransport(first.scope, first.route);
3936
+ try {
3937
+ await this.connectivityPreflight(fetcher);
3938
+ for (const { scope } of group) await this.testers.get(scope)?.(fetcher);
3939
+ } finally {
3940
+ fetcher.destroy?.();
3893
3941
  }
3894
3942
  }
3895
3943
  }
3944
+ candidateTransportIdentity(resolution) {
3945
+ return JSON.stringify({ route: resolution.route, profile: resolution.profile });
3946
+ }
3896
3947
  createProfileFetch(profile, secret) {
3897
3948
  const url = proxyUrl(profile, secret);
3898
3949
  const agent = new ProxyAgent({
@@ -4180,11 +4231,13 @@ var init_telegram_command_scopes = __esm({
4180
4231
  // src/core/plugin/settings-manager.ts
4181
4232
  var settings_manager_exports = {};
4182
4233
  __export(settings_manager_exports, {
4234
+ SettingsConflictError: () => SettingsConflictError,
4183
4235
  SettingsManager: () => SettingsManager
4184
4236
  });
4185
4237
  import fs21 from "fs";
4186
4238
  import path20 from "path";
4187
- import { randomUUID as randomUUID4 } from "crypto";
4239
+ import { createHash as createHash4, randomUUID as randomUUID4 } from "crypto";
4240
+ import { execFileSync as execFileSync4 } from "child_process";
4188
4241
  function secureSettingsDirectory(directory) {
4189
4242
  fs21.mkdirSync(directory, { recursive: true, mode: 448 });
4190
4243
  fs21.chmodSync(directory, 448);
@@ -4205,13 +4258,67 @@ function repairSettingsPermissions(settingsPath, basePath) {
4205
4258
  secureSettingsTree(basePath, settingsPath, false);
4206
4259
  fs21.chmodSync(settingsPath, 384);
4207
4260
  }
4208
- function atomicSettingsWrite(settingsPath, basePath, data) {
4261
+ function settingsRevision(raw) {
4262
+ return createHash4("sha256").update(raw === null ? "missing\0" : `present\0${raw}`).digest("hex");
4263
+ }
4264
+ function parseSettings(raw) {
4265
+ try {
4266
+ const parsed = JSON.parse(raw);
4267
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4268
+ } catch {
4269
+ return {};
4270
+ }
4271
+ }
4272
+ function readSettingsSnapshot(settingsPath, basePath) {
4273
+ secureSettingsDirectory(basePath);
4274
+ if (!fs21.existsSync(settingsPath)) return { data: {}, raw: null, revision: settingsRevision(null) };
4275
+ repairSettingsPermissions(settingsPath, basePath);
4276
+ let raw;
4277
+ try {
4278
+ raw = fs21.readFileSync(settingsPath, "utf-8");
4279
+ } catch (error) {
4280
+ if (error.code === "ENOENT") {
4281
+ return { data: {}, raw: null, revision: settingsRevision(null) };
4282
+ }
4283
+ throw error;
4284
+ }
4285
+ return { data: parseSettings(raw), raw, revision: settingsRevision(raw) };
4286
+ }
4287
+ function assertSettingsRevision(settingsPath, basePath, expectedRevision) {
4288
+ const actual = readSettingsSnapshot(settingsPath, basePath).revision;
4289
+ if (actual !== expectedRevision) throw new SettingsConflictError();
4290
+ }
4291
+ function fsyncDirectory2(directory) {
4292
+ try {
4293
+ const directoryFd = fs21.openSync(directory, "r");
4294
+ try {
4295
+ fs21.fsyncSync(directoryFd);
4296
+ } finally {
4297
+ fs21.closeSync(directoryFd);
4298
+ }
4299
+ } catch {
4300
+ }
4301
+ }
4302
+ function atomicSettingsContentWrite(settingsPath, basePath, content, expectedRevision) {
4209
4303
  const directory = path20.dirname(settingsPath);
4210
4304
  secureSettingsTree(basePath, settingsPath, true);
4305
+ if (content === null) {
4306
+ assertSettingsRevision(settingsPath, basePath, expectedRevision);
4307
+ if (!fs21.existsSync(settingsPath)) return settingsRevision(null);
4308
+ let replaced2 = false;
4309
+ try {
4310
+ fs21.unlinkSync(settingsPath);
4311
+ replaced2 = true;
4312
+ fsyncDirectory2(directory);
4313
+ return settingsRevision(null);
4314
+ } catch (error) {
4315
+ throw new AtomicSettingsWriteError(error, replaced2);
4316
+ }
4317
+ }
4211
4318
  const temporary = `${settingsPath}.${process.pid}.${randomUUID4()}.tmp`;
4319
+ let replaced = false;
4212
4320
  try {
4213
- fs21.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}
4214
- `, { mode: 384, flag: "wx" });
4321
+ fs21.writeFileSync(temporary, content, { mode: 384, flag: "wx" });
4215
4322
  fs21.chmodSync(temporary, 384);
4216
4323
  const fd = fs21.openSync(temporary, "r");
4217
4324
  try {
@@ -4219,55 +4326,274 @@ function atomicSettingsWrite(settingsPath, basePath, data) {
4219
4326
  } finally {
4220
4327
  fs21.closeSync(fd);
4221
4328
  }
4329
+ assertSettingsRevision(settingsPath, basePath, expectedRevision);
4222
4330
  fs21.renameSync(temporary, settingsPath);
4331
+ replaced = true;
4223
4332
  fs21.chmodSync(settingsPath, 384);
4333
+ fsyncDirectory2(directory);
4334
+ return settingsRevision(content);
4335
+ } catch (error) {
4336
+ if (error instanceof SettingsConflictError) throw error;
4337
+ throw new AtomicSettingsWriteError(error, replaced);
4338
+ } finally {
4224
4339
  try {
4225
- const directoryFd = fs21.openSync(directory, "r");
4226
- try {
4227
- fs21.fsyncSync(directoryFd);
4228
- } finally {
4229
- fs21.closeSync(directoryFd);
4230
- }
4340
+ if (fs21.existsSync(temporary)) fs21.unlinkSync(temporary);
4231
4341
  } catch {
4232
4342
  }
4233
- } finally {
4343
+ }
4344
+ }
4345
+ function serializeSettings(data) {
4346
+ return `${JSON.stringify(data, null, 2)}
4347
+ `;
4348
+ }
4349
+ function processLiveness(pid) {
4350
+ if (!Number.isSafeInteger(pid) || pid <= 0) return "dead";
4351
+ try {
4352
+ process.kill(pid, 0);
4353
+ return "alive";
4354
+ } catch (error) {
4355
+ return error.code === "ESRCH" ? "dead" : "inaccessible";
4356
+ }
4357
+ }
4358
+ function readProcessStartIdentity(pid) {
4359
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
4360
+ if (process.platform === "linux") {
4234
4361
  try {
4235
- if (fs21.existsSync(temporary)) fs21.unlinkSync(temporary);
4362
+ const stat = fs21.readFileSync(`/proc/${pid}/stat`, "utf8");
4363
+ const endOfCommand = stat.lastIndexOf(") ");
4364
+ if (endOfCommand < 0) return null;
4365
+ const ticks = stat.slice(endOfCommand + 2).trim().split(/\s+/)[19];
4366
+ return /^\d+$/.test(ticks ?? "") ? `linux:${ticks}` : null;
4236
4367
  } catch {
4368
+ return null;
4369
+ }
4370
+ }
4371
+ if (process.platform === "win32") return null;
4372
+ try {
4373
+ const started = execFileSync4("ps", ["-o", "lstart=", "-p", String(pid)], {
4374
+ encoding: "utf8",
4375
+ stdio: ["ignore", "pipe", "ignore"],
4376
+ timeout: 500
4377
+ }).trim();
4378
+ return started ? `${process.platform}:${started}` : null;
4379
+ } catch {
4380
+ return null;
4381
+ }
4382
+ }
4383
+ function readLockMetadata(lockPath) {
4384
+ try {
4385
+ const lockStat = fs21.lstatSync(lockPath);
4386
+ if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) return void 0;
4387
+ const noFollow = fs21.constants.O_NOFOLLOW ?? 0;
4388
+ const directoryOnly = fs21.constants.O_DIRECTORY ?? 0;
4389
+ const lockFd = fs21.openSync(lockPath, fs21.constants.O_RDONLY | directoryOnly | noFollow);
4390
+ try {
4391
+ const opened = fs21.fstatSync(lockFd);
4392
+ if (!opened.isDirectory() || opened.dev !== lockStat.dev || opened.ino !== lockStat.ino) return void 0;
4393
+ fs21.fchmodSync(lockFd, 448);
4394
+ } finally {
4395
+ fs21.closeSync(lockFd);
4396
+ }
4397
+ const ownerPath = path20.join(lockPath, "owner.json");
4398
+ const ownerStat = fs21.lstatSync(ownerPath);
4399
+ if (!ownerStat.isFile() || ownerStat.isSymbolicLink()) return void 0;
4400
+ const fd = fs21.openSync(ownerPath, fs21.constants.O_RDONLY | noFollow);
4401
+ let raw;
4402
+ try {
4403
+ const opened = fs21.fstatSync(fd);
4404
+ if (!opened.isFile() || opened.dev !== ownerStat.dev || opened.ino !== ownerStat.ino) return void 0;
4405
+ fs21.fchmodSync(fd, 384);
4406
+ raw = fs21.readFileSync(fd, "utf-8");
4407
+ } finally {
4408
+ fs21.closeSync(fd);
4409
+ }
4410
+ const parsed = JSON.parse(raw);
4411
+ if (!Number.isSafeInteger(parsed.pid) || typeof parsed.owner !== "string" || !Number.isFinite(parsed.acquiredAt)) return void 0;
4412
+ if (parsed.processStartIdentity !== void 0 && parsed.processStartIdentity !== null && typeof parsed.processStartIdentity !== "string") return void 0;
4413
+ return parsed;
4414
+ } catch {
4415
+ return void 0;
4416
+ }
4417
+ }
4418
+ function inspectSettingsLock(lockPath, now, previousObservation) {
4419
+ let stat;
4420
+ try {
4421
+ stat = fs21.lstatSync(lockPath);
4422
+ } catch {
4423
+ return { reclaim: false };
4424
+ }
4425
+ if (stat.isSymbolicLink()) return { reclaim: true, stat };
4426
+ const metadata = readLockMetadata(lockPath);
4427
+ if (metadata) {
4428
+ const liveness = processLiveness(metadata.pid);
4429
+ if (liveness === "dead") return { reclaim: true, stat };
4430
+ const actualIdentity = readProcessStartIdentity(metadata.pid);
4431
+ if (metadata.processStartIdentity && actualIdentity) {
4432
+ return { reclaim: metadata.processStartIdentity !== actualIdentity, stat };
4433
+ }
4434
+ const acquiredAt = metadata.acquiredAt > now ? stat.mtimeMs : metadata.acquiredAt;
4435
+ const lastOwnerActivity = Math.max(acquiredAt, stat.mtimeMs);
4436
+ return { reclaim: now - lastOwnerActivity >= SETTINGS_UNVERIFIED_OWNER_STALE_MS, stat };
4437
+ }
4438
+ const sameEntry = previousObservation?.dev === stat.dev && previousObservation.ino === stat.ino;
4439
+ const observation = sameEntry ? previousObservation : { dev: stat.dev, ino: stat.ino, firstSeenAt: now };
4440
+ const oldOnDisk = now - stat.mtimeMs >= SETTINGS_UNKNOWN_LOCK_GRACE_MS;
4441
+ return {
4442
+ reclaim: oldOnDisk || now - observation.firstSeenAt >= SETTINGS_UNKNOWN_LOCK_GRACE_MS,
4443
+ stat,
4444
+ observation
4445
+ };
4446
+ }
4447
+ function reclaimStaleLock(lockPath, expected) {
4448
+ const reclaimed = `${lockPath}.stale.${process.pid}.${randomUUID4()}`;
4449
+ try {
4450
+ const current = fs21.lstatSync(lockPath);
4451
+ if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
4452
+ fs21.renameSync(lockPath, reclaimed);
4453
+ } catch (error) {
4454
+ if (["ENOENT", "EEXIST"].includes(error.code ?? "")) return false;
4455
+ throw error;
4456
+ }
4457
+ try {
4458
+ fs21.rmSync(reclaimed, { recursive: true, force: true });
4459
+ } catch {
4460
+ }
4461
+ return true;
4462
+ }
4463
+ function delay(ms) {
4464
+ return new Promise((resolve8) => setTimeout(resolve8, ms));
4465
+ }
4466
+ async function acquireSettingsLock(settingsPath, basePath) {
4467
+ secureSettingsTree(basePath, settingsPath, true);
4468
+ const lockPath = `${settingsPath}.lock`;
4469
+ const startedAt = Date.now();
4470
+ let unknownObservation;
4471
+ while (true) {
4472
+ try {
4473
+ fs21.mkdirSync(lockPath, { mode: 448 });
4474
+ fs21.chmodSync(lockPath, 448);
4475
+ const owner = randomUUID4();
4476
+ const metadata = {
4477
+ version: 1,
4478
+ pid: process.pid,
4479
+ owner,
4480
+ acquiredAt: Date.now(),
4481
+ processStartIdentity: readProcessStartIdentity(process.pid)
4482
+ };
4483
+ const ownerPath = path20.join(lockPath, "owner.json");
4484
+ try {
4485
+ const noFollow = fs21.constants.O_NOFOLLOW ?? 0;
4486
+ const ownerFd = fs21.openSync(
4487
+ ownerPath,
4488
+ fs21.constants.O_WRONLY | fs21.constants.O_CREAT | fs21.constants.O_EXCL | noFollow,
4489
+ 384
4490
+ );
4491
+ try {
4492
+ fs21.writeFileSync(ownerFd, `${JSON.stringify(metadata)}
4493
+ `);
4494
+ fs21.fchmodSync(ownerFd, 384);
4495
+ fs21.fsyncSync(ownerFd);
4496
+ } finally {
4497
+ fs21.closeSync(ownerFd);
4498
+ }
4499
+ } catch (error) {
4500
+ try {
4501
+ fs21.rmSync(lockPath, { recursive: true, force: true });
4502
+ } catch {
4503
+ }
4504
+ throw error;
4505
+ }
4506
+ let released = false;
4507
+ return {
4508
+ release: () => {
4509
+ if (released) return;
4510
+ released = true;
4511
+ const current = readLockMetadata(lockPath);
4512
+ if (current?.owner !== owner || current.pid !== process.pid || current.processStartIdentity !== metadata.processStartIdentity) return;
4513
+ const releasedPath = `${lockPath}.released.${process.pid}.${randomUUID4()}`;
4514
+ try {
4515
+ fs21.renameSync(lockPath, releasedPath);
4516
+ fs21.rmSync(releasedPath, { recursive: true, force: true });
4517
+ } catch (error) {
4518
+ if (error.code !== "ENOENT") throw error;
4519
+ }
4520
+ }
4521
+ };
4522
+ } catch (error) {
4523
+ if (error.code !== "EEXIST") throw error;
4524
+ const inspection = inspectSettingsLock(lockPath, Date.now(), unknownObservation);
4525
+ unknownObservation = inspection.observation;
4526
+ if (!inspection.stat) {
4527
+ if (Date.now() - startedAt >= SETTINGS_LOCK_WAIT_MS) {
4528
+ throw new Error(`Timed out waiting for plugin settings lock: ${path20.basename(path20.dirname(settingsPath))}`);
4529
+ }
4530
+ await delay(10 + Math.floor(Math.random() * 20));
4531
+ continue;
4532
+ }
4533
+ if (inspection.reclaim && reclaimStaleLock(lockPath, inspection.stat)) {
4534
+ unknownObservation = void 0;
4535
+ continue;
4536
+ }
4537
+ if (Date.now() - startedAt >= SETTINGS_LOCK_WAIT_MS) {
4538
+ throw new Error(`Timed out waiting for plugin settings lock: ${path20.basename(path20.dirname(settingsPath))}`);
4539
+ }
4540
+ await delay(10 + Math.floor(Math.random() * 20));
4237
4541
  }
4238
4542
  }
4239
4543
  }
4240
- var SettingsManager, SettingsAPIImpl;
4544
+ function cloneSettings(settings) {
4545
+ return JSON.parse(JSON.stringify(settings));
4546
+ }
4547
+ var SETTINGS_LOCK_WAIT_MS, SETTINGS_UNKNOWN_LOCK_GRACE_MS, SETTINGS_UNVERIFIED_OWNER_STALE_MS, SETTINGS_CONFLICT_RETRIES, AtomicSettingsWriteError, RetriableSettingsConflictError, SettingsConflictError, SettingsManager, SettingsAPIImpl;
4241
4548
  var init_settings_manager = __esm({
4242
4549
  "src/core/plugin/settings-manager.ts"() {
4243
4550
  "use strict";
4244
- SettingsManager = class {
4551
+ SETTINGS_LOCK_WAIT_MS = 3e4;
4552
+ SETTINGS_UNKNOWN_LOCK_GRACE_MS = 750;
4553
+ SETTINGS_UNVERIFIED_OWNER_STALE_MS = 2 * 6e4;
4554
+ SETTINGS_CONFLICT_RETRIES = 3;
4555
+ AtomicSettingsWriteError = class extends Error {
4556
+ constructor(original, replaced) {
4557
+ super(original instanceof Error ? original.message : "Settings write failed", { cause: original });
4558
+ this.original = original;
4559
+ this.replaced = replaced;
4560
+ this.name = "AtomicSettingsWriteError";
4561
+ }
4562
+ original;
4563
+ replaced;
4564
+ };
4565
+ RetriableSettingsConflictError = class extends Error {
4566
+ constructor(conflict) {
4567
+ super(conflict.message, { cause: conflict });
4568
+ this.conflict = conflict;
4569
+ this.name = "RetriableSettingsConflictError";
4570
+ }
4571
+ conflict;
4572
+ };
4573
+ SettingsConflictError = class extends Error {
4574
+ constructor() {
4575
+ super("Plugin settings changed concurrently; the attempted write was not applied");
4576
+ this.name = "SettingsConflictError";
4577
+ }
4578
+ };
4579
+ SettingsManager = class _SettingsManager {
4245
4580
  constructor(basePath) {
4246
4581
  this.basePath = basePath;
4247
4582
  secureSettingsDirectory(basePath);
4248
4583
  }
4249
4584
  basePath;
4585
+ static mutationTails = /* @__PURE__ */ new Map();
4250
4586
  /** Returns the base path for all plugin settings directories. */
4251
4587
  getBasePath() {
4252
4588
  return this.basePath;
4253
4589
  }
4254
4590
  /** Create a SettingsAPI instance scoped to a specific plugin. */
4255
4591
  createAPI(pluginName) {
4256
- const settingsPath = this.getSettingsPath(pluginName);
4257
- return new SettingsAPIImpl(settingsPath, this.basePath);
4592
+ return new SettingsAPIImpl(this, pluginName);
4258
4593
  }
4259
- /** Load a plugin's settings from disk. Returns empty object if file doesn't exist. */
4594
+ /** Load a fresh plugin settings snapshot. Returns empty object if the file doesn't exist. */
4260
4595
  async loadSettings(pluginName) {
4261
- const settingsPath = this.getSettingsPath(pluginName);
4262
- secureSettingsDirectory(this.basePath);
4263
- if (!fs21.existsSync(settingsPath)) return {};
4264
- repairSettingsPermissions(settingsPath, this.basePath);
4265
- try {
4266
- const content = fs21.readFileSync(settingsPath, "utf-8");
4267
- return JSON.parse(content);
4268
- } catch {
4269
- return {};
4270
- }
4596
+ return cloneSettings(readSettingsSnapshot(this.getSettingsPath(pluginName), this.basePath).data);
4271
4597
  }
4272
4598
  /** Validate settings against a Zod schema. Returns valid if no schema is provided. */
4273
4599
  validateSettings(_pluginName, settings, schema) {
@@ -4288,63 +4614,136 @@ var init_settings_manager = __esm({
4288
4614
  async getPluginSettings(pluginName) {
4289
4615
  return this.loadSettings(pluginName);
4290
4616
  }
4617
+ /**
4618
+ * Serialize a read/prepare/persist/apply transaction for one plugin.
4619
+ *
4620
+ * The transaction holds both an in-process queue and a filesystem lock. Its
4621
+ * prepare callback receives a fresh disk snapshot. Persistence uses a content
4622
+ * revision CAS; a pre-commit conflict is retried from a new snapshot, while an
4623
+ * exhausted conflict aborts without applying the runtime side effect. Apply
4624
+ * failures restore the exact previous file content and invoke runtime rollback
4625
+ * before releasing the cross-process lock.
4626
+ */
4627
+ async transactPluginSettings(pluginName, prepare) {
4628
+ const settingsPath = this.getSettingsPath(pluginName);
4629
+ return _SettingsManager.serialize(path20.resolve(settingsPath), async () => {
4630
+ let lastConflict;
4631
+ for (let attempt = 0; attempt < SETTINGS_CONFLICT_RETRIES; attempt += 1) {
4632
+ try {
4633
+ return await this.transactOnce(settingsPath, prepare);
4634
+ } catch (error) {
4635
+ if (!(error instanceof RetriableSettingsConflictError)) throw error;
4636
+ lastConflict = error.conflict;
4637
+ }
4638
+ }
4639
+ throw lastConflict ?? new SettingsConflictError();
4640
+ });
4641
+ }
4291
4642
  /** Merge updates into existing settings (shallow merge). */
4292
4643
  async updatePluginSettings(pluginName, updates) {
4293
- const api = this.createAPI(pluginName);
4294
- const current = await api.getAll();
4295
- await api.setAll({ ...current, ...updates });
4644
+ await this.transactPluginSettings(pluginName, (current) => ({
4645
+ settings: { ...current, ...updates },
4646
+ result: void 0
4647
+ }));
4296
4648
  }
4297
- };
4298
- SettingsAPIImpl = class {
4299
- constructor(settingsPath, basePath) {
4300
- this.settingsPath = settingsPath;
4301
- this.basePath = basePath;
4649
+ async transactOnce(settingsPath, prepare) {
4650
+ const lock = await acquireSettingsLock(settingsPath, this.basePath);
4651
+ try {
4652
+ const previous = readSettingsSnapshot(settingsPath, this.basePath);
4653
+ const plan = await prepare(cloneSettings(previous.data));
4654
+ const next = cloneSettings(plan.settings);
4655
+ const nextContent = serializeSettings(next);
4656
+ const nextRevision = settingsRevision(nextContent);
4657
+ try {
4658
+ atomicSettingsContentWrite(settingsPath, this.basePath, nextContent, previous.revision);
4659
+ } catch (error) {
4660
+ if (error instanceof SettingsConflictError) throw new RetriableSettingsConflictError(error);
4661
+ if (error instanceof AtomicSettingsWriteError && error.replaced) {
4662
+ try {
4663
+ atomicSettingsContentWrite(settingsPath, this.basePath, previous.raw, nextRevision);
4664
+ } catch (restoreError) {
4665
+ throw new AggregateError([error.original, restoreError], "Settings persistence failed and the previous snapshot could not be restored");
4666
+ }
4667
+ }
4668
+ throw error instanceof AtomicSettingsWriteError ? error.original : error;
4669
+ }
4670
+ try {
4671
+ await plan.apply?.();
4672
+ } catch (error) {
4673
+ const rollbackErrors = [error];
4674
+ try {
4675
+ await plan.rollback?.();
4676
+ } catch (rollbackError) {
4677
+ rollbackErrors.push(rollbackError);
4678
+ }
4679
+ try {
4680
+ atomicSettingsContentWrite(settingsPath, this.basePath, previous.raw, nextRevision);
4681
+ } catch (restoreError) {
4682
+ rollbackErrors.push(restoreError instanceof AtomicSettingsWriteError ? restoreError.original : restoreError);
4683
+ }
4684
+ if (rollbackErrors.length > 1) throw new AggregateError(rollbackErrors, "Settings runtime apply failed and rollback was incomplete");
4685
+ throw error;
4686
+ }
4687
+ return plan.result;
4688
+ } finally {
4689
+ lock.release();
4690
+ }
4302
4691
  }
4303
- settingsPath;
4304
- basePath;
4305
- cache = null;
4306
- readFile() {
4307
- if (this.cache !== null) return this.cache;
4308
- secureSettingsDirectory(this.basePath);
4309
- if (fs21.existsSync(this.settingsPath)) repairSettingsPermissions(this.settingsPath, this.basePath);
4692
+ static async serialize(key, operation) {
4693
+ const previous = this.mutationTails.get(key) ?? Promise.resolve();
4694
+ let release;
4695
+ const gate = new Promise((resolve8) => {
4696
+ release = resolve8;
4697
+ });
4698
+ const tail = previous.catch(() => void 0).then(() => gate);
4699
+ this.mutationTails.set(key, tail);
4700
+ await previous.catch(() => void 0);
4310
4701
  try {
4311
- const content = fs21.readFileSync(this.settingsPath, "utf-8");
4312
- this.cache = JSON.parse(content);
4313
- return this.cache;
4314
- } catch {
4315
- this.cache = {};
4316
- return this.cache;
4702
+ return await operation();
4703
+ } finally {
4704
+ release();
4705
+ if (this.mutationTails.get(key) === tail) this.mutationTails.delete(key);
4317
4706
  }
4318
4707
  }
4319
- writeFile(data) {
4320
- atomicSettingsWrite(this.settingsPath, this.basePath, data);
4321
- this.cache = data;
4708
+ };
4709
+ SettingsAPIImpl = class {
4710
+ constructor(manager, pluginName) {
4711
+ this.manager = manager;
4712
+ this.pluginName = pluginName;
4322
4713
  }
4714
+ manager;
4715
+ pluginName;
4323
4716
  async get(key) {
4324
- const data = this.readFile();
4717
+ const data = await this.manager.loadSettings(this.pluginName);
4325
4718
  return data[key];
4326
4719
  }
4327
4720
  async set(key, value) {
4328
- const data = this.readFile();
4329
- data[key] = value;
4330
- this.writeFile(data);
4721
+ await this.manager.transactPluginSettings(this.pluginName, (current) => ({
4722
+ settings: { ...current, [key]: value },
4723
+ result: void 0
4724
+ }));
4331
4725
  }
4332
4726
  async getAll() {
4333
- return { ...this.readFile() };
4727
+ return this.manager.loadSettings(this.pluginName);
4334
4728
  }
4335
4729
  async setAll(settings) {
4336
- this.writeFile({ ...settings });
4730
+ await this.manager.transactPluginSettings(this.pluginName, () => ({
4731
+ settings: { ...settings },
4732
+ result: void 0
4733
+ }));
4337
4734
  }
4338
4735
  async delete(key) {
4339
- const data = this.readFile();
4340
- delete data[key];
4341
- this.writeFile(data);
4736
+ await this.manager.transactPluginSettings(this.pluginName, (current) => {
4737
+ const next = { ...current };
4738
+ delete next[key];
4739
+ return { settings: next, result: void 0 };
4740
+ });
4342
4741
  }
4343
4742
  async clear() {
4344
- this.writeFile({});
4743
+ await this.manager.transactPluginSettings(this.pluginName, () => ({ settings: {}, result: void 0 }));
4345
4744
  }
4346
4745
  async has(key) {
4347
- const data = this.readFile();
4746
+ const data = await this.manager.loadSettings(this.pluginName);
4348
4747
  return key in data;
4349
4748
  }
4350
4749
  };
@@ -4362,17 +4761,17 @@ __export(command_ownership_store_exports, {
4362
4761
  import fs22 from "fs";
4363
4762
  import path21 from "path";
4364
4763
  import os5 from "os";
4365
- import { createHash as createHash4 } from "crypto";
4764
+ import { createHash as createHash5 } from "crypto";
4366
4765
  function telegramCommandHostId() {
4367
4766
  let source = `${os5.hostname()}|${typeof process.getuid === "function" ? process.getuid() : "unknown"}`;
4368
4767
  try {
4369
4768
  source = fs22.readFileSync("/etc/machine-id", "utf8").trim() || source;
4370
4769
  } catch {
4371
4770
  }
4372
- return createHash4("sha256").update(source).digest("hex");
4771
+ return createHash5("sha256").update(source).digest("hex");
4373
4772
  }
4374
4773
  function telegramCommandInstanceKey(instanceRoot) {
4375
- return createHash4("sha256").update(path21.resolve(instanceRoot)).digest("hex");
4774
+ return createHash5("sha256").update(path21.resolve(instanceRoot)).digest("hex");
4376
4775
  }
4377
4776
  function emptyLedger() {
4378
4777
  return { version: STORE_VERSION, bots: {} };
@@ -5171,7 +5570,7 @@ import fs28 from "fs";
5171
5570
  import path26 from "path";
5172
5571
  import https from "https";
5173
5572
  import os7 from "os";
5174
- import { execFileSync as execFileSync4 } from "child_process";
5573
+ import { execFileSync as execFileSync5 } from "child_process";
5175
5574
  function downloadFile(url, dest, maxRedirects = 10) {
5176
5575
  return new Promise((resolve8, reject) => {
5177
5576
  const file = fs28.createWriteStream(dest);
@@ -5260,9 +5659,9 @@ async function ensureBinary(spec, binDir) {
5260
5659
  const downloadDest = isArchive ? path26.join(resolvedBinDir, `${spec.name}.tgz`) : binPath;
5261
5660
  await downloadFile(url, downloadDest);
5262
5661
  if (isArchive) {
5263
- const listing = execFileSync4("tar", ["-tf", downloadDest], { stdio: "pipe" }).toString().trim().split("\n").filter(Boolean);
5662
+ const listing = execFileSync5("tar", ["-tf", downloadDest], { stdio: "pipe" }).toString().trim().split("\n").filter(Boolean);
5264
5663
  validateTarContents(listing, resolvedBinDir);
5265
- execFileSync4("tar", ["-xzf", downloadDest, "-C", resolvedBinDir], { stdio: "pipe" });
5664
+ execFileSync5("tar", ["-xzf", downloadDest, "-C", resolvedBinDir], { stdio: "pipe" });
5266
5665
  try {
5267
5666
  fs28.unlinkSync(downloadDest);
5268
5667
  } catch {
@@ -5329,7 +5728,7 @@ var init_install_cloudflared = __esm({
5329
5728
  import * as fs29 from "fs";
5330
5729
  import * as path27 from "path";
5331
5730
  import * as os8 from "os";
5332
- import { execFileSync as execFileSync5 } from "child_process";
5731
+ import { execFileSync as execFileSync6 } from "child_process";
5333
5732
  var tunnelCheck;
5334
5733
  var init_tunnel = __esm({
5335
5734
  "src/core/doctor/checks/tunnel.ts"() {
@@ -5362,7 +5761,7 @@ var init_tunnel = __esm({
5362
5761
  found = true;
5363
5762
  } else {
5364
5763
  try {
5365
- execFileSync5("which", ["cloudflared"], { stdio: "pipe" });
5764
+ execFileSync6("which", ["cloudflared"], { stdio: "pipe" });
5366
5765
  found = true;
5367
5766
  } catch {
5368
5767
  }
@@ -5392,48 +5791,393 @@ var init_tunnel = __esm({
5392
5791
  } else {
5393
5792
  results.push({ status: "pass", message: `Tunnel port: ${tunnelPort}` });
5394
5793
  }
5395
- return results;
5794
+ return results;
5795
+ }
5796
+ };
5797
+ }
5798
+ });
5799
+
5800
+ // src/core/instance/instance-registry.ts
5801
+ import fs30 from "fs";
5802
+ import path28 from "path";
5803
+ import { randomUUID as randomUUID5 } from "crypto";
5804
+ function readIdFromConfig(instanceRoot) {
5805
+ try {
5806
+ const raw = JSON.parse(fs30.readFileSync(path28.join(instanceRoot, "config.json"), "utf-8"));
5807
+ return typeof raw.id === "string" && raw.id ? raw.id : null;
5808
+ } catch {
5809
+ return null;
5810
+ }
5811
+ }
5812
+ var InstanceRegistry;
5813
+ var init_instance_registry = __esm({
5814
+ "src/core/instance/instance-registry.ts"() {
5815
+ "use strict";
5816
+ InstanceRegistry = class {
5817
+ constructor(registryPath) {
5818
+ this.registryPath = registryPath;
5819
+ }
5820
+ registryPath;
5821
+ data = { version: 1, instances: {} };
5822
+ /** Load the registry from disk. If the file is missing or corrupt, starts fresh. */
5823
+ load() {
5824
+ try {
5825
+ const raw = fs30.readFileSync(this.registryPath, "utf-8");
5826
+ const parsed = JSON.parse(raw);
5827
+ if (parsed.version === 1 && parsed.instances) {
5828
+ this.data = parsed;
5829
+ this.deduplicate();
5830
+ }
5831
+ } catch {
5832
+ }
5833
+ }
5834
+ /** Remove duplicate entries that point to the same root, keeping the first one */
5835
+ deduplicate() {
5836
+ const seen = /* @__PURE__ */ new Set();
5837
+ const toRemove = [];
5838
+ for (const [id, entry] of Object.entries(this.data.instances)) {
5839
+ if (seen.has(entry.root)) {
5840
+ toRemove.push(id);
5841
+ } else {
5842
+ seen.add(entry.root);
5843
+ }
5844
+ }
5845
+ if (toRemove.length > 0) {
5846
+ for (const id of toRemove) delete this.data.instances[id];
5847
+ this.save();
5848
+ }
5849
+ }
5850
+ /** Persist the registry to disk, creating parent directories if needed. */
5851
+ save() {
5852
+ const dir = path28.dirname(this.registryPath);
5853
+ fs30.mkdirSync(dir, { recursive: true });
5854
+ fs30.writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2));
5855
+ }
5856
+ /** Add or update an instance entry in the registry. Does not persist — call save() after. */
5857
+ register(id, root) {
5858
+ this.data.instances[id] = { id, root };
5859
+ }
5860
+ /** Remove an instance entry. Does not persist — call save() after. */
5861
+ remove(id) {
5862
+ delete this.data.instances[id];
5863
+ }
5864
+ /** Look up an instance by its ID. */
5865
+ get(id) {
5866
+ return this.data.instances[id];
5867
+ }
5868
+ /** Look up an instance by its root directory path. */
5869
+ getByRoot(root) {
5870
+ return Object.values(this.data.instances).find((e) => e.root === root);
5871
+ }
5872
+ /** Returns all registered instances. */
5873
+ list() {
5874
+ return Object.values(this.data.instances);
5875
+ }
5876
+ /** Returns `baseId` if available, otherwise appends `-2`, `-3`, etc. until unique. */
5877
+ uniqueId(baseId) {
5878
+ if (!this.data.instances[baseId]) return baseId;
5879
+ let n = 2;
5880
+ while (this.data.instances[`${baseId}-${n}`]) n++;
5881
+ return `${baseId}-${n}`;
5882
+ }
5883
+ /**
5884
+ * Resolve the authoritative id for an instance root.
5885
+ *
5886
+ * config.json is the source of truth. If the registry entry disagrees with
5887
+ * config.json, the registry is updated to match. If neither exists, a fresh
5888
+ * UUID is generated and registered (but NOT written to config.json — the
5889
+ * caller must call initInstanceFiles to persist it).
5890
+ *
5891
+ * Returns the resolved id and whether the registry was mutated (so callers
5892
+ * can decide whether to persist with save()).
5893
+ */
5894
+ resolveId(instanceRoot) {
5895
+ const configId = readIdFromConfig(instanceRoot);
5896
+ const entry = this.getByRoot(instanceRoot);
5897
+ if (entry && configId && entry.id !== configId) {
5898
+ this.remove(entry.id);
5899
+ this.register(configId, instanceRoot);
5900
+ return { id: configId, registryUpdated: true };
5901
+ }
5902
+ const id = configId ?? entry?.id ?? randomUUID5();
5903
+ if (!this.getByRoot(instanceRoot)) {
5904
+ this.register(id, instanceRoot);
5905
+ return { id, registryUpdated: true };
5906
+ }
5907
+ return { id, registryUpdated: false };
5396
5908
  }
5397
5909
  };
5398
5910
  }
5399
5911
  });
5400
5912
 
5913
+ // src/core/instance/daemon-identity.ts
5914
+ import fs31 from "fs";
5915
+ import path29 from "path";
5916
+ function daemonIdentityPath(pidPath) {
5917
+ return `${pidPath}.identity`;
5918
+ }
5919
+ function processStartTicks(pid) {
5920
+ if (process.platform !== "linux") return null;
5921
+ try {
5922
+ const stat = fs31.readFileSync(`/proc/${pid}/stat`, "utf8");
5923
+ const endOfCommand = stat.lastIndexOf(") ");
5924
+ if (endOfCommand < 0) return null;
5925
+ const value = stat.slice(endOfCommand + 2).trim().split(/\s+/)[19];
5926
+ return /^\d+$/.test(value ?? "") ? value : null;
5927
+ } catch {
5928
+ return null;
5929
+ }
5930
+ }
5931
+ function writeDaemonIdentity(pidPath, pid) {
5932
+ const instanceRoot = path29.resolve(path29.dirname(pidPath));
5933
+ const marker = {
5934
+ version: 1,
5935
+ pid,
5936
+ instanceRoot,
5937
+ instanceId: readIdFromConfig(instanceRoot),
5938
+ processStartTicks: processStartTicks(pid)
5939
+ };
5940
+ fs31.writeFileSync(daemonIdentityPath(pidPath), JSON.stringify(marker), { mode: 384 });
5941
+ }
5942
+ function removeDaemonIdentity(pidPath) {
5943
+ try {
5944
+ fs31.unlinkSync(daemonIdentityPath(pidPath));
5945
+ } catch {
5946
+ }
5947
+ }
5948
+ function verifyDaemonIdentity(pidPath, pid, expectedInstanceRoot) {
5949
+ try {
5950
+ const marker = JSON.parse(fs31.readFileSync(daemonIdentityPath(pidPath), "utf8"));
5951
+ const expectedRoot = path29.resolve(expectedInstanceRoot);
5952
+ if (marker.version !== 1 || marker.pid !== pid || marker.instanceRoot !== expectedRoot) return false;
5953
+ const expectedId = readIdFromConfig(expectedRoot);
5954
+ if (expectedId && marker.instanceId !== expectedId) return false;
5955
+ const currentStartTicks = processStartTicks(pid);
5956
+ if (process.platform === "linux") {
5957
+ if (!marker.processStartTicks || !currentStartTicks || marker.processStartTicks !== currentStartTicks) return false;
5958
+ }
5959
+ return true;
5960
+ } catch {
5961
+ return false;
5962
+ }
5963
+ }
5964
+ var init_daemon_identity = __esm({
5965
+ "src/core/instance/daemon-identity.ts"() {
5966
+ "use strict";
5967
+ init_instance_registry();
5968
+ }
5969
+ });
5970
+
5401
5971
  // src/core/doctor/checks/proxy.ts
5402
- var proxyCheck;
5972
+ import fs32 from "fs";
5973
+ import path30 from "path";
5974
+ function activeCallerProxyVariables() {
5975
+ return [...ROUTING_PROXY_KEYS].filter((key) => process.env[key] !== void 0 && process.env[key] !== "");
5976
+ }
5977
+ function isProcessAlive2(pid) {
5978
+ try {
5979
+ process.kill(pid, 0);
5980
+ return true;
5981
+ } catch (error) {
5982
+ return error.code === "EPERM";
5983
+ }
5984
+ }
5985
+ function inspectDaemonProxyEnvironment(pidPath, expectedInstanceRoot = path30.dirname(pidPath)) {
5986
+ let pid;
5987
+ try {
5988
+ pid = Number(fs32.readFileSync(pidPath, "utf8").trim());
5989
+ } catch {
5990
+ return { state: "not-running", variableNames: [], source: "none" };
5991
+ }
5992
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !isProcessAlive2(pid)) {
5993
+ return { state: "not-running", variableNames: [], source: "none" };
5994
+ }
5995
+ if (!verifyDaemonIdentity(pidPath, pid, expectedInstanceRoot)) {
5996
+ return { state: "unavailable", variableNames: [], source: "none" };
5997
+ }
5998
+ if (pid === process.pid) {
5999
+ return { state: "known", variableNames: activeCallerProxyVariables(), source: "current-process" };
6000
+ }
6001
+ if (process.platform !== "linux") {
6002
+ return { state: "unavailable", variableNames: [], source: "none" };
6003
+ }
6004
+ try {
6005
+ const names = fs32.readFileSync(`/proc/${pid}/environ`).toString("utf8").split("\0").filter((entry) => entry.includes("=")).map((entry) => entry.slice(0, entry.indexOf("="))).filter((key) => ROUTING_PROXY_KEYS.has(key));
6006
+ return { state: "known", variableNames: [...new Set(names)].sort(), source: "proc" };
6007
+ } catch {
6008
+ return { state: "unavailable", variableNames: [], source: "none" };
6009
+ }
6010
+ }
6011
+ var ROUTING_PROXY_KEYS, proxyCheck;
5403
6012
  var init_proxy = __esm({
5404
6013
  "src/core/doctor/checks/proxy.ts"() {
5405
6014
  "use strict";
5406
6015
  init_proxy_service();
5407
6016
  init_proxy_store();
6017
+ init_daemon_identity();
6018
+ ROUTING_PROXY_KEYS = new Set(PROXY_ENV_KEYS.filter((key) => !["NO_PROXY", "no_proxy", "NODE_USE_ENV_PROXY"].includes(key)));
5408
6019
  proxyCheck = {
5409
- name: "Proxy routing",
6020
+ name: "Network proxy",
5410
6021
  order: 85,
5411
6022
  async run(ctx) {
5412
6023
  try {
5413
6024
  if (ctx.dataDir) new ProxyStore(ctx.dataDir).load();
5414
6025
  } catch (error) {
5415
- if (error instanceof ProxyStoreCorruptError) return [{ status: "fail", message: `${error.message}. Network consumers are fail-closed; inspect quarantine/LKG before recovery.` }];
6026
+ if (error instanceof ProxyStoreCorruptError) return [{ status: "fail", message: `${error.message}. Network traffic is blocked for safety. Inspect the quarantined file and last known-good copy before recovery.` }];
5416
6027
  throw error;
5417
6028
  }
5418
- const variables = PROXY_ENV_KEYS.filter((key) => {
5419
- const value = process.env[key];
5420
- return !["NO_PROXY", "no_proxy", "NODE_USE_ENV_PROXY"].includes(key) && value !== void 0 && value !== "";
5421
- });
5422
- if (!variables.length) {
5423
- return [{ status: "pass", message: "No daemon-wide proxy environment; native scoped routing is authoritative" }];
6029
+ const callerVariables = activeCallerProxyVariables();
6030
+ const daemon = ctx.daemonProxyEnvironment ?? inspectDaemonProxyEnvironment(ctx.pidPath ?? "", ctx.dataDir);
6031
+ if (daemon.state === "known" && daemon.variableNames.length) {
6032
+ return [{
6033
+ status: "warn",
6034
+ message: `Compatibility mode: the running OpenACP daemon has proxy variables (${daemon.variableNames.join(", ")}). Scoped routes still apply, but \u201CUse host proxy settings\u201D can use these variables. Remove only the OpenACP wrapper or service override when migration is complete.`
6035
+ }];
6036
+ }
6037
+ if (daemon.state === "known") {
6038
+ const shellNote = callerVariables.length && daemon.source !== "current-process" ? ` Current command shell has proxy variables (${callerVariables.join(", ")}), but they are not present in the running daemon.` : "";
6039
+ return [{ status: "pass", message: `Scoped routing is active; the running daemon has no proxy variables.${shellNote}` }];
6040
+ }
6041
+ if (callerVariables.length) {
6042
+ const daemonState = daemon.state === "not-running" ? "No running daemon was available to inspect" : "The running daemon environment could not be inspected on this platform";
6043
+ return [{
6044
+ status: "warn",
6045
+ message: `Current command shell has proxy variables (${callerVariables.join(", ")}). ${daemonState}, so daemon compatibility mode was not inferred. A daemon started from this shell may inherit them.`
6046
+ }];
5424
6047
  }
5425
6048
  return [{
5426
- status: "warn",
5427
- message: `Daemon-wide proxy environment active (${variables.join(", ")}); scoped routing is in compatibility mode until legacy wrapper/drop-in injection is removed`
6049
+ status: "pass",
6050
+ message: daemon.state === "not-running" ? "Current command shell has no proxy variables; no running daemon environment was available" : "Current command shell has no proxy variables; the running daemon environment could not be inspected, so compatibility mode was not inferred"
5428
6051
  }];
5429
6052
  }
5430
6053
  };
5431
6054
  }
5432
6055
  });
5433
6056
 
6057
+ // src/plugins/speech/local-readiness.ts
6058
+ import { statSync } from "fs";
6059
+ function getLocalWhisperReadiness(settings, options = {}) {
6060
+ const env = options.env ?? process.env;
6061
+ const envScriptPath = env[LOCAL_WHISPER_SCRIPT_PATH_ENV]?.trim();
6062
+ const effectiveSettings = envScriptPath ? { ...settings, localWhisperScriptPath: envScriptPath } : settings;
6063
+ const scriptPath = readLocalWhisperSettings(effectiveSettings).scriptPath;
6064
+ const commandAvailable = options.commandAvailable ?? commandExists;
6065
+ let script;
6066
+ try {
6067
+ const stat = statSync(scriptPath);
6068
+ script = !stat.isFile() ? "not-file" : (stat.mode & 73) === 0 ? "not-executable" : "ready";
6069
+ } catch {
6070
+ script = "missing";
6071
+ }
6072
+ const runtimeReady = commandAvailable("uv") || commandAvailable("python3");
6073
+ return { ready: script === "ready" && runtimeReady, script, runtimeReady };
6074
+ }
6075
+ var LOCAL_WHISPER_SCRIPT_PATH_ENV;
6076
+ var init_local_readiness = __esm({
6077
+ "src/plugins/speech/local-readiness.ts"() {
6078
+ "use strict";
6079
+ init_agent_dependencies();
6080
+ init_native_stt();
6081
+ LOCAL_WHISPER_SCRIPT_PATH_ENV = "OPENACP_SPEECH_LOCAL_WHISPER_SCRIPT_PATH";
6082
+ }
6083
+ });
6084
+
6085
+ // src/core/network/proxy-labels.ts
6086
+ function humanizeIdentifier(value) {
6087
+ const words = value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[._-]+/g, " ").trim();
6088
+ return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "Unknown traffic";
6089
+ }
6090
+ function proxyCategoryLabel(category) {
6091
+ return CATEGORY_LABELS[category] ?? humanizeIdentifier(category);
6092
+ }
6093
+ function proxyScopeLabel(scope) {
6094
+ if (SCOPE_LABELS[scope]) return SCOPE_LABELS[scope];
6095
+ const [category, ...rest] = scope.split(".");
6096
+ const detail = humanizeIdentifier(rest.join("."));
6097
+ return rest.length ? `${proxyCategoryLabel(category)} \xB7 ${detail}` : humanizeIdentifier(scope);
6098
+ }
6099
+ var CATEGORY_LABELS, SCOPE_LABELS;
6100
+ var init_proxy_labels = __esm({
6101
+ "src/core/network/proxy-labels.ts"() {
6102
+ "use strict";
6103
+ CATEGORY_LABELS = {
6104
+ channels: "Messaging connectors",
6105
+ agents: "Coding agents",
6106
+ services: "OpenACP services",
6107
+ plugins: "Plugins"
6108
+ };
6109
+ SCOPE_LABELS = {
6110
+ global: "Default for all traffic",
6111
+ "channels.default": "Other messaging connectors",
6112
+ "channels.telegram": "Telegram",
6113
+ "agents.default": "Other coding agents",
6114
+ "agents.codex": "Codex",
6115
+ "agents.cursor": "Cursor",
6116
+ "services.default": "Other OpenACP services",
6117
+ "services.npmUpdate": "OpenACP updates",
6118
+ "services.agentRegistry": "Agent catalog",
6119
+ "services.pluginInstaller": "Plugin installation",
6120
+ "services.speech": "Groq transcription",
6121
+ "services.speechDownloads": "Local speech model downloads",
6122
+ "plugins.default": "Plugin network traffic"
6123
+ };
6124
+ }
6125
+ });
6126
+
6127
+ // src/core/doctor/checks/speech.ts
6128
+ import path31 from "path";
6129
+ var groqRoute, modelDownloadRoute, speechCheck;
6130
+ var init_speech = __esm({
6131
+ "src/core/doctor/checks/speech.ts"() {
6132
+ "use strict";
6133
+ init_settings_manager();
6134
+ init_native_stt();
6135
+ init_local_readiness();
6136
+ init_groq();
6137
+ init_proxy_labels();
6138
+ groqRoute = proxyScopeLabel("services.speech");
6139
+ modelDownloadRoute = proxyScopeLabel("services.speechDownloads");
6140
+ speechCheck = {
6141
+ name: "Speech-to-text",
6142
+ order: 84,
6143
+ async run(ctx) {
6144
+ const settings = await new SettingsManager(path31.join(ctx.pluginsDir, "data")).loadSettings("@openacp/speech");
6145
+ const legacyGroqKey = typeof settings.groqApiKey === "string" && settings.groqApiKey.trim().length > 0;
6146
+ const method = Object.prototype.hasOwnProperty.call(settings, "sttProvider") ? settings.sttProvider : legacyGroqKey ? "groq" : void 0;
6147
+ if (method !== LOCAL_WHISPER_PROVIDER && method !== "groq") {
6148
+ return [{ status: "pass", message: "Off (optional); no voice messages are sent for transcription" }];
6149
+ }
6150
+ if (method === LOCAL_WHISPER_PROVIDER) {
6151
+ const readiness = getLocalWhisperReadiness(settings);
6152
+ if (readiness.ready) {
6153
+ return [{ status: "pass", message: `Local transcription selected; runtime ready. The model downloads on first use using the ${modelDownloadRoute} route` }];
6154
+ }
6155
+ const issue = readiness.script === "missing" ? "configured transcription executable missing" : readiness.script === "not-file" ? "configured transcription executable is not a file" : readiness.script === "not-executable" ? "configured transcription file is not executable" : "Python 3 or uv not found";
6156
+ return [{
6157
+ status: "warn",
6158
+ message: `Local transcription selected but setup is not ready (${issue}). Restore or authorize the configured executable, or install Python 3/uv; model downloads use the ${modelDownloadRoute} route`
6159
+ }];
6160
+ }
6161
+ const key = typeof settings.groqApiKey === "string" ? settings.groqApiKey.trim() : "";
6162
+ if (!key) return [{ status: "warn", message: "Groq cloud transcription selected; API key not set. Add it securely in Settings \u2192 Speech-to-text" }];
6163
+ try {
6164
+ const provider = new GroqSTT(key, void 0, ctx.fetchForScope("services.speech"));
6165
+ const result = await provider.checkAccess(AbortSignal.timeout(8e3));
6166
+ return [{
6167
+ status: result.ok ? "pass" : "warn",
6168
+ message: result.ok ? `Groq cloud transcription selected; API key saved (hidden) and access verified using the ${groqRoute} route` : `Groq cloud transcription selected; API key saved (hidden), but access is not ready: ${result.message} Route: ${groqRoute}`
6169
+ }];
6170
+ } catch {
6171
+ return [{ status: "warn", message: `Groq cloud transcription selected; API key saved (hidden), but the ${groqRoute} route could not start an access check` }];
6172
+ }
6173
+ }
6174
+ };
6175
+ }
6176
+ });
6177
+
5434
6178
  // src/core/doctor/index.ts
5435
- import * as fs30 from "fs";
5436
- import * as path28 from "path";
6179
+ import * as fs33 from "fs";
6180
+ import * as path32 from "path";
5437
6181
  var ALL_CHECKS, CHECK_TIMEOUT_MS, DoctorEngine;
5438
6182
  var init_doctor = __esm({
5439
6183
  "src/core/doctor/index.ts"() {
@@ -5449,6 +6193,7 @@ var init_doctor = __esm({
5449
6193
  init_daemon();
5450
6194
  init_tunnel();
5451
6195
  init_proxy();
6196
+ init_speech();
5452
6197
  ALL_CHECKS = [
5453
6198
  configCheck,
5454
6199
  agentsCheck,
@@ -5458,6 +6203,7 @@ var init_doctor = __esm({
5458
6203
  pluginsCheck,
5459
6204
  daemonCheck,
5460
6205
  tunnelCheck,
6206
+ speechCheck,
5461
6207
  proxyCheck
5462
6208
  ];
5463
6209
  CHECK_TIMEOUT_MS = 1e4;
@@ -5526,28 +6272,29 @@ var init_doctor = __esm({
5526
6272
  /** Constructs the shared context used by all checks — loads config if available. */
5527
6273
  async buildContext() {
5528
6274
  const dataDir = this.dataDir;
5529
- const configPath = process.env.OPENACP_CONFIG_PATH || path28.join(dataDir, "config.json");
6275
+ const configPath = process.env.OPENACP_CONFIG_PATH || path32.join(dataDir, "config.json");
5530
6276
  let config = null;
5531
6277
  let rawConfig = null;
5532
6278
  try {
5533
- const content = fs30.readFileSync(configPath, "utf-8");
6279
+ const content = fs33.readFileSync(configPath, "utf-8");
5534
6280
  rawConfig = JSON.parse(content);
5535
6281
  const cm = new ConfigManager(configPath);
5536
6282
  await cm.load();
5537
6283
  config = cm.get();
5538
6284
  } catch {
5539
6285
  }
5540
- const logsDir = config ? expandHome2(config.logging.logDir) : path28.join(dataDir, "logs");
6286
+ const logsDir = config ? expandHome2(config.logging.logDir) : path32.join(dataDir, "logs");
5541
6287
  return {
5542
6288
  config,
5543
6289
  rawConfig,
5544
6290
  configPath,
5545
6291
  dataDir,
5546
- sessionsPath: path28.join(dataDir, "sessions.json"),
5547
- pidPath: path28.join(dataDir, "openacp.pid"),
5548
- portFilePath: path28.join(dataDir, "api.port"),
5549
- pluginsDir: path28.join(dataDir, "plugins"),
6292
+ sessionsPath: path32.join(dataDir, "sessions.json"),
6293
+ pidPath: path32.join(dataDir, "openacp.pid"),
6294
+ portFilePath: path32.join(dataDir, "api.port"),
6295
+ pluginsDir: path32.join(dataDir, "plugins"),
5550
6296
  logsDir,
6297
+ daemonProxyEnvironment: inspectDaemonProxyEnvironment(path32.join(dataDir, "openacp.pid"), dataDir),
5551
6298
  fetchForScope: (scope) => this.proxyService.createFetch(scope)
5552
6299
  };
5553
6300
  }
@@ -5555,119 +6302,6 @@ var init_doctor = __esm({
5555
6302
  }
5556
6303
  });
5557
6304
 
5558
- // src/core/instance/instance-registry.ts
5559
- import fs32 from "fs";
5560
- import path30 from "path";
5561
- import { randomUUID as randomUUID5 } from "crypto";
5562
- function readIdFromConfig(instanceRoot) {
5563
- try {
5564
- const raw = JSON.parse(fs32.readFileSync(path30.join(instanceRoot, "config.json"), "utf-8"));
5565
- return typeof raw.id === "string" && raw.id ? raw.id : null;
5566
- } catch {
5567
- return null;
5568
- }
5569
- }
5570
- var InstanceRegistry;
5571
- var init_instance_registry = __esm({
5572
- "src/core/instance/instance-registry.ts"() {
5573
- "use strict";
5574
- InstanceRegistry = class {
5575
- constructor(registryPath) {
5576
- this.registryPath = registryPath;
5577
- }
5578
- registryPath;
5579
- data = { version: 1, instances: {} };
5580
- /** Load the registry from disk. If the file is missing or corrupt, starts fresh. */
5581
- load() {
5582
- try {
5583
- const raw = fs32.readFileSync(this.registryPath, "utf-8");
5584
- const parsed = JSON.parse(raw);
5585
- if (parsed.version === 1 && parsed.instances) {
5586
- this.data = parsed;
5587
- this.deduplicate();
5588
- }
5589
- } catch {
5590
- }
5591
- }
5592
- /** Remove duplicate entries that point to the same root, keeping the first one */
5593
- deduplicate() {
5594
- const seen = /* @__PURE__ */ new Set();
5595
- const toRemove = [];
5596
- for (const [id, entry] of Object.entries(this.data.instances)) {
5597
- if (seen.has(entry.root)) {
5598
- toRemove.push(id);
5599
- } else {
5600
- seen.add(entry.root);
5601
- }
5602
- }
5603
- if (toRemove.length > 0) {
5604
- for (const id of toRemove) delete this.data.instances[id];
5605
- this.save();
5606
- }
5607
- }
5608
- /** Persist the registry to disk, creating parent directories if needed. */
5609
- save() {
5610
- const dir = path30.dirname(this.registryPath);
5611
- fs32.mkdirSync(dir, { recursive: true });
5612
- fs32.writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2));
5613
- }
5614
- /** Add or update an instance entry in the registry. Does not persist — call save() after. */
5615
- register(id, root) {
5616
- this.data.instances[id] = { id, root };
5617
- }
5618
- /** Remove an instance entry. Does not persist — call save() after. */
5619
- remove(id) {
5620
- delete this.data.instances[id];
5621
- }
5622
- /** Look up an instance by its ID. */
5623
- get(id) {
5624
- return this.data.instances[id];
5625
- }
5626
- /** Look up an instance by its root directory path. */
5627
- getByRoot(root) {
5628
- return Object.values(this.data.instances).find((e) => e.root === root);
5629
- }
5630
- /** Returns all registered instances. */
5631
- list() {
5632
- return Object.values(this.data.instances);
5633
- }
5634
- /** Returns `baseId` if available, otherwise appends `-2`, `-3`, etc. until unique. */
5635
- uniqueId(baseId) {
5636
- if (!this.data.instances[baseId]) return baseId;
5637
- let n = 2;
5638
- while (this.data.instances[`${baseId}-${n}`]) n++;
5639
- return `${baseId}-${n}`;
5640
- }
5641
- /**
5642
- * Resolve the authoritative id for an instance root.
5643
- *
5644
- * config.json is the source of truth. If the registry entry disagrees with
5645
- * config.json, the registry is updated to match. If neither exists, a fresh
5646
- * UUID is generated and registered (but NOT written to config.json — the
5647
- * caller must call initInstanceFiles to persist it).
5648
- *
5649
- * Returns the resolved id and whether the registry was mutated (so callers
5650
- * can decide whether to persist with save()).
5651
- */
5652
- resolveId(instanceRoot) {
5653
- const configId = readIdFromConfig(instanceRoot);
5654
- const entry = this.getByRoot(instanceRoot);
5655
- if (entry && configId && entry.id !== configId) {
5656
- this.remove(entry.id);
5657
- this.register(configId, instanceRoot);
5658
- return { id: configId, registryUpdated: true };
5659
- }
5660
- const id = configId ?? entry?.id ?? randomUUID5();
5661
- if (!this.getByRoot(instanceRoot)) {
5662
- this.register(id, instanceRoot);
5663
- return { id, registryUpdated: true };
5664
- }
5665
- return { id, registryUpdated: false };
5666
- }
5667
- };
5668
- }
5669
- });
5670
-
5671
6305
  // src/core/plugin/terminal-io.ts
5672
6306
  import * as clack from "@clack/prompts";
5673
6307
  function isCancel(value) {
@@ -5731,17 +6365,18 @@ var install_context_exports = {};
5731
6365
  __export(install_context_exports, {
5732
6366
  createInstallContext: () => createInstallContext
5733
6367
  });
5734
- import path32 from "path";
6368
+ import path35 from "path";
5735
6369
  function createInstallContext(opts) {
5736
6370
  const { pluginName, settingsManager, basePath, instanceRoot } = opts;
5737
- const dataDir = path32.join(basePath, pluginName, "data");
6371
+ const dataDir = path35.join(basePath, pluginName, "data");
5738
6372
  return {
5739
6373
  pluginName,
5740
6374
  terminal: createTerminalIO(),
5741
6375
  settings: settingsManager.createAPI(pluginName),
5742
6376
  dataDir,
5743
6377
  log: log.child({ plugin: pluginName }),
5744
- instanceRoot
6378
+ instanceRoot,
6379
+ transactSettings: (prepare) => settingsManager.transactPluginSettings(pluginName, prepare)
5745
6380
  };
5746
6381
  }
5747
6382
  var init_install_context = __esm({
@@ -5762,14 +6397,14 @@ __export(api_client_exports, {
5762
6397
  waitForApiReady: () => waitForApiReady,
5763
6398
  waitForPortFile: () => waitForPortFile
5764
6399
  });
5765
- import * as fs34 from "fs";
5766
- import * as path33 from "path";
6400
+ import * as fs36 from "fs";
6401
+ import * as path36 from "path";
5767
6402
  import { setTimeout as sleep } from "timers/promises";
5768
6403
  function defaultPortFile(root) {
5769
- return path33.join(root, "api.port");
6404
+ return path36.join(root, "api.port");
5770
6405
  }
5771
6406
  function defaultSecretFile(root) {
5772
- return path33.join(root, "api-secret");
6407
+ return path36.join(root, "api-secret");
5773
6408
  }
5774
6409
  async function waitForPortFile(portFilePath, timeoutMs = 5e3, intervalMs = 100) {
5775
6410
  const deadline = Date.now() + timeoutMs;
@@ -5803,7 +6438,7 @@ async function waitForApiReady(instanceRoot, expectedInstanceId, timeoutMs = 1e4
5803
6438
  function readApiPort(portFilePath, instanceRoot) {
5804
6439
  const filePath = portFilePath ?? defaultPortFile(instanceRoot);
5805
6440
  try {
5806
- const content = fs34.readFileSync(filePath, "utf-8").trim();
6441
+ const content = fs36.readFileSync(filePath, "utf-8").trim();
5807
6442
  const port = parseInt(content, 10);
5808
6443
  return isNaN(port) ? null : port;
5809
6444
  } catch {
@@ -5813,7 +6448,7 @@ function readApiPort(portFilePath, instanceRoot) {
5813
6448
  function readApiSecret(secretFilePath, instanceRoot) {
5814
6449
  const filePath = secretFilePath ?? defaultSecretFile(instanceRoot);
5815
6450
  try {
5816
- const content = fs34.readFileSync(filePath, "utf-8").trim();
6451
+ const content = fs36.readFileSync(filePath, "utf-8").trim();
5817
6452
  return content || null;
5818
6453
  } catch {
5819
6454
  return null;
@@ -5822,7 +6457,7 @@ function readApiSecret(secretFilePath, instanceRoot) {
5822
6457
  function removeStalePortFile(portFilePath, instanceRoot) {
5823
6458
  const filePath = portFilePath ?? defaultPortFile(instanceRoot);
5824
6459
  try {
5825
- fs34.unlinkSync(filePath);
6460
+ fs36.unlinkSync(filePath);
5826
6461
  } catch {
5827
6462
  }
5828
6463
  }
@@ -5959,8 +6594,8 @@ var init_notification = __esm({
5959
6594
  });
5960
6595
 
5961
6596
  // src/plugins/file-service/file-service.ts
5962
- import fs36 from "fs";
5963
- import path36 from "path";
6597
+ import fs38 from "fs";
6598
+ import path39 from "path";
5964
6599
  import { OggOpusDecoder } from "ogg-opus-decoder";
5965
6600
  import wav from "node-wav";
5966
6601
  function classifyMime(mimeType) {
@@ -6017,14 +6652,14 @@ var init_file_service = __esm({
6017
6652
  const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1e3;
6018
6653
  let removed = 0;
6019
6654
  try {
6020
- const entries = await fs36.promises.readdir(this.baseDir, { withFileTypes: true });
6655
+ const entries = await fs38.promises.readdir(this.baseDir, { withFileTypes: true });
6021
6656
  for (const entry of entries) {
6022
6657
  if (!entry.isDirectory()) continue;
6023
- const dirPath = path36.join(this.baseDir, entry.name);
6658
+ const dirPath = path39.join(this.baseDir, entry.name);
6024
6659
  try {
6025
- const stat = await fs36.promises.stat(dirPath);
6660
+ const stat = await fs38.promises.stat(dirPath);
6026
6661
  if (stat.mtimeMs < cutoff) {
6027
- await fs36.promises.rm(dirPath, { recursive: true, force: true });
6662
+ await fs38.promises.rm(dirPath, { recursive: true, force: true });
6028
6663
  removed++;
6029
6664
  }
6030
6665
  } catch {
@@ -6043,11 +6678,11 @@ var init_file_service = __esm({
6043
6678
  * so the user-facing name is not lost.
6044
6679
  */
6045
6680
  async saveFile(sessionId, fileName, data, mimeType) {
6046
- const sessionDir = path36.join(this.baseDir, sessionId);
6047
- await fs36.promises.mkdir(sessionDir, { recursive: true });
6681
+ const sessionDir = path39.join(this.baseDir, sessionId);
6682
+ await fs38.promises.mkdir(sessionDir, { recursive: true });
6048
6683
  const safeName = `${Date.now()}-${fileName.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
6049
- const filePath = path36.join(sessionDir, safeName);
6050
- await fs36.promises.writeFile(filePath, data);
6684
+ const filePath = path39.join(sessionDir, safeName);
6685
+ await fs38.promises.writeFile(filePath, data);
6051
6686
  return {
6052
6687
  type: classifyMime(mimeType),
6053
6688
  filePath,
@@ -6062,14 +6697,14 @@ var init_file_service = __esm({
6062
6697
  */
6063
6698
  async resolveFile(filePath) {
6064
6699
  try {
6065
- const stat = await fs36.promises.stat(filePath);
6700
+ const stat = await fs38.promises.stat(filePath);
6066
6701
  if (!stat.isFile()) return null;
6067
- const ext = path36.extname(filePath).toLowerCase();
6702
+ const ext = path39.extname(filePath).toLowerCase();
6068
6703
  const mimeType = EXT_TO_MIME[ext] || "application/octet-stream";
6069
6704
  return {
6070
6705
  type: classifyMime(mimeType),
6071
6706
  filePath,
6072
- fileName: path36.basename(filePath),
6707
+ fileName: path39.basename(filePath),
6073
6708
  mimeType,
6074
6709
  size: stat.size
6075
6710
  };
@@ -6408,6 +7043,9 @@ import fastifyRateLimit from "@fastify/rate-limit";
6408
7043
  import { serializerCompiler, validatorCompiler } from "fastify-type-provider-zod";
6409
7044
  async function createApiServer(options) {
6410
7045
  const app = Fastify({ logger: options.logger ?? false, forceCloseConnections: true });
7046
+ app.addHook("onClose", async () => {
7047
+ if (options.tokenStore) await options.tokenStore.close();
7048
+ });
6411
7049
  app.setValidatorCompiler(validatorCompiler);
6412
7050
  app.setSerializerCompiler(serializerCompiler);
6413
7051
  app.addHook("onRequest", async (request, reply) => {
@@ -6712,8 +7350,8 @@ data: ${JSON.stringify(data)}
6712
7350
  });
6713
7351
 
6714
7352
  // src/plugins/api-server/static-server.ts
6715
- import * as fs37 from "fs";
6716
- import * as path37 from "path";
7353
+ import * as fs39 from "fs";
7354
+ import * as path40 from "path";
6717
7355
  import { fileURLToPath as fileURLToPath2 } from "url";
6718
7356
  var MIME_TYPES, StaticServer;
6719
7357
  var init_static_server = __esm({
@@ -6737,16 +7375,16 @@ var init_static_server = __esm({
6737
7375
  this.uiDir = uiDir;
6738
7376
  if (!this.uiDir) {
6739
7377
  const __filename = fileURLToPath2(import.meta.url);
6740
- const candidate = path37.resolve(path37.dirname(__filename), "../../ui/dist");
6741
- if (fs37.existsSync(path37.join(candidate, "index.html"))) {
7378
+ const candidate = path40.resolve(path40.dirname(__filename), "../../ui/dist");
7379
+ if (fs39.existsSync(path40.join(candidate, "index.html"))) {
6742
7380
  this.uiDir = candidate;
6743
7381
  }
6744
7382
  if (!this.uiDir) {
6745
- const publishCandidate = path37.resolve(
6746
- path37.dirname(__filename),
7383
+ const publishCandidate = path40.resolve(
7384
+ path40.dirname(__filename),
6747
7385
  "../ui"
6748
7386
  );
6749
- if (fs37.existsSync(path37.join(publishCandidate, "index.html"))) {
7387
+ if (fs39.existsSync(path40.join(publishCandidate, "index.html"))) {
6750
7388
  this.uiDir = publishCandidate;
6751
7389
  }
6752
7390
  }
@@ -6764,23 +7402,23 @@ var init_static_server = __esm({
6764
7402
  serve(req, res) {
6765
7403
  if (!this.uiDir) return false;
6766
7404
  const urlPath = (req.url || "/").split("?")[0];
6767
- const safePath = path37.normalize(urlPath);
6768
- const filePath = path37.join(this.uiDir, safePath);
6769
- if (!filePath.startsWith(this.uiDir + path37.sep) && filePath !== this.uiDir)
7405
+ const safePath = path40.normalize(urlPath);
7406
+ const filePath = path40.join(this.uiDir, safePath);
7407
+ if (!filePath.startsWith(this.uiDir + path40.sep) && filePath !== this.uiDir)
6770
7408
  return false;
6771
7409
  let realFilePath;
6772
7410
  try {
6773
- realFilePath = fs37.realpathSync(filePath);
7411
+ realFilePath = fs39.realpathSync(filePath);
6774
7412
  } catch {
6775
7413
  realFilePath = null;
6776
7414
  }
6777
7415
  if (realFilePath !== null) {
6778
- const realUiDir = fs37.realpathSync(this.uiDir);
6779
- if (!realFilePath.startsWith(realUiDir + path37.sep) && realFilePath !== realUiDir)
7416
+ const realUiDir = fs39.realpathSync(this.uiDir);
7417
+ if (!realFilePath.startsWith(realUiDir + path40.sep) && realFilePath !== realUiDir)
6780
7418
  return false;
6781
7419
  }
6782
- if (realFilePath !== null && fs37.existsSync(realFilePath) && fs37.statSync(realFilePath).isFile()) {
6783
- const ext = path37.extname(filePath);
7420
+ if (realFilePath !== null && fs39.existsSync(realFilePath) && fs39.statSync(realFilePath).isFile()) {
7421
+ const ext = path40.extname(filePath);
6784
7422
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
6785
7423
  const isHashed = /\.[a-zA-Z0-9]{8,}\.(js|css)$/.test(filePath);
6786
7424
  const cacheControl = isHashed ? "public, max-age=31536000, immutable" : "no-cache";
@@ -6788,16 +7426,16 @@ var init_static_server = __esm({
6788
7426
  "Content-Type": contentType,
6789
7427
  "Cache-Control": cacheControl
6790
7428
  });
6791
- fs37.createReadStream(realFilePath).pipe(res);
7429
+ fs39.createReadStream(realFilePath).pipe(res);
6792
7430
  return true;
6793
7431
  }
6794
- const indexPath = path37.join(this.uiDir, "index.html");
6795
- if (fs37.existsSync(indexPath)) {
7432
+ const indexPath = path40.join(this.uiDir, "index.html");
7433
+ if (fs39.existsSync(indexPath)) {
6796
7434
  res.writeHead(200, {
6797
7435
  "Content-Type": "text/html; charset=utf-8",
6798
7436
  "Cache-Control": "no-cache"
6799
7437
  });
6800
- fs37.createReadStream(indexPath).pipe(res);
7438
+ fs39.createReadStream(indexPath).pipe(res);
6801
7439
  return true;
6802
7440
  }
6803
7441
  return false;
@@ -6821,23 +7459,28 @@ var init_speech_service = __esm({
6821
7459
  providerFactory;
6822
7460
  factoryOwnedSTT = /* @__PURE__ */ new Set();
6823
7461
  factoryOwnedTTS = /* @__PURE__ */ new Set();
7462
+ runtimeRevision = 0;
6824
7463
  /** Set a factory function that can recreate providers from config (for hot-reload) */
6825
7464
  setProviderFactory(factory) {
6826
7465
  this.providerFactory = factory;
7466
+ this.runtimeRevision += 1;
6827
7467
  }
6828
7468
  /** Register an STT provider by name. Overwrites any existing provider with the same name. */
6829
7469
  registerSTTProvider(name, provider) {
6830
7470
  this.sttProviders.set(name, provider);
6831
7471
  this.factoryOwnedSTT.delete(name);
7472
+ this.runtimeRevision += 1;
6832
7473
  }
6833
7474
  /** Register a TTS provider by name. Called by external TTS plugins (e.g. msedge-tts-plugin). */
6834
7475
  registerTTSProvider(name, provider) {
6835
7476
  this.ttsProviders.set(name, provider);
6836
7477
  this.factoryOwnedTTS.delete(name);
7478
+ this.runtimeRevision += 1;
6837
7479
  }
6838
7480
  /** Remove a TTS provider — called by external plugins on teardown. */
6839
7481
  unregisterTTSProvider(name) {
6840
7482
  this.ttsProviders.delete(name);
7483
+ this.runtimeRevision += 1;
6841
7484
  }
6842
7485
  /** Returns true if an STT provider is configured and has credentials. */
6843
7486
  isSTTAvailable() {
@@ -6862,7 +7505,7 @@ var init_speech_service = __esm({
6862
7505
  async transcribe(audioBuffer, mimeType, options) {
6863
7506
  const providerName = this.config.stt.provider;
6864
7507
  if (!providerName || !this.config.stt.providers[providerName]?.apiKey) {
6865
- throw new Error("STT not configured. Set speech.stt.provider and API key in config.");
7508
+ throw new Error("Speech-to-text is off or not ready. Open Settings \u2192 Speech-to-text or run /speech.");
6866
7509
  }
6867
7510
  const provider = this.sttProviders.get(providerName);
6868
7511
  if (!provider) {
@@ -6889,6 +7532,77 @@ var init_speech_service = __esm({
6889
7532
  /** Replace the active config without rebuilding providers. Use `refreshProviders` to also rebuild. */
6890
7533
  updateConfig(config) {
6891
7534
  this.config = config;
7535
+ this.runtimeRevision += 1;
7536
+ }
7537
+ /**
7538
+ * Build and validate a complete factory-owned provider replacement without
7539
+ * changing the active runtime. The returned commit is revision-checked and
7540
+ * preserves providers registered by external plugins. Rollback restores the
7541
+ * exact config, maps, ownership sets, and provider object references captured
7542
+ * immediately before commit.
7543
+ */
7544
+ prepareProviderRefresh(newConfig) {
7545
+ if (!this.providerFactory) {
7546
+ const baseRevision2 = this.runtimeRevision;
7547
+ let previousConfig;
7548
+ let committed2 = false;
7549
+ return {
7550
+ commit: () => {
7551
+ if (committed2 || this.runtimeRevision !== baseRevision2) throw new Error("Speech runtime changed while provider refresh was being prepared");
7552
+ previousConfig = this.config;
7553
+ this.config = newConfig;
7554
+ this.runtimeRevision = baseRevision2 + 1;
7555
+ committed2 = true;
7556
+ },
7557
+ rollback: () => {
7558
+ if (!committed2 || previousConfig === void 0) return;
7559
+ if (this.runtimeRevision !== baseRevision2 + 1) throw new Error("Speech runtime changed before provider refresh rollback");
7560
+ this.config = previousConfig;
7561
+ this.runtimeRevision = baseRevision2;
7562
+ committed2 = false;
7563
+ }
7564
+ };
7565
+ }
7566
+ const baseRevision = this.runtimeRevision;
7567
+ const built = this.providerFactory(newConfig);
7568
+ let previous;
7569
+ let committed = false;
7570
+ return {
7571
+ commit: () => {
7572
+ if (committed || this.runtimeRevision !== baseRevision) throw new Error("Speech runtime changed while provider refresh was being prepared");
7573
+ previous = {
7574
+ config: this.config,
7575
+ sttProviders: this.sttProviders,
7576
+ ttsProviders: this.ttsProviders,
7577
+ factoryOwnedSTT: this.factoryOwnedSTT,
7578
+ factoryOwnedTTS: this.factoryOwnedTTS
7579
+ };
7580
+ const nextSTT = new Map(this.sttProviders);
7581
+ const nextTTS = new Map(this.ttsProviders);
7582
+ for (const name of this.factoryOwnedSTT) nextSTT.delete(name);
7583
+ for (const name of this.factoryOwnedTTS) nextTTS.delete(name);
7584
+ for (const [name, provider] of built.stt) nextSTT.set(name, provider);
7585
+ for (const [name, provider] of built.tts) nextTTS.set(name, provider);
7586
+ this.config = newConfig;
7587
+ this.sttProviders = nextSTT;
7588
+ this.ttsProviders = nextTTS;
7589
+ this.factoryOwnedSTT = new Set(built.stt.keys());
7590
+ this.factoryOwnedTTS = new Set(built.tts.keys());
7591
+ this.runtimeRevision = baseRevision + 1;
7592
+ committed = true;
7593
+ },
7594
+ rollback: () => {
7595
+ if (!committed || !previous) return;
7596
+ if (this.runtimeRevision !== baseRevision + 1) throw new Error("Speech runtime changed before provider refresh rollback");
7597
+ this.config = previous.config;
7598
+ this.sttProviders = previous.sttProviders;
7599
+ this.ttsProviders = previous.ttsProviders;
7600
+ this.factoryOwnedSTT = previous.factoryOwnedSTT;
7601
+ this.factoryOwnedTTS = previous.factoryOwnedTTS;
7602
+ this.runtimeRevision = baseRevision;
7603
+ committed = false;
7604
+ }
7605
+ };
6892
7606
  }
6893
7607
  /**
6894
7608
  * Reloads TTS and STT providers from a new config snapshot.
@@ -6898,22 +7612,7 @@ var init_speech_service = __esm({
6898
7612
  * (e.g. from `@openacp/msedge-tts-plugin`) are preserved rather than discarded.
6899
7613
  */
6900
7614
  refreshProviders(newConfig) {
6901
- if (!this.providerFactory) {
6902
- this.config = newConfig;
6903
- return;
6904
- }
6905
- const built = this.providerFactory(newConfig);
6906
- const nextSTT = new Map(this.sttProviders);
6907
- const nextTTS = new Map(this.ttsProviders);
6908
- for (const name of this.factoryOwnedSTT) nextSTT.delete(name);
6909
- for (const name of this.factoryOwnedTTS) nextTTS.delete(name);
6910
- for (const [name, provider] of built.stt) nextSTT.set(name, provider);
6911
- for (const [name, provider] of built.tts) nextTTS.set(name, provider);
6912
- this.config = newConfig;
6913
- this.sttProviders = nextSTT;
6914
- this.ttsProviders = nextTTS;
6915
- this.factoryOwnedSTT = new Set(built.stt.keys());
6916
- this.factoryOwnedTTS = new Set(built.tts.keys());
7615
+ this.prepareProviderRefresh(newConfig).commit();
6917
7616
  }
6918
7617
  };
6919
7618
  }
@@ -6930,8 +7629,8 @@ var init_exports = __esm({
6930
7629
  });
6931
7630
 
6932
7631
  // src/plugins/context/context-cache.ts
6933
- import * as fs38 from "fs";
6934
- import * as path38 from "path";
7632
+ import * as fs40 from "fs";
7633
+ import * as path41 from "path";
6935
7634
  import * as crypto2 from "crypto";
6936
7635
  var DEFAULT_TTL_MS, ContextCache;
6937
7636
  var init_context_cache = __esm({
@@ -6942,7 +7641,7 @@ var init_context_cache = __esm({
6942
7641
  constructor(cacheDir, ttlMs = DEFAULT_TTL_MS) {
6943
7642
  this.cacheDir = cacheDir;
6944
7643
  this.ttlMs = ttlMs;
6945
- fs38.mkdirSync(cacheDir, { recursive: true });
7644
+ fs40.mkdirSync(cacheDir, { recursive: true });
6946
7645
  }
6947
7646
  cacheDir;
6948
7647
  ttlMs;
@@ -6950,23 +7649,23 @@ var init_context_cache = __esm({
6950
7649
  return crypto2.createHash("sha256").update(`${repoPath}:${queryKey}`).digest("hex").slice(0, 16);
6951
7650
  }
6952
7651
  filePath(repoPath, queryKey) {
6953
- return path38.join(this.cacheDir, `${this.keyHash(repoPath, queryKey)}.json`);
7652
+ return path41.join(this.cacheDir, `${this.keyHash(repoPath, queryKey)}.json`);
6954
7653
  }
6955
7654
  get(repoPath, queryKey) {
6956
7655
  const fp = this.filePath(repoPath, queryKey);
6957
7656
  try {
6958
- const stat = fs38.statSync(fp);
7657
+ const stat = fs40.statSync(fp);
6959
7658
  if (Date.now() - stat.mtimeMs > this.ttlMs) {
6960
- fs38.unlinkSync(fp);
7659
+ fs40.unlinkSync(fp);
6961
7660
  return null;
6962
7661
  }
6963
- return JSON.parse(fs38.readFileSync(fp, "utf-8"));
7662
+ return JSON.parse(fs40.readFileSync(fp, "utf-8"));
6964
7663
  } catch {
6965
7664
  return null;
6966
7665
  }
6967
7666
  }
6968
7667
  set(repoPath, queryKey, result) {
6969
- fs38.writeFileSync(this.filePath(repoPath, queryKey), JSON.stringify(result));
7668
+ fs40.writeFileSync(this.filePath(repoPath, queryKey), JSON.stringify(result));
6970
7669
  }
6971
7670
  };
6972
7671
  }
@@ -7092,7 +7791,7 @@ var init_context_provider = __esm({
7092
7791
  });
7093
7792
 
7094
7793
  // src/plugins/context/entire/checkpoint-reader.ts
7095
- import { execFileSync as execFileSync7 } from "child_process";
7794
+ import { execFileSync as execFileSync8 } from "child_process";
7096
7795
  var ENTIRE_BRANCH, CHECKPOINT_ID_RE, SESSION_ID_RE, CheckpointReader;
7097
7796
  var init_checkpoint_reader = __esm({
7098
7797
  "src/plugins/context/entire/checkpoint-reader.ts"() {
@@ -7112,7 +7811,7 @@ var init_checkpoint_reader = __esm({
7112
7811
  */
7113
7812
  git(...args) {
7114
7813
  try {
7115
- return execFileSync7("git", ["-C", this.repoPath, ...args], {
7814
+ return execFileSync8("git", ["-C", this.repoPath, ...args], {
7116
7815
  encoding: "utf-8"
7117
7816
  }).trim();
7118
7817
  } catch {
@@ -7940,8 +8639,8 @@ function formatToolSummary(name, rawInput, displaySummary) {
7940
8639
  }
7941
8640
  if (lowerName === "grep") {
7942
8641
  const pattern = args.pattern ?? "";
7943
- const path39 = args.path ?? "";
7944
- return pattern ? `\u{1F50D} Grep "${pattern}"${path39 ? ` in ${path39}` : ""}` : `\u{1F527} ${name}`;
8642
+ const path42 = args.path ?? "";
8643
+ return pattern ? `\u{1F50D} Grep "${pattern}"${path42 ? ` in ${path42}` : ""}` : `\u{1F527} ${name}`;
7945
8644
  }
7946
8645
  if (lowerName === "glob") {
7947
8646
  const pattern = args.pattern ?? "";
@@ -7977,8 +8676,8 @@ function formatToolTitle(name, rawInput, displayTitle) {
7977
8676
  }
7978
8677
  if (lowerName === "grep") {
7979
8678
  const pattern = args.pattern ?? "";
7980
- const path39 = args.path ?? "";
7981
- return pattern ? `"${pattern}"${path39 ? ` in ${path39}` : ""}` : name;
8679
+ const path42 = args.path ?? "";
8680
+ return pattern ? `"${pattern}"${path42 ? ` in ${path42}` : ""}` : name;
7982
8681
  }
7983
8682
  if (lowerName === "glob") {
7984
8683
  return String(args.pattern ?? name);
@@ -8456,9 +9155,9 @@ var init_send_queue = __esm({
8456
9155
  const interval = this.getInterval(item.category);
8457
9156
  const lastExec = item.category ? this.lastCategoryExec.get(item.category) ?? 0 : this.lastExec;
8458
9157
  const elapsed = Date.now() - lastExec;
8459
- const delay = Math.max(0, interval - elapsed);
9158
+ const delay2 = Math.max(0, interval - elapsed);
8460
9159
  this.processing = true;
8461
- setTimeout(() => void this.processNext(), delay);
9160
+ setTimeout(() => void this.processNext(), delay2);
8462
9161
  }
8463
9162
  getInterval(category) {
8464
9163
  if (category && this.config.categoryIntervals?.[category] != null) {
@@ -11157,6 +11856,8 @@ import { InlineKeyboard as InlineKeyboard7 } from "grammy";
11157
11856
  async function buildSettingsKeyboard(core) {
11158
11857
  const fields = getSafeFields();
11159
11858
  const kb = new InlineKeyboard7();
11859
+ kb.text("\u{1F399} Speech-to-text", settingsCommandCallback("/speech")).row();
11860
+ kb.text("\u{1F310} Network proxy", settingsCommandCallback("/proxy")).row();
11160
11861
  for (const field of fields) {
11161
11862
  const value = getConfigValue(core.configManager.get(), field.path);
11162
11863
  const label = formatFieldLabel(field, value);
@@ -11168,8 +11869,6 @@ async function buildSettingsKeyboard(core) {
11168
11869
  kb.text(`${label}`, `s:input:${field.path}`).row();
11169
11870
  }
11170
11871
  }
11171
- kb.text("\u{1F310} Proxy Management", settingsCommandCallback("/proxy")).row();
11172
- kb.text("\u{1F399} Speech-to-Text", settingsCommandCallback("/speech")).row();
11173
11872
  kb.text("\u25C0\uFE0F Back to Menu", "s:back");
11174
11873
  return kb;
11175
11874
  }
@@ -11193,7 +11892,7 @@ function formatFieldLabel(field, value) {
11193
11892
  async function handleSettings(ctx, core) {
11194
11893
  const kb = await buildSettingsKeyboard(core);
11195
11894
  await ctx.reply(`<b>\u2699\uFE0F Settings</b>
11196
- Tap to change:`, {
11895
+ Choose a section. Changes that need a restart are marked.`, {
11197
11896
  parse_mode: "HTML",
11198
11897
  reply_markup: kb
11199
11898
  });
@@ -11264,7 +11963,7 @@ Select a value:`, {
11264
11963
  }
11265
11964
  try {
11266
11965
  await ctx.editMessageText(`<b>\u2699\uFE0F Settings</b>
11267
- Tap to change:`, {
11966
+ Choose a section. Changes that need a restart are marked.`, {
11268
11967
  parse_mode: "HTML",
11269
11968
  reply_markup: await buildSettingsKeyboard(core)
11270
11969
  });
@@ -11321,7 +12020,7 @@ Choose an action:`, {
11321
12020
  }
11322
12021
  try {
11323
12022
  await ctx.editMessageText(`<b>\u2699\uFE0F Settings</b>
11324
- Tap to change:`, {
12023
+ Choose a section. Changes that need a restart are marked.`, {
11325
12024
  parse_mode: "HTML",
11326
12025
  reply_markup: await buildSettingsKeyboard(core)
11327
12026
  });
@@ -11340,30 +12039,107 @@ var init_settings = __esm({
11340
12039
  }
11341
12040
  });
11342
12041
 
12042
+ // src/core/doctor/format.ts
12043
+ function countNoun(count, singular, plural) {
12044
+ return `${count} ${count === 1 ? singular : plural}`;
12045
+ }
12046
+ function doctorHeadline(summary) {
12047
+ if (summary.failed === 1) return "1 failure needs attention";
12048
+ if (summary.failed > 1) return `${summary.failed} failures need attention`;
12049
+ if (summary.warnings > 0) return `${countNoun(summary.warnings, "warning", "warnings")} to review`;
12050
+ return "All checks passed";
12051
+ }
12052
+ function doctorSummary(summary, separator = ", ") {
12053
+ const counts = [
12054
+ `${summary.passed} passed`,
12055
+ countNoun(summary.warnings, "warning", "warnings"),
12056
+ countNoun(summary.failed, "failure", "failures")
12057
+ ];
12058
+ if (summary.fixed) counts.push(countNoun(summary.fixed, "fix", "fixes"));
12059
+ return counts.join(separator);
12060
+ }
12061
+ var init_format = __esm({
12062
+ "src/core/doctor/format.ts"() {
12063
+ "use strict";
12064
+ }
12065
+ });
12066
+
11343
12067
  // src/plugins/telegram/commands/doctor.ts
11344
12068
  import { InlineKeyboard as InlineKeyboard8 } from "grammy";
12069
+ function escapeHtmlToLimit(text3, limit) {
12070
+ if (limit <= 0) return "";
12071
+ const chunks = [];
12072
+ let length = 0;
12073
+ let shortened = false;
12074
+ for (const character of text3) {
12075
+ const escaped = escapeHtml2(character);
12076
+ if (length + escaped.length > limit) {
12077
+ shortened = true;
12078
+ break;
12079
+ }
12080
+ chunks.push(escaped);
12081
+ length += escaped.length;
12082
+ }
12083
+ if (!shortened) return chunks.join("");
12084
+ while (chunks.length && length + 1 > limit) length -= chunks.pop().length;
12085
+ return limit > 0 ? `${chunks.join("")}\u2026` : "";
12086
+ }
12087
+ function renderTruncatedReport(report, header2) {
12088
+ const icons = { pass: "\u2705", warn: "\u26A0\uFE0F", fail: "\u274C" };
12089
+ const footer = `
12090
+
12091
+ ${SHORTENED_FOOTER}`;
12092
+ const budget = TELEGRAM_REPORT_LIMIT - footer.length;
12093
+ const lines = [...header2];
12094
+ let length = lines.join("\n").length;
12095
+ const append = (line) => {
12096
+ const added = (lines.length ? 1 : 0) + line.length;
12097
+ if (length + added > budget) return false;
12098
+ lines.push(line);
12099
+ length += added;
12100
+ return true;
12101
+ };
12102
+ outer: for (const category of report.categories.filter((item) => item.results.some((result) => result.status !== "pass"))) {
12103
+ const remainingForCategory = budget - length - 1;
12104
+ const categoryText = escapeHtmlToLimit(category.name, Math.max(0, remainingForCategory - 7));
12105
+ if (!categoryText || !append(`<b>${categoryText}</b>`)) break;
12106
+ for (const result of category.results.filter((item) => item.status !== "pass")) {
12107
+ const prefix = ` ${icons[result.status]} `;
12108
+ const remaining = budget - length - 1 - prefix.length;
12109
+ if (remaining <= 0) break outer;
12110
+ const message = escapeHtmlToLimit(result.message, remaining);
12111
+ if (!append(`${prefix}${message}`)) break outer;
12112
+ if (message !== escapeHtml2(result.message)) break outer;
12113
+ }
12114
+ if (!append("")) break;
12115
+ }
12116
+ return `${lines.join("\n")}${footer}`;
12117
+ }
11345
12118
  function renderReport(report) {
11346
12119
  const icons = { pass: "\u2705", warn: "\u26A0\uFE0F", fail: "\u274C" };
11347
- const lines = ["\u{1FA7A} <b>OpenACP Doctor</b>\n"];
11348
- for (const category of report.categories) {
11349
- lines.push(`<b>${category.name}</b>`);
11350
- for (const result of category.results) {
12120
+ const { passed } = report.summary;
12121
+ const header2 = ["\u{1FA7A} <b>OpenACP Doctor</b>", `<b>${doctorHeadline(report.summary)}</b>`, doctorSummary(report.summary, " \xB7 "), ""];
12122
+ const lines = [...header2];
12123
+ for (const category of report.categories.filter((item) => item.results.some((result) => result.status !== "pass"))) {
12124
+ lines.push(`<b>${escapeHtml2(category.name)}</b>`);
12125
+ for (const result of category.results.filter((item) => item.status !== "pass")) {
11351
12126
  lines.push(` ${icons[result.status]} ${escapeHtml2(result.message)}`);
11352
12127
  }
11353
12128
  lines.push("");
11354
12129
  }
11355
- const { passed, warnings, failed, fixed } = report.summary;
11356
- const fixedStr = fixed > 0 ? `, ${fixed} fixed` : "";
11357
- lines.push(`<b>Result:</b> ${passed} passed, ${warnings} warnings, ${failed} failed${fixedStr}`);
11358
- let keyboard;
12130
+ if (passed) lines.push(`\u2705 ${passed} passed`);
12131
+ const keyboard = new InlineKeyboard8();
11359
12132
  if (report.pendingFixes.length > 0) {
11360
- keyboard = new InlineKeyboard8();
11361
12133
  for (let i = 0; i < report.pendingFixes.length; i++) {
11362
12134
  const label = `\u{1F527} Fix: ${report.pendingFixes[i].message.slice(0, 30)}`;
11363
12135
  keyboard.text(label, `m:doctor:fix:${i}`).row();
11364
12136
  }
11365
12137
  }
11366
- return { text: lines.join("\n"), keyboard };
12138
+ keyboard.text("Run again", "m:doctor").row();
12139
+ keyboard.text("Speech-to-text settings", settingsCommandCallback("/speech")).row();
12140
+ keyboard.text("Network proxy settings", settingsCommandCallback("/proxy"));
12141
+ const text3 = lines.join("\n");
12142
+ return { text: text3.length <= TELEGRAM_REPORT_LIMIT ? text3 : renderTruncatedReport(report, header2), keyboard };
11367
12143
  }
11368
12144
  function escapeHtml2(text3) {
11369
12145
  return text3.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -11387,8 +12163,8 @@ async function handleDoctor(ctx, core) {
11387
12163
  await ctx.api.editMessageText(
11388
12164
  ctx.chat.id,
11389
12165
  statusMsg.message_id,
11390
- `\u274C Doctor failed: ${err instanceof Error ? err.message : String(err)}`,
11391
- { parse_mode: "HTML" }
12166
+ `\u274C Diagnostics could not finish. Check the OpenACP logs, then run the checks again.`,
12167
+ { parse_mode: "HTML", reply_markup: new InlineKeyboard8().text("Run again", "m:doctor") }
11392
12168
  );
11393
12169
  }
11394
12170
  }
@@ -11443,14 +12219,18 @@ function setupDoctorCallbacks(bot, core) {
11443
12219
  await handleDoctor(ctx, core);
11444
12220
  });
11445
12221
  }
11446
- var log30, pendingFixesStore;
12222
+ var log30, pendingFixesStore, TELEGRAM_REPORT_LIMIT, SHORTENED_FOOTER;
11447
12223
  var init_doctor2 = __esm({
11448
12224
  "src/plugins/telegram/commands/doctor.ts"() {
11449
12225
  "use strict";
11450
12226
  init_doctor();
11451
12227
  init_log();
12228
+ init_callback_navigation();
12229
+ init_format();
11452
12230
  log30 = createChildLogger({ module: "telegram-cmd-doctor" });
11453
12231
  pendingFixesStore = /* @__PURE__ */ new Map();
12232
+ TELEGRAM_REPORT_LIMIT = 3900;
12233
+ SHORTENED_FOOTER = "\u2026Output shortened. Run openacp doctor on the host for full details.";
11454
12234
  }
11455
12235
  });
11456
12236
 
@@ -11932,8 +12712,8 @@ var init_commands = __esm({
11932
12712
  { command: "handoff", description: "Continue this session in your terminal" },
11933
12713
  { command: "restart", description: "Restart OpenACP" },
11934
12714
  { command: "update", description: "Update to latest version and restart" },
11935
- { command: "doctor", description: "Run system diagnostics" },
11936
- { command: "proxy", description: "Manage scoped proxy routing" },
12715
+ { command: "doctor", description: "Check OpenACP health" },
12716
+ { command: "proxy", description: "Configure network proxy" },
11937
12717
  { command: "retry", description: "Re-check setup prerequisites (use if bot is stuck on startup)" },
11938
12718
  { command: "tunnel", description: "Create/stop tunnel for a local port" },
11939
12719
  { command: "tunnels", description: "List active tunnels" },
@@ -12953,7 +13733,7 @@ var init_types = __esm({
12953
13733
  });
12954
13734
 
12955
13735
  // src/core/commands/proxy.ts
12956
- import { randomUUID as randomUUID6 } from "crypto";
13736
+ import { createHash as createHash7, randomUUID as randomUUID6 } from "crypto";
12957
13737
  function clearProxyDraftsForChannel(channelId) {
12958
13738
  const prefix = `${channelId}:`;
12959
13739
  for (const [id, draft] of drafts) if (draft.owner.startsWith(prefix)) drafts.delete(id);
@@ -12966,13 +13746,14 @@ var init_proxy2 = __esm({
12966
13746
  init_types();
12967
13747
  init_proxy_store();
12968
13748
  init_proxy_service();
13749
+ init_proxy_labels();
12969
13750
  DRAFT_TTL_MS = 10 * 6e4;
12970
13751
  drafts = /* @__PURE__ */ new Map();
12971
13752
  }
12972
13753
  });
12973
13754
 
12974
13755
  // src/plugins/telegram/command-sync.ts
12975
- import { createHash as createHash6 } from "crypto";
13756
+ import { createHash as createHash8 } from "crypto";
12976
13757
  function validDescription(description) {
12977
13758
  if (typeof description !== "string") return false;
12978
13759
  const trimmed = description.trim();
@@ -13041,7 +13822,7 @@ function localeLabel(locale) {
13041
13822
  async function synchronizeTelegramCommands(api, chatId, desired, options) {
13042
13823
  if (!/^\d{1,20}$/.test(options.botId)) throw new Error("Telegram bot identity is invalid");
13043
13824
  assertTelegramCommandBoundary(desired);
13044
- const chatKey = createHash6("sha256").update(String(chatId)).digest("hex").slice(0, 16);
13825
+ const chatKey = createHash8("sha256").update(String(chatId)).digest("hex").slice(0, 16);
13045
13826
  const scopes = [
13046
13827
  { name: "default", scope: { type: "default" }, ledgerName: "default" },
13047
13828
  { name: "chat", scope: { type: "chat", chat_id: chatId }, ledgerName: `chat:${chatKey}` },
@@ -13940,12 +14721,12 @@ ${p}` : p;
13940
14721
  return await fn();
13941
14722
  } catch (err) {
13942
14723
  if (attempt === maxRetries) throw err;
13943
- const delay = baseDelayMs * Math.pow(2, attempt - 1);
14724
+ const delay2 = baseDelayMs * Math.pow(2, attempt - 1);
13944
14725
  log38.warn(
13945
- { err, attempt, maxRetries, delayMs: delay, operation: label },
13946
- `${label} failed, retrying in ${delay}ms`
14726
+ { err, attempt, maxRetries, delayMs: delay2, operation: label },
14727
+ `${label} failed, retrying in ${delay2}ms`
13947
14728
  );
13948
- await new Promise((r) => setTimeout(r, delay));
14729
+ await new Promise((r) => setTimeout(r, delay2));
13949
14730
  }
13950
14731
  }
13951
14732
  throw new Error("unreachable");
@@ -14320,16 +15101,16 @@ System topics have been created. Tap${assistantTopicLink} to get started.`,
14320
15101
  );
14321
15102
  } catch (err) {
14322
15103
  log38.error({ err }, "Failed to complete initialization after prerequisites met \u2014 will retry");
14323
- const delay2 = schedule[Math.min(attempt, schedule.length - 1)];
15104
+ const delay3 = schedule[Math.min(attempt, schedule.length - 1)];
14324
15105
  attempt++;
14325
- this._prerequisiteWatcher = setTimeout(retry, delay2);
15106
+ this._prerequisiteWatcher = setTimeout(retry, delay3);
14326
15107
  }
14327
15108
  return;
14328
15109
  }
14329
15110
  log38.debug({ issues: result.issues }, "Prerequisites not yet met, retrying");
14330
- const delay = schedule[Math.min(attempt, schedule.length - 1)];
15111
+ const delay2 = schedule[Math.min(attempt, schedule.length - 1)];
14331
15112
  attempt++;
14332
- this._prerequisiteWatcher = setTimeout(retry, delay);
15113
+ this._prerequisiteWatcher = setTimeout(retry, delay2);
14333
15114
  };
14334
15115
  this._prerequisiteWatcher = setTimeout(retry, schedule[0]);
14335
15116
  }
@@ -19098,12 +19879,12 @@ var SessionBridge = class {
19098
19879
  break;
19099
19880
  case "image_content": {
19100
19881
  if (this.deps.fileService) {
19101
- const fs39 = this.deps.fileService;
19882
+ const fs41 = this.deps.fileService;
19102
19883
  const sid = this.session.id;
19103
19884
  const { data, mimeType } = event;
19104
19885
  const buffer = Buffer.from(data, "base64");
19105
- const ext = fs39.extensionFromMime(mimeType);
19106
- fs39.saveFile(sid, `agent-image${ext}`, buffer, mimeType).then((att) => {
19886
+ const ext = fs41.extensionFromMime(mimeType);
19887
+ fs41.saveFile(sid, `agent-image${ext}`, buffer, mimeType).then((att) => {
19107
19888
  this.sendMessage(sid, {
19108
19889
  type: "attachment",
19109
19890
  text: "",
@@ -19115,12 +19896,12 @@ var SessionBridge = class {
19115
19896
  }
19116
19897
  case "audio_content": {
19117
19898
  if (this.deps.fileService) {
19118
- const fs39 = this.deps.fileService;
19899
+ const fs41 = this.deps.fileService;
19119
19900
  const sid = this.session.id;
19120
19901
  const { data, mimeType } = event;
19121
19902
  const buffer = Buffer.from(data, "base64");
19122
- const ext = fs39.extensionFromMime(mimeType);
19123
- fs39.saveFile(sid, `agent-audio${ext}`, buffer, mimeType).then((att) => {
19903
+ const ext = fs41.extensionFromMime(mimeType);
19904
+ fs41.saveFile(sid, `agent-audio${ext}`, buffer, mimeType).then((att) => {
19124
19905
  this.sendMessage(sid, {
19125
19906
  type: "attachment",
19126
19907
  text: "",
@@ -22931,36 +23712,36 @@ init_doctor();
22931
23712
  init_config_registry();
22932
23713
 
22933
23714
  // src/core/config/config-editor.ts
22934
- import * as path34 from "path";
23715
+ import * as path37 from "path";
22935
23716
  import * as clack2 from "@clack/prompts";
22936
23717
 
22937
23718
  // src/cli/autostart.ts
22938
23719
  init_log();
22939
- import { execFileSync as execFileSync6 } from "child_process";
22940
- import * as fs31 from "fs";
22941
- import * as path29 from "path";
23720
+ import { execFileSync as execFileSync7 } from "child_process";
23721
+ import * as fs34 from "fs";
23722
+ import * as path33 from "path";
22942
23723
  import * as os9 from "os";
22943
23724
  var log20 = createChildLogger({ module: "autostart" });
22944
- var LEGACY_LAUNCHD_PLIST_PATH = path29.join(os9.homedir(), "Library", "LaunchAgents", "com.openacp.daemon.plist");
22945
- var LEGACY_SYSTEMD_SERVICE_PATH = path29.join(os9.homedir(), ".config", "systemd", "user", "openacp.service");
23725
+ var LEGACY_LAUNCHD_PLIST_PATH = path33.join(os9.homedir(), "Library", "LaunchAgents", "com.openacp.daemon.plist");
23726
+ var LEGACY_SYSTEMD_SERVICE_PATH = path33.join(os9.homedir(), ".config", "systemd", "user", "openacp.service");
22946
23727
  function getLaunchdLabel(instanceId) {
22947
23728
  return `com.openacp.daemon.${instanceId}`;
22948
23729
  }
22949
23730
  function getLaunchdPlistPath(instanceId) {
22950
- return path29.join(os9.homedir(), "Library", "LaunchAgents", `${getLaunchdLabel(instanceId)}.plist`);
23731
+ return path33.join(os9.homedir(), "Library", "LaunchAgents", `${getLaunchdLabel(instanceId)}.plist`);
22951
23732
  }
22952
23733
  function getSystemdServiceName(instanceId) {
22953
23734
  return `openacp-${instanceId}`;
22954
23735
  }
22955
23736
  function getSystemdServicePath(instanceId) {
22956
- return path29.join(os9.homedir(), ".config", "systemd", "user", `${getSystemdServiceName(instanceId)}.service`);
23737
+ return path33.join(os9.homedir(), ".config", "systemd", "user", `${getSystemdServiceName(instanceId)}.service`);
22957
23738
  }
22958
23739
  function getAutoStartState(instanceId) {
22959
23740
  if (process.platform === "linux") {
22960
- const installed = fs31.existsSync(getSystemdServicePath(instanceId));
23741
+ const installed = fs34.existsSync(getSystemdServicePath(instanceId));
22961
23742
  if (!installed) return { installed: false, manager: null, active: false };
22962
23743
  try {
22963
- const output = execFileSync6("systemctl", [
23744
+ const output = execFileSync7("systemctl", [
22964
23745
  "--user",
22965
23746
  "show",
22966
23747
  getSystemdServiceName(instanceId),
@@ -22979,11 +23760,11 @@ function getAutoStartState(instanceId) {
22979
23760
  }
22980
23761
  }
22981
23762
  if (process.platform === "darwin") {
22982
- const installed = fs31.existsSync(getLaunchdPlistPath(instanceId));
23763
+ const installed = fs34.existsSync(getLaunchdPlistPath(instanceId));
22983
23764
  if (!installed) return { installed: false, manager: null, active: false };
22984
23765
  try {
22985
23766
  const uid = process.getuid();
22986
- const output = execFileSync6("launchctl", ["print", `gui/${uid}/${getLaunchdLabel(instanceId)}`], {
23767
+ const output = execFileSync7("launchctl", ["print", `gui/${uid}/${getLaunchdLabel(instanceId)}`], {
22987
23768
  encoding: "utf8",
22988
23769
  stdio: ["ignore", "pipe", "pipe"]
22989
23770
  });
@@ -23009,7 +23790,7 @@ function escapeSystemdValue(str) {
23009
23790
  }
23010
23791
  function generateLaunchdPlist(nodePath, cliPath, logDir2, instanceRoot, instanceId) {
23011
23792
  const label = getLaunchdLabel(instanceId);
23012
- const logFile = path29.join(logDir2, "openacp.log");
23793
+ const logFile = path33.join(logDir2, "openacp.log");
23013
23794
  return `<?xml version="1.0" encoding="UTF-8"?>
23014
23795
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
23015
23796
  <plist version="1.0">
@@ -23061,29 +23842,29 @@ WantedBy=default.target
23061
23842
  `;
23062
23843
  }
23063
23844
  function migrateLegacy() {
23064
- if (process.platform === "darwin" && fs31.existsSync(LEGACY_LAUNCHD_PLIST_PATH)) {
23845
+ if (process.platform === "darwin" && fs34.existsSync(LEGACY_LAUNCHD_PLIST_PATH)) {
23065
23846
  try {
23066
23847
  const uid = process.getuid();
23067
- execFileSync6("launchctl", ["bootout", `gui/${uid}`, "com.openacp.daemon"], { stdio: "pipe" });
23848
+ execFileSync7("launchctl", ["bootout", `gui/${uid}`, "com.openacp.daemon"], { stdio: "pipe" });
23068
23849
  } catch {
23069
23850
  }
23070
23851
  try {
23071
- fs31.unlinkSync(LEGACY_LAUNCHD_PLIST_PATH);
23852
+ fs34.unlinkSync(LEGACY_LAUNCHD_PLIST_PATH);
23072
23853
  } catch {
23073
23854
  }
23074
23855
  log20.info("Removed legacy single-instance LaunchAgent");
23075
23856
  }
23076
- if (process.platform === "linux" && fs31.existsSync(LEGACY_SYSTEMD_SERVICE_PATH)) {
23857
+ if (process.platform === "linux" && fs34.existsSync(LEGACY_SYSTEMD_SERVICE_PATH)) {
23077
23858
  try {
23078
- execFileSync6("systemctl", ["--user", "disable", "openacp"], { stdio: "pipe" });
23859
+ execFileSync7("systemctl", ["--user", "disable", "openacp"], { stdio: "pipe" });
23079
23860
  } catch {
23080
23861
  }
23081
23862
  try {
23082
- fs31.unlinkSync(LEGACY_SYSTEMD_SERVICE_PATH);
23863
+ fs34.unlinkSync(LEGACY_SYSTEMD_SERVICE_PATH);
23083
23864
  } catch {
23084
23865
  }
23085
23866
  try {
23086
- execFileSync6("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23867
+ execFileSync7("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23087
23868
  } catch {
23088
23869
  }
23089
23870
  log20.info("Removed legacy single-instance systemd service");
@@ -23094,50 +23875,50 @@ function installAutoStart(logDir2, instanceRoot, instanceId) {
23094
23875
  return { success: false, error: "Auto-start not supported on this platform" };
23095
23876
  }
23096
23877
  const nodePath = process.execPath;
23097
- const cliPath = path29.resolve(process.argv[1]);
23098
- const resolvedLogDir = logDir2.startsWith("~") ? path29.join(os9.homedir(), logDir2.slice(1)) : logDir2;
23878
+ const cliPath = path33.resolve(process.argv[1]);
23879
+ const resolvedLogDir = logDir2.startsWith("~") ? path33.join(os9.homedir(), logDir2.slice(1)) : logDir2;
23099
23880
  try {
23100
23881
  migrateLegacy();
23101
23882
  if (process.platform === "darwin") {
23102
23883
  const plistPath = getLaunchdPlistPath(instanceId);
23103
23884
  const plist = generateLaunchdPlist(nodePath, cliPath, resolvedLogDir, instanceRoot, instanceId);
23104
- const dir = path29.dirname(plistPath);
23105
- fs31.mkdirSync(dir, { recursive: true });
23885
+ const dir = path33.dirname(plistPath);
23886
+ fs34.mkdirSync(dir, { recursive: true });
23106
23887
  const uid = process.getuid();
23107
23888
  const domain = `gui/${uid}`;
23108
- const previous = fs31.existsSync(plistPath) ? fs31.readFileSync(plistPath, "utf8") : void 0;
23889
+ const previous = fs34.existsSync(plistPath) ? fs34.readFileSync(plistPath, "utf8") : void 0;
23109
23890
  const previousState = getAutoStartState(instanceId);
23110
23891
  const tmp = `${plistPath}.${process.pid}.tmp`;
23111
23892
  let replaced = false;
23112
23893
  let bootedOut = false;
23113
23894
  try {
23114
- fs31.writeFileSync(tmp, plist, { mode: 384 });
23115
- execFileSync6("plutil", ["-lint", tmp], { stdio: "pipe" });
23116
- fs31.renameSync(tmp, plistPath);
23895
+ fs34.writeFileSync(tmp, plist, { mode: 384 });
23896
+ execFileSync7("plutil", ["-lint", tmp], { stdio: "pipe" });
23897
+ fs34.renameSync(tmp, plistPath);
23117
23898
  replaced = true;
23118
23899
  if (previousState.active) {
23119
- execFileSync6("launchctl", ["bootout", domain, plistPath], { stdio: "pipe" });
23900
+ execFileSync7("launchctl", ["bootout", domain, plistPath], { stdio: "pipe" });
23120
23901
  bootedOut = true;
23121
23902
  }
23122
- execFileSync6("launchctl", ["bootstrap", domain, plistPath], { stdio: "pipe" });
23903
+ execFileSync7("launchctl", ["bootstrap", domain, plistPath], { stdio: "pipe" });
23123
23904
  } catch (error) {
23124
23905
  try {
23125
- fs31.rmSync(tmp, { force: true });
23906
+ fs34.rmSync(tmp, { force: true });
23126
23907
  } catch {
23127
23908
  }
23128
23909
  if (replaced) {
23129
23910
  let rollbackBootedOut = false;
23130
23911
  try {
23131
- execFileSync6("launchctl", ["bootout", domain, plistPath], { stdio: "pipe" });
23912
+ execFileSync7("launchctl", ["bootout", domain, plistPath], { stdio: "pipe" });
23132
23913
  rollbackBootedOut = true;
23133
23914
  } catch {
23134
23915
  }
23135
- if (previous === void 0) fs31.rmSync(plistPath, { force: true });
23916
+ if (previous === void 0) fs34.rmSync(plistPath, { force: true });
23136
23917
  else {
23137
23918
  const rollbackTmp = `${plistPath}.${process.pid}.rollback.tmp`;
23138
- fs31.writeFileSync(rollbackTmp, previous, { mode: 384 });
23139
- fs31.renameSync(rollbackTmp, plistPath);
23140
- if (previousState.active || bootedOut || rollbackBootedOut) execFileSync6("launchctl", ["bootstrap", domain, plistPath], { stdio: "pipe" });
23919
+ fs34.writeFileSync(rollbackTmp, previous, { mode: 384 });
23920
+ fs34.renameSync(rollbackTmp, plistPath);
23921
+ if (previousState.active || bootedOut || rollbackBootedOut) execFileSync7("launchctl", ["bootstrap", domain, plistPath], { stdio: "pipe" });
23141
23922
  }
23142
23923
  }
23143
23924
  throw error;
@@ -23149,20 +23930,20 @@ function installAutoStart(logDir2, instanceRoot, instanceId) {
23149
23930
  const servicePath = getSystemdServicePath(instanceId);
23150
23931
  const serviceName = getSystemdServiceName(instanceId);
23151
23932
  const unit = generateSystemdUnit(nodePath, cliPath, instanceRoot, instanceId);
23152
- const dir = path29.dirname(servicePath);
23153
- fs31.mkdirSync(dir, { recursive: true });
23154
- const previous = fs31.existsSync(servicePath) ? fs31.readFileSync(servicePath, "utf8") : void 0;
23933
+ const dir = path33.dirname(servicePath);
23934
+ fs34.mkdirSync(dir, { recursive: true });
23935
+ const previous = fs34.existsSync(servicePath) ? fs34.readFileSync(servicePath, "utf8") : void 0;
23155
23936
  const tmp = `${servicePath}.${process.pid}.tmp`;
23156
- fs31.writeFileSync(tmp, unit, { mode: 384 });
23157
- fs31.renameSync(tmp, servicePath);
23937
+ fs34.writeFileSync(tmp, unit, { mode: 384 });
23938
+ fs34.renameSync(tmp, servicePath);
23158
23939
  try {
23159
- execFileSync6("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23160
- execFileSync6("systemctl", ["--user", "enable", serviceName], { stdio: "pipe" });
23940
+ execFileSync7("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23941
+ execFileSync7("systemctl", ["--user", "enable", serviceName], { stdio: "pipe" });
23161
23942
  } catch (error) {
23162
- if (previous === void 0) fs31.unlinkSync(servicePath);
23163
- else fs31.writeFileSync(servicePath, previous);
23943
+ if (previous === void 0) fs34.unlinkSync(servicePath);
23944
+ else fs34.writeFileSync(servicePath, previous);
23164
23945
  try {
23165
- execFileSync6("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23946
+ execFileSync7("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23166
23947
  } catch {
23167
23948
  }
23168
23949
  throw error;
@@ -23184,13 +23965,13 @@ function uninstallAutoStart(instanceId) {
23184
23965
  try {
23185
23966
  if (process.platform === "darwin") {
23186
23967
  const plistPath = getLaunchdPlistPath(instanceId);
23187
- if (fs31.existsSync(plistPath)) {
23968
+ if (fs34.existsSync(plistPath)) {
23188
23969
  const uid = process.getuid();
23189
23970
  try {
23190
- execFileSync6("launchctl", ["bootout", `gui/${uid}`, plistPath], { stdio: "pipe" });
23971
+ execFileSync7("launchctl", ["bootout", `gui/${uid}`, plistPath], { stdio: "pipe" });
23191
23972
  } catch {
23192
23973
  }
23193
- fs31.unlinkSync(plistPath);
23974
+ fs34.unlinkSync(plistPath);
23194
23975
  log20.info({ instanceId }, "LaunchAgent removed");
23195
23976
  }
23196
23977
  return { success: true };
@@ -23198,13 +23979,13 @@ function uninstallAutoStart(instanceId) {
23198
23979
  if (process.platform === "linux") {
23199
23980
  const servicePath = getSystemdServicePath(instanceId);
23200
23981
  const serviceName = getSystemdServiceName(instanceId);
23201
- if (fs31.existsSync(servicePath)) {
23982
+ if (fs34.existsSync(servicePath)) {
23202
23983
  try {
23203
- execFileSync6("systemctl", ["--user", "disable", serviceName], { stdio: "pipe" });
23984
+ execFileSync7("systemctl", ["--user", "disable", serviceName], { stdio: "pipe" });
23204
23985
  } catch {
23205
23986
  }
23206
- fs31.unlinkSync(servicePath);
23207
- execFileSync6("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23987
+ fs34.unlinkSync(servicePath);
23988
+ execFileSync7("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
23208
23989
  log20.info({ instanceId }, "systemd user service removed");
23209
23990
  }
23210
23991
  return { success: true };
@@ -23218,10 +23999,10 @@ function uninstallAutoStart(instanceId) {
23218
23999
  }
23219
24000
  function isAutoStartInstalled(instanceId) {
23220
24001
  if (process.platform === "darwin") {
23221
- return fs31.existsSync(getLaunchdPlistPath(instanceId));
24002
+ return fs34.existsSync(getLaunchdPlistPath(instanceId));
23222
24003
  }
23223
24004
  if (process.platform === "linux") {
23224
- return fs31.existsSync(getSystemdServicePath(instanceId));
24005
+ return fs34.existsSync(getSystemdServicePath(instanceId));
23225
24006
  }
23226
24007
  return false;
23227
24008
  }
@@ -23230,25 +24011,25 @@ function isAutoStartInstalled(instanceId) {
23230
24011
  init_instance_context();
23231
24012
  init_instance_registry();
23232
24013
  init_log();
23233
- import fs33 from "fs";
23234
- import path31 from "path";
24014
+ import fs35 from "fs";
24015
+ import path34 from "path";
23235
24016
  var log21 = createChildLogger({ module: "resolve-instance-id" });
23236
24017
  function resolveInstanceId(instanceRoot) {
23237
24018
  try {
23238
- const configPath = path31.join(instanceRoot, "config.json");
23239
- const raw = JSON.parse(fs33.readFileSync(configPath, "utf-8"));
24019
+ const configPath = path34.join(instanceRoot, "config.json");
24020
+ const raw = JSON.parse(fs35.readFileSync(configPath, "utf-8"));
23240
24021
  if (raw.id && typeof raw.id === "string") return raw.id;
23241
24022
  } catch {
23242
24023
  }
23243
24024
  try {
23244
- const reg = new InstanceRegistry(path31.join(getGlobalRoot(), "instances.json"));
24025
+ const reg = new InstanceRegistry(path34.join(getGlobalRoot(), "instances.json"));
23245
24026
  reg.load();
23246
24027
  const entry = reg.getByRoot(instanceRoot);
23247
24028
  if (entry?.id) return entry.id;
23248
24029
  } catch (err) {
23249
24030
  log21.debug({ err: err.message, instanceRoot }, "Could not read instance registry, using fallback id");
23250
24031
  }
23251
- return path31.basename(path31.dirname(instanceRoot)).replace(/[^a-zA-Z0-9-]/g, "-") || "default";
24032
+ return path34.basename(path34.dirname(instanceRoot)).replace(/[^a-zA-Z0-9-]/g, "-") || "default";
23252
24033
  }
23253
24034
 
23254
24035
  // src/core/config/config-editor.ts
@@ -23820,7 +24601,7 @@ async function runConfigEditor(configManager, mode = "file", apiPort, settingsMa
23820
24601
  await configManager.load();
23821
24602
  const config = configManager.get();
23822
24603
  const updates = {};
23823
- const instanceRoot = path34.dirname(configManager.getConfigPath());
24604
+ const instanceRoot = path37.dirname(configManager.getConfigPath());
23824
24605
  console.log(`
23825
24606
  ${c.cyan}${c.bold}OpenACP Config Editor${c.reset}`);
23826
24607
  console.log(dim(`Config: ${configManager.getConfigPath()}`));
@@ -23877,17 +24658,17 @@ ${c.cyan}${c.bold}OpenACP Config Editor${c.reset}`);
23877
24658
  async function sendConfigViaApi(port, updates) {
23878
24659
  const { apiCall: call } = await Promise.resolve().then(() => (init_api_client(), api_client_exports));
23879
24660
  const paths = flattenToPaths(updates);
23880
- for (const { path: path39, value } of paths) {
24661
+ for (const { path: path42, value } of paths) {
23881
24662
  const res = await call(port, "/api/config", {
23882
24663
  method: "PATCH",
23883
24664
  headers: { "Content-Type": "application/json" },
23884
- body: JSON.stringify({ path: path39, value })
24665
+ body: JSON.stringify({ path: path42, value })
23885
24666
  });
23886
24667
  const data = await res.json();
23887
24668
  if (!res.ok) {
23888
- console.log(warn(`Failed to update ${path39}: ${data.error}`));
24669
+ console.log(warn(`Failed to update ${path42}: ${data.error}`));
23889
24670
  } else if (data.needsRestart) {
23890
- console.log(warn(`${path39} updated \u2014 restart required`));
24671
+ console.log(warn(`${path42} updated \u2014 restart required`));
23891
24672
  }
23892
24673
  }
23893
24674
  }
@@ -23906,26 +24687,28 @@ function flattenToPaths(obj, prefix = "") {
23906
24687
 
23907
24688
  // src/cli/daemon.ts
23908
24689
  init_config();
24690
+ init_daemon_identity();
23909
24691
  import { spawn as spawn3 } from "child_process";
23910
- import * as fs35 from "fs";
23911
- import * as path35 from "path";
24692
+ import * as fs37 from "fs";
24693
+ import * as path38 from "path";
23912
24694
  function getPidPath(root) {
23913
- return path35.join(root, "openacp.pid");
24695
+ return path38.join(root, "openacp.pid");
23914
24696
  }
23915
24697
  function getLogDir(root) {
23916
- return path35.join(root, "logs");
24698
+ return path38.join(root, "logs");
23917
24699
  }
23918
24700
  function getRunningMarker(root) {
23919
- return path35.join(root, "running");
24701
+ return path38.join(root, "running");
23920
24702
  }
23921
24703
  function writePidFile(pidPath, pid) {
23922
- const dir = path35.dirname(pidPath);
23923
- fs35.mkdirSync(dir, { recursive: true });
23924
- fs35.writeFileSync(pidPath, String(pid));
24704
+ const dir = path38.dirname(pidPath);
24705
+ fs37.mkdirSync(dir, { recursive: true });
24706
+ fs37.writeFileSync(pidPath, String(pid));
24707
+ writeDaemonIdentity(pidPath, pid);
23925
24708
  }
23926
24709
  function readPidFile(pidPath) {
23927
24710
  try {
23928
- const content = fs35.readFileSync(pidPath, "utf-8").trim();
24711
+ const content = fs37.readFileSync(pidPath, "utf-8").trim();
23929
24712
  const pid = parseInt(content, 10);
23930
24713
  return isNaN(pid) ? null : pid;
23931
24714
  } catch {
@@ -23934,9 +24717,10 @@ function readPidFile(pidPath) {
23934
24717
  }
23935
24718
  function removePidFile(pidPath) {
23936
24719
  try {
23937
- fs35.unlinkSync(pidPath);
24720
+ fs37.unlinkSync(pidPath);
23938
24721
  } catch {
23939
24722
  }
24723
+ removeDaemonIdentity(pidPath);
23940
24724
  }
23941
24725
  function isProcessRunning(pidPath) {
23942
24726
  const pid = readPidFile(pidPath);
@@ -23967,12 +24751,12 @@ function startDaemon(pidPath, logDir2, instanceRoot) {
23967
24751
  return { error: `Already running (PID ${pid})` };
23968
24752
  }
23969
24753
  const resolvedLogDir = logDir2 ? expandHome2(logDir2) : getLogDir(instanceRoot);
23970
- fs35.mkdirSync(resolvedLogDir, { recursive: true });
23971
- const logFile = path35.join(resolvedLogDir, "openacp.log");
23972
- const cliPath = path35.resolve(process.argv[1]);
24754
+ fs37.mkdirSync(resolvedLogDir, { recursive: true });
24755
+ const logFile = path38.join(resolvedLogDir, "openacp.log");
24756
+ const cliPath = path38.resolve(process.argv[1]);
23973
24757
  const nodePath = process.execPath;
23974
- const out = fs35.openSync(logFile, "a");
23975
- const err = fs35.openSync(logFile, "a");
24758
+ const out = fs37.openSync(logFile, "a");
24759
+ const err = fs37.openSync(logFile, "a");
23976
24760
  const child = spawn3(nodePath, [cliPath, "--daemon-child"], {
23977
24761
  detached: true,
23978
24762
  stdio: ["ignore", out, err],
@@ -23981,8 +24765,8 @@ function startDaemon(pidPath, logDir2, instanceRoot) {
23981
24765
  ...instanceRoot ? { OPENACP_INSTANCE_ROOT: instanceRoot } : {}
23982
24766
  }
23983
24767
  });
23984
- fs35.closeSync(out);
23985
- fs35.closeSync(err);
24768
+ fs37.closeSync(out);
24769
+ fs37.closeSync(err);
23986
24770
  if (!child.pid) {
23987
24771
  return { error: "Failed to spawn daemon process" };
23988
24772
  }
@@ -23993,7 +24777,7 @@ function startDaemon(pidPath, logDir2, instanceRoot) {
23993
24777
  function sleep2(ms) {
23994
24778
  return new Promise((resolve8) => setTimeout(resolve8, ms));
23995
24779
  }
23996
- function isProcessAlive2(pid) {
24780
+ function isProcessAlive3(pid) {
23997
24781
  try {
23998
24782
  process.kill(pid, 0);
23999
24783
  return "alive";
@@ -24006,7 +24790,7 @@ function isProcessAlive2(pid) {
24006
24790
  async function stopDaemon(pidPath, instanceRoot) {
24007
24791
  const pid = readPidFile(pidPath);
24008
24792
  if (pid === null) return { stopped: false, error: "Not running (no PID file)" };
24009
- const status = isProcessAlive2(pid);
24793
+ const status = isProcessAlive3(pid);
24010
24794
  if (status === "dead") {
24011
24795
  removePidFile(pidPath);
24012
24796
  return { stopped: false, error: "Not running (stale PID file removed)" };
@@ -24026,7 +24810,7 @@ async function stopDaemon(pidPath, instanceRoot) {
24026
24810
  const start = Date.now();
24027
24811
  while (Date.now() - start < TIMEOUT) {
24028
24812
  await sleep2(POLL_INTERVAL);
24029
- const s = isProcessAlive2(pid);
24813
+ const s = isProcessAlive3(pid);
24030
24814
  if (s === "dead" || s === "eperm") {
24031
24815
  removePidFile(pidPath);
24032
24816
  return { stopped: true, pid };
@@ -24043,7 +24827,7 @@ async function stopDaemon(pidPath, instanceRoot) {
24043
24827
  const killStart = Date.now();
24044
24828
  while (Date.now() - killStart < 1e3) {
24045
24829
  await sleep2(POLL_INTERVAL);
24046
- const s = isProcessAlive2(pid);
24830
+ const s = isProcessAlive3(pid);
24047
24831
  if (s === "dead" || s === "eperm") {
24048
24832
  removePidFile(pidPath);
24049
24833
  return { stopped: true, pid };
@@ -24053,12 +24837,12 @@ async function stopDaemon(pidPath, instanceRoot) {
24053
24837
  }
24054
24838
  function markRunning(root) {
24055
24839
  const marker = getRunningMarker(root);
24056
- fs35.mkdirSync(path35.dirname(marker), { recursive: true });
24057
- fs35.writeFileSync(marker, "");
24840
+ fs37.mkdirSync(path38.dirname(marker), { recursive: true });
24841
+ fs37.writeFileSync(marker, "");
24058
24842
  }
24059
24843
  function clearRunning(root) {
24060
24844
  try {
24061
- fs35.unlinkSync(getRunningMarker(root));
24845
+ fs37.unlinkSync(getRunningMarker(root));
24062
24846
  } catch {
24063
24847
  }
24064
24848
  }