@openacp/cli 2026.410.1 → 2026.410.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/cli.js CHANGED
@@ -429,37 +429,46 @@ var init_plugin_registry = __esm({
429
429
  this.registryPath = registryPath;
430
430
  }
431
431
  data = { installed: {} };
432
+ /** Return all installed plugins as a Map. */
432
433
  list() {
433
434
  return new Map(Object.entries(this.data.installed));
434
435
  }
436
+ /** Look up a plugin by name. Returns undefined if not installed. */
435
437
  get(name) {
436
438
  return this.data.installed[name];
437
439
  }
440
+ /** Record a newly installed plugin. Timestamps are set automatically. */
438
441
  register(name, entry) {
439
442
  const now = (/* @__PURE__ */ new Date()).toISOString();
440
443
  this.data.installed[name] = { ...entry, installedAt: now, updatedAt: now };
441
444
  }
445
+ /** Remove a plugin from the registry. */
442
446
  remove(name) {
443
447
  delete this.data.installed[name];
444
448
  }
449
+ /** Enable or disable a plugin. Disabled plugins are skipped at boot. */
445
450
  setEnabled(name, enabled) {
446
451
  const entry = this.data.installed[name];
447
452
  if (!entry) return;
448
453
  entry.enabled = enabled;
449
454
  entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
450
455
  }
456
+ /** Update the stored version (called after successful migration). */
451
457
  updateVersion(name, version) {
452
458
  const entry = this.data.installed[name];
453
459
  if (!entry) return;
454
460
  entry.version = version;
455
461
  entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
456
462
  }
463
+ /** Return only enabled plugins. */
457
464
  listEnabled() {
458
465
  return new Map(Object.entries(this.data.installed).filter(([, e]) => e.enabled));
459
466
  }
467
+ /** Filter plugins by installation source. */
460
468
  listBySource(source) {
461
469
  return new Map(Object.entries(this.data.installed).filter(([, e]) => e.source === source));
462
470
  }
471
+ /** Load registry data from disk. Silently starts empty if file doesn't exist. */
463
472
  async load() {
464
473
  try {
465
474
  const content = fs4.readFileSync(this.registryPath, "utf-8");
@@ -471,6 +480,7 @@ var init_plugin_registry = __esm({
471
480
  this.data = { installed: {} };
472
481
  }
473
482
  }
483
+ /** Persist registry data to disk. */
474
484
  async save() {
475
485
  const dir = path4.dirname(this.registryPath);
476
486
  fs4.mkdirSync(dir, { recursive: true });
@@ -497,6 +507,7 @@ var init_registry_client = __esm({
497
507
  constructor(registryUrl) {
498
508
  this.registryUrl = registryUrl ?? REGISTRY_URL;
499
509
  }
510
+ /** Fetch the registry, returning cached data if still fresh. */
500
511
  async getRegistry() {
501
512
  if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL) {
502
513
  return this.cache.data;
@@ -507,6 +518,7 @@ var init_registry_client = __esm({
507
518
  this.cache = { data, fetchedAt: Date.now() };
508
519
  return data;
509
520
  }
521
+ /** Search plugins by name, description, or tags (case-insensitive substring match). */
510
522
  async search(query) {
511
523
  const registry = await this.getRegistry();
512
524
  const q = query.toLowerCase();
@@ -515,11 +527,13 @@ var init_registry_client = __esm({
515
527
  return text5.includes(q);
516
528
  });
517
529
  }
530
+ /** Resolve a registry plugin name to its npm package name. Returns null if not found. */
518
531
  async resolve(name) {
519
532
  const registry = await this.getRegistry();
520
533
  const plugin2 = registry.plugins.find((p2) => p2.name === name);
521
534
  return plugin2?.npm ?? null;
522
535
  }
536
+ /** Force next getRegistry() call to refetch from network. */
523
537
  clearCache() {
524
538
  this.cache = null;
525
539
  }
@@ -1738,6 +1752,16 @@ var init_security_guard = __esm({
1738
1752
  this.getSecurityConfig = getSecurityConfig;
1739
1753
  this.sessionManager = sessionManager;
1740
1754
  }
1755
+ /**
1756
+ * Returns `{ allowed: true }` when the message may proceed, or
1757
+ * `{ allowed: false, reason }` when it should be blocked.
1758
+ *
1759
+ * Two checks run in order:
1760
+ * 1. **Allowlist** — if `allowedUserIds` is non-empty, the user's ID (coerced to string)
1761
+ * must appear in the list. Telegram/Slack IDs are numbers, so coercion is required.
1762
+ * 2. **Session cap** — counts sessions in `active` or `initializing` state. `initializing`
1763
+ * is included because a session holds resources before it reaches `active`.
1764
+ */
1741
1765
  async checkAccess(message) {
1742
1766
  const config = await this.getSecurityConfig();
1743
1767
  const allowedIds = config.allowedUserIds ?? [];
@@ -1813,44 +1837,75 @@ var init_events = __esm({
1813
1837
  };
1814
1838
  BusEvent = {
1815
1839
  // --- Session lifecycle ---
1840
+ /** Fired when a new session is created and ready. */
1816
1841
  SESSION_CREATED: "session:created",
1842
+ /** Fired when session metadata changes (status, name, overrides). */
1817
1843
  SESSION_UPDATED: "session:updated",
1844
+ /** Fired when a session record is deleted from the store. */
1818
1845
  SESSION_DELETED: "session:deleted",
1846
+ /** Fired when a session ends (agent finished or error). */
1819
1847
  SESSION_ENDED: "session:ended",
1848
+ /** Fired when a session receives its auto-generated name. */
1820
1849
  SESSION_NAMED: "session:named",
1850
+ /** Fired after a new session thread is created and bridge connected. */
1821
1851
  SESSION_THREAD_READY: "session:threadReady",
1852
+ /** Fired when an agent's config options change (adapters update control UIs). */
1822
1853
  SESSION_CONFIG_CHANGED: "session:configChanged",
1854
+ /** Fired during agent switch lifecycle (starting/succeeded/failed). */
1823
1855
  SESSION_AGENT_SWITCH: "session:agentSwitch",
1824
1856
  // --- Agent ---
1857
+ /** Fired for every agent event (text, tool_call, usage, etc.). */
1825
1858
  AGENT_EVENT: "agent:event",
1859
+ /** Fired when a prompt is sent to the agent. */
1826
1860
  AGENT_PROMPT: "agent:prompt",
1827
1861
  // --- Permissions ---
1862
+ /** Fired when the agent requests user permission (blocks until resolved). */
1828
1863
  PERMISSION_REQUEST: "permission:request",
1864
+ /** Fired after a permission request is resolved (approved or denied). */
1829
1865
  PERMISSION_RESOLVED: "permission:resolved",
1830
1866
  // --- Message visibility ---
1867
+ /** Fired when a user message is queued (for cross-adapter input visibility). */
1831
1868
  MESSAGE_QUEUED: "message:queued",
1869
+ /** Fired when a queued message starts processing. */
1832
1870
  MESSAGE_PROCESSING: "message:processing",
1833
1871
  // --- System lifecycle ---
1872
+ /** Fired after kernel (core + plugin infrastructure) has booted. */
1834
1873
  KERNEL_BOOTED: "kernel:booted",
1874
+ /** Fired when the system is fully ready (all adapters connected). */
1835
1875
  SYSTEM_READY: "system:ready",
1876
+ /** Fired during graceful shutdown. */
1836
1877
  SYSTEM_SHUTDOWN: "system:shutdown",
1878
+ /** Fired when all system commands are registered and available. */
1837
1879
  SYSTEM_COMMANDS_READY: "system:commands-ready",
1838
1880
  // --- Plugin lifecycle ---
1881
+ /** Fired when a plugin loads successfully. */
1839
1882
  PLUGIN_LOADED: "plugin:loaded",
1883
+ /** Fired when a plugin fails to load. */
1840
1884
  PLUGIN_FAILED: "plugin:failed",
1885
+ /** Fired when a plugin is disabled (e.g., missing config). */
1841
1886
  PLUGIN_DISABLED: "plugin:disabled",
1887
+ /** Fired when a plugin is unloaded during shutdown. */
1842
1888
  PLUGIN_UNLOADED: "plugin:unloaded",
1843
1889
  // --- Usage ---
1890
+ /** Fired when a token usage record is captured (consumed by usage plugin). */
1844
1891
  USAGE_RECORDED: "usage:recorded"
1845
1892
  };
1846
1893
  SessionEv = {
1894
+ /** Agent produced an event (text, tool_call, etc.) during a turn. */
1847
1895
  AGENT_EVENT: "agent_event",
1896
+ /** Agent is requesting user permission — blocks until resolved. */
1848
1897
  PERMISSION_REQUEST: "permission_request",
1898
+ /** Session ended (agent finished, cancelled, or errored). */
1849
1899
  SESSION_END: "session_end",
1900
+ /** Session status changed (e.g., initializing → active). */
1850
1901
  STATUS_CHANGE: "status_change",
1902
+ /** Session received an auto-generated name from the first response. */
1851
1903
  NAMED: "named",
1904
+ /** An unrecoverable error occurred in the session. */
1852
1905
  ERROR: "error",
1906
+ /** The session's prompt count changed (used for UI counters). */
1853
1907
  PROMPT_COUNT_CHANGED: "prompt_count_changed",
1908
+ /** A new prompt turn started (provides TurnContext for middleware). */
1854
1909
  TURN_STARTED: "turn_started"
1855
1910
  };
1856
1911
  }
@@ -1868,6 +1923,7 @@ function createSecurityPlugin() {
1868
1923
  description: "User access control and session limits",
1869
1924
  essential: false,
1870
1925
  permissions: ["services:register", "middleware:register", "kernel:access", "commands:register"],
1926
+ // These keys are propagated to child plugin configs so adapters can read them directly.
1871
1927
  inheritableKeys: ["allowedUserIds", "maxConcurrentSessions", "sessionTimeoutMinutes"],
1872
1928
  async install(ctx) {
1873
1929
  await ctx.settings.setAll({
@@ -2074,6 +2130,14 @@ var init_file_service = __esm({
2074
2130
  }
2075
2131
  return removed;
2076
2132
  }
2133
+ /**
2134
+ * Persist a file to the session's directory and return an `Attachment` descriptor.
2135
+ *
2136
+ * The file name is sanitized (non-safe characters replaced with `_`) and prefixed
2137
+ * with a millisecond timestamp to prevent name collisions across multiple saves in
2138
+ * the same session. The original `fileName` is preserved in the returned descriptor
2139
+ * so the user-facing name is not lost.
2140
+ */
2077
2141
  async saveFile(sessionId, fileName, data, mimeType) {
2078
2142
  const sessionDir = path6.join(this.baseDir, sessionId);
2079
2143
  await fs7.promises.mkdir(sessionDir, { recursive: true });
@@ -2088,6 +2152,10 @@ var init_file_service = __esm({
2088
2152
  size: data.length
2089
2153
  };
2090
2154
  }
2155
+ /**
2156
+ * Build an `Attachment` descriptor for a file that already exists on disk.
2157
+ * Returns `null` if the path does not exist or is not a regular file.
2158
+ */
2091
2159
  async resolveFile(filePath) {
2092
2160
  try {
2093
2161
  const stat = await fs7.promises.stat(filePath);
@@ -2132,6 +2200,7 @@ var init_file_service = __esm({
2132
2200
  extensionFromMime(mimeType) {
2133
2201
  return _FileService.extensionFromMime(mimeType);
2134
2202
  }
2203
+ /** Returns the canonical file extension for a given MIME type (e.g. `"image/png"` → `".png"`). */
2135
2204
  static extensionFromMime(mimeType) {
2136
2205
  return MIME_TO_EXT[mimeType] || ".bin";
2137
2206
  }
@@ -2258,6 +2327,12 @@ var init_context_manager = __esm({
2258
2327
  constructor(cachePath) {
2259
2328
  this.cache = new ContextCache(cachePath);
2260
2329
  }
2330
+ /**
2331
+ * Wire in the history store after construction.
2332
+ *
2333
+ * Injected separately because the history store (backed by the context plugin's
2334
+ * recorder) may not be ready when ContextManager is first instantiated.
2335
+ */
2261
2336
  setHistoryStore(store) {
2262
2337
  this.historyStore = store;
2263
2338
  }
@@ -2273,19 +2348,43 @@ var init_context_manager = __esm({
2273
2348
  async flushSession(sessionId) {
2274
2349
  if (this.sessionFlusher) await this.sessionFlusher(sessionId);
2275
2350
  }
2351
+ /**
2352
+ * Read the raw history for a session directly from the history store.
2353
+ *
2354
+ * Returns null if no historyStore has been configured via `setHistoryStore()`,
2355
+ * or if the session has no recorded history.
2356
+ */
2276
2357
  async getHistory(sessionId) {
2277
2358
  if (!this.historyStore) return null;
2278
2359
  return this.historyStore.read(sessionId);
2279
2360
  }
2361
+ /**
2362
+ * Register a provider. Providers are queried in insertion order.
2363
+ * Register higher-priority sources (e.g. local history) before lower-priority ones (e.g. entire).
2364
+ */
2280
2365
  register(provider) {
2281
2366
  this.providers.push(provider);
2282
2367
  }
2368
+ /**
2369
+ * Return the first provider that reports itself available for the given repo.
2370
+ *
2371
+ * This is a availability check — it returns the highest-priority available provider
2372
+ * (i.e. the first registered one that passes `isAvailable`), not necessarily the
2373
+ * one that would yield the richest context for a specific query.
2374
+ */
2283
2375
  async getProvider(repoPath) {
2284
2376
  for (const provider of this.providers) {
2285
2377
  if (await provider.isAvailable(repoPath)) return provider;
2286
2378
  }
2287
2379
  return null;
2288
2380
  }
2381
+ /**
2382
+ * List sessions using the same provider-waterfall logic as `buildContext`.
2383
+ *
2384
+ * Tries each registered provider in order, returning the first non-empty result.
2385
+ * Unlike `buildContext`, results are not cached — callers should avoid calling
2386
+ * this in hot paths.
2387
+ */
2289
2388
  async listSessions(query) {
2290
2389
  for (const provider of this.providers) {
2291
2390
  if (!await provider.isAvailable(query.repoPath)) continue;
@@ -2294,6 +2393,13 @@ var init_context_manager = __esm({
2294
2393
  }
2295
2394
  return null;
2296
2395
  }
2396
+ /**
2397
+ * Build a context block for injection into an agent prompt.
2398
+ *
2399
+ * Tries each registered provider in order. Results are cached by (repoPath + queryKey)
2400
+ * to avoid redundant disk reads. Pass `options.noCache = true` when the caller knows
2401
+ * the history just changed (e.g. immediately after an agent switch + flush).
2402
+ */
2297
2403
  async buildContext(query, options) {
2298
2404
  const queryKey = `${query.type}:${query.value}:${options?.limit ?? ""}:${options?.maxTokens ?? ""}:${options?.labelAgent ?? ""}`;
2299
2405
  if (!options?.noCache) {
@@ -2456,8 +2562,9 @@ var init_checkpoint_reader = __esm({
2456
2562
  sessionIndex: String(idx),
2457
2563
  transcriptPath,
2458
2564
  createdAt,
2565
+ // endedAt isn't stored in the checkpoint metadata; EntireProvider fills
2566
+ // it from the last turn timestamp when parsing the JSONL transcript.
2459
2567
  endedAt: createdAt,
2460
- // will be filled from JSONL by conversation builder
2461
2568
  branch: smeta.branch ?? cpMeta.branch ?? "",
2462
2569
  agent: smeta.agent ?? "",
2463
2570
  turnCount: smeta.session_metrics?.turn_count ?? 0,
@@ -3588,6 +3695,7 @@ var init_history_recorder = __esm({
3588
3695
  status: event.status
3589
3696
  };
3590
3697
  if (event.kind) step2.kind = event.kind;
3698
+ if (event.rawInput !== void 0) step2.input = event.rawInput;
3591
3699
  steps.push(step2);
3592
3700
  break;
3593
3701
  }
@@ -3744,6 +3852,8 @@ var init_history_recorder = __esm({
3744
3852
  this.debounceTimers.delete(sessionId);
3745
3853
  }
3746
3854
  }
3855
+ // Search backwards so we match the most recent tool call with this ID first,
3856
+ // in case the same tool is invoked multiple times within one turn.
3747
3857
  findToolCall(steps, id) {
3748
3858
  for (let i = steps.length - 1; i >= 0; i--) {
3749
3859
  const s = steps[i];
@@ -3931,23 +4041,38 @@ var init_speech_service = __esm({
3931
4041
  setProviderFactory(factory) {
3932
4042
  this.providerFactory = factory;
3933
4043
  }
4044
+ /** Register an STT provider by name. Overwrites any existing provider with the same name. */
3934
4045
  registerSTTProvider(name, provider) {
3935
4046
  this.sttProviders.set(name, provider);
3936
4047
  }
4048
+ /** Register a TTS provider by name. Called by external TTS plugins (e.g. msedge-tts-plugin). */
3937
4049
  registerTTSProvider(name, provider) {
3938
4050
  this.ttsProviders.set(name, provider);
3939
4051
  }
4052
+ /** Remove a TTS provider — called by external plugins on teardown. */
3940
4053
  unregisterTTSProvider(name) {
3941
4054
  this.ttsProviders.delete(name);
3942
4055
  }
4056
+ /** Returns true if an STT provider is configured and has credentials. */
3943
4057
  isSTTAvailable() {
3944
4058
  const { provider, providers } = this.config.stt;
3945
4059
  return provider !== null && providers[provider]?.apiKey !== void 0;
3946
4060
  }
4061
+ /**
4062
+ * Returns true if a TTS provider is configured and an implementation is registered.
4063
+ *
4064
+ * Config alone is not enough — the TTS provider plugin must have registered
4065
+ * its implementation via `registerTTSProvider` before this returns true.
4066
+ */
3947
4067
  isTTSAvailable() {
3948
4068
  const provider = this.config.tts.provider;
3949
4069
  return provider !== null && this.ttsProviders.has(provider);
3950
4070
  }
4071
+ /**
4072
+ * Transcribes audio using the configured STT provider.
4073
+ *
4074
+ * @throws if no STT provider is configured or if the named provider is not registered.
4075
+ */
3951
4076
  async transcribe(audioBuffer, mimeType, options) {
3952
4077
  const providerName = this.config.stt.provider;
3953
4078
  if (!providerName || !this.config.stt.providers[providerName]?.apiKey) {
@@ -3959,6 +4084,11 @@ var init_speech_service = __esm({
3959
4084
  }
3960
4085
  return provider.transcribe(audioBuffer, mimeType, options);
3961
4086
  }
4087
+ /**
4088
+ * Synthesizes speech using the configured TTS provider.
4089
+ *
4090
+ * @throws if no TTS provider is configured or if the named provider is not registered.
4091
+ */
3962
4092
  async synthesize(text5, options) {
3963
4093
  const providerName = this.config.tts.provider;
3964
4094
  if (!providerName) {
@@ -3970,10 +4100,17 @@ var init_speech_service = __esm({
3970
4100
  }
3971
4101
  return provider.synthesize(text5, options);
3972
4102
  }
4103
+ /** Replace the active config without rebuilding providers. Use `refreshProviders` to also rebuild. */
3973
4104
  updateConfig(config) {
3974
4105
  this.config = config;
3975
4106
  }
3976
- /** Re-create factory-managed providers from config. Preserves externally-registered providers (e.g. from plugins). */
4107
+ /**
4108
+ * Reloads TTS and STT providers from a new config snapshot.
4109
+ *
4110
+ * Called after config changes or plugin hot-reload. Factory-managed providers are
4111
+ * rebuilt via the registered `ProviderFactory`; externally-registered providers
4112
+ * (e.g. from `@openacp/msedge-tts-plugin`) are preserved rather than discarded.
4113
+ */
3977
4114
  refreshProviders(newConfig) {
3978
4115
  this.config = newConfig;
3979
4116
  if (this.providerFactory) {
@@ -4013,6 +4150,12 @@ var init_groq = __esm({
4013
4150
  this.defaultModel = defaultModel;
4014
4151
  }
4015
4152
  name = "groq";
4153
+ /**
4154
+ * Transcribes audio using the Groq Whisper API.
4155
+ *
4156
+ * `verbose_json` response format is requested so the API returns language
4157
+ * detection and duration metadata alongside the transcript text.
4158
+ */
4016
4159
  async transcribe(audioBuffer, mimeType, options) {
4017
4160
  const ext = mimeToExt(mimeType);
4018
4161
  const form = new FormData();
@@ -4138,6 +4281,7 @@ var init_speech = __esm({
4138
4281
  version: "1.0.0",
4139
4282
  description: "Text-to-speech and speech-to-text with pluggable providers",
4140
4283
  essential: false,
4284
+ // file-service is needed to persist synthesized audio for adapters that send files
4141
4285
  optionalPluginDependencies: { "@openacp/file-service": "^1.0.0" },
4142
4286
  permissions: ["services:register", "commands:register", "kernel:access"],
4143
4287
  inheritableKeys: ["ttsProvider", "ttsVoice"],
@@ -4349,6 +4493,12 @@ var init_notification = __esm({
4349
4493
  constructor(adapters) {
4350
4494
  this.adapters = adapters;
4351
4495
  }
4496
+ /**
4497
+ * Send a notification to a specific channel adapter.
4498
+ *
4499
+ * Failures are swallowed — notifications are best-effort and must not crash
4500
+ * the session or caller (e.g. on session completion).
4501
+ */
4352
4502
  async notify(channelId, notification) {
4353
4503
  const adapter = this.adapters.get(channelId);
4354
4504
  if (!adapter) return;
@@ -4357,6 +4507,12 @@ var init_notification = __esm({
4357
4507
  } catch {
4358
4508
  }
4359
4509
  }
4510
+ /**
4511
+ * Broadcast a notification to every registered adapter.
4512
+ *
4513
+ * Used for system-wide alerts (e.g. global budget exhausted). Each adapter
4514
+ * failure is isolated so one broken adapter cannot block the rest.
4515
+ */
4360
4516
  async notifyAll(notification) {
4361
4517
  for (const adapter of this.adapters.values()) {
4362
4518
  try {
@@ -4380,6 +4536,7 @@ function createNotificationsPlugin() {
4380
4536
  version: "1.0.0",
4381
4537
  description: "Cross-session notification routing",
4382
4538
  essential: false,
4539
+ // Depends on security so the notification service is only active for authorized sessions
4383
4540
  pluginDependencies: { "@openacp/security": "^1.0.0" },
4384
4541
  permissions: ["services:register", "kernel:access"],
4385
4542
  async install(ctx) {
@@ -4434,6 +4591,10 @@ var init_keepalive = __esm({
4434
4591
  static PING_INTERVAL = 3e4;
4435
4592
  static FAIL_THRESHOLD = 3;
4436
4593
  static PING_TIMEOUT = 5e3;
4594
+ /**
4595
+ * Start polling. Replaces any existing interval.
4596
+ * `onDead` is called once when the failure threshold is reached.
4597
+ */
4437
4598
  start(tunnelUrl, onDead) {
4438
4599
  this.stop();
4439
4600
  this.interval = setInterval(async () => {
@@ -4455,6 +4616,10 @@ var init_keepalive = __esm({
4455
4616
  }
4456
4617
  }, _TunnelKeepAlive.PING_INTERVAL);
4457
4618
  }
4619
+ /**
4620
+ * Stop the keepalive interval and reset the failure counter.
4621
+ * Resetting ensures a clean slate if the keepalive is restarted after stopping.
4622
+ */
4458
4623
  stop() {
4459
4624
  if (this.interval) {
4460
4625
  clearInterval(this.interval);
@@ -5741,6 +5906,8 @@ var init_openacp = __esm({
5741
5906
  getPublicUrl() {
5742
5907
  return this.publicUrl;
5743
5908
  }
5909
+ // Try to reuse a persisted tunnel (stable URL across restarts).
5910
+ // If the saved tunnel is no longer alive on the worker, create a fresh one.
5744
5911
  async resolveCredentials(saved, all, localPort) {
5745
5912
  if (saved) {
5746
5913
  const alive = await this.pingWorker(saved.tunnelId);
@@ -5904,6 +6071,13 @@ var init_tunnel_registry = __esm({
5904
6071
  this.binDir = opts.binDir;
5905
6072
  this.storage = opts.storage ?? null;
5906
6073
  }
6074
+ /**
6075
+ * Spawn a new tunnel process for the given port and register it.
6076
+ *
6077
+ * Persists the entry to `tunnels.json` once the tunnel reaches `active` status.
6078
+ * Throws if the port is already in use by an active or starting tunnel, or if the
6079
+ * user tunnel limit is reached.
6080
+ */
5907
6081
  async add(port, opts, _autoRetry = true) {
5908
6082
  if (this.entries.has(port)) {
5909
6083
  const existing = this.entries.get(port);
@@ -6022,6 +6196,12 @@ var init_tunnel_registry = __esm({
6022
6196
  this.scheduleSave();
6023
6197
  }
6024
6198
  }
6199
+ /**
6200
+ * Stop a user tunnel by port and remove it from the registry.
6201
+ *
6202
+ * Cancels any pending retry timer and waits for an in-progress spawn to settle
6203
+ * before terminating the process. Throws if the port is a system tunnel.
6204
+ */
6025
6205
  async stop(port) {
6026
6206
  const live = this.entries.get(port);
6027
6207
  if (!live) return;
@@ -6054,6 +6234,7 @@ var init_tunnel_registry = __esm({
6054
6234
  }
6055
6235
  return stopped;
6056
6236
  }
6237
+ /** Stop all user tunnels. Errors from individual stops are silently ignored. */
6057
6238
  async stopAllUser() {
6058
6239
  const userEntries = this.list(false);
6059
6240
  for (const entry of userEntries) {
@@ -6083,6 +6264,12 @@ var init_tunnel_registry = __esm({
6083
6264
  this.save();
6084
6265
  this.entries.clear();
6085
6266
  }
6267
+ /**
6268
+ * Return all current tunnel entries.
6269
+ *
6270
+ * Pass `includeSystem = true` to include the system tunnel in the result;
6271
+ * by default only user tunnels are returned.
6272
+ */
6086
6273
  list(includeSystem = false) {
6087
6274
  const entries = Array.from(this.entries.values()).map((l) => l.entry);
6088
6275
  if (includeSystem) return entries;
@@ -6094,12 +6281,23 @@ var init_tunnel_registry = __esm({
6094
6281
  getBySession(sessionId) {
6095
6282
  return this.list(false).filter((e) => e.sessionId === sessionId);
6096
6283
  }
6284
+ /**
6285
+ * Return the system tunnel entry, or null if it hasn't been registered yet
6286
+ * (e.g. `TunnelService.start()` hasn't been called, or the tunnel failed to start).
6287
+ */
6097
6288
  getSystemEntry() {
6098
6289
  for (const live of this.entries.values()) {
6099
6290
  if (live.entry.type === "system") return live.entry;
6100
6291
  }
6101
6292
  return null;
6102
6293
  }
6294
+ /**
6295
+ * Re-launch tunnels persisted from a previous run.
6296
+ *
6297
+ * Only user tunnels are restored — the system tunnel is registered separately by
6298
+ * `TunnelService.start()`. `sessionId` is intentionally dropped: sessions do not
6299
+ * survive a restart, so restored tunnels are session-less until an agent claims them.
6300
+ */
6103
6301
  async restore() {
6104
6302
  if (!fs16.existsSync(this.registryPath)) return;
6105
6303
  try {
@@ -6112,7 +6310,6 @@ var init_tunnel_registry = __esm({
6112
6310
  type: persisted.type,
6113
6311
  provider: persisted.provider,
6114
6312
  label: persisted.label
6115
- // sessionId intentionally omitted — sessions don't survive restart
6116
6313
  })
6117
6314
  )
6118
6315
  );
@@ -6151,6 +6348,8 @@ var init_tunnel_registry = __esm({
6151
6348
  return new OpenACPTunnelProvider(this.providerOptions, this.binDir ?? "", this.storage);
6152
6349
  }
6153
6350
  }
6351
+ // Debounce disk writes — multiple tunnel state changes may occur in rapid succession
6352
+ // (e.g. status update + URL assignment). Coalesce them into a single write after 2s.
6154
6353
  scheduleSave() {
6155
6354
  if (this.saveTimeout) clearTimeout(this.saveTimeout);
6156
6355
  this.saveTimeout = setTimeout(() => this.save(), 2e3);
@@ -6814,6 +7013,9 @@ var init_viewer_store = __esm({
6814
7013
  log10.debug({ removed, remaining: this.entries.size }, "Cleaned up expired viewer entries");
6815
7014
  }
6816
7015
  }
7016
+ // Guard against agents trying to serve files outside the session workspace
7017
+ // (e.g. /etc/passwd or ~/ paths). Uses realpath to handle symlinks and canonicalize
7018
+ // case on macOS/Windows where the filesystem is case-insensitive.
6817
7019
  isPathAllowed(filePath, workingDirectory) {
6818
7020
  const caseInsensitive = process.platform === "darwin" || process.platform === "win32";
6819
7021
  let resolved;
@@ -7254,6 +7456,7 @@ var init_token_store = __esm({
7254
7456
  codes = /* @__PURE__ */ new Map();
7255
7457
  savePromise = null;
7256
7458
  savePending = false;
7459
+ /** Loads token and code state from disk. Safe to call at startup; missing file is not an error. */
7257
7460
  async load() {
7258
7461
  try {
7259
7462
  const data = await readFile2(this.filePath, "utf-8");
@@ -7284,6 +7487,10 @@ var init_token_store = __esm({
7284
7487
  };
7285
7488
  await writeFile(this.filePath, JSON.stringify(data, null, 2), "utf-8");
7286
7489
  }
7490
+ /**
7491
+ * Coalesces concurrent writes: if a save is in-flight, sets a pending flag
7492
+ * so the next save fires immediately after the current one completes.
7493
+ */
7287
7494
  scheduleSave() {
7288
7495
  if (this.savePromise) {
7289
7496
  this.savePending = true;
@@ -7299,6 +7506,7 @@ var init_token_store = __esm({
7299
7506
  }
7300
7507
  });
7301
7508
  }
7509
+ /** Creates a new token record and schedules a persist. Returns the stored token including its generated id. */
7302
7510
  create(opts) {
7303
7511
  const now = /* @__PURE__ */ new Date();
7304
7512
  const token = {
@@ -7317,6 +7525,7 @@ var init_token_store = __esm({
7317
7525
  get(id) {
7318
7526
  return this.tokens.get(id);
7319
7527
  }
7528
+ /** Marks a token as revoked; future auth checks will reject it immediately. */
7320
7529
  revoke(id) {
7321
7530
  const token = this.tokens.get(id);
7322
7531
  if (token) {
@@ -7324,10 +7533,17 @@ var init_token_store = __esm({
7324
7533
  this.scheduleSave();
7325
7534
  }
7326
7535
  }
7536
+ /** Returns all non-revoked tokens. Revoked tokens are retained until cleanup() removes them. */
7327
7537
  list() {
7328
7538
  return Array.from(this.tokens.values()).filter((t) => !t.revoked);
7329
7539
  }
7330
7540
  lastUsedSaveTimer = null;
7541
+ /**
7542
+ * Records the current timestamp as `lastUsedAt` for the given token.
7543
+ *
7544
+ * Writes are debounced to 60 seconds — every API request updates this field,
7545
+ * so flushing on every call would cause excessive disk I/O.
7546
+ */
7331
7547
  updateLastUsed(id) {
7332
7548
  const token = this.tokens.get(id);
7333
7549
  if (token) {
@@ -7357,6 +7573,12 @@ var init_token_store = __esm({
7357
7573
  this.lastUsedSaveTimer = null;
7358
7574
  }
7359
7575
  }
7576
+ /**
7577
+ * Generates a one-time authorization code that can be exchanged for a JWT.
7578
+ *
7579
+ * Used for the CLI login flow: the server emits a code that the user copies into
7580
+ * the App, which exchanges it for a proper JWT without ever exposing the raw API secret.
7581
+ */
7360
7582
  createCode(opts) {
7361
7583
  const code = randomBytes(16).toString("hex");
7362
7584
  const now = /* @__PURE__ */ new Date();
@@ -7382,6 +7604,13 @@ var init_token_store = __esm({
7382
7604
  if (new Date(stored.expiresAt).getTime() < Date.now()) return void 0;
7383
7605
  return stored;
7384
7606
  }
7607
+ /**
7608
+ * Atomically marks a code as used and returns it.
7609
+ *
7610
+ * Returns undefined if the code is unknown, already used, or expired.
7611
+ * The one-time-use flag is set before returning, so concurrent calls for the
7612
+ * same code will only succeed once.
7613
+ */
7385
7614
  exchangeCode(code) {
7386
7615
  const stored = this.codes.get(code);
7387
7616
  if (!stored) return void 0;
@@ -7401,6 +7630,13 @@ var init_token_store = __esm({
7401
7630
  this.codes.delete(code);
7402
7631
  this.scheduleSave();
7403
7632
  }
7633
+ /**
7634
+ * Removes tokens past their refresh deadline and expired/used codes.
7635
+ *
7636
+ * Called on a 1-hour interval from the plugin setup to prevent unbounded file growth.
7637
+ * Tokens within their refresh deadline are retained even if revoked, so that the
7638
+ * "token revoked" error can be returned instead of "token unknown".
7639
+ */
7404
7640
  cleanup() {
7405
7641
  const now = Date.now();
7406
7642
  for (const [id, token] of this.tokens) {
@@ -7928,6 +8164,13 @@ var init_sse_manager = __esm({
7928
8164
  sseCleanupHandlers = /* @__PURE__ */ new Map();
7929
8165
  healthInterval;
7930
8166
  boundHandlers = [];
8167
+ /**
8168
+ * Subscribes to EventBus events and starts the health heartbeat interval.
8169
+ *
8170
+ * Must be called after the HTTP server is listening, not during plugin setup —
8171
+ * before `setup()` runs, no EventBus listeners are registered, so any events
8172
+ * emitted in the interim are silently missed.
8173
+ */
7931
8174
  setup() {
7932
8175
  if (!this.eventBus) return;
7933
8176
  const events = [
@@ -7961,6 +8204,13 @@ var init_sse_manager = __esm({
7961
8204
  });
7962
8205
  }, 15e3);
7963
8206
  }
8207
+ /**
8208
+ * Handles an incoming SSE request by upgrading the HTTP response to an event stream.
8209
+ *
8210
+ * The response is kept open indefinitely; cleanup runs when the client disconnects.
8211
+ * An initial `: connected` comment is written immediately so proxies and browsers
8212
+ * flush the response headers before the first real event arrives.
8213
+ */
7964
8214
  handleRequest(req, res) {
7965
8215
  if (this.sseConnections.size >= MAX_SSE_CONNECTIONS) {
7966
8216
  res.writeHead(503, { "Content-Type": "application/json" });
@@ -7995,6 +8245,13 @@ var init_sse_manager = __esm({
7995
8245
  this.sseCleanupHandlers.set(res, cleanup);
7996
8246
  req.on("close", cleanup);
7997
8247
  }
8248
+ /**
8249
+ * Broadcasts an event to all connected SSE clients.
8250
+ *
8251
+ * Session-scoped events (agent_event, permission_request, etc.) are filtered per-connection:
8252
+ * a client that subscribed with `?sessionId=X` only receives events for session X.
8253
+ * Global events (session_created, health) are delivered to every client.
8254
+ */
7998
8255
  broadcast(event, data) {
7999
8256
  const payload = `event: ${event}
8000
8257
  data: ${JSON.stringify(data)}
@@ -8030,6 +8287,12 @@ data: ${JSON.stringify(data)}
8030
8287
  this.handleRequest(request.raw, reply.raw);
8031
8288
  };
8032
8289
  }
8290
+ /**
8291
+ * Stops the heartbeat, removes all EventBus listeners, and closes all open SSE connections.
8292
+ *
8293
+ * Only listeners registered by this instance are removed — other consumers on the same
8294
+ * EventBus are unaffected.
8295
+ */
8033
8296
  stop() {
8034
8297
  if (this.healthInterval) clearInterval(this.healthInterval);
8035
8298
  if (this.eventBus) {
@@ -8093,9 +8356,15 @@ var init_static_server = __esm({
8093
8356
  }
8094
8357
  }
8095
8358
  }
8359
+ /** Returns true if a UI build was found and static serving is active. */
8096
8360
  isAvailable() {
8097
8361
  return this.uiDir !== void 0;
8098
8362
  }
8363
+ /**
8364
+ * Attempts to serve a static file or SPA fallback for the given request.
8365
+ *
8366
+ * @returns true if the response was handled, false if the caller should return a 404.
8367
+ */
8099
8368
  serve(req, res) {
8100
8369
  if (!this.uiDir) return false;
8101
8370
  const urlPath = (req.url || "/").split("?")[0];
@@ -8828,6 +9097,8 @@ var init_config_migrations = __esm({
8828
9097
  log13 = createChildLogger({ module: "config-migrations" });
8829
9098
  migrations = [
8830
9099
  {
9100
+ // v2025.x: instanceName was added to support multi-instance setups.
9101
+ // Old configs lack this field — default to "Main" so the UI has a display name.
8831
9102
  name: "add-instance-name",
8832
9103
  apply(raw) {
8833
9104
  if (raw.instanceName) return false;
@@ -8837,6 +9108,8 @@ var init_config_migrations = __esm({
8837
9108
  }
8838
9109
  },
8839
9110
  {
9111
+ // displayVerbosity was replaced by outputMode — remove the legacy key
9112
+ // so it doesn't confuse Zod strict parsing or the config editor.
8840
9113
  name: "delete-display-verbosity",
8841
9114
  apply(raw) {
8842
9115
  if (!("displayVerbosity" in raw)) return false;
@@ -8846,6 +9119,9 @@ var init_config_migrations = __esm({
8846
9119
  }
8847
9120
  },
8848
9121
  {
9122
+ // Instance IDs were originally only in instances.json (the global registry).
9123
+ // This migration copies the ID into config.json so each instance is self-identifying
9124
+ // without needing to cross-reference the registry.
8849
9125
  name: "add-instance-id",
8850
9126
  apply(raw, ctx) {
8851
9127
  if (raw.id) return false;
@@ -8906,10 +9182,11 @@ var init_config2 = __esm({
8906
9182
  sessionLogRetentionDays: z4.number().default(30)
8907
9183
  }).default({});
8908
9184
  ConfigSchema = z4.object({
9185
+ /** Instance UUID, written once at creation time. */
8909
9186
  id: z4.string().optional(),
8910
- // instance UUID, written once at creation time
8911
9187
  instanceName: z4.string().optional(),
8912
9188
  defaultAgent: z4.string(),
9189
+ // --- Workspace security & path resolution ---
8913
9190
  workspace: z4.object({
8914
9191
  allowExternalWorkspaces: z4.boolean().default(true),
8915
9192
  security: z4.object({
@@ -8917,12 +9194,16 @@ var init_config2 = __esm({
8917
9194
  envWhitelist: z4.array(z4.string()).default([])
8918
9195
  }).default({})
8919
9196
  }).default({}),
9197
+ // --- Logging ---
8920
9198
  logging: LoggingSchema,
9199
+ // --- Process lifecycle ---
8921
9200
  runMode: z4.enum(["foreground", "daemon"]).default("foreground"),
8922
9201
  autoStart: z4.boolean().default(false),
9202
+ // --- Session persistence ---
8923
9203
  sessionStore: z4.object({
8924
9204
  ttlDays: z4.number().default(30)
8925
9205
  }).default({}),
9206
+ // --- Installed integration tracking (e.g. plugins installed via CLI) ---
8926
9207
  integrations: z4.record(
8927
9208
  z4.string(),
8928
9209
  z4.object({
@@ -8930,7 +9211,9 @@ var init_config2 = __esm({
8930
9211
  installedAt: z4.string().optional()
8931
9212
  })
8932
9213
  ).default({}),
9214
+ // --- Agent output verbosity control ---
8933
9215
  outputMode: z4.enum(["low", "medium", "high"]).default("medium").optional(),
9216
+ // --- Multi-agent switching behavior ---
8934
9217
  agentSwitch: z4.object({
8935
9218
  labelHistory: z4.boolean().default(true)
8936
9219
  }).default({})
@@ -8946,6 +9229,13 @@ var init_config2 = __esm({
8946
9229
  super();
8947
9230
  this.configPath = process.env.OPENACP_CONFIG_PATH || configPath || expandHome2("~/.openacp/config.json");
8948
9231
  }
9232
+ /**
9233
+ * Loads config from disk through the full validation pipeline:
9234
+ * 1. Create default config if missing (first run)
9235
+ * 2. Apply migrations for older config formats
9236
+ * 3. Apply environment variable overrides
9237
+ * 4. Validate against Zod schema — exits on failure
9238
+ */
8949
9239
  async load() {
8950
9240
  const dir = path24.dirname(this.configPath);
8951
9241
  fs21.mkdirSync(dir, { recursive: true });
@@ -8979,9 +9269,17 @@ var init_config2 = __esm({
8979
9269
  }
8980
9270
  this.config = result.data;
8981
9271
  }
9272
+ /** Returns a deep clone of the current config to prevent external mutation. */
8982
9273
  get() {
8983
9274
  return structuredClone(this.config);
8984
9275
  }
9276
+ /**
9277
+ * Merges partial updates into the config file using atomic write (write tmp + rename).
9278
+ *
9279
+ * Validates the merged result before writing. If `changePath` is provided,
9280
+ * emits a `config:changed` event with old and new values for that path,
9281
+ * enabling hot-reload without restart.
9282
+ */
8985
9283
  async save(updates, changePath) {
8986
9284
  const oldConfig = this.config ? structuredClone(this.config) : void 0;
8987
9285
  const raw = JSON.parse(fs21.readFileSync(this.configPath, "utf-8"));
@@ -9003,9 +9301,12 @@ var init_config2 = __esm({
9003
9301
  }
9004
9302
  }
9005
9303
  /**
9006
- * Set a single config value by dot-path (e.g. "logging.level").
9007
- * Builds the nested update object, validates, and saves.
9008
- * Throws if the path contains blocked keys or the value fails Zod validation.
9304
+ * Convenience wrapper for updating a single deeply-nested config field
9305
+ * without constructing the full update object manually.
9306
+ *
9307
+ * Accepts a dot-path (e.g. "logging.level") and builds the nested
9308
+ * update object internally before delegating to `save()`.
9309
+ * Throws if the path contains prototype-pollution keys.
9009
9310
  */
9010
9311
  async setPath(dotPath, value) {
9011
9312
  const BLOCKED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
@@ -9022,6 +9323,13 @@ var init_config2 = __esm({
9022
9323
  target[parts[parts.length - 1]] = value;
9023
9324
  await this.save(updates, dotPath);
9024
9325
  }
9326
+ /**
9327
+ * Resolves a workspace path from user input.
9328
+ *
9329
+ * Supports three forms: no input (returns base dir), absolute/tilde paths
9330
+ * (validated against allowExternalWorkspaces), and named workspaces
9331
+ * (alphanumeric subdirectories under the base).
9332
+ */
9025
9333
  resolveWorkspace(input2) {
9026
9334
  const workspaceBase = path24.dirname(path24.dirname(this.configPath));
9027
9335
  if (!input2) {
@@ -9056,17 +9364,26 @@ var init_config2 = __esm({
9056
9364
  fs21.mkdirSync(namedPath, { recursive: true });
9057
9365
  return namedPath;
9058
9366
  }
9367
+ /** Checks whether the config file exists on disk. Wraps synchronous `fs.existsSync` behind an async interface for consistency with the rest of the ConfigManager API. */
9059
9368
  async exists() {
9060
9369
  return fs21.existsSync(this.configPath);
9061
9370
  }
9371
+ /** Returns the resolved path to the config JSON file. */
9062
9372
  getConfigPath() {
9063
9373
  return this.configPath;
9064
9374
  }
9375
+ /** Writes a complete config object to disk, creating the directory if needed. Used during initial setup. */
9065
9376
  async writeNew(config) {
9066
9377
  const dir = path24.dirname(this.configPath);
9067
9378
  fs21.mkdirSync(dir, { recursive: true });
9068
9379
  fs21.writeFileSync(this.configPath, JSON.stringify(config, null, 2));
9069
9380
  }
9381
+ /**
9382
+ * Applies `OPENACP_*` environment variables as overrides to per-plugin settings.
9383
+ *
9384
+ * This lets users configure plugin values (bot tokens, ports, etc.) via env vars
9385
+ * without editing settings files — useful for Docker, CI, and headless setups.
9386
+ */
9070
9387
  async applyEnvToPluginSettings(settingsManager) {
9071
9388
  const pluginOverrides = [
9072
9389
  { envVar: "OPENACP_TUNNEL_ENABLED", pluginName: "@openacp/tunnel", key: "enabled", transform: (v) => v === "true" },
@@ -9093,6 +9410,7 @@ var init_config2 = __esm({
9093
9410
  }
9094
9411
  }
9095
9412
  }
9413
+ /** Applies env var overrides to the raw config object before Zod validation. */
9096
9414
  applyEnvOverrides(raw) {
9097
9415
  const overrides = [
9098
9416
  ["OPENACP_DEFAULT_AGENT", ["defaultAgent"]],
@@ -9123,6 +9441,7 @@ var init_config2 = __esm({
9123
9441
  raw.logging.level = "debug";
9124
9442
  }
9125
9443
  }
9444
+ /** Recursively merges source into target, skipping prototype-pollution keys. */
9126
9445
  deepMerge(target, source) {
9127
9446
  const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
9128
9447
  for (const key of Object.keys(source)) {
@@ -9933,6 +10252,7 @@ var init_instance_registry = __esm({
9933
10252
  this.registryPath = registryPath;
9934
10253
  }
9935
10254
  data = { version: 1, instances: {} };
10255
+ /** Load the registry from disk. If the file is missing or corrupt, starts fresh. */
9936
10256
  load() {
9937
10257
  try {
9938
10258
  const raw = fs22.readFileSync(this.registryPath, "utf-8");
@@ -9960,26 +10280,33 @@ var init_instance_registry = __esm({
9960
10280
  this.save();
9961
10281
  }
9962
10282
  }
10283
+ /** Persist the registry to disk, creating parent directories if needed. */
9963
10284
  save() {
9964
10285
  const dir = path26.dirname(this.registryPath);
9965
10286
  fs22.mkdirSync(dir, { recursive: true });
9966
10287
  fs22.writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2));
9967
10288
  }
10289
+ /** Add or update an instance entry in the registry. Does not persist — call save() after. */
9968
10290
  register(id, root) {
9969
10291
  this.data.instances[id] = { id, root };
9970
10292
  }
10293
+ /** Remove an instance entry. Does not persist — call save() after. */
9971
10294
  remove(id) {
9972
10295
  delete this.data.instances[id];
9973
10296
  }
10297
+ /** Look up an instance by its ID. */
9974
10298
  get(id) {
9975
10299
  return this.data.instances[id];
9976
10300
  }
10301
+ /** Look up an instance by its root directory path. */
9977
10302
  getByRoot(root) {
9978
10303
  return Object.values(this.data.instances).find((e) => e.root === root);
9979
10304
  }
10305
+ /** Returns all registered instances. */
9980
10306
  list() {
9981
10307
  return Object.values(this.data.instances);
9982
10308
  }
10309
+ /** Returns `baseId` if available, otherwise appends `-2`, `-3`, etc. until unique. */
9983
10310
  uniqueId(baseId) {
9984
10311
  if (!this.data.instances[baseId]) return baseId;
9985
10312
  let n = 2;
@@ -10485,6 +10812,7 @@ var init_connection_manager = __esm({
10485
10812
  "use strict";
10486
10813
  ConnectionManager = class {
10487
10814
  connections = /* @__PURE__ */ new Map();
10815
+ // Secondary index: sessionId → Set of connection IDs for O(1) broadcast targeting
10488
10816
  sessionIndex = /* @__PURE__ */ new Map();
10489
10817
  maxConnectionsPerSession;
10490
10818
  maxTotalConnections;
@@ -10492,6 +10820,14 @@ var init_connection_manager = __esm({
10492
10820
  this.maxConnectionsPerSession = opts?.maxPerSession ?? 10;
10493
10821
  this.maxTotalConnections = opts?.maxTotal ?? 100;
10494
10822
  }
10823
+ /**
10824
+ * Registers a new SSE connection for the given session.
10825
+ *
10826
+ * Wires a `close` listener on the response so the connection is automatically
10827
+ * removed when the client disconnects (browser tab closed, network drop, etc.).
10828
+ *
10829
+ * @throws if the global or per-session connection limit is reached.
10830
+ */
10495
10831
  addConnection(sessionId, tokenId, response) {
10496
10832
  if (this.connections.size >= this.maxTotalConnections) {
10497
10833
  throw new Error("Maximum total connections reached");
@@ -10512,6 +10848,7 @@ var init_connection_manager = __esm({
10512
10848
  response.on("close", () => this.removeConnection(id));
10513
10849
  return connection;
10514
10850
  }
10851
+ /** Remove a connection from both indexes. Called automatically on client disconnect. */
10515
10852
  removeConnection(connectionId) {
10516
10853
  const conn = this.connections.get(connectionId);
10517
10854
  if (!conn) return;
@@ -10522,11 +10859,20 @@ var init_connection_manager = __esm({
10522
10859
  if (sessionConns.size === 0) this.sessionIndex.delete(conn.sessionId);
10523
10860
  }
10524
10861
  }
10862
+ /** Returns all active connections for a session. */
10525
10863
  getConnectionsBySession(sessionId) {
10526
10864
  const connIds = this.sessionIndex.get(sessionId);
10527
10865
  if (!connIds) return [];
10528
10866
  return Array.from(connIds).map((id) => this.connections.get(id)).filter((c3) => c3 !== void 0);
10529
10867
  }
10868
+ /**
10869
+ * Writes a serialized SSE event to all connections for the given session.
10870
+ *
10871
+ * Backpressure handling: if `response.write()` returns false (OS send buffer full),
10872
+ * the connection is flagged as `backpressured`. On the next write attempt, if it is
10873
+ * still backpressured, the connection is forcibly closed to prevent unbounded memory
10874
+ * growth from queuing writes on a slow or stalled client.
10875
+ */
10530
10876
  broadcast(sessionId, serializedEvent) {
10531
10877
  for (const conn of this.getConnectionsBySession(sessionId)) {
10532
10878
  if (conn.response.writableEnded) continue;
@@ -10548,6 +10894,10 @@ var init_connection_manager = __esm({
10548
10894
  }
10549
10895
  }
10550
10896
  }
10897
+ /**
10898
+ * Force-close all connections associated with a given auth token.
10899
+ * Called when a token is revoked to immediately terminate those streams.
10900
+ */
10551
10901
  disconnectByToken(tokenId) {
10552
10902
  for (const [id, conn] of this.connections) {
10553
10903
  if (conn.tokenId === tokenId) {
@@ -10556,9 +10906,11 @@ var init_connection_manager = __esm({
10556
10906
  }
10557
10907
  }
10558
10908
  }
10909
+ /** Returns a snapshot of all active connections (used by the admin endpoint). */
10559
10910
  listConnections() {
10560
10911
  return Array.from(this.connections.values());
10561
10912
  }
10913
+ /** Close all connections and clear all indexes. Called on plugin teardown. */
10562
10914
  cleanup() {
10563
10915
  for (const [, conn] of this.connections) {
10564
10916
  if (!conn.response.writableEnded) conn.response.end();
@@ -10576,10 +10928,15 @@ var init_event_buffer = __esm({
10576
10928
  "src/plugins/sse-adapter/event-buffer.ts"() {
10577
10929
  "use strict";
10578
10930
  EventBuffer = class {
10931
+ /**
10932
+ * @param maxSize Maximum events retained per session. Older events are evicted when
10933
+ * this limit is exceeded. Defaults to 100.
10934
+ */
10579
10935
  constructor(maxSize = 100) {
10580
10936
  this.maxSize = maxSize;
10581
10937
  }
10582
10938
  buffers = /* @__PURE__ */ new Map();
10939
+ /** Append an event to the session's buffer, evicting the oldest entry if at capacity. */
10583
10940
  push(sessionId, event) {
10584
10941
  let buffer = this.buffers.get(sessionId);
10585
10942
  if (!buffer) {
@@ -10591,6 +10948,14 @@ var init_event_buffer = __esm({
10591
10948
  buffer.shift();
10592
10949
  }
10593
10950
  }
10951
+ /**
10952
+ * Returns events that occurred after `lastEventId`.
10953
+ *
10954
+ * - If `lastEventId` is `undefined`, returns all buffered events (fresh connection).
10955
+ * - If `lastEventId` is not found in the buffer, returns `null` — the event has been
10956
+ * evicted and the client must be informed that a gap may exist.
10957
+ * - Otherwise returns the slice after the matching event.
10958
+ */
10594
10959
  getSince(sessionId, lastEventId) {
10595
10960
  const buffer = this.buffers.get(sessionId);
10596
10961
  if (!buffer || buffer.length === 0) return [];
@@ -10599,6 +10964,7 @@ var init_event_buffer = __esm({
10599
10964
  if (index === -1) return null;
10600
10965
  return buffer.slice(index + 1);
10601
10966
  }
10967
+ /** Remove the buffer for a session — called when the session ends to free memory. */
10602
10968
  cleanup(sessionId) {
10603
10969
  this.buffers.delete(sessionId);
10604
10970
  }
@@ -10680,6 +11046,12 @@ var init_adapter = __esm({
10680
11046
  voice: false
10681
11047
  };
10682
11048
  heartbeatTimer;
11049
+ /**
11050
+ * Starts the heartbeat timer that keeps idle SSE connections alive.
11051
+ *
11052
+ * `.unref()` prevents the timer from blocking the Node.js event loop from
11053
+ * exiting if this is the only remaining async operation (e.g. during tests).
11054
+ */
10683
11055
  async start() {
10684
11056
  this.heartbeatTimer = setInterval(() => {
10685
11057
  const heartbeat = serializeHeartbeat();
@@ -10696,6 +11068,7 @@ var init_adapter = __esm({
10696
11068
  this.heartbeatTimer.unref();
10697
11069
  }
10698
11070
  }
11071
+ /** Stops the heartbeat timer and closes all active connections. */
10699
11072
  async stop() {
10700
11073
  if (this.heartbeatTimer) {
10701
11074
  clearInterval(this.heartbeatTimer);
@@ -10703,18 +11076,32 @@ var init_adapter = __esm({
10703
11076
  }
10704
11077
  this.connectionManager.cleanup();
10705
11078
  }
11079
+ /**
11080
+ * Serializes an outgoing agent message, pushes it to the event buffer,
11081
+ * then broadcasts it to all active connections for the session.
11082
+ *
11083
+ * Buffering before broadcast ensures that a client reconnecting immediately
11084
+ * after this call can still replay the event via `Last-Event-ID`.
11085
+ */
10706
11086
  async sendMessage(sessionId, content) {
10707
11087
  const eventId = generateEventId();
10708
11088
  const serialized = serializeOutgoingMessage(sessionId, eventId, content);
10709
11089
  this.eventBuffer.push(sessionId, { id: eventId, data: serialized });
10710
11090
  this.connectionManager.broadcast(sessionId, serialized);
10711
11091
  }
11092
+ /** Serializes and delivers a permission request UI to the session's SSE clients. */
10712
11093
  async sendPermissionRequest(sessionId, request) {
10713
11094
  const eventId = generateEventId();
10714
11095
  const serialized = serializePermissionRequest(sessionId, eventId, request);
10715
11096
  this.eventBuffer.push(sessionId, { id: eventId, data: serialized });
10716
11097
  this.connectionManager.broadcast(sessionId, serialized);
10717
11098
  }
11099
+ /**
11100
+ * Delivers a cross-session notification to the target session's SSE clients.
11101
+ *
11102
+ * Notifications are always buffered in addition to being broadcast so that
11103
+ * a client reconnecting shortly after (e.g. page refresh) still sees the alert.
11104
+ */
10718
11105
  async sendNotification(notification) {
10719
11106
  if (notification.sessionId) {
10720
11107
  const eventId = generateEventId();
@@ -10723,9 +11110,11 @@ var init_adapter = __esm({
10723
11110
  this.connectionManager.broadcast(notification.sessionId, serialized);
10724
11111
  }
10725
11112
  }
11113
+ /** SSE has no concept of threads — return sessionId as the threadId */
10726
11114
  async createSessionThread(sessionId, _name) {
10727
11115
  return sessionId;
10728
11116
  }
11117
+ /** No-op for SSE — there are no named threads to rename. */
10729
11118
  async renameSessionThread(_sessionId, _newName) {
10730
11119
  }
10731
11120
  };
@@ -10765,6 +11154,7 @@ async function sseRoutes(app, deps) {
10765
11154
  "Content-Type": "text/event-stream",
10766
11155
  "Cache-Control": "no-cache",
10767
11156
  "Connection": "keep-alive",
11157
+ // Disable buffering in Nginx/Cloudflare so events arrive without delay
10768
11158
  "X-Accel-Buffering": "no"
10769
11159
  });
10770
11160
  raw.write(serializeConnected(connection.id, sessionId));
@@ -13694,13 +14084,16 @@ var init_settings_manager = __esm({
13694
14084
  constructor(basePath) {
13695
14085
  this.basePath = basePath;
13696
14086
  }
14087
+ /** Returns the base path for all plugin settings directories. */
13697
14088
  getBasePath() {
13698
14089
  return this.basePath;
13699
14090
  }
14091
+ /** Create a SettingsAPI instance scoped to a specific plugin. */
13700
14092
  createAPI(pluginName) {
13701
14093
  const settingsPath = this.getSettingsPath(pluginName);
13702
14094
  return new SettingsAPIImpl(settingsPath);
13703
14095
  }
14096
+ /** Load a plugin's settings from disk. Returns empty object if file doesn't exist. */
13704
14097
  async loadSettings(pluginName) {
13705
14098
  const settingsPath = this.getSettingsPath(pluginName);
13706
14099
  try {
@@ -13710,6 +14103,7 @@ var init_settings_manager = __esm({
13710
14103
  return {};
13711
14104
  }
13712
14105
  }
14106
+ /** Validate settings against a Zod schema. Returns valid if no schema is provided. */
13713
14107
  validateSettings(_pluginName, settings, schema) {
13714
14108
  if (!schema) return { valid: true };
13715
14109
  const result = schema.safeParse(settings);
@@ -13721,12 +14115,14 @@ var init_settings_manager = __esm({
13721
14115
  )
13722
14116
  };
13723
14117
  }
14118
+ /** Resolve the absolute path to a plugin's settings.json file. */
13724
14119
  getSettingsPath(pluginName) {
13725
14120
  return path30.join(this.basePath, pluginName, "settings.json");
13726
14121
  }
13727
14122
  async getPluginSettings(pluginName) {
13728
14123
  return this.loadSettings(pluginName);
13729
14124
  }
14125
+ /** Merge updates into existing settings (shallow merge). */
13730
14126
  async updatePluginSettings(pluginName, updates) {
13731
14127
  const api = this.createAPI(pluginName);
13732
14128
  const current = await api.getAll();
@@ -14297,6 +14693,12 @@ var init_doctor = __esm({
14297
14693
  this.dryRun = options?.dryRun ?? false;
14298
14694
  this.dataDir = options.dataDir;
14299
14695
  }
14696
+ /**
14697
+ * Executes all checks and returns an aggregated report.
14698
+ *
14699
+ * Safe fixes are applied inline (mutating CheckResult.message to show "Fixed").
14700
+ * Risky fixes are deferred to `report.pendingFixes` for user confirmation.
14701
+ */
14300
14702
  async runAll() {
14301
14703
  const ctx = await this.buildContext();
14302
14704
  const checks = [...ALL_CHECKS].sort((a, b) => a.order - b.order);
@@ -14344,6 +14746,7 @@ var init_doctor = __esm({
14344
14746
  }
14345
14747
  return { categories, summary, pendingFixes };
14346
14748
  }
14749
+ /** Constructs the shared context used by all checks — loads config if available. */
14347
14750
  async buildContext() {
14348
14751
  const dataDir = this.dataDir;
14349
14752
  const configPath = process.env.OPENACP_CONFIG_PATH || path35.join(dataDir, "config.json");
@@ -14991,6 +15394,14 @@ var init_permissions = __esm({
14991
15394
  this.sendNotification = sendNotification;
14992
15395
  }
14993
15396
  pending = /* @__PURE__ */ new Map();
15397
+ /**
15398
+ * Send a permission request to the session's topic as an inline keyboard message,
15399
+ * and fire a notification to the Notifications topic so the user is alerted.
15400
+ *
15401
+ * Each button encodes `p:<callbackKey>:<optionId>`. The callbackKey is a short
15402
+ * nanoid that maps to the full pending state stored in-memory, avoiding the
15403
+ * 64-byte Telegram callback_data limit.
15404
+ */
14994
15405
  async sendPermissionRequest(session, request) {
14995
15406
  const threadId = Number(session.threadId);
14996
15407
  const callbackKey = nanoid3(8);
@@ -15025,6 +15436,12 @@ ${escapeHtml4(request.description)}`,
15025
15436
  deepLink
15026
15437
  });
15027
15438
  }
15439
+ /**
15440
+ * Register the `p:` callback handler in the bot's middleware chain.
15441
+ *
15442
+ * Must be called during setup so grammY processes permission responses
15443
+ * before other generic callback handlers.
15444
+ */
15028
15445
  setupCallbackHandler() {
15029
15446
  this.bot.on("callback_query:data", async (ctx, next) => {
15030
15447
  const data = ctx.callbackQuery.data;
@@ -15072,6 +15489,9 @@ var init_tool_card_state = __esm({
15072
15489
  specs = [];
15073
15490
  planEntries;
15074
15491
  usage;
15492
+ // Lifecycle: active (first flush pending) → active (subsequent updates debounced) → finalized.
15493
+ // Once finalized, all updateFromSpec/updatePlan/appendUsage/finalize() calls are no-ops —
15494
+ // guards against events arriving after the session has ended or the tool has already completed.
15075
15495
  finalized = false;
15076
15496
  isFirstFlush = true;
15077
15497
  debounceTimer;
@@ -15079,6 +15499,7 @@ var init_tool_card_state = __esm({
15079
15499
  constructor(config) {
15080
15500
  this.onFlush = config.onFlush;
15081
15501
  }
15502
+ /** Adds or updates a tool spec. First call flushes immediately; subsequent calls are debounced. */
15082
15503
  updateFromSpec(spec) {
15083
15504
  if (this.finalized) return;
15084
15505
  const existingIdx = this.specs.findIndex((s) => s.id === spec.id);
@@ -15094,6 +15515,7 @@ var init_tool_card_state = __esm({
15094
15515
  this.scheduleFlush();
15095
15516
  }
15096
15517
  }
15518
+ /** Updates the plan entries displayed alongside tool cards. */
15097
15519
  updatePlan(entries) {
15098
15520
  if (this.finalized) return;
15099
15521
  this.planEntries = entries;
@@ -15104,17 +15526,20 @@ var init_tool_card_state = __esm({
15104
15526
  this.scheduleFlush();
15105
15527
  }
15106
15528
  }
15529
+ /** Appends token usage data to the tool card (typically at end of turn). */
15107
15530
  appendUsage(usage) {
15108
15531
  if (this.finalized) return;
15109
15532
  this.usage = usage;
15110
15533
  this.scheduleFlush();
15111
15534
  }
15535
+ /** Marks the turn as complete and flushes the final snapshot immediately. */
15112
15536
  finalize() {
15113
15537
  if (this.finalized) return;
15114
15538
  this.finalized = true;
15115
15539
  this.clearDebounce();
15116
15540
  this.flush();
15117
15541
  }
15542
+ /** Stops all pending flushes without emitting a final snapshot. */
15118
15543
  destroy() {
15119
15544
  this.finalized = true;
15120
15545
  this.clearDebounce();
@@ -15220,9 +15645,11 @@ var init_stream_accumulator = __esm({
15220
15645
  entry.diffStats = update.diffStats;
15221
15646
  }
15222
15647
  }
15648
+ /** Retrieves a tool entry by ID, or undefined if not yet tracked. */
15223
15649
  get(id) {
15224
15650
  return this.entries.get(id);
15225
15651
  }
15652
+ /** Resets all state between turns. */
15226
15653
  clear() {
15227
15654
  this.entries.clear();
15228
15655
  this.pendingUpdates.clear();
@@ -15231,20 +15658,24 @@ var init_stream_accumulator = __esm({
15231
15658
  ThoughtBuffer = class {
15232
15659
  chunks = [];
15233
15660
  sealed = false;
15661
+ /** Appends a thought text chunk. Ignored if already sealed. */
15234
15662
  append(chunk) {
15235
15663
  if (this.sealed) return;
15236
15664
  this.chunks.push(chunk);
15237
15665
  }
15666
+ /** Marks the thought as complete and returns the full accumulated text. */
15238
15667
  seal() {
15239
15668
  this.sealed = true;
15240
15669
  return this.chunks.join("");
15241
15670
  }
15671
+ /** Returns the text accumulated so far without sealing. */
15242
15672
  getText() {
15243
15673
  return this.chunks.join("");
15244
15674
  }
15245
15675
  isSealed() {
15246
15676
  return this.sealed;
15247
15677
  }
15678
+ /** Resets the buffer for reuse in a new turn. */
15248
15679
  reset() {
15249
15680
  this.chunks = [];
15250
15681
  this.sealed = false;
@@ -15416,6 +15847,13 @@ var init_display_spec_builder = __esm({
15416
15847
  constructor(tunnelService) {
15417
15848
  this.tunnelService = tunnelService;
15418
15849
  }
15850
+ /**
15851
+ * Builds a display spec for a single tool call entry.
15852
+ *
15853
+ * Deduplicates fields to avoid repeating the same info (e.g., if the title
15854
+ * was derived from the command, the command field is omitted). For long
15855
+ * output, generates a viewer link via the tunnel service when available.
15856
+ */
15419
15857
  buildToolSpec(entry, mode, sessionContext) {
15420
15858
  const effectiveKind = entry.displayKind ?? (isApplyPatchOtherTool(entry.kind, entry.name, entry.rawInput) ? "edit" : entry.kind);
15421
15859
  const icon = KIND_ICONS[effectiveKind] ?? KIND_ICONS["other"] ?? "\u{1F6E0}\uFE0F";
@@ -15474,6 +15912,7 @@ var init_display_spec_builder = __esm({
15474
15912
  isHidden
15475
15913
  };
15476
15914
  }
15915
+ /** Builds a display spec for an agent thought. Content is only included at high verbosity. */
15477
15916
  buildThoughtSpec(content, mode) {
15478
15917
  const indicator = "Thinking...";
15479
15918
  return {
@@ -15607,7 +16046,10 @@ var init_activity = __esm({
15607
16046
  this.sessionId = sessionId;
15608
16047
  this.state = new ToolCardState({
15609
16048
  onFlush: (snapshot) => {
15610
- this.flushPromise = this.flushPromise.then(() => this._sendOrEdit(snapshot)).catch(() => {
16049
+ this.flushPromise = this.flushPromise.then(() => {
16050
+ if (this.aborted) return;
16051
+ return this._sendOrEdit(snapshot);
16052
+ }).catch(() => {
15611
16053
  });
15612
16054
  }
15613
16055
  });
@@ -15617,6 +16059,7 @@ var init_activity = __esm({
15617
16059
  lastSentText;
15618
16060
  flushPromise = Promise.resolve();
15619
16061
  overflowMsgIds = [];
16062
+ aborted = false;
15620
16063
  tracer;
15621
16064
  sessionId;
15622
16065
  updateFromSpec(spec) {
@@ -15633,6 +16076,7 @@ var init_activity = __esm({
15633
16076
  await this.flushPromise;
15634
16077
  }
15635
16078
  destroy() {
16079
+ this.aborted = true;
15636
16080
  this.state.destroy();
15637
16081
  }
15638
16082
  hasContent() {
@@ -15864,6 +16308,13 @@ var init_send_queue = __esm({
15864
16308
  get pending() {
15865
16309
  return this.items.length;
15866
16310
  }
16311
+ /**
16312
+ * Queues an async operation for rate-limited execution.
16313
+ *
16314
+ * For text-type items with a key, replaces any existing queued item with
16315
+ * the same key (deduplication). This is used for streaming draft updates
16316
+ * where only the latest content matters.
16317
+ */
15867
16318
  enqueue(fn, opts) {
15868
16319
  const type = opts?.type ?? "other";
15869
16320
  const key = opts?.key;
@@ -15891,6 +16342,11 @@ var init_send_queue = __esm({
15891
16342
  this.scheduleProcess();
15892
16343
  return promise;
15893
16344
  }
16345
+ /**
16346
+ * Called when a platform rate limit is hit. Drops all pending text items
16347
+ * (draft updates) to reduce backlog, keeping only non-text items that
16348
+ * represent important operations (e.g., permission requests).
16349
+ */
15894
16350
  onRateLimited() {
15895
16351
  this.config.onRateLimited?.();
15896
16352
  const remaining2 = [];
@@ -15909,6 +16365,10 @@ var init_send_queue = __esm({
15909
16365
  }
15910
16366
  this.items = [];
15911
16367
  }
16368
+ /**
16369
+ * Schedules the next item for processing after the rate-limit delay.
16370
+ * Uses per-category timing when available, falling back to the global minInterval.
16371
+ */
15912
16372
  scheduleProcess() {
15913
16373
  if (this.processing) return;
15914
16374
  if (this.items.length === 0) return;
@@ -15981,6 +16441,7 @@ var init_streaming = __esm({
15981
16441
  lastSentHtml = "";
15982
16442
  displayTruncated = false;
15983
16443
  tracer;
16444
+ /** Append a text chunk to the buffer and schedule a throttled flush. */
15984
16445
  append(text5) {
15985
16446
  if (!text5) return;
15986
16447
  this.buffer += text5;
@@ -16060,6 +16521,16 @@ var init_streaming = __esm({
16060
16521
  }
16061
16522
  }
16062
16523
  }
16524
+ /**
16525
+ * Flush the complete buffer as the final message for this turn.
16526
+ *
16527
+ * Cancels any pending debounce timer, waits for in-flight flushes to settle,
16528
+ * then sends the full content. If the HTML exceeds 4096 bytes, the content is
16529
+ * split at markdown boundaries to avoid breaking HTML tags mid-tag.
16530
+ *
16531
+ * Returns the Telegram message ID of the sent/edited message, or undefined
16532
+ * if nothing was sent (empty buffer or all network calls failed).
16533
+ */
16063
16534
  async finalize() {
16064
16535
  this.tracer?.log("telegram", { action: "draft:finalize", sessionId: this.sessionId, bufferLen: this.buffer.length, msgId: this.messageId });
16065
16536
  if (this.flushTimer) {
@@ -16131,9 +16602,14 @@ var init_streaming = __esm({
16131
16602
  this.tracer?.log("telegram", { action: "draft:finalize:split", sessionId: this.sessionId, chunks: mdChunks.length });
16132
16603
  return this.messageId;
16133
16604
  }
16605
+ /** Returns the Telegram message ID for this draft, or undefined if not yet sent. */
16134
16606
  getMessageId() {
16135
16607
  return this.messageId;
16136
16608
  }
16609
+ /**
16610
+ * Strip occurrences of `pattern` from the buffer and edit the message in-place.
16611
+ * Used by the TTS plugin to remove [TTS]...[/TTS] blocks after audio is sent.
16612
+ */
16137
16613
  async stripPattern(pattern) {
16138
16614
  if (!this.messageId || !this.buffer) return;
16139
16615
  const stripped = this.buffer.replace(pattern, "").trim();
@@ -16171,6 +16647,10 @@ var init_draft_manager = __esm({
16171
16647
  drafts = /* @__PURE__ */ new Map();
16172
16648
  textBuffers = /* @__PURE__ */ new Map();
16173
16649
  finalizedDrafts = /* @__PURE__ */ new Map();
16650
+ /**
16651
+ * Return the active draft for a session, creating one if it doesn't exist yet.
16652
+ * Only one draft per session exists at a time.
16653
+ */
16174
16654
  getOrCreate(sessionId, threadId, tracer = null) {
16175
16655
  let draft = this.drafts.get(sessionId);
16176
16656
  if (!draft) {
@@ -16199,7 +16679,12 @@ var init_draft_manager = __esm({
16199
16679
  );
16200
16680
  }
16201
16681
  /**
16202
- * Finalize the current draft and return the message ID.
16682
+ * Finalize the active draft for a session and retain a short-lived reference for post-send edits.
16683
+ *
16684
+ * Removes the draft from the active map before awaiting to prevent concurrent calls from
16685
+ * double-finalizing the same draft. If the draft produces a message ID, stores it as a
16686
+ * `FinalizedDraft` so `stripPattern` (e.g. TTS block removal) can still edit the message
16687
+ * after it has been sent.
16203
16688
  */
16204
16689
  async finalize(sessionId, _assistantSessionId) {
16205
16690
  const draft = this.drafts.get(sessionId);
@@ -16226,6 +16711,12 @@ var init_draft_manager = __esm({
16226
16711
  await finalized.draft.stripPattern(pattern);
16227
16712
  }
16228
16713
  }
16714
+ /**
16715
+ * Discard all draft state for a session without sending anything.
16716
+ *
16717
+ * Removes the active draft, text buffer, and finalized draft reference. Called when a
16718
+ * session ends or is reset and any unsent content should be silently dropped.
16719
+ */
16229
16720
  cleanup(sessionId) {
16230
16721
  this.drafts.delete(sessionId);
16231
16722
  this.textBuffers.delete(sessionId);
@@ -16244,14 +16735,19 @@ var init_skill_command_manager = __esm({
16244
16735
  init_log();
16245
16736
  log28 = createChildLogger({ module: "skill-commands" });
16246
16737
  SkillCommandManager = class {
16247
- // sessionId → pinned msgId
16248
16738
  constructor(bot, chatId, sendQueue, sessionManager) {
16249
16739
  this.bot = bot;
16250
16740
  this.chatId = chatId;
16251
16741
  this.sendQueue = sendQueue;
16252
16742
  this.sessionManager = sessionManager;
16253
16743
  }
16744
+ // sessionId → Telegram message ID of the pinned skills message
16254
16745
  messages = /* @__PURE__ */ new Map();
16746
+ /**
16747
+ * Send or update the pinned skill commands message for a session.
16748
+ * Creates a new pinned message if none exists; edits the existing one otherwise.
16749
+ * Passing an empty `commands` array removes the pinned message.
16750
+ */
16255
16751
  async send(sessionId, threadId, commands) {
16256
16752
  if (!this.messages.has(sessionId)) {
16257
16753
  const record = this.sessionManager.getSessionRecord(sessionId);
@@ -16351,11 +16847,21 @@ var init_messaging_adapter = __esm({
16351
16847
  this.adapterConfig = adapterConfig;
16352
16848
  }
16353
16849
  // === Message dispatch flow ===
16850
+ /**
16851
+ * Entry point for all outbound messages from sessions to the platform.
16852
+ * Resolves the current verbosity, filters messages that should be hidden,
16853
+ * then dispatches to the appropriate type-specific handler.
16854
+ */
16354
16855
  async sendMessage(sessionId, content) {
16355
16856
  const verbosity = this.getVerbosity();
16356
16857
  if (!this.shouldDisplay(content, verbosity)) return;
16357
16858
  await this.dispatchMessage(sessionId, content, verbosity);
16358
16859
  }
16860
+ /**
16861
+ * Routes a message to its type-specific handler.
16862
+ * Subclasses can override this for custom dispatch logic, but typically
16863
+ * override individual handle* methods instead.
16864
+ */
16359
16865
  async dispatchMessage(sessionId, content, verbosity) {
16360
16866
  switch (content.type) {
16361
16867
  case "text":
@@ -16393,6 +16899,8 @@ var init_messaging_adapter = __esm({
16393
16899
  }
16394
16900
  }
16395
16901
  // === Default handlers — all protected, all overridable ===
16902
+ // Each handler is a no-op by default. Subclasses override only the message
16903
+ // types they support (e.g., Telegram overrides handleText, handleToolCall, etc.).
16396
16904
  async handleText(_sessionId, _content) {
16397
16905
  }
16398
16906
  async handleThought(_sessionId, _content, _verbosity) {
@@ -16426,6 +16934,10 @@ var init_messaging_adapter = __esm({
16426
16934
  async handleResourceLink(_sessionId, _content) {
16427
16935
  }
16428
16936
  // === Helpers ===
16937
+ /**
16938
+ * Resolves the current output verbosity by checking (in priority order):
16939
+ * per-channel config, global config, then adapter default. Falls back to "medium".
16940
+ */
16429
16941
  getVerbosity() {
16430
16942
  const config = this.context.configManager.get();
16431
16943
  const channelConfig = config.channels;
@@ -16434,6 +16946,13 @@ var init_messaging_adapter = __esm({
16434
16946
  if (v === "low" || v === "high") return v;
16435
16947
  return "medium";
16436
16948
  }
16949
+ /**
16950
+ * Determines whether a message should be displayed at the given verbosity.
16951
+ *
16952
+ * Noise filtering: tool calls matching noise rules (e.g., `ls`, `glob`, `grep`)
16953
+ * are hidden at medium/low verbosity to reduce clutter. Thoughts and usage
16954
+ * stats are hidden entirely at "low".
16955
+ */
16437
16956
  shouldDisplay(content, verbosity) {
16438
16957
  if (verbosity === "low" && HIDDEN_ON_LOW.has(content.type)) return false;
16439
16958
  if (content.type === "tool_call") {
@@ -16665,6 +17184,7 @@ var init_output_mode_resolver = __esm({
16665
17184
  "use strict";
16666
17185
  VALID_MODES = /* @__PURE__ */ new Set(["low", "medium", "high"]);
16667
17186
  OutputModeResolver = class {
17187
+ /** Resolves the effective output mode by walking the override cascade. */
16668
17188
  resolve(configManager, adapterName, sessionId, sessionManager) {
16669
17189
  const config = configManager.get();
16670
17190
  let mode = toOutputMode(config.outputMode) ?? "medium";
@@ -16763,7 +17283,11 @@ var init_adapter2 = __esm({
16763
17283
  _topicsInitialized = false;
16764
17284
  /** Background watcher timer — cancelled on stop() or when topics succeed */
16765
17285
  _prerequisiteWatcher = null;
16766
- /** Store control message ID in memory + persist to session record */
17286
+ /**
17287
+ * Persist the control message ID both in-memory and to the session record.
17288
+ * The control message is the pinned status card with bypass/TTS buttons; its ID
17289
+ * is needed after a restart to edit it when config changes.
17290
+ */
16767
17291
  storeControlMsgId(sessionId, msgId) {
16768
17292
  this.controlMsgIds.set(sessionId, msgId);
16769
17293
  const record = this.core.sessionManager.getSessionRecord(sessionId);
@@ -16828,6 +17352,12 @@ var init_adapter2 = __esm({
16828
17352
  this.telegramConfig = config;
16829
17353
  this.saveTopicIds = saveTopicIds;
16830
17354
  }
17355
+ /**
17356
+ * Set up the grammY bot, register all callback and message handlers, then perform
17357
+ * two-phase startup: Phase 1 starts polling immediately; Phase 2 checks group
17358
+ * prerequisites (bot is admin, topics are enabled) and creates/restores system topics.
17359
+ * If prerequisites are not met, a background watcher retries until they are.
17360
+ */
16831
17361
  async start() {
16832
17362
  this.bot = new Bot(this.telegramConfig.botToken, {
16833
17363
  client: {
@@ -17258,6 +17788,13 @@ OpenACP will automatically retry until this is resolved.`;
17258
17788
  };
17259
17789
  this._prerequisiteWatcher = setTimeout(retry, schedule[0]);
17260
17790
  }
17791
+ /**
17792
+ * Tear down the bot and release all associated resources.
17793
+ *
17794
+ * Cancels the background prerequisite watcher, destroys all per-session activity
17795
+ * trackers (which hold interval timers), removes eventBus listeners, clears the
17796
+ * send queue, and stops the grammY bot polling loop.
17797
+ */
17261
17798
  async stop() {
17262
17799
  if (this._prerequisiteWatcher !== null) {
17263
17800
  clearTimeout(this._prerequisiteWatcher);
@@ -17391,8 +17928,7 @@ ${lines.join("\n")}`;
17391
17928
  await this.draftManager.finalize(sessionId, assistantSession?.id);
17392
17929
  }
17393
17930
  if (sessionId) {
17394
- const tracker = this.sessionTrackers.get(sessionId);
17395
- if (tracker) await tracker.onNewPrompt();
17931
+ await this.drainAndResetTracker(sessionId);
17396
17932
  }
17397
17933
  ctx.replyWithChatAction("typing").catch(() => {
17398
17934
  });
@@ -17481,9 +18017,26 @@ ${lines.join("\n")}`;
17481
18017
  * its creation event. This queue ensures events are processed in the order they arrive.
17482
18018
  */
17483
18019
  _dispatchQueues = /* @__PURE__ */ new Map();
18020
+ /**
18021
+ * Drain pending event dispatches from the previous prompt, then reset the
18022
+ * activity tracker so late tool_call events don't leak into the new card.
18023
+ */
18024
+ async drainAndResetTracker(sessionId) {
18025
+ const pendingDispatch = this._dispatchQueues.get(sessionId);
18026
+ if (pendingDispatch) await pendingDispatch;
18027
+ const tracker = this.sessionTrackers.get(sessionId);
18028
+ if (tracker) await tracker.onNewPrompt();
18029
+ }
17484
18030
  getTracer(sessionId) {
17485
18031
  return this.core.sessionManager.getSession(sessionId)?.agentInstance?.debugTracer ?? null;
17486
18032
  }
18033
+ /**
18034
+ * Primary outbound dispatch method — routes an agent message to the session's Telegram topic.
18035
+ *
18036
+ * Wraps the base class `sendMessage` in a per-session promise chain (`_dispatchQueues`)
18037
+ * so that concurrent events fired from SessionBridge are serialized and delivered in the
18038
+ * order they arrive, preventing fast handlers from overtaking slower ones.
18039
+ */
17487
18040
  async sendMessage(sessionId, content) {
17488
18041
  const session = this.core.sessionManager.getSession(sessionId);
17489
18042
  if (!session) return;
@@ -17797,6 +18350,11 @@ Task completed.
17797
18350
  )
17798
18351
  );
17799
18352
  }
18353
+ /**
18354
+ * Render a PermissionRequest as an inline keyboard in the session topic and
18355
+ * notify the Notifications topic. Runs inside a sendQueue item, so
18356
+ * notification is fire-and-forget to avoid deadlock.
18357
+ */
17800
18358
  async sendPermissionRequest(sessionId, request) {
17801
18359
  this.getTracer(sessionId)?.log("telegram", { action: "permission:send", sessionId, requestId: request.id, description: request.description });
17802
18360
  log29.info({ sessionId, requestId: request.id }, "Permission request sent");
@@ -17806,6 +18364,11 @@ Task completed.
17806
18364
  () => this.permissionHandler.sendPermissionRequest(session, request)
17807
18365
  );
17808
18366
  }
18367
+ /**
18368
+ * Post a notification to the Notifications topic.
18369
+ * Assistant session notifications are suppressed — the assistant topic is
18370
+ * the user's primary interface and does not need a separate alert.
18371
+ */
17809
18372
  async sendNotification(notification) {
17810
18373
  this.getTracer(notification.sessionId)?.log("telegram", { action: "notification:send", sessionId: notification.sessionId, type: notification.type });
17811
18374
  if (notification.sessionId === this.core.assistantManager?.get("telegram")?.id) return;
@@ -17846,6 +18409,10 @@ Task completed.
17846
18409
  })
17847
18410
  );
17848
18411
  }
18412
+ /**
18413
+ * Create a new Telegram forum topic for a session and return its thread ID as a string.
18414
+ * Called by the core when a session is created via the API or CLI (not from the Telegram UI).
18415
+ */
17849
18416
  async createSessionThread(sessionId, name) {
17850
18417
  this.getTracer(sessionId)?.log("telegram", { action: "thread:create", sessionId, name });
17851
18418
  log29.info({ sessionId, name }, "Session topic created");
@@ -17853,6 +18420,10 @@ Task completed.
17853
18420
  await createSessionTopic(this.bot, this.telegramConfig.chatId, name)
17854
18421
  );
17855
18422
  }
18423
+ /**
18424
+ * Rename the forum topic for a session and update the session record's display name.
18425
+ * No-ops silently if the session doesn't have a threadId yet (e.g. still initializing).
18426
+ */
17856
18427
  async renameSessionThread(sessionId, newName) {
17857
18428
  this.getTracer(sessionId)?.log("telegram", { action: "thread:rename", sessionId, newName });
17858
18429
  const session = this.core.sessionManager.getSession(sessionId);
@@ -17870,6 +18441,7 @@ Task completed.
17870
18441
  );
17871
18442
  await this.core.sessionManager.patchRecord(sessionId, { name: newName });
17872
18443
  }
18444
+ /** Delete the forum topic associated with a session. */
17873
18445
  async deleteSessionThread(sessionId) {
17874
18446
  const record = this.core.sessionManager.getSessionRecord(sessionId);
17875
18447
  const platform2 = record?.platform;
@@ -17884,6 +18456,11 @@ Task completed.
17884
18456
  );
17885
18457
  }
17886
18458
  }
18459
+ /**
18460
+ * Display or update the pinned skill commands message for a session.
18461
+ * If the session's threadId is not yet set (e.g. session created from API),
18462
+ * the commands are queued and flushed once the thread becomes available.
18463
+ */
17887
18464
  async sendSkillCommands(sessionId, commands) {
17888
18465
  if (sessionId === this.core.assistantManager?.get("telegram")?.id) return;
17889
18466
  const session = this.core.sessionManager.getSession(sessionId);
@@ -17968,7 +18545,10 @@ Task completed.
17968
18545
  return;
17969
18546
  }
17970
18547
  const sid = await this.resolveSessionId(threadId);
17971
- if (sid) await this.draftManager.finalize(sid, this.core.assistantManager?.get("telegram")?.id);
18548
+ if (sid) {
18549
+ await this.draftManager.finalize(sid, this.core.assistantManager?.get("telegram")?.id);
18550
+ await this.drainAndResetTracker(sid);
18551
+ }
17972
18552
  this.core.handleMessage({
17973
18553
  channelId: "telegram",
17974
18554
  threadId: String(threadId),
@@ -17977,10 +18557,24 @@ Task completed.
17977
18557
  attachments: [att]
17978
18558
  }).catch((err) => log29.error({ err }, "handleMessage error"));
17979
18559
  }
18560
+ /**
18561
+ * Remove skill slash commands from the Telegram bot command list for a session.
18562
+ *
18563
+ * Clears any queued pending commands that hadn't been sent yet, then delegates
18564
+ * to `SkillCommandManager` to delete the commands from the Telegram API. Called
18565
+ * when a session with registered skill commands ends.
18566
+ */
17980
18567
  async cleanupSkillCommands(sessionId) {
17981
18568
  this._pendingSkillCommands.delete(sessionId);
17982
18569
  await this.skillManager.cleanup(sessionId);
17983
18570
  }
18571
+ /**
18572
+ * Clean up all adapter state associated with a session.
18573
+ *
18574
+ * Finalizes and discards any in-flight draft, destroys the activity tracker
18575
+ * (stopping ThinkingIndicator timers and finalizing any open ToolCard), and
18576
+ * clears pending skill commands. Called when a session ends or is reset.
18577
+ */
17984
18578
  async cleanupSessionState(sessionId) {
17985
18579
  this._pendingSkillCommands.delete(sessionId);
17986
18580
  await this.draftManager.finalize(sessionId, this.core.assistantManager?.get("telegram")?.id);
@@ -17991,9 +18585,22 @@ Task completed.
17991
18585
  this.sessionTrackers.delete(sessionId);
17992
18586
  }
17993
18587
  }
18588
+ /**
18589
+ * Remove `[TTS]...[/TTS]` blocks from the active or finalized draft for a session.
18590
+ *
18591
+ * The agent embeds these blocks so the speech plugin can extract the TTS text, but
18592
+ * they should never appear in the chat message. Called after TTS audio has been sent.
18593
+ */
17994
18594
  async stripTTSBlock(sessionId) {
17995
18595
  await this.draftManager.stripPattern(sessionId, /\[TTS\][\s\S]*?\[\/TTS\]/g);
17996
18596
  }
18597
+ /**
18598
+ * Archive a session by deleting its forum topic.
18599
+ *
18600
+ * Sets `session.archiving = true` to suppress any outgoing messages while the
18601
+ * topic is being torn down, finalizes pending drafts, cleans up all trackers,
18602
+ * then deletes the Telegram topic (which removes all messages).
18603
+ */
17997
18604
  async archiveSessionTopic(sessionId) {
17998
18605
  this.getTracer(sessionId)?.log("telegram", { action: "thread:archive", sessionId });
17999
18606
  const core = this.core;
@@ -19022,13 +19629,18 @@ var init_path_guard = __esm({
19022
19629
  "src/core/security/path-guard.ts"() {
19023
19630
  "use strict";
19024
19631
  DEFAULT_DENY_PATTERNS = [
19632
+ // Environment files — contain API keys, database URLs, etc.
19025
19633
  ".env",
19026
19634
  ".env.*",
19635
+ // Cryptographic keys
19027
19636
  "*.key",
19028
19637
  "*.pem",
19638
+ // SSH and cloud credentials
19029
19639
  ".ssh/",
19030
19640
  ".aws/",
19641
+ // OpenACP workspace — contains bot tokens and secrets
19031
19642
  ".openacp/",
19643
+ // Generic credential/secret files
19032
19644
  "**/credentials*",
19033
19645
  "**/secrets*",
19034
19646
  "**/*.secret"
@@ -19056,6 +19668,20 @@ var init_path_guard = __esm({
19056
19668
  this.ig.add(options.ignorePatterns);
19057
19669
  }
19058
19670
  }
19671
+ /**
19672
+ * Checks whether an agent is allowed to access the given path.
19673
+ *
19674
+ * Validation order:
19675
+ * 1. Write to .openacpignore is always blocked (prevents agents from weakening their own restrictions)
19676
+ * 2. Path must be within cwd or an explicitly allowlisted path
19677
+ * 3. If within cwd but not allowlisted, path must not match any deny pattern
19678
+ *
19679
+ * @param targetPath - The path the agent is attempting to access.
19680
+ * @param operation - The operation type. Write operations are subject to stricter
19681
+ * restrictions than reads — specifically, writing to `.openacpignore` is blocked
19682
+ * (to prevent agents from weakening their own restrictions), while reading it is allowed.
19683
+ * @returns `{ allowed: true }` or `{ allowed: false, reason: "..." }`
19684
+ */
19059
19685
  validatePath(targetPath, operation) {
19060
19686
  const resolved = path43.resolve(targetPath);
19061
19687
  let realPath;
@@ -19091,6 +19717,7 @@ var init_path_guard = __esm({
19091
19717
  }
19092
19718
  return { allowed: true, reason: "" };
19093
19719
  }
19720
+ /** Adds an additional allowed path at runtime (e.g. for file-service uploads). */
19094
19721
  addAllowedPath(p2) {
19095
19722
  try {
19096
19723
  this.allowedPaths.push(fs38.realpathSync(path43.resolve(p2)));
@@ -19098,6 +19725,10 @@ var init_path_guard = __esm({
19098
19725
  this.allowedPaths.push(path43.resolve(p2));
19099
19726
  }
19100
19727
  }
19728
+ /**
19729
+ * Loads additional deny patterns from .openacpignore in the workspace root.
19730
+ * Follows .gitignore syntax — blank lines and lines starting with # are skipped.
19731
+ */
19101
19732
  static loadIgnoreFile(cwd) {
19102
19733
  const ignorePath = path43.join(cwd, ".openacpignore");
19103
19734
  try {
@@ -19137,6 +19768,7 @@ var init_env_filter = __esm({
19137
19768
  "src/core/security/env-filter.ts"() {
19138
19769
  "use strict";
19139
19770
  DEFAULT_ENV_WHITELIST = [
19771
+ // Shell basics — agents need these to resolve commands and write temp files
19140
19772
  "PATH",
19141
19773
  "HOME",
19142
19774
  "SHELL",
@@ -19149,11 +19781,11 @@ var init_env_filter = __esm({
19149
19781
  "XDG_*",
19150
19782
  "NODE_ENV",
19151
19783
  "EDITOR",
19152
- // Git
19784
+ // Git — agents need git config and SSH access for code operations
19153
19785
  "GIT_*",
19154
19786
  "SSH_AUTH_SOCK",
19155
19787
  "SSH_AGENT_PID",
19156
- // Terminal rendering
19788
+ // Terminal rendering — ensures correct color output in agent responses
19157
19789
  "COLORTERM",
19158
19790
  "FORCE_COLOR",
19159
19791
  "NO_COLOR",
@@ -19221,12 +19853,14 @@ var init_stderr_capture = __esm({
19221
19853
  this.maxLines = maxLines;
19222
19854
  }
19223
19855
  lines = [];
19856
+ /** Append a chunk of stderr output, splitting on newlines and trimming to maxLines. */
19224
19857
  append(chunk) {
19225
19858
  this.lines.push(...chunk.split("\n").filter(Boolean));
19226
19859
  if (this.lines.length > this.maxLines) {
19227
19860
  this.lines = this.lines.slice(-this.maxLines);
19228
19861
  }
19229
19862
  }
19863
+ /** Return all captured lines joined as a single string. */
19230
19864
  getLastLines() {
19231
19865
  return this.lines.join("\n");
19232
19866
  }
@@ -19245,6 +19879,7 @@ var init_typed_emitter = __esm({
19245
19879
  listeners = /* @__PURE__ */ new Map();
19246
19880
  paused = false;
19247
19881
  buffer = [];
19882
+ /** Register a listener for the given event. Returns `this` for chaining. */
19248
19883
  on(event, listener) {
19249
19884
  let set = this.listeners.get(event);
19250
19885
  if (!set) {
@@ -19254,10 +19889,17 @@ var init_typed_emitter = __esm({
19254
19889
  set.add(listener);
19255
19890
  return this;
19256
19891
  }
19892
+ /** Remove a specific listener for the given event. */
19257
19893
  off(event, listener) {
19258
19894
  this.listeners.get(event)?.delete(listener);
19259
19895
  return this;
19260
19896
  }
19897
+ /**
19898
+ * Emit an event to all registered listeners.
19899
+ *
19900
+ * When paused, events are buffered (up to MAX_BUFFER_SIZE) unless
19901
+ * the passthrough filter allows them through immediately.
19902
+ */
19261
19903
  emit(event, ...args2) {
19262
19904
  if (this.paused) {
19263
19905
  if (this.passthroughFn?.(event, args2)) {
@@ -19301,6 +19943,7 @@ var init_typed_emitter = __esm({
19301
19943
  get bufferSize() {
19302
19944
  return this.buffer.length;
19303
19945
  }
19946
+ /** Remove all listeners for a specific event, or all events if none specified. */
19304
19947
  removeAllListeners(event) {
19305
19948
  if (event) {
19306
19949
  this.listeners.delete(event);
@@ -19308,6 +19951,7 @@ var init_typed_emitter = __esm({
19308
19951
  this.listeners.clear();
19309
19952
  }
19310
19953
  }
19954
+ /** Deliver an event to listeners, isolating errors so one broken listener doesn't break others. */
19311
19955
  deliver(event, args2) {
19312
19956
  const set = this.listeners.get(event);
19313
19957
  if (!set) return;
@@ -19379,6 +20023,11 @@ var init_terminal_manager = __esm({
19379
20023
  constructor(maxOutputBytes = 1024 * 1024) {
19380
20024
  this.maxOutputBytes = maxOutputBytes;
19381
20025
  }
20026
+ /**
20027
+ * Spawn a new terminal process. Runs terminal:beforeCreate middleware first
20028
+ * (which can modify command/args/env or block creation entirely).
20029
+ * Returns a terminalId for subsequent output/wait/kill operations.
20030
+ */
19382
20031
  async createTerminal(sessionId, params, middlewareChain) {
19383
20032
  let termCommand = params.command;
19384
20033
  let termArgs = params.args ?? [];
@@ -19460,6 +20109,7 @@ var init_terminal_manager = __esm({
19460
20109
  });
19461
20110
  return { terminalId };
19462
20111
  }
20112
+ /** Retrieve accumulated stdout/stderr output for a terminal. */
19463
20113
  getOutput(terminalId) {
19464
20114
  const state = this.terminals.get(terminalId);
19465
20115
  if (!state) {
@@ -19474,6 +20124,7 @@ var init_terminal_manager = __esm({
19474
20124
  } : void 0
19475
20125
  };
19476
20126
  }
20127
+ /** Block until the terminal process exits, returning exit code and signal. */
19477
20128
  async waitForExit(terminalId) {
19478
20129
  const state = this.terminals.get(terminalId);
19479
20130
  if (!state) {
@@ -19497,6 +20148,7 @@ var init_terminal_manager = __esm({
19497
20148
  }
19498
20149
  });
19499
20150
  }
20151
+ /** Send SIGTERM to a terminal process (graceful shutdown). */
19500
20152
  kill(terminalId) {
19501
20153
  const state = this.terminals.get(terminalId);
19502
20154
  if (!state) {
@@ -19504,6 +20156,7 @@ var init_terminal_manager = __esm({
19504
20156
  }
19505
20157
  state.process.kill("SIGTERM");
19506
20158
  }
20159
+ /** Force-kill (SIGKILL) and immediately remove a terminal from the registry. */
19507
20160
  release(terminalId) {
19508
20161
  const state = this.terminals.get(terminalId);
19509
20162
  if (!state) {
@@ -19512,6 +20165,7 @@ var init_terminal_manager = __esm({
19512
20165
  state.process.kill("SIGKILL");
19513
20166
  this.terminals.delete(terminalId);
19514
20167
  }
20168
+ /** Force-kill all terminals. Used during session/system teardown. */
19515
20169
  destroyAll() {
19516
20170
  for (const [, t] of this.terminals) {
19517
20171
  t.process.kill("SIGKILL");
@@ -19559,6 +20213,12 @@ var init_debug_tracer = __esm({
19559
20213
  }
19560
20214
  dirCreated = false;
19561
20215
  logDir;
20216
+ /**
20217
+ * Write a timestamped JSONL entry to the trace file for the given layer.
20218
+ *
20219
+ * Handles circular references gracefully and silently swallows errors —
20220
+ * debug logging must never crash the application.
20221
+ */
19562
20222
  log(layer, data) {
19563
20223
  try {
19564
20224
  if (!this.dirCreated) {
@@ -19716,9 +20376,13 @@ var init_agent_instance = __esm({
19716
20376
  connection;
19717
20377
  child;
19718
20378
  stderrCapture;
20379
+ /** Manages terminal subprocesses that agents can spawn for shell commands. */
19719
20380
  terminalManager = new TerminalManager();
20381
+ /** Shared across all instances — resolves MCP server configs for ACP sessions. */
19720
20382
  static mcpManager = new McpManager();
20383
+ /** Guards against emitting crash events during intentional shutdown. */
19721
20384
  _destroying = false;
20385
+ /** Restricts agent file I/O to the workspace directory and explicitly allowed paths. */
19722
20386
  pathGuard;
19723
20387
  sessionId;
19724
20388
  agentName;
@@ -19728,16 +20392,36 @@ var init_agent_instance = __esm({
19728
20392
  initialSessionResponse;
19729
20393
  middlewareChain;
19730
20394
  debugTracer = null;
19731
- /** Allow external callers (e.g. SessionFactory) to whitelist additional read paths */
20395
+ /**
20396
+ * Whitelist an additional filesystem path for agent read access.
20397
+ *
20398
+ * Called by SessionFactory to allow agents to read files outside the
20399
+ * workspace (e.g., the file-service upload directory for attachments).
20400
+ */
19732
20401
  addAllowedPath(p2) {
19733
20402
  this.pathGuard.addAllowedPath(p2);
19734
20403
  }
19735
- // Callback — set by core when wiring events
20404
+ // Callback — set by Session/Core when wiring events. Returns the selected
20405
+ // permission option ID. Default no-op auto-selects the first option.
19736
20406
  onPermissionRequest = async () => "";
19737
20407
  constructor(agentName) {
19738
20408
  super();
19739
20409
  this.agentName = agentName;
19740
20410
  }
20411
+ /**
20412
+ * Spawn the agent child process and complete the ACP protocol handshake.
20413
+ *
20414
+ * Steps:
20415
+ * 1. Resolve the agent command to a directly executable path
20416
+ * 2. Create a PathGuard scoped to the working directory
20417
+ * 3. Spawn the subprocess with a filtered environment (security: only whitelisted
20418
+ * env vars are passed to prevent leaking secrets like API keys)
20419
+ * 4. Wire stdin/stdout through debug-tracing Transform streams
20420
+ * 5. Convert Node streams → Web streams for the ACP SDK
20421
+ * 6. Perform the ACP `initialize` handshake and negotiate capabilities
20422
+ *
20423
+ * Does NOT create a session — callers must follow up with newSession or loadSession.
20424
+ */
19741
20425
  static async spawnSubprocess(agentDef, workingDirectory, allowedPaths = []) {
19742
20426
  const instance = new _AgentInstance(agentDef.name);
19743
20427
  const resolved = resolveAgentCommand(agentDef.command);
@@ -19836,6 +20520,13 @@ var init_agent_instance = __esm({
19836
20520
  );
19837
20521
  return instance;
19838
20522
  }
20523
+ /**
20524
+ * Monitor the subprocess for unexpected exits and emit error events.
20525
+ *
20526
+ * Distinguishes intentional shutdown (SIGTERM/SIGINT during destroy) from
20527
+ * crashes (non-zero exit code or unexpected signal). Crash events include
20528
+ * captured stderr output for diagnostic context.
20529
+ */
19839
20530
  setupCrashDetection() {
19840
20531
  this.child.on("exit", (code, signal) => {
19841
20532
  if (this._destroying) return;
@@ -19858,6 +20549,18 @@ ${stderr}`
19858
20549
  log33.debug({ sessionId: this.sessionId }, "ACP connection closed");
19859
20550
  });
19860
20551
  }
20552
+ /**
20553
+ * Spawn a new agent subprocess and create a fresh ACP session.
20554
+ *
20555
+ * This is the primary entry point for starting an agent. It spawns the
20556
+ * subprocess, completes the ACP handshake, and calls `newSession` to
20557
+ * initialize the agent's working context (cwd, MCP servers).
20558
+ *
20559
+ * @param agentDef - Agent definition (command, args, env) from the catalog
20560
+ * @param workingDirectory - Workspace root the agent operates in
20561
+ * @param mcpServers - Optional MCP server configs to extend agent capabilities
20562
+ * @param allowedPaths - Extra filesystem paths the agent may access
20563
+ */
19861
20564
  static async spawn(agentDef, workingDirectory, mcpServers, allowedPaths) {
19862
20565
  log33.debug(
19863
20566
  { agentName: agentDef.name, command: agentDef.command },
@@ -19891,6 +20594,15 @@ ${stderr}`
19891
20594
  );
19892
20595
  return instance;
19893
20596
  }
20597
+ /**
20598
+ * Spawn a new subprocess and restore an existing agent session.
20599
+ *
20600
+ * Tries loadSession first (preferred, stable API), falls back to the
20601
+ * unstable resumeSession, and finally falls back to creating a brand-new
20602
+ * session if resume fails entirely (e.g., agent lost its state).
20603
+ *
20604
+ * @param agentSessionId - The agent-side session ID to restore
20605
+ */
19894
20606
  static async resume(agentDef, workingDirectory, agentSessionId, mcpServers, allowedPaths) {
19895
20607
  log33.debug({ agentName: agentDef.name, agentSessionId }, "Resuming agent");
19896
20608
  const spawnStart = Date.now();
@@ -19957,12 +20669,26 @@ ${stderr}`
19957
20669
  instance.setupCrashDetection();
19958
20670
  return instance;
19959
20671
  }
19960
- // createClient — implemented in Task 6b
20672
+ /**
20673
+ * Build the ACP Client callback object.
20674
+ *
20675
+ * The ACP SDK invokes these callbacks when the agent sends notifications
20676
+ * or requests. Each callback maps an ACP protocol message to either:
20677
+ * - An internal AgentEvent (emitted for Session/adapters to consume)
20678
+ * - A filesystem or terminal operation (executed on the agent's behalf)
20679
+ * - A permission request (proxied to the user via the adapter)
20680
+ */
19961
20681
  createClient(_agent) {
19962
20682
  const self = this;
19963
20683
  const MAX_OUTPUT_BYTES = 1024 * 1024;
19964
20684
  return {
19965
20685
  // ── Session updates ──────────────────────────────────────────────────
20686
+ // The agent streams its response as a series of session update events.
20687
+ // Each event type maps to an internal AgentEvent that Session relays
20688
+ // to adapters for rendering (text chunks, tool calls, usage stats, etc.).
20689
+ // Chunks are forwarded to Session individually as they arrive — no buffering
20690
+ // happens at this layer. If buffering is needed (e.g., to avoid rate limits),
20691
+ // it is the responsibility of the Session or adapter layer.
19966
20692
  async sessionUpdate(params) {
19967
20693
  const update = params.update;
19968
20694
  let event = null;
@@ -20091,6 +20817,10 @@ ${stderr}`
20091
20817
  }
20092
20818
  },
20093
20819
  // ── Permission requests ──────────────────────────────────────────────
20820
+ // The agent needs user approval before performing sensitive operations
20821
+ // (e.g., file writes, shell commands). This proxies the request up
20822
+ // through Session → PermissionGate → adapter → user, then returns
20823
+ // the user's chosen option ID back to the agent.
20094
20824
  async requestPermission(params) {
20095
20825
  const permissionRequest = {
20096
20826
  id: params.toolCall.toolCallId,
@@ -20107,6 +20837,9 @@ ${stderr}`
20107
20837
  };
20108
20838
  },
20109
20839
  // ── File operations ──────────────────────────────────────────────────
20840
+ // The agent reads/writes files through these callbacks rather than
20841
+ // accessing the filesystem directly. This allows PathGuard to enforce
20842
+ // workspace boundaries and middleware hooks to intercept I/O.
20110
20843
  async readTextFile(params) {
20111
20844
  const p2 = params;
20112
20845
  const pathCheck = self.pathGuard.validatePath(p2.path, "read");
@@ -20142,6 +20875,8 @@ ${stderr}`
20142
20875
  return {};
20143
20876
  },
20144
20877
  // ── Terminal operations (delegated to TerminalManager) ─────────────
20878
+ // Agents can spawn shell commands via terminal operations. TerminalManager
20879
+ // handles subprocess lifecycle, output capture, and byte-limit enforcement.
20145
20880
  async createTerminal(params) {
20146
20881
  return self.terminalManager.createTerminal(
20147
20882
  self.sessionId,
@@ -20171,6 +20906,13 @@ ${stderr}`
20171
20906
  };
20172
20907
  }
20173
20908
  // ── New ACP methods ──────────────────────────────────────────────────
20909
+ /**
20910
+ * Update a session config option (mode, model, etc.) on the agent.
20911
+ *
20912
+ * Falls back to legacy `setSessionMode`/`unstable_setSessionModel` methods
20913
+ * for agents that haven't adopted the unified `session/set_config_option`
20914
+ * ACP method (detected via JSON-RPC -32601 "Method Not Found" error).
20915
+ */
20174
20916
  async setConfigOption(configId, value) {
20175
20917
  try {
20176
20918
  return await this.connection.setSessionConfigOption({
@@ -20198,12 +20940,14 @@ ${stderr}`
20198
20940
  throw err;
20199
20941
  }
20200
20942
  }
20943
+ /** List the agent's known sessions, optionally filtered by working directory. */
20201
20944
  async listSessions(cwd, cursor) {
20202
20945
  return await this.connection.listSessions({
20203
20946
  cwd: cwd ?? null,
20204
20947
  cursor: cursor ?? null
20205
20948
  });
20206
20949
  }
20950
+ /** Load an existing agent session by ID into this subprocess. */
20207
20951
  async loadSession(sessionId, cwd, mcpServers) {
20208
20952
  const resolvedMcp = _AgentInstance.mcpManager.resolve(mcpServers);
20209
20953
  return await this.connection.loadSession({
@@ -20212,9 +20956,11 @@ ${stderr}`
20212
20956
  mcpServers: resolvedMcp
20213
20957
  });
20214
20958
  }
20959
+ /** Trigger agent-managed authentication (e.g., OAuth flow). */
20215
20960
  async authenticate(methodId) {
20216
20961
  await this.connection.authenticate({ methodId });
20217
20962
  }
20963
+ /** Fork an existing session, creating a new branch with shared history. */
20218
20964
  async forkSession(sessionId, cwd, mcpServers) {
20219
20965
  const resolvedMcp = _AgentInstance.mcpManager.resolve(mcpServers);
20220
20966
  return await this.connection.unstable_forkSession({
@@ -20223,10 +20969,25 @@ ${stderr}`
20223
20969
  mcpServers: resolvedMcp
20224
20970
  });
20225
20971
  }
20972
+ /** Close a session on the agent side (cleanup agent-internal state). */
20226
20973
  async closeSession(sessionId) {
20227
20974
  await this.connection.unstable_closeSession({ sessionId });
20228
20975
  }
20229
20976
  // ── Prompt & lifecycle ──────────────────────────────────────────────
20977
+ /**
20978
+ * Send a user prompt to the agent and wait for the complete response.
20979
+ *
20980
+ * Builds ACP content blocks from the text and any attachments (images
20981
+ * are base64-encoded if the agent supports them, otherwise appended as
20982
+ * file paths). The promise resolves when the agent finishes responding;
20983
+ * streaming events arrive via the `agent_event` emitter during execution.
20984
+ *
20985
+ * Attachments that exceed size limits or use unsupported formats are
20986
+ * skipped with a note appended to the prompt text.
20987
+ *
20988
+ * Call `cancel()` to interrupt a running prompt; the agent will stop and
20989
+ * the promise resolves with partial results.
20990
+ */
20230
20991
  async prompt(text5, attachments) {
20231
20992
  const contentBlocks = [{ type: "text", text: text5 }];
20232
20993
  const capabilities = this.promptCapabilities ?? {};
@@ -20276,9 +21037,18 @@ ${skipNote}`;
20276
21037
  prompt: contentBlocks
20277
21038
  });
20278
21039
  }
21040
+ /** Cancel the currently running prompt. The agent should stop and return partial results. */
20279
21041
  async cancel() {
20280
21042
  await this.connection.cancel({ sessionId: this.sessionId });
20281
21043
  }
21044
+ /**
21045
+ * Gracefully shut down the agent subprocess.
21046
+ *
21047
+ * Sends SIGTERM first, giving the agent up to 10 seconds to clean up.
21048
+ * If the process hasn't exited by then, SIGKILL forces termination.
21049
+ * The timer is unref'd so it doesn't keep the Node process alive
21050
+ * during shutdown.
21051
+ */
20282
21052
  async destroy() {
20283
21053
  this._destroying = true;
20284
21054
  this.terminalManager.destroyAll();
@@ -20313,6 +21083,7 @@ var init_agent_manager = __esm({
20313
21083
  constructor(catalog) {
20314
21084
  this.catalog = catalog;
20315
21085
  }
21086
+ /** Return definitions for all installed agents. */
20316
21087
  getAvailableAgents() {
20317
21088
  const installed = this.catalog.getInstalledEntries();
20318
21089
  return Object.entries(installed).map(([key, agent]) => ({
@@ -20322,14 +21093,25 @@ var init_agent_manager = __esm({
20322
21093
  env: agent.env
20323
21094
  }));
20324
21095
  }
21096
+ /** Look up a single agent definition by its short name (e.g., "claude", "gemini"). */
20325
21097
  getAgent(name) {
20326
21098
  return this.catalog.resolve(name);
20327
21099
  }
21100
+ /**
21101
+ * Spawn a new agent subprocess with a fresh session.
21102
+ *
21103
+ * @throws If the agent is not installed — includes install instructions in the error message.
21104
+ */
20328
21105
  async spawn(agentName, workingDirectory, allowedPaths) {
20329
21106
  const agentDef = this.getAgent(agentName);
20330
21107
  if (!agentDef) throw new Error(`Agent "${agentName}" is not installed. Run "openacp agents install ${agentName}" to add it.`);
20331
21108
  return AgentInstance.spawn(agentDef, workingDirectory, void 0, allowedPaths);
20332
21109
  }
21110
+ /**
21111
+ * Spawn a subprocess and resume an existing agent session.
21112
+ *
21113
+ * Falls back to a new session if the agent cannot restore the given session ID.
21114
+ */
20333
21115
  async resume(agentName, workingDirectory, agentSessionId, allowedPaths) {
20334
21116
  const agentDef = this.getAgent(agentName);
20335
21117
  if (!agentDef) throw new Error(`Agent "${agentName}" is not installed. Run "openacp agents install ${agentName}" to add it.`);
@@ -20354,6 +21136,11 @@ var init_prompt_queue = __esm({
20354
21136
  abortController = null;
20355
21137
  /** Set when abort is triggered; drainNext waits for the current processor to settle before starting the next item. */
20356
21138
  processorSettled = null;
21139
+ /**
21140
+ * Add a prompt to the queue. If no prompt is currently processing, it runs
21141
+ * immediately. Otherwise, it's buffered and the returned promise resolves
21142
+ * only after the prompt finishes processing.
21143
+ */
20357
21144
  async enqueue(text5, attachments, routing, turnId) {
20358
21145
  if (this.processing) {
20359
21146
  return new Promise((resolve9) => {
@@ -20362,6 +21149,7 @@ var init_prompt_queue = __esm({
20362
21149
  }
20363
21150
  await this.process(text5, attachments, routing, turnId);
20364
21151
  }
21152
+ /** Run a single prompt through the processor, then drain the next queued item. */
20365
21153
  async process(text5, attachments, routing, turnId) {
20366
21154
  this.processing = true;
20367
21155
  this.abortController = new AbortController();
@@ -20389,12 +21177,17 @@ var init_prompt_queue = __esm({
20389
21177
  this.drainNext();
20390
21178
  }
20391
21179
  }
21180
+ /** Dequeue and process the next pending prompt, if any. Called after each prompt completes. */
20392
21181
  drainNext() {
20393
21182
  const next = this.queue.shift();
20394
21183
  if (next) {
20395
21184
  this.process(next.text, next.attachments, next.routing, next.turnId).then(next.resolve);
20396
21185
  }
20397
21186
  }
21187
+ /**
21188
+ * Abort the in-flight prompt and discard all queued prompts.
21189
+ * Pending promises are resolved (not rejected) so callers don't see unhandled rejections.
21190
+ */
20398
21191
  clear() {
20399
21192
  if (this.abortController) {
20400
21193
  this.abortController.abort();
@@ -20430,6 +21223,10 @@ var init_permission_gate = __esm({
20430
21223
  constructor(timeoutMs) {
20431
21224
  this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
20432
21225
  }
21226
+ /**
21227
+ * Register a new permission request and return a promise that resolves with the
21228
+ * chosen option ID when the user responds, or rejects on timeout / supersession.
21229
+ */
20433
21230
  setPending(request) {
20434
21231
  if (!this.settled && this.rejectFn) {
20435
21232
  this.rejectFn(new Error("Superseded by new permission request"));
@@ -20448,6 +21245,7 @@ var init_permission_gate = __esm({
20448
21245
  }
20449
21246
  });
20450
21247
  }
21248
+ /** Approve the pending request with the given option ID. No-op if already settled. */
20451
21249
  resolve(optionId) {
20452
21250
  if (this.settled || !this.resolveFn) return;
20453
21251
  this.settled = true;
@@ -20455,6 +21253,7 @@ var init_permission_gate = __esm({
20455
21253
  this.resolveFn(optionId);
20456
21254
  this.cleanup();
20457
21255
  }
21256
+ /** Deny the pending request. No-op if already settled. */
20458
21257
  reject(reason) {
20459
21258
  if (this.settled || !this.rejectFn) return;
20460
21259
  this.settled = true;
@@ -20563,6 +21362,8 @@ Additionally, include a [TTS]...[/TTS] block with a spoken-friendly summary of y
20563
21362
  get agentInstance() {
20564
21363
  return this._agentInstance;
20565
21364
  }
21365
+ /** Setting agentInstance wires the agent→session event relay and commands buffer.
21366
+ * This happens both at construction and on agent switch (switchAgent). */
20566
21367
  set agentInstance(agent) {
20567
21368
  this._agentInstance = agent;
20568
21369
  this.wireAgentRelay();
@@ -20665,7 +21466,7 @@ Additionally, include a [TTS]...[/TTS] block with a spoken-friendly summary of y
20665
21466
  this.transition("finished");
20666
21467
  this.emit(SessionEv.SESSION_END, reason ?? "completed");
20667
21468
  }
20668
- /** Transition to cancelled — from active only (terminal session cancel) */
21469
+ /** Transition to cancelled — from active or error (terminal session cancel) */
20669
21470
  markCancelled() {
20670
21471
  this.transition("cancelled");
20671
21472
  }
@@ -20685,19 +21486,29 @@ Additionally, include a [TTS]...[/TTS] block with a spoken-friendly summary of y
20685
21486
  get queueDepth() {
20686
21487
  return this.queue.pending;
20687
21488
  }
21489
+ /** Whether a prompt is currently being processed by the agent */
20688
21490
  get promptRunning() {
20689
21491
  return this.queue.isProcessing;
20690
21492
  }
20691
21493
  // --- Context Injection ---
21494
+ /** Store context markdown to be prepended to the next prompt (used for session resume with history). */
20692
21495
  setContext(markdown) {
20693
21496
  this.pendingContext = markdown;
20694
21497
  }
20695
21498
  // --- Voice Mode ---
21499
+ /** Set TTS mode: "off" = disabled, "next" = one-shot (auto-resets after prompt), "on" = persistent. */
20696
21500
  setVoiceMode(mode) {
20697
21501
  this.voiceMode = mode;
20698
21502
  this.log.info({ voiceMode: mode }, "TTS mode changed");
20699
21503
  }
20700
21504
  // --- Public API ---
21505
+ /**
21506
+ * Enqueue a user prompt for serial processing.
21507
+ *
21508
+ * Runs the prompt through agent:beforePrompt middleware (which can modify or block),
21509
+ * then adds it to the PromptQueue. Returns a turnId that callers can use to correlate
21510
+ * queued/processing events before the prompt actually runs.
21511
+ */
20701
21512
  async enqueuePrompt(text5, attachments, routing, externalTurnId) {
20702
21513
  const turnId = externalTurnId ?? nanoid5(8);
20703
21514
  if (this.middlewareChain) {
@@ -20807,6 +21618,10 @@ ${text5}`;
20807
21618
  await this.autoName();
20808
21619
  }
20809
21620
  }
21621
+ /**
21622
+ * Transcribe audio attachments to text if the agent doesn't support audio natively.
21623
+ * Audio attachments are removed and their transcriptions are appended to the prompt text.
21624
+ */
20810
21625
  async maybeTranscribeAudio(text5, attachments) {
20811
21626
  if (!attachments?.length || !this.speechService) {
20812
21627
  return { text: text5, attachments };
@@ -20852,6 +21667,7 @@ ${result.text}` : result.text;
20852
21667
  attachments: remainingAttachments.length > 0 ? remainingAttachments : void 0
20853
21668
  };
20854
21669
  }
21670
+ /** Extract [TTS] block from agent response, synthesize speech, and emit audio_content event. */
20855
21671
  async processTTSResponse(responseText) {
20856
21672
  const match = TTS_BLOCK_REGEX.exec(responseText);
20857
21673
  if (!match?.[1]) {
@@ -20888,7 +21704,9 @@ ${result.text}` : result.text;
20888
21704
  this.log.warn({ err }, "TTS synthesis failed, skipping");
20889
21705
  }
20890
21706
  }
20891
- // NOTE: This injects a summary prompt into the agent's conversation history.
21707
+ // Sends a special prompt to the agent to generate a short session title.
21708
+ // The session emitter is paused (excluding non-agent_event emissions) so the naming
21709
+ // prompt's output is intercepted by a capture handler instead of being forwarded to adapters.
20892
21710
  async autoName() {
20893
21711
  let title = "";
20894
21712
  const captureHandler = (event) => {
@@ -21047,6 +21865,7 @@ ${result.text}` : result.text;
21047
21865
  this.applySpawnResponse(newAgent.initialSessionResponse, newAgent.agentCapabilities);
21048
21866
  this.log.info({ from: this.agentSwitchHistory.at(-1).agentName, to: agentName }, "Agent switched");
21049
21867
  }
21868
+ /** Tear down the session: reject pending permissions, clear queue, destroy agent subprocess. */
21050
21869
  async destroy() {
21051
21870
  this.log.info("Session destroyed");
21052
21871
  if (this.permissionGate.isPending) {
@@ -21072,12 +21891,17 @@ var init_session_manager = __esm({
21072
21891
  store;
21073
21892
  eventBus;
21074
21893
  middlewareChain;
21894
+ /**
21895
+ * Inject the EventBus after construction. Deferred because EventBus is created
21896
+ * after SessionManager during bootstrap, so it cannot be passed to the constructor.
21897
+ */
21075
21898
  setEventBus(eventBus) {
21076
21899
  this.eventBus = eventBus;
21077
21900
  }
21078
21901
  constructor(store = null) {
21079
21902
  this.store = store;
21080
21903
  }
21904
+ /** Create a new session by spawning an agent and persisting the initial record. */
21081
21905
  async createSession(channelId, agentName, workingDirectory, agentManager) {
21082
21906
  const agentInstance = await agentManager.spawn(agentName, workingDirectory);
21083
21907
  const session = new Session({
@@ -21105,9 +21929,11 @@ var init_session_manager = __esm({
21105
21929
  }
21106
21930
  return session;
21107
21931
  }
21932
+ /** Look up a live session by its OpenACP session ID. */
21108
21933
  getSession(sessionId) {
21109
21934
  return this.sessions.get(sessionId);
21110
21935
  }
21936
+ /** Look up a live session by adapter channel and thread ID (checks per-adapter threadIds map first, then legacy fields). */
21111
21937
  getSessionByThread(channelId, threadId) {
21112
21938
  for (const session of this.sessions.values()) {
21113
21939
  const adapterThread = session.threadIds.get(channelId);
@@ -21118,6 +21944,7 @@ var init_session_manager = __esm({
21118
21944
  }
21119
21945
  return void 0;
21120
21946
  }
21947
+ /** Look up a live session by the agent's internal session ID (assigned by the ACP subprocess). */
21121
21948
  getSessionByAgentSessionId(agentSessionId) {
21122
21949
  for (const session of this.sessions.values()) {
21123
21950
  if (session.agentSessionId === agentSessionId) {
@@ -21126,18 +21953,26 @@ var init_session_manager = __esm({
21126
21953
  }
21127
21954
  return void 0;
21128
21955
  }
21956
+ /** Look up the persisted SessionRecord by the agent's internal session ID. */
21129
21957
  getRecordByAgentSessionId(agentSessionId) {
21130
21958
  return this.store?.findByAgentSessionId(agentSessionId);
21131
21959
  }
21960
+ /** Look up the persisted SessionRecord by channel and thread ID. */
21132
21961
  getRecordByThread(channelId, threadId) {
21133
21962
  return this.store?.findByPlatform(
21134
21963
  channelId,
21135
21964
  (p2) => String(p2.topicId) === threadId || p2.threadId === threadId
21136
21965
  );
21137
21966
  }
21967
+ /** Register a session that was created externally (e.g. restored from store on startup). */
21138
21968
  registerSession(session) {
21139
21969
  this.sessions.set(session.id, session);
21140
21970
  }
21971
+ /**
21972
+ * Merge a partial update into the stored SessionRecord. If no record exists yet and
21973
+ * the patch includes `sessionId`, it is treated as an initial save.
21974
+ * Pass `{ immediate: true }` to flush the store to disk synchronously.
21975
+ */
21141
21976
  async patchRecord(sessionId, patch, options) {
21142
21977
  if (!this.store) return;
21143
21978
  const record = this.store.get(sessionId);
@@ -21150,9 +21985,11 @@ var init_session_manager = __esm({
21150
21985
  this.store.flush();
21151
21986
  }
21152
21987
  }
21988
+ /** Retrieve the persisted SessionRecord for a given session ID. Returns undefined if no store or record not found. */
21153
21989
  getSessionRecord(sessionId) {
21154
21990
  return this.store?.get(sessionId);
21155
21991
  }
21992
+ /** Cancel a session: abort in-flight prompt, transition to cancelled, destroy agent, and persist. */
21156
21993
  async cancelSession(sessionId) {
21157
21994
  const session = this.sessions.get(sessionId);
21158
21995
  if (session) {
@@ -21175,11 +22012,16 @@ var init_session_manager = __esm({
21175
22012
  });
21176
22013
  }
21177
22014
  }
22015
+ /** List live (in-memory) sessions, optionally filtered by channel. Excludes assistant sessions. */
21178
22016
  listSessions(channelId) {
21179
22017
  const all = Array.from(this.sessions.values()).filter((s) => !s.isAssistant);
21180
22018
  if (channelId) return all.filter((s) => s.channelId === channelId);
21181
22019
  return all;
21182
22020
  }
22021
+ /**
22022
+ * List all sessions (live + stored) as SessionSummary. Live sessions take precedence
22023
+ * over stored records — their real-time state (queueDepth, promptRunning) is used.
22024
+ */
21183
22025
  listAllSessions(channelId) {
21184
22026
  if (this.store) {
21185
22027
  let records = this.store.list().filter((r) => !r.isAssistant);
@@ -21241,6 +22083,7 @@ var init_session_manager = __esm({
21241
22083
  isLive: true
21242
22084
  }));
21243
22085
  }
22086
+ /** List all stored SessionRecords, optionally filtered by status. Excludes assistant sessions. */
21244
22087
  listRecords(filter) {
21245
22088
  if (!this.store) return [];
21246
22089
  let records = this.store.list().filter((r) => !r.isAssistant);
@@ -21249,6 +22092,7 @@ var init_session_manager = __esm({
21249
22092
  }
21250
22093
  return records;
21251
22094
  }
22095
+ /** Remove a session's stored record and emit a SESSION_DELETED event. */
21252
22096
  async removeRecord(sessionId) {
21253
22097
  if (!this.store) return;
21254
22098
  await this.store.remove(sessionId);
@@ -21359,7 +22203,10 @@ var init_session_bridge = __esm({
21359
22203
  log34.error({ err, sessionId }, "Error in sendMessage middleware");
21360
22204
  }
21361
22205
  }
21362
- /** Determine if this bridge should forward the given event based on turn routing. */
22206
+ /**
22207
+ * Determine if this bridge should forward the given event based on turn routing.
22208
+ * System events are always forwarded; turn events are routed only to the target adapter.
22209
+ */
21363
22210
  shouldForward(event) {
21364
22211
  if (isSystemEvent(event)) return true;
21365
22212
  const ctx = this.session.activeTurnContext;
@@ -21368,6 +22215,13 @@ var init_session_bridge = __esm({
21368
22215
  if (target === null) return false;
21369
22216
  return this.adapterId === target;
21370
22217
  }
22218
+ /**
22219
+ * Subscribe to session events and start forwarding them to the adapter.
22220
+ *
22221
+ * Wires: agent events → adapter dispatch, permission UI, lifecycle persistence
22222
+ * (status changes, naming, prompt count), and EventBus notifications.
22223
+ * Also replays any commands or config options that arrived before the bridge connected.
22224
+ */
21371
22225
  connect() {
21372
22226
  if (this.connected) return;
21373
22227
  this.connected = true;
@@ -21444,6 +22298,7 @@ var init_session_bridge = __esm({
21444
22298
  this.session.emit(SessionEv.AGENT_EVENT, { type: "config_option_update", options: this.session.configOptions });
21445
22299
  }
21446
22300
  }
22301
+ /** Unsubscribe all session event listeners and clean up adapter state. */
21447
22302
  disconnect() {
21448
22303
  if (!this.connected) return;
21449
22304
  this.connected = false;
@@ -21976,6 +22831,12 @@ var init_message_transformer = __esm({
21976
22831
  constructor(tunnelService) {
21977
22832
  this.tunnelService = tunnelService;
21978
22833
  }
22834
+ /**
22835
+ * Convert an agent event to an outgoing message for adapter delivery.
22836
+ *
22837
+ * For tool events, enriches the metadata with diff stats and viewer links
22838
+ * when a tunnel service is available.
22839
+ */
21979
22840
  transform(event, sessionContext) {
21980
22841
  switch (event.type) {
21981
22842
  case "text":
@@ -22250,6 +23111,11 @@ var init_session_store = __esm({
22250
23111
  }
22251
23112
  return void 0;
22252
23113
  }
23114
+ /**
23115
+ * Find a session by its ACP agent session ID.
23116
+ * Checks current, original, and historical agent session IDs (from agent switches)
23117
+ * since the agent session ID changes on each switch.
23118
+ */
22253
23119
  findByAgentSessionId(agentSessionId) {
22254
23120
  for (const record of this.records.values()) {
22255
23121
  if (record.agentSessionId === agentSessionId || record.originalAgentSessionId === agentSessionId) {
@@ -22294,6 +23160,7 @@ var init_session_store = __esm({
22294
23160
  if (!fs42.existsSync(dir)) fs42.mkdirSync(dir, { recursive: true });
22295
23161
  fs42.writeFileSync(this.filePath, JSON.stringify(data, null, 2));
22296
23162
  }
23163
+ /** Clean up timers and process listeners. Call on shutdown to prevent leaks. */
22297
23164
  destroy() {
22298
23165
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
22299
23166
  if (this.cleanupInterval) clearInterval(this.cleanupInterval);
@@ -22329,7 +23196,11 @@ var init_session_store = __esm({
22329
23196
  }
22330
23197
  }
22331
23198
  }
22332
- /** Migrate old SessionRecord format to new multi-adapter format. */
23199
+ /**
23200
+ * Migrate old SessionRecord format to new multi-adapter format.
23201
+ * Converts single-adapter `platform` field to per-adapter `platforms` map,
23202
+ * and initializes `attachedAdapters` for records created before multi-adapter support.
23203
+ */
22333
23204
  migrateRecord(record) {
22334
23205
  if (!record.platforms && record.platform && typeof record.platform === "object") {
22335
23206
  const platformData = record.platform;
@@ -22342,6 +23213,7 @@ var init_session_store = __esm({
22342
23213
  }
22343
23214
  return record;
22344
23215
  }
23216
+ /** Remove expired session records (past TTL). Active and assistant sessions are preserved. */
22345
23217
  cleanup() {
22346
23218
  const cutoff = Date.now() - this.ttlDays * 24 * 60 * 60 * 1e3;
22347
23219
  let removed = 0;
@@ -22412,6 +23284,10 @@ var init_session_factory = __esm({
22412
23284
  get speechService() {
22413
23285
  return typeof this.speechServiceAccessor === "function" ? this.speechServiceAccessor() : this.speechServiceAccessor;
22414
23286
  }
23287
+ /**
23288
+ * Create a new Session: spawn agent → create Session instance → hydrate ACP state → register.
23289
+ * Runs session:beforeCreate middleware (which can modify params or block creation).
23290
+ */
22415
23291
  async create(params) {
22416
23292
  let createParams = params;
22417
23293
  if (this.middlewareChain) {
@@ -22595,6 +23471,11 @@ var init_session_factory = __esm({
22595
23471
  this.resumeLocks.set(sessionId, resumePromise);
22596
23472
  return resumePromise;
22597
23473
  }
23474
+ /**
23475
+ * Attempt to resume a session from disk when a message arrives on a thread with
23476
+ * no live session. Deduplicates concurrent resume attempts for the same thread
23477
+ * via resumeLocks to avoid spawning multiple agents.
23478
+ */
22598
23479
  async lazyResume(channelId, threadId) {
22599
23480
  const store = this.sessionStore;
22600
23481
  if (!store || !this.createFullSession) return null;
@@ -22698,6 +23579,7 @@ var init_session_factory = __esm({
22698
23579
  this.resumeLocks.set(lockKey, resumePromise);
22699
23580
  return resumePromise;
22700
23581
  }
23582
+ /** Create a brand-new session, resolving agent name and workspace from config if not provided. */
22701
23583
  async handleNewSession(channelId, agentName, workspacePath, options) {
22702
23584
  if (!this.configManager || !this.agentCatalog || !this.createFullSession) {
22703
23585
  throw new Error("SessionFactory not fully initialized");
@@ -22742,6 +23624,7 @@ var init_session_factory = __esm({
22742
23624
  record.workingDir
22743
23625
  );
22744
23626
  }
23627
+ /** Create a session and inject conversation context from a ContextProvider (e.g., history from a previous session). */
22745
23628
  async createSessionWithContext(params) {
22746
23629
  if (!this.createFullSession) throw new Error("SessionFactory not fully initialized");
22747
23630
  let contextResult = null;
@@ -22768,6 +23651,7 @@ var init_session_factory = __esm({
22768
23651
  }
22769
23652
  return { session, contextResult };
22770
23653
  }
23654
+ /** Wire session-level side effects: usage tracking (via EventBus) and tunnel cleanup on session end. */
22771
23655
  wireSideEffects(session, deps) {
22772
23656
  session.on(SessionEv.AGENT_EVENT, (event) => {
22773
23657
  if (event.type !== "usage") return;
@@ -22814,7 +23698,12 @@ var init_agent_switch_handler = __esm({
22814
23698
  constructor(deps) {
22815
23699
  this.deps = deps;
22816
23700
  }
23701
+ /** Prevents concurrent switch operations on the same session */
22817
23702
  switchingLocks = /* @__PURE__ */ new Set();
23703
+ /**
23704
+ * Switch a session to a different agent. Returns whether the previous
23705
+ * agent session was resumed or a new one was spawned.
23706
+ */
22818
23707
  async switch(sessionId, toAgent) {
22819
23708
  if (this.switchingLocks.has(sessionId)) {
22820
23709
  throw new Error("Switch already in progress");
@@ -23025,20 +23914,29 @@ var init_agent_catalog = __esm({
23025
23914
  DEFAULT_TTL_HOURS = 24;
23026
23915
  AgentCatalog = class {
23027
23916
  store;
23917
+ /** Agents available in the remote registry (cached in memory after load). */
23028
23918
  registryAgents = [];
23029
23919
  cachePath;
23920
+ /** Directory where binary agent archives are extracted to. */
23030
23921
  agentsDir;
23031
23922
  constructor(store, cachePath, agentsDir) {
23032
23923
  this.store = store;
23033
23924
  this.cachePath = cachePath;
23034
23925
  this.agentsDir = agentsDir;
23035
23926
  }
23927
+ /**
23928
+ * Load installed agents from disk and hydrate the registry from cache/snapshot.
23929
+ *
23930
+ * Also enriches installed agents with registry metadata — fixes agents that
23931
+ * were migrated from older config formats with incomplete data.
23932
+ */
23036
23933
  load() {
23037
23934
  this.store.load();
23038
23935
  this.loadRegistryFromCacheOrSnapshot();
23039
23936
  this.enrichInstalledFromRegistry();
23040
23937
  }
23041
23938
  // --- Registry ---
23939
+ /** Fetch the latest agent registry from the CDN and update the local cache. */
23042
23940
  async fetchRegistry() {
23043
23941
  try {
23044
23942
  log39.info("Fetching agent registry from CDN...");
@@ -23058,6 +23956,7 @@ var init_agent_catalog = __esm({
23058
23956
  log39.warn({ err }, "Failed to fetch registry, using cached data");
23059
23957
  }
23060
23958
  }
23959
+ /** Re-fetch registry only if the local cache has expired (24-hour TTL). */
23061
23960
  async refreshRegistryIfStale() {
23062
23961
  if (this.isCacheStale()) {
23063
23962
  await this.fetchRegistry();
@@ -23069,6 +23968,7 @@ var init_agent_catalog = __esm({
23069
23968
  getRegistryAgent(registryId) {
23070
23969
  return this.registryAgents.find((a) => a.id === registryId);
23071
23970
  }
23971
+ /** Find a registry agent by registry ID or by its short alias (e.g., "claude"). */
23072
23972
  findRegistryAgent(keyOrId) {
23073
23973
  const byId = this.registryAgents.find((a) => a.id === keyOrId);
23074
23974
  if (byId) return byId;
@@ -23085,6 +23985,15 @@ var init_agent_catalog = __esm({
23085
23985
  return this.store.getAgent(key);
23086
23986
  }
23087
23987
  // --- Discovery ---
23988
+ /**
23989
+ * Build the unified list of all agents (installed + registry-only).
23990
+ *
23991
+ * Installed agents appear first with their live availability status.
23992
+ * Registry agents that aren't installed yet show whether a distribution
23993
+ * exists for the current platform. Missing external dependencies
23994
+ * (e.g., claude CLI) are surfaced as `missingDeps` for UI display
23995
+ * but do NOT block installation.
23996
+ */
23088
23997
  getAvailable() {
23089
23998
  const installed = this.getInstalledEntries();
23090
23999
  const items = [];
@@ -23125,6 +24034,7 @@ var init_agent_catalog = __esm({
23125
24034
  }
23126
24035
  return items;
23127
24036
  }
24037
+ /** Check if an agent can be installed on this system (platform + dependencies). */
23128
24038
  checkAvailability(keyOrId) {
23129
24039
  const agent = this.findRegistryAgent(keyOrId);
23130
24040
  if (!agent) return { available: false, reason: "Not found in the agent registry." };
@@ -23135,6 +24045,12 @@ var init_agent_catalog = __esm({
23135
24045
  return checkDependencies(agent.id);
23136
24046
  }
23137
24047
  // --- Install/Uninstall ---
24048
+ /**
24049
+ * Install an agent from the registry.
24050
+ *
24051
+ * Resolves the distribution (npx/uvx/binary), downloads binary archives
24052
+ * if needed, and persists the agent definition in the store.
24053
+ */
23138
24054
  async install(keyOrId, progress, force) {
23139
24055
  const agent = this.findRegistryAgent(keyOrId);
23140
24056
  if (!agent) {
@@ -23159,6 +24075,7 @@ var init_agent_catalog = __esm({
23159
24075
  registerFallbackAgent(key, data) {
23160
24076
  this.store.addAgent(key, data);
23161
24077
  }
24078
+ /** Remove an installed agent and delete its binary directory if applicable. */
23162
24079
  async uninstall(key) {
23163
24080
  if (this.store.hasAgent(key)) {
23164
24081
  await uninstallAgent(key, this.store);
@@ -23167,6 +24084,7 @@ var init_agent_catalog = __esm({
23167
24084
  return { ok: false, error: `"${key}" is not installed.` };
23168
24085
  }
23169
24086
  // --- Resolution (for AgentManager) ---
24087
+ /** Convert an installed agent's short key to an AgentDefinition for spawning. */
23170
24088
  resolve(key) {
23171
24089
  const agent = this.store.getAgent(key);
23172
24090
  if (!agent) return void 0;
@@ -23310,6 +24228,7 @@ var init_agent_store = __esm({
23310
24228
  constructor(filePath) {
23311
24229
  this.filePath = filePath;
23312
24230
  }
24231
+ /** Load and validate the store from disk. Starts fresh if file is missing or invalid. */
23313
24232
  load() {
23314
24233
  if (!fs44.existsSync(this.filePath)) {
23315
24234
  this.data = { version: 1, installed: {} };
@@ -23349,6 +24268,11 @@ var init_agent_store = __esm({
23349
24268
  hasAgent(key) {
23350
24269
  return key in this.data.installed;
23351
24270
  }
24271
+ /**
24272
+ * Persist the store to disk using atomic write (write to .tmp, then rename).
24273
+ * File permissions are restricted to owner-only (0o600) since the store
24274
+ * may contain agent binary paths and environment variables.
24275
+ */
23352
24276
  save() {
23353
24277
  fs44.mkdirSync(path49.dirname(this.filePath), { recursive: true });
23354
24278
  const tmpPath = this.filePath + ".tmp";
@@ -23446,6 +24370,10 @@ var init_service_registry = __esm({
23446
24370
  "use strict";
23447
24371
  ServiceRegistry = class {
23448
24372
  services = /* @__PURE__ */ new Map();
24373
+ /**
24374
+ * Register a service. Throws if the service name is already taken.
24375
+ * Use `registerOverride` to intentionally replace an existing service.
24376
+ */
23449
24377
  register(name, implementation, pluginName) {
23450
24378
  if (this.services.has(name)) {
23451
24379
  const existing = this.services.get(name);
@@ -23453,21 +24381,27 @@ var init_service_registry = __esm({
23453
24381
  }
23454
24382
  this.services.set(name, { implementation, pluginName });
23455
24383
  }
24384
+ /** Register a service, replacing any existing registration (used by override plugins). */
23456
24385
  registerOverride(name, implementation, pluginName) {
23457
24386
  this.services.set(name, { implementation, pluginName });
23458
24387
  }
24388
+ /** Retrieve a service by name. Returns undefined if not registered. */
23459
24389
  get(name) {
23460
24390
  return this.services.get(name)?.implementation;
23461
24391
  }
24392
+ /** Check whether a service is registered. */
23462
24393
  has(name) {
23463
24394
  return this.services.has(name);
23464
24395
  }
24396
+ /** List all registered services with their owning plugin names. */
23465
24397
  list() {
23466
24398
  return [...this.services.entries()].map(([name, { pluginName }]) => ({ name, pluginName }));
23467
24399
  }
24400
+ /** Remove a single service by name. */
23468
24401
  unregister(name) {
23469
24402
  this.services.delete(name);
23470
24403
  }
24404
+ /** Remove all services owned by a specific plugin (called during plugin unload). */
23471
24405
  unregisterByPlugin(pluginName) {
23472
24406
  for (const [name, entry] of this.services) {
23473
24407
  if (entry.pluginName === pluginName) {
@@ -23489,6 +24423,7 @@ var init_middleware_chain = __esm({
23489
24423
  chains = /* @__PURE__ */ new Map();
23490
24424
  errorHandler;
23491
24425
  errorTracker;
24426
+ /** Register a middleware handler for a hook. Handlers are kept sorted by priority. */
23492
24427
  add(hook, pluginName, opts) {
23493
24428
  const entry = {
23494
24429
  pluginName,
@@ -23503,6 +24438,15 @@ var init_middleware_chain = __esm({
23503
24438
  this.chains.set(hook, [entry]);
23504
24439
  }
23505
24440
  }
24441
+ /**
24442
+ * Execute the middleware chain for a hook, ending with the core handler.
24443
+ *
24444
+ * The chain is built recursively: each handler calls `next()` to invoke the
24445
+ * next handler, with the core handler at the end. If no middleware is registered,
24446
+ * the core handler runs directly.
24447
+ *
24448
+ * @returns The final payload, or `null` if any handler short-circuited.
24449
+ */
23506
24450
  async execute(hook, payload, coreHandler) {
23507
24451
  const handlers = this.chains.get(hook);
23508
24452
  if (!handlers || handlers.length === 0) {
@@ -23572,6 +24516,7 @@ var init_middleware_chain = __esm({
23572
24516
  const start = buildNext(0, payload);
23573
24517
  return start();
23574
24518
  }
24519
+ /** Remove all middleware handlers registered by a specific plugin. */
23575
24520
  removeAll(pluginName) {
23576
24521
  for (const [hook, handlers] of this.chains.entries()) {
23577
24522
  const filtered = handlers.filter((h) => h.pluginName !== pluginName);
@@ -23582,9 +24527,11 @@ var init_middleware_chain = __esm({
23582
24527
  }
23583
24528
  }
23584
24529
  }
24530
+ /** Set a callback for middleware errors (e.g., logging). */
23585
24531
  setErrorHandler(fn) {
23586
24532
  this.errorHandler = fn;
23587
24533
  }
24534
+ /** Attach an ErrorTracker for circuit-breaking misbehaving plugins. */
23588
24535
  setErrorTracker(tracker) {
23589
24536
  this.errorTracker = tracker;
23590
24537
  }
@@ -23602,10 +24549,15 @@ var init_error_tracker = __esm({
23602
24549
  disabled = /* @__PURE__ */ new Set();
23603
24550
  exempt = /* @__PURE__ */ new Set();
23604
24551
  config;
24552
+ /** Callback fired when a plugin is auto-disabled due to error budget exhaustion. */
23605
24553
  onDisabled;
23606
24554
  constructor(config) {
23607
24555
  this.config = { maxErrors: config?.maxErrors ?? 10, windowMs: config?.windowMs ?? 36e5 };
23608
24556
  }
24557
+ /**
24558
+ * Record an error for a plugin. If the error budget is exceeded,
24559
+ * the plugin is disabled and the `onDisabled` callback fires.
24560
+ */
23609
24561
  increment(pluginName) {
23610
24562
  if (this.exempt.has(pluginName)) return;
23611
24563
  const now = Date.now();
@@ -23622,13 +24574,16 @@ var init_error_tracker = __esm({
23622
24574
  this.onDisabled?.(pluginName, reason);
23623
24575
  }
23624
24576
  }
24577
+ /** Check if a plugin has been disabled due to errors. */
23625
24578
  isDisabled(pluginName) {
23626
24579
  return this.disabled.has(pluginName);
23627
24580
  }
24581
+ /** Re-enable a plugin and clear its error history. */
23628
24582
  reset(pluginName) {
23629
24583
  this.disabled.delete(pluginName);
23630
24584
  this.errors.delete(pluginName);
23631
24585
  }
24586
+ /** Mark a plugin as exempt from circuit-breaking (e.g., essential plugins). */
23632
24587
  setExempt(pluginName) {
23633
24588
  this.exempt.add(pluginName);
23634
24589
  }
@@ -23646,6 +24601,7 @@ var init_plugin_storage = __esm({
23646
24601
  PluginStorageImpl = class {
23647
24602
  kvPath;
23648
24603
  dataDir;
24604
+ /** Serializes writes to prevent concurrent file corruption */
23649
24605
  writeChain = Promise.resolve();
23650
24606
  constructor(baseDir) {
23651
24607
  this.dataDir = path50.join(baseDir, "data");
@@ -23686,6 +24642,7 @@ var init_plugin_storage = __esm({
23686
24642
  async list() {
23687
24643
  return Object.keys(this.readKv());
23688
24644
  }
24645
+ /** Returns the plugin's data directory, creating it lazily on first access. */
23689
24646
  getDataDir() {
23690
24647
  fs45.mkdirSync(this.dataDir, { recursive: true });
23691
24648
  return this.dataDir;
@@ -23861,6 +24818,11 @@ function createPluginContext(opts) {
23861
24818
  return core;
23862
24819
  },
23863
24820
  instanceRoot,
24821
+ /**
24822
+ * Called by LifecycleManager during plugin teardown.
24823
+ * Unregisters all event handlers, middleware, commands, and services
24824
+ * registered by this plugin, preventing leaks across reloads.
24825
+ */
23864
24826
  cleanup() {
23865
24827
  for (const { event, handler } of registeredListeners) {
23866
24828
  eventBus.off(event, handler);
@@ -23967,12 +24929,15 @@ var init_lifecycle_manager = __esm({
23967
24929
  loadOrder = [];
23968
24930
  _loaded = /* @__PURE__ */ new Set();
23969
24931
  _failed = /* @__PURE__ */ new Set();
24932
+ /** Names of plugins that successfully completed setup(). */
23970
24933
  get loadedPlugins() {
23971
24934
  return [...this._loaded];
23972
24935
  }
24936
+ /** Names of plugins whose setup() threw an error. These plugins are skipped but don't crash the system. */
23973
24937
  get failedPlugins() {
23974
24938
  return [...this._failed];
23975
24939
  }
24940
+ /** The PluginRegistry tracking installed and enabled plugin state. */
23976
24941
  get registry() {
23977
24942
  return this.pluginRegistry;
23978
24943
  }
@@ -24019,6 +24984,12 @@ var init_lifecycle_manager = __esm({
24019
24984
  return this;
24020
24985
  } };
24021
24986
  }
24987
+ /**
24988
+ * Boot a set of plugins in dependency order.
24989
+ *
24990
+ * Can be called multiple times (e.g., core plugins first, then dev plugins later).
24991
+ * Already-loaded plugins are included in dependency resolution but not re-booted.
24992
+ */
24022
24993
  async boot(plugins) {
24023
24994
  const newNames = new Set(plugins.map((p2) => p2.name));
24024
24995
  const allForResolution = [...this.loadOrder.filter((p2) => !newNames.has(p2.name)), ...plugins];
@@ -24130,6 +25101,11 @@ var init_lifecycle_manager = __esm({
24130
25101
  }
24131
25102
  }
24132
25103
  }
25104
+ /**
25105
+ * Unload a single plugin: call teardown(), clean up its context
25106
+ * (listeners, middleware, services), and remove from tracked state.
25107
+ * Used for hot-reload: unload → rebuild → re-boot.
25108
+ */
24133
25109
  async unloadPlugin(name) {
24134
25110
  if (!this._loaded.has(name)) return;
24135
25111
  const plugin2 = this.loadOrder.find((p2) => p2.name === name);
@@ -24149,6 +25125,10 @@ var init_lifecycle_manager = __esm({
24149
25125
  this.loadOrder = this.loadOrder.filter((p2) => p2.name !== name);
24150
25126
  this.eventBus?.emit(BusEvent.PLUGIN_UNLOADED, { name });
24151
25127
  }
25128
+ /**
25129
+ * Gracefully shut down all loaded plugins.
25130
+ * Teardown runs in reverse boot order so that dependencies outlive their dependents.
25131
+ */
24152
25132
  async shutdown() {
24153
25133
  const reversed = [...this.loadOrder].reverse();
24154
25134
  for (const plugin2 of reversed) {
@@ -24182,16 +25162,19 @@ var init_menu_registry = __esm({
24182
25162
  log41 = createChildLogger({ module: "menu-registry" });
24183
25163
  MenuRegistry = class {
24184
25164
  items = /* @__PURE__ */ new Map();
25165
+ /** Register or replace a menu item by its unique ID. */
24185
25166
  register(item) {
24186
25167
  this.items.set(item.id, item);
24187
25168
  }
25169
+ /** Remove a menu item by ID. */
24188
25170
  unregister(id) {
24189
25171
  this.items.delete(id);
24190
25172
  }
25173
+ /** Look up a single menu item by ID. */
24191
25174
  getItem(id) {
24192
25175
  return this.items.get(id);
24193
25176
  }
24194
- /** Get all visible items sorted by priority */
25177
+ /** Get all visible items sorted by priority (lower number = shown first). */
24195
25178
  getItems() {
24196
25179
  return [...this.items.values()].filter((item) => {
24197
25180
  if (!item.visible) return true;
@@ -24281,19 +25264,31 @@ var init_assistant_registry = __esm({
24281
25264
  AssistantRegistry = class {
24282
25265
  sections = /* @__PURE__ */ new Map();
24283
25266
  _instanceRoot = "";
24284
- /** Set the instance root path used in assistant guidelines */
25267
+ /** Set the instance root path used in assistant guidelines. */
24285
25268
  setInstanceRoot(root) {
24286
25269
  this._instanceRoot = root;
24287
25270
  }
25271
+ /** Register a prompt section. Overwrites any existing section with the same id. */
24288
25272
  register(section) {
24289
25273
  if (this.sections.has(section.id)) {
24290
25274
  log42.warn({ id: section.id }, "Assistant section overwritten");
24291
25275
  }
24292
25276
  this.sections.set(section.id, section);
24293
25277
  }
25278
+ /** Remove a previously registered section by id. */
24294
25279
  unregister(id) {
24295
25280
  this.sections.delete(id);
24296
25281
  }
25282
+ /**
25283
+ * Compose the full system prompt from all registered sections.
25284
+ *
25285
+ * Sections are sorted by priority (ascending), each contributing a titled
25286
+ * markdown block. If a section's `buildContext()` throws, it is skipped
25287
+ * gracefully so one broken section doesn't break the entire prompt.
25288
+ *
25289
+ * If `channelId` is provided, a "Current Channel" block is injected at the
25290
+ * top of the prompt so the assistant can adapt its behavior to the platform.
25291
+ */
24297
25292
  buildSystemPrompt(channelId) {
24298
25293
  const sorted = [...this.sections.values()].sort((a, b) => a.priority - b.priority);
24299
25294
  const parts = [ASSISTANT_PREAMBLE];
@@ -24336,6 +25331,13 @@ var init_assistant_manager = __esm({
24336
25331
  }
24337
25332
  sessions = /* @__PURE__ */ new Map();
24338
25333
  pendingSystemPrompts = /* @__PURE__ */ new Map();
25334
+ /**
25335
+ * Returns the assistant session for a channel, creating one if needed.
25336
+ *
25337
+ * If a persisted assistant session exists in the store, it is reused
25338
+ * (same session ID) to preserve conversation history. The system prompt
25339
+ * is always rebuilt fresh and deferred until the first user message.
25340
+ */
24339
25341
  async getOrSpawn(channelId, threadId) {
24340
25342
  const existing = this.core.sessionStore?.findAssistant(channelId);
24341
25343
  const session = await this.core.createSession({
@@ -24356,6 +25358,7 @@ var init_assistant_manager = __esm({
24356
25358
  );
24357
25359
  return session;
24358
25360
  }
25361
+ /** Returns the active assistant session for a channel, or null if none exists. */
24359
25362
  get(channelId) {
24360
25363
  return this.sessions.get(channelId) ?? null;
24361
25364
  }
@@ -24368,6 +25371,7 @@ var init_assistant_manager = __esm({
24368
25371
  if (prompt) this.pendingSystemPrompts.delete(channelId);
24369
25372
  return prompt;
24370
25373
  }
25374
+ /** Checks whether a given session ID belongs to the built-in assistant. */
24371
25375
  isAssistant(sessionId) {
24372
25376
  for (const s of this.sessions.values()) {
24373
25377
  if (s.id === sessionId) return true;
@@ -24666,30 +25670,49 @@ var init_core = __esm({
24666
25670
  menuRegistry = new MenuRegistry();
24667
25671
  assistantRegistry = new AssistantRegistry();
24668
25672
  assistantManager;
24669
- // --- Lazy getters: resolve from ServiceRegistry (populated by plugins during boot) ---
25673
+ // Services (security, notifications, speech, etc.) are provided by plugins that
25674
+ // register during boot. Core accesses them lazily via ServiceRegistry so it doesn't
25675
+ // need compile-time dependencies on plugin implementations.
25676
+ /** @throws if the service hasn't been registered by its plugin yet */
24670
25677
  getService(name) {
24671
25678
  const svc = this.lifecycleManager.serviceRegistry.get(name);
24672
25679
  if (!svc) throw new Error(`Service '${name}' not registered \u2014 is the ${name} plugin loaded?`);
24673
25680
  return svc;
24674
25681
  }
25682
+ /** Access control and rate-limiting guard (provided by security plugin). */
24675
25683
  get securityGuard() {
24676
25684
  return this.getService("security");
24677
25685
  }
25686
+ /** Cross-session notification delivery (provided by notifications plugin). */
24678
25687
  get notificationManager() {
24679
25688
  return this.getService("notifications");
24680
25689
  }
25690
+ /** File I/O service for agent attachment storage (provided by file-service plugin). */
24681
25691
  get fileService() {
24682
25692
  return this.getService("file-service");
24683
25693
  }
25694
+ /** Text-to-speech / speech-to-text engine (provided by speech plugin). */
24684
25695
  get speechService() {
24685
25696
  return this.getService("speech");
24686
25697
  }
25698
+ /** Conversation history builder for context injection (provided by context plugin). */
24687
25699
  get contextManager() {
24688
25700
  return this.getService("context");
24689
25701
  }
25702
+ /** Per-plugin persistent settings (e.g. API keys). */
24690
25703
  get settingsManager() {
24691
25704
  return this.lifecycleManager.settingsManager;
24692
25705
  }
25706
+ /**
25707
+ * Bootstrap all core subsystems. The boot order matters:
25708
+ * 1. AgentCatalog + AgentManager (agent definitions)
25709
+ * 2. SessionStore + SessionManager (session persistence and lookup)
25710
+ * 3. EventBus (inter-module communication)
25711
+ * 4. SessionFactory (session creation pipeline)
25712
+ * 5. LifecycleManager (plugin infrastructure)
25713
+ * 6. Wire middleware chain into factory + manager
25714
+ * 7. AgentSwitchHandler, config listeners, menu/assistant registries
25715
+ */
24693
25716
  constructor(configManager, ctx) {
24694
25717
  this.configManager = configManager;
24695
25718
  this.instanceContext = ctx;
@@ -24800,16 +25823,28 @@ var init_core = __esm({
24800
25823
  this.lifecycleManager.serviceRegistry.register("menu-registry", this.menuRegistry, "core");
24801
25824
  this.lifecycleManager.serviceRegistry.register("assistant-registry", this.assistantRegistry, "core");
24802
25825
  }
25826
+ /** Optional tunnel for generating public URLs (code viewer links, etc.). */
24803
25827
  get tunnelService() {
24804
25828
  return this._tunnelService;
24805
25829
  }
25830
+ /** Propagate tunnel service to MessageTransformer so it can generate viewer links. */
24806
25831
  set tunnelService(service) {
24807
25832
  this._tunnelService = service;
24808
25833
  this.messageTransformer.tunnelService = service;
24809
25834
  }
25835
+ /**
25836
+ * Register a messaging adapter (e.g. Telegram, Slack, SSE).
25837
+ *
25838
+ * Adapters must be registered before `start()`. The adapter name serves as its
25839
+ * channel ID throughout the system — used in session records, bridge keys, and routing.
25840
+ */
24810
25841
  registerAdapter(name, adapter) {
24811
25842
  this.adapters.set(name, adapter);
24812
25843
  }
25844
+ /**
25845
+ * Start all registered adapters. Adapters that fail are logged but do not
25846
+ * prevent others from starting. Throws only if ALL adapters fail.
25847
+ */
24813
25848
  async start() {
24814
25849
  this.agentCatalog.refreshRegistryIfStale().catch((err) => {
24815
25850
  log44.warn({ err }, "Background registry refresh failed");
@@ -24829,6 +25864,10 @@ var init_core = __esm({
24829
25864
  );
24830
25865
  }
24831
25866
  }
25867
+ /**
25868
+ * Graceful shutdown: notify users, persist session state, stop adapters.
25869
+ * Agent subprocesses are not explicitly killed — they exit with the parent process.
25870
+ */
24832
25871
  async stop() {
24833
25872
  try {
24834
25873
  const nm = this.lifecycleManager.serviceRegistry.get("notifications");
@@ -24847,6 +25886,13 @@ var init_core = __esm({
24847
25886
  }
24848
25887
  }
24849
25888
  // --- Archive ---
25889
+ /**
25890
+ * Archive a session: delete its adapter topic/thread and cancel the session.
25891
+ *
25892
+ * Only sessions in archivable states (active, cancelled, error) can be archived —
25893
+ * initializing and finished sessions are excluded.
25894
+ * The adapter handles platform-side cleanup (e.g. deleting a Telegram topic).
25895
+ */
24850
25896
  async archiveSession(sessionId) {
24851
25897
  const session = this.sessionManager.getSession(sessionId);
24852
25898
  if (!session) return { ok: false, error: "Session not found (must be in memory)" };
@@ -24866,6 +25912,18 @@ var init_core = __esm({
24866
25912
  }
24867
25913
  }
24868
25914
  // --- Message Routing ---
25915
+ /**
25916
+ * Route an incoming platform message to the appropriate session.
25917
+ *
25918
+ * Flow:
25919
+ * 1. Run `message:incoming` middleware (plugins can modify or block)
25920
+ * 2. SecurityGuard checks user access and per-user session limits
25921
+ * 3. Find session by channel+thread (in-memory lookup, then lazy resume from disk)
25922
+ * 4. For assistant sessions, prepend any deferred system prompt
25923
+ * 5. Emit `message:queued` for SSE clients, then enqueue the prompt on the session
25924
+ *
25925
+ * If no session is found, the user is told to start one with /new.
25926
+ */
24869
25927
  async handleMessage(message) {
24870
25928
  log44.debug(
24871
25929
  {
@@ -24947,6 +26005,17 @@ ${text5}`;
24947
26005
  }
24948
26006
  }
24949
26007
  // --- Unified Session Creation Pipeline ---
26008
+ /**
26009
+ * Create (or resume) a session with full wiring: agent, adapter thread, bridge, persistence.
26010
+ *
26011
+ * This is the single entry point for session creation. The pipeline:
26012
+ * 1. SessionFactory spawns/resumes the agent process
26013
+ * 2. Adapter creates a thread/topic if requested
26014
+ * 3. Initial session record is persisted (so lazy resume can find it by threadId)
26015
+ * 4. SessionBridge connects agent events to the adapter
26016
+ * 5. For headless sessions (no adapter), fallback event handlers are wired inline
26017
+ * 6. Side effects (usage tracking, tunnel cleanup) are attached
26018
+ */
24950
26019
  async createSession(params) {
24951
26020
  const session = await this.sessionFactory.create(params);
24952
26021
  if (params.threadId) {
@@ -25069,9 +26138,16 @@ ${text5}`;
25069
26138
  );
25070
26139
  return session;
25071
26140
  }
26141
+ /** Convenience wrapper: create a new session with default agent/workspace resolution. */
25072
26142
  async handleNewSession(channelId, agentName, workspacePath, options) {
25073
26143
  return this.sessionFactory.handleNewSession(channelId, agentName, workspacePath, options);
25074
26144
  }
26145
+ /**
26146
+ * Adopt an externally-started agent session (e.g. from a CLI `openacp adopt` command).
26147
+ *
26148
+ * Validates that the agent supports resume, checks session limits, avoids duplicates,
26149
+ * then creates a full session via the unified pipeline with resume semantics.
26150
+ */
25075
26151
  async adoptSession(agentName, agentSessionId, cwd, channelId) {
25076
26152
  const caps = getAgentCapabilities(agentName);
25077
26153
  if (!caps.supportsResume) {
@@ -25182,22 +26258,33 @@ ${text5}`;
25182
26258
  status: "adopted"
25183
26259
  };
25184
26260
  }
26261
+ /** Start a new chat within the same agent and workspace as the current session's thread. */
25185
26262
  async handleNewChat(channelId, currentThreadId) {
25186
26263
  return this.sessionFactory.handleNewChat(channelId, currentThreadId);
25187
26264
  }
26265
+ /** Create a session and inject conversation context from a prior session or repo. */
25188
26266
  async createSessionWithContext(params) {
25189
26267
  return this.sessionFactory.createSessionWithContext(params);
25190
26268
  }
25191
26269
  // --- Agent Switch ---
26270
+ /** Switch a session's active agent. Delegates to AgentSwitchHandler for state coordination. */
25192
26271
  async switchSessionAgent(sessionId, toAgent) {
25193
26272
  return this.agentSwitchHandler.switch(sessionId, toAgent);
25194
26273
  }
26274
+ /** Find a session by channel+thread, resuming from disk if not in memory. */
25195
26275
  async getOrResumeSession(channelId, threadId) {
25196
26276
  return this.sessionFactory.getOrResume(channelId, threadId);
25197
26277
  }
26278
+ /** Find a session by ID, resuming from disk if not in memory. */
25198
26279
  async getOrResumeSessionById(sessionId) {
25199
26280
  return this.sessionFactory.getOrResumeById(sessionId);
25200
26281
  }
26282
+ /**
26283
+ * Attach an additional adapter to an existing session (multi-adapter support).
26284
+ *
26285
+ * Creates a thread on the target adapter and connects a SessionBridge so the
26286
+ * session's agent events are forwarded to both the primary and attached adapters.
26287
+ */
25201
26288
  async attachAdapter(sessionId, adapterId) {
25202
26289
  const session = this.sessionManager.getSession(sessionId);
25203
26290
  if (!session) throw new Error(`Session ${sessionId} not found`);
@@ -25221,6 +26308,10 @@ ${text5}`;
25221
26308
  });
25222
26309
  return { threadId };
25223
26310
  }
26311
+ /**
26312
+ * Detach a secondary adapter from a session. The primary adapter (channelId) cannot
26313
+ * be detached. Disconnects the bridge and cleans up thread mappings.
26314
+ */
25224
26315
  async detachAdapter(sessionId, adapterId) {
25225
26316
  const session = this.sessionManager.getSession(sessionId);
25226
26317
  if (!session) throw new Error(`Session ${sessionId} not found`);
@@ -25253,6 +26344,7 @@ ${text5}`;
25253
26344
  platforms: this.buildPlatformsFromSession(session)
25254
26345
  });
25255
26346
  }
26347
+ /** Build the platforms map (adapter → thread/topic IDs) for persistence. */
25256
26348
  buildPlatformsFromSession(session) {
25257
26349
  const platforms = {};
25258
26350
  for (const [adapterId, threadId] of session.threadIds) {
@@ -25284,8 +26376,13 @@ ${text5}`;
25284
26376
  const bridge = this.createBridge(session, adapter, session.channelId);
25285
26377
  bridge.connect();
25286
26378
  }
25287
- /** Create a SessionBridge for the given session and adapter.
25288
- * Disconnects any existing bridge for the same adapter+session first. */
26379
+ /**
26380
+ * Create a SessionBridge for the given session and adapter.
26381
+ *
26382
+ * The bridge subscribes to Session events (agent output, status changes, naming)
26383
+ * and forwards them to the adapter for platform delivery. Disconnects any existing
26384
+ * bridge for the same adapter+session first to avoid duplicate event handlers.
26385
+ */
25289
26386
  createBridge(session, adapter, adapterId) {
25290
26387
  const id = adapterId ?? adapter.name;
25291
26388
  const key = this.bridgeKey(id, session.id);
@@ -25395,10 +26492,15 @@ var init_command_registry = __esm({
25395
26492
  return this.getAll().filter((cmd) => cmd.category === category);
25396
26493
  }
25397
26494
  /**
25398
- * Parse and execute a command string.
25399
- * @param commandString - Full command string, e.g. "/greet hello world"
25400
- * @param baseArgs - Base arguments (channelId, userId, etc.)
25401
- * @returns CommandResponse
26495
+ * Parse and execute a command string (e.g. "/greet hello world").
26496
+ *
26497
+ * Resolution order:
26498
+ * 1. Adapter-specific override (e.g. Telegram's version of /new)
26499
+ * 2. Short name or qualified name in the main registry
26500
+ *
26501
+ * Strips Telegram-style bot mentions (e.g. "/help@MyBot" → "help").
26502
+ * Returns `{ type: 'delegated' }` if the handler returns null/undefined
26503
+ * (meaning it handled the response itself, e.g. via assistant).
25402
26504
  */
25403
26505
  async execute(commandString, baseArgs) {
25404
26506
  const trimmed = commandString.trim();
@@ -26193,12 +27295,15 @@ var init_plugin_field_registry = __esm({
26193
27295
  "use strict";
26194
27296
  PluginFieldRegistry = class {
26195
27297
  fields = /* @__PURE__ */ new Map();
27298
+ /** Register (or replace) the editable fields for a plugin. */
26196
27299
  register(pluginName, fields) {
26197
27300
  this.fields.set(pluginName, fields);
26198
27301
  }
27302
+ /** Get the editable fields for a specific plugin. */
26199
27303
  getForPlugin(pluginName) {
26200
27304
  return this.fields.get(pluginName) ?? [];
26201
27305
  }
27306
+ /** Get all fields grouped by plugin name. */
26202
27307
  getAll() {
26203
27308
  return new Map(this.fields);
26204
27309
  }
@@ -27555,6 +28660,10 @@ var init_dev_loader = __esm({
27555
28660
  constructor(pluginPath) {
27556
28661
  this.pluginPath = path55.resolve(pluginPath);
27557
28662
  }
28663
+ /**
28664
+ * Import the plugin's default export from dist/index.js.
28665
+ * Each call uses a unique URL query to bypass Node's ESM cache.
28666
+ */
27558
28667
  async load() {
27559
28668
  const distIndex = path55.join(this.pluginPath, "dist", "index.js");
27560
28669
  const srcIndex = path55.join(this.pluginPath, "src", "index.ts");
@@ -27572,9 +28681,11 @@ var init_dev_loader = __esm({
27572
28681
  }
27573
28682
  return plugin2;
27574
28683
  }
28684
+ /** Returns the resolved absolute path to the plugin's root directory. */
27575
28685
  getPluginPath() {
27576
28686
  return this.pluginPath;
27577
28687
  }
28688
+ /** Returns the path to the plugin's dist directory. */
27578
28689
  getDistPath() {
27579
28690
  return path55.join(this.pluginPath, "dist");
27580
28691
  }