@openacp/cli 2026.410.1 → 2026.410.3

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];
@@ -8292,32 +8561,48 @@ async function sessionRoutes(app, deps) {
8292
8561
  { preHandler: requireScopes("sessions:read") },
8293
8562
  async (request) => {
8294
8563
  const { sessionId } = SessionIdParamSchema.parse(request.params);
8295
- const session = deps.core.sessionManager.getSession(
8296
- decodeURIComponent(sessionId)
8297
- );
8298
- if (!session) {
8299
- throw new NotFoundError(
8300
- "SESSION_NOT_FOUND",
8301
- `Session "${sessionId}" not found`
8302
- );
8564
+ const id = decodeURIComponent(sessionId);
8565
+ const session = deps.core.sessionManager.getSession(id);
8566
+ if (session) {
8567
+ return {
8568
+ session: {
8569
+ id: session.id,
8570
+ agent: session.agentName,
8571
+ status: session.status,
8572
+ name: session.name ?? null,
8573
+ workspace: session.workingDirectory,
8574
+ createdAt: session.createdAt.toISOString(),
8575
+ dangerousMode: session.clientOverrides.bypassPermissions ?? false,
8576
+ queueDepth: session.queueDepth,
8577
+ promptRunning: session.promptRunning,
8578
+ threadId: session.threadId,
8579
+ channelId: session.channelId,
8580
+ agentSessionId: session.agentSessionId,
8581
+ configOptions: session.configOptions?.length ? session.configOptions : void 0,
8582
+ capabilities: session.agentCapabilities ?? null
8583
+ }
8584
+ };
8585
+ }
8586
+ const record = deps.core.sessionManager.getSessionRecord(id);
8587
+ if (!record) {
8588
+ throw new NotFoundError("SESSION_NOT_FOUND", `Session "${id}" not found`);
8303
8589
  }
8304
8590
  return {
8305
8591
  session: {
8306
- id: session.id,
8307
- agent: session.agentName,
8308
- status: session.status,
8309
- name: session.name ?? null,
8310
- workspace: session.workingDirectory,
8311
- createdAt: session.createdAt.toISOString(),
8312
- dangerousMode: session.clientOverrides.bypassPermissions ?? false,
8313
- queueDepth: session.queueDepth,
8314
- promptRunning: session.promptRunning,
8315
- threadId: session.threadId,
8316
- channelId: session.channelId,
8317
- agentSessionId: session.agentSessionId,
8318
- // ACP state
8319
- configOptions: session.configOptions?.length ? session.configOptions : void 0,
8320
- capabilities: session.agentCapabilities ?? null
8592
+ id: record.sessionId,
8593
+ agent: record.agentName,
8594
+ status: record.status,
8595
+ name: record.name ?? null,
8596
+ workspace: record.workingDir,
8597
+ createdAt: record.createdAt,
8598
+ dangerousMode: record.clientOverrides?.bypassPermissions ?? false,
8599
+ queueDepth: 0,
8600
+ promptRunning: false,
8601
+ threadId: null,
8602
+ channelId: record.channelId,
8603
+ agentSessionId: record.agentSessionId,
8604
+ configOptions: record.acpState?.configOptions?.length ? record.acpState.configOptions : void 0,
8605
+ capabilities: record.acpState?.agentCapabilities ?? null
8321
8606
  }
8322
8607
  };
8323
8608
  }
@@ -8452,7 +8737,7 @@ async function sessionRoutes(app, deps) {
8452
8737
  async (request, reply) => {
8453
8738
  const { sessionId: rawId } = SessionIdParamSchema.parse(request.params);
8454
8739
  const sessionId = decodeURIComponent(rawId);
8455
- const session = deps.core.sessionManager.getSession(sessionId);
8740
+ const session = await deps.core.getOrResumeSessionById(sessionId);
8456
8741
  if (!session) {
8457
8742
  throw new NotFoundError(
8458
8743
  "SESSION_NOT_FOUND",
@@ -8481,7 +8766,7 @@ async function sessionRoutes(app, deps) {
8481
8766
  async (request) => {
8482
8767
  const { sessionId: rawId } = SessionIdParamSchema.parse(request.params);
8483
8768
  const sessionId = decodeURIComponent(rawId);
8484
- const session = deps.core.sessionManager.getSession(sessionId);
8769
+ const session = await deps.core.getOrResumeSessionById(sessionId);
8485
8770
  if (!session) {
8486
8771
  throw new NotFoundError(
8487
8772
  "SESSION_NOT_FOUND",
@@ -8518,7 +8803,7 @@ async function sessionRoutes(app, deps) {
8518
8803
  async (request) => {
8519
8804
  const { sessionId: rawId } = SessionIdParamSchema.parse(request.params);
8520
8805
  const sessionId = decodeURIComponent(rawId);
8521
- const session = deps.core.sessionManager.getSession(sessionId);
8806
+ const session = await deps.core.getOrResumeSessionById(sessionId);
8522
8807
  if (!session) {
8523
8808
  throw new NotFoundError(
8524
8809
  "SESSION_NOT_FOUND",
@@ -8540,15 +8825,19 @@ async function sessionRoutes(app, deps) {
8540
8825
  const { sessionId: rawId } = SessionIdParamSchema.parse(request.params);
8541
8826
  const sessionId = decodeURIComponent(rawId);
8542
8827
  const session = deps.core.sessionManager.getSession(sessionId);
8543
- if (!session) {
8544
- throw new NotFoundError(
8545
- "SESSION_NOT_FOUND",
8546
- `Session "${sessionId}" not found`
8547
- );
8828
+ if (session) {
8829
+ return {
8830
+ configOptions: session.configOptions,
8831
+ clientOverrides: session.clientOverrides
8832
+ };
8833
+ }
8834
+ const record = deps.core.sessionManager.getSessionRecord(sessionId);
8835
+ if (!record) {
8836
+ throw new NotFoundError("SESSION_NOT_FOUND", `Session "${sessionId}" not found`);
8548
8837
  }
8549
8838
  return {
8550
- configOptions: session.configOptions,
8551
- clientOverrides: session.clientOverrides
8839
+ configOptions: record.acpState?.configOptions,
8840
+ clientOverrides: record.clientOverrides ?? {}
8552
8841
  };
8553
8842
  }
8554
8843
  );
@@ -8558,7 +8847,7 @@ async function sessionRoutes(app, deps) {
8558
8847
  async (request) => {
8559
8848
  const { sessionId: rawId, configId } = ConfigIdParamSchema.parse(request.params);
8560
8849
  const sessionId = decodeURIComponent(rawId);
8561
- const session = deps.core.sessionManager.getSession(sessionId);
8850
+ const session = await deps.core.getOrResumeSessionById(sessionId);
8562
8851
  if (!session) {
8563
8852
  throw new NotFoundError(
8564
8853
  "SESSION_NOT_FOUND",
@@ -8583,13 +8872,14 @@ async function sessionRoutes(app, deps) {
8583
8872
  const { sessionId: rawId } = SessionIdParamSchema.parse(request.params);
8584
8873
  const sessionId = decodeURIComponent(rawId);
8585
8874
  const session = deps.core.sessionManager.getSession(sessionId);
8586
- if (!session) {
8587
- throw new NotFoundError(
8588
- "SESSION_NOT_FOUND",
8589
- `Session "${sessionId}" not found`
8590
- );
8875
+ if (session) {
8876
+ return { clientOverrides: session.clientOverrides };
8591
8877
  }
8592
- return { clientOverrides: session.clientOverrides };
8878
+ const record = deps.core.sessionManager.getSessionRecord(sessionId);
8879
+ if (!record) {
8880
+ throw new NotFoundError("SESSION_NOT_FOUND", `Session "${sessionId}" not found`);
8881
+ }
8882
+ return { clientOverrides: record.clientOverrides ?? {} };
8593
8883
  }
8594
8884
  );
8595
8885
  app.put(
@@ -8598,7 +8888,7 @@ async function sessionRoutes(app, deps) {
8598
8888
  async (request) => {
8599
8889
  const { sessionId: rawId } = SessionIdParamSchema.parse(request.params);
8600
8890
  const sessionId = decodeURIComponent(rawId);
8601
- const session = deps.core.sessionManager.getSession(sessionId);
8891
+ const session = await deps.core.getOrResumeSessionById(sessionId);
8602
8892
  if (!session) {
8603
8893
  throw new NotFoundError(
8604
8894
  "SESSION_NOT_FOUND",
@@ -8664,8 +8954,8 @@ async function sessionRoutes(app, deps) {
8664
8954
  { preHandler: requireScopes("sessions:read") },
8665
8955
  async (request, reply) => {
8666
8956
  const { sessionId } = SessionIdParamSchema.parse(request.params);
8667
- const session = deps.core.sessionManager.getSession(sessionId);
8668
- if (!session) {
8957
+ const isKnown = deps.core.sessionManager.getSession(sessionId) ?? deps.core.sessionManager.getSessionRecord(sessionId);
8958
+ if (!isKnown) {
8669
8959
  throw new NotFoundError(
8670
8960
  "SESSION_NOT_FOUND",
8671
8961
  `Session "${sessionId}" not found`
@@ -8687,8 +8977,8 @@ async function sessionRoutes(app, deps) {
8687
8977
  async (request) => {
8688
8978
  const { sessionId: rawId } = SessionIdParamSchema.parse(request.params);
8689
8979
  const sessionId = decodeURIComponent(rawId);
8690
- const session = deps.core.sessionManager.getSession(sessionId);
8691
- if (!session) {
8980
+ const isKnown = deps.core.sessionManager.getSession(sessionId) ?? deps.core.sessionManager.getSessionRecord(sessionId);
8981
+ if (!isKnown) {
8692
8982
  throw new NotFoundError(
8693
8983
  "SESSION_NOT_FOUND",
8694
8984
  `Session "${sessionId}" not found`
@@ -8828,6 +9118,8 @@ var init_config_migrations = __esm({
8828
9118
  log13 = createChildLogger({ module: "config-migrations" });
8829
9119
  migrations = [
8830
9120
  {
9121
+ // v2025.x: instanceName was added to support multi-instance setups.
9122
+ // Old configs lack this field — default to "Main" so the UI has a display name.
8831
9123
  name: "add-instance-name",
8832
9124
  apply(raw) {
8833
9125
  if (raw.instanceName) return false;
@@ -8837,6 +9129,8 @@ var init_config_migrations = __esm({
8837
9129
  }
8838
9130
  },
8839
9131
  {
9132
+ // displayVerbosity was replaced by outputMode — remove the legacy key
9133
+ // so it doesn't confuse Zod strict parsing or the config editor.
8840
9134
  name: "delete-display-verbosity",
8841
9135
  apply(raw) {
8842
9136
  if (!("displayVerbosity" in raw)) return false;
@@ -8846,6 +9140,9 @@ var init_config_migrations = __esm({
8846
9140
  }
8847
9141
  },
8848
9142
  {
9143
+ // Instance IDs were originally only in instances.json (the global registry).
9144
+ // This migration copies the ID into config.json so each instance is self-identifying
9145
+ // without needing to cross-reference the registry.
8849
9146
  name: "add-instance-id",
8850
9147
  apply(raw, ctx) {
8851
9148
  if (raw.id) return false;
@@ -8906,10 +9203,11 @@ var init_config2 = __esm({
8906
9203
  sessionLogRetentionDays: z4.number().default(30)
8907
9204
  }).default({});
8908
9205
  ConfigSchema = z4.object({
9206
+ /** Instance UUID, written once at creation time. */
8909
9207
  id: z4.string().optional(),
8910
- // instance UUID, written once at creation time
8911
9208
  instanceName: z4.string().optional(),
8912
9209
  defaultAgent: z4.string(),
9210
+ // --- Workspace security & path resolution ---
8913
9211
  workspace: z4.object({
8914
9212
  allowExternalWorkspaces: z4.boolean().default(true),
8915
9213
  security: z4.object({
@@ -8917,12 +9215,16 @@ var init_config2 = __esm({
8917
9215
  envWhitelist: z4.array(z4.string()).default([])
8918
9216
  }).default({})
8919
9217
  }).default({}),
9218
+ // --- Logging ---
8920
9219
  logging: LoggingSchema,
9220
+ // --- Process lifecycle ---
8921
9221
  runMode: z4.enum(["foreground", "daemon"]).default("foreground"),
8922
9222
  autoStart: z4.boolean().default(false),
9223
+ // --- Session persistence ---
8923
9224
  sessionStore: z4.object({
8924
9225
  ttlDays: z4.number().default(30)
8925
9226
  }).default({}),
9227
+ // --- Installed integration tracking (e.g. plugins installed via CLI) ---
8926
9228
  integrations: z4.record(
8927
9229
  z4.string(),
8928
9230
  z4.object({
@@ -8930,7 +9232,9 @@ var init_config2 = __esm({
8930
9232
  installedAt: z4.string().optional()
8931
9233
  })
8932
9234
  ).default({}),
9235
+ // --- Agent output verbosity control ---
8933
9236
  outputMode: z4.enum(["low", "medium", "high"]).default("medium").optional(),
9237
+ // --- Multi-agent switching behavior ---
8934
9238
  agentSwitch: z4.object({
8935
9239
  labelHistory: z4.boolean().default(true)
8936
9240
  }).default({})
@@ -8946,6 +9250,13 @@ var init_config2 = __esm({
8946
9250
  super();
8947
9251
  this.configPath = process.env.OPENACP_CONFIG_PATH || configPath || expandHome2("~/.openacp/config.json");
8948
9252
  }
9253
+ /**
9254
+ * Loads config from disk through the full validation pipeline:
9255
+ * 1. Create default config if missing (first run)
9256
+ * 2. Apply migrations for older config formats
9257
+ * 3. Apply environment variable overrides
9258
+ * 4. Validate against Zod schema — exits on failure
9259
+ */
8949
9260
  async load() {
8950
9261
  const dir = path24.dirname(this.configPath);
8951
9262
  fs21.mkdirSync(dir, { recursive: true });
@@ -8979,9 +9290,17 @@ var init_config2 = __esm({
8979
9290
  }
8980
9291
  this.config = result.data;
8981
9292
  }
9293
+ /** Returns a deep clone of the current config to prevent external mutation. */
8982
9294
  get() {
8983
9295
  return structuredClone(this.config);
8984
9296
  }
9297
+ /**
9298
+ * Merges partial updates into the config file using atomic write (write tmp + rename).
9299
+ *
9300
+ * Validates the merged result before writing. If `changePath` is provided,
9301
+ * emits a `config:changed` event with old and new values for that path,
9302
+ * enabling hot-reload without restart.
9303
+ */
8985
9304
  async save(updates, changePath) {
8986
9305
  const oldConfig = this.config ? structuredClone(this.config) : void 0;
8987
9306
  const raw = JSON.parse(fs21.readFileSync(this.configPath, "utf-8"));
@@ -9003,9 +9322,12 @@ var init_config2 = __esm({
9003
9322
  }
9004
9323
  }
9005
9324
  /**
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.
9325
+ * Convenience wrapper for updating a single deeply-nested config field
9326
+ * without constructing the full update object manually.
9327
+ *
9328
+ * Accepts a dot-path (e.g. "logging.level") and builds the nested
9329
+ * update object internally before delegating to `save()`.
9330
+ * Throws if the path contains prototype-pollution keys.
9009
9331
  */
9010
9332
  async setPath(dotPath, value) {
9011
9333
  const BLOCKED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
@@ -9022,6 +9344,13 @@ var init_config2 = __esm({
9022
9344
  target[parts[parts.length - 1]] = value;
9023
9345
  await this.save(updates, dotPath);
9024
9346
  }
9347
+ /**
9348
+ * Resolves a workspace path from user input.
9349
+ *
9350
+ * Supports three forms: no input (returns base dir), absolute/tilde paths
9351
+ * (validated against allowExternalWorkspaces), and named workspaces
9352
+ * (alphanumeric subdirectories under the base).
9353
+ */
9025
9354
  resolveWorkspace(input2) {
9026
9355
  const workspaceBase = path24.dirname(path24.dirname(this.configPath));
9027
9356
  if (!input2) {
@@ -9056,17 +9385,26 @@ var init_config2 = __esm({
9056
9385
  fs21.mkdirSync(namedPath, { recursive: true });
9057
9386
  return namedPath;
9058
9387
  }
9388
+ /** 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
9389
  async exists() {
9060
9390
  return fs21.existsSync(this.configPath);
9061
9391
  }
9392
+ /** Returns the resolved path to the config JSON file. */
9062
9393
  getConfigPath() {
9063
9394
  return this.configPath;
9064
9395
  }
9396
+ /** Writes a complete config object to disk, creating the directory if needed. Used during initial setup. */
9065
9397
  async writeNew(config) {
9066
9398
  const dir = path24.dirname(this.configPath);
9067
9399
  fs21.mkdirSync(dir, { recursive: true });
9068
9400
  fs21.writeFileSync(this.configPath, JSON.stringify(config, null, 2));
9069
9401
  }
9402
+ /**
9403
+ * Applies `OPENACP_*` environment variables as overrides to per-plugin settings.
9404
+ *
9405
+ * This lets users configure plugin values (bot tokens, ports, etc.) via env vars
9406
+ * without editing settings files — useful for Docker, CI, and headless setups.
9407
+ */
9070
9408
  async applyEnvToPluginSettings(settingsManager) {
9071
9409
  const pluginOverrides = [
9072
9410
  { envVar: "OPENACP_TUNNEL_ENABLED", pluginName: "@openacp/tunnel", key: "enabled", transform: (v) => v === "true" },
@@ -9093,6 +9431,7 @@ var init_config2 = __esm({
9093
9431
  }
9094
9432
  }
9095
9433
  }
9434
+ /** Applies env var overrides to the raw config object before Zod validation. */
9096
9435
  applyEnvOverrides(raw) {
9097
9436
  const overrides = [
9098
9437
  ["OPENACP_DEFAULT_AGENT", ["defaultAgent"]],
@@ -9123,6 +9462,7 @@ var init_config2 = __esm({
9123
9462
  raw.logging.level = "debug";
9124
9463
  }
9125
9464
  }
9465
+ /** Recursively merges source into target, skipping prototype-pollution keys. */
9126
9466
  deepMerge(target, source) {
9127
9467
  const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
9128
9468
  for (const key of Object.keys(source)) {
@@ -9933,6 +10273,7 @@ var init_instance_registry = __esm({
9933
10273
  this.registryPath = registryPath;
9934
10274
  }
9935
10275
  data = { version: 1, instances: {} };
10276
+ /** Load the registry from disk. If the file is missing or corrupt, starts fresh. */
9936
10277
  load() {
9937
10278
  try {
9938
10279
  const raw = fs22.readFileSync(this.registryPath, "utf-8");
@@ -9960,26 +10301,33 @@ var init_instance_registry = __esm({
9960
10301
  this.save();
9961
10302
  }
9962
10303
  }
10304
+ /** Persist the registry to disk, creating parent directories if needed. */
9963
10305
  save() {
9964
10306
  const dir = path26.dirname(this.registryPath);
9965
10307
  fs22.mkdirSync(dir, { recursive: true });
9966
10308
  fs22.writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2));
9967
10309
  }
10310
+ /** Add or update an instance entry in the registry. Does not persist — call save() after. */
9968
10311
  register(id, root) {
9969
10312
  this.data.instances[id] = { id, root };
9970
10313
  }
10314
+ /** Remove an instance entry. Does not persist — call save() after. */
9971
10315
  remove(id) {
9972
10316
  delete this.data.instances[id];
9973
10317
  }
10318
+ /** Look up an instance by its ID. */
9974
10319
  get(id) {
9975
10320
  return this.data.instances[id];
9976
10321
  }
10322
+ /** Look up an instance by its root directory path. */
9977
10323
  getByRoot(root) {
9978
10324
  return Object.values(this.data.instances).find((e) => e.root === root);
9979
10325
  }
10326
+ /** Returns all registered instances. */
9980
10327
  list() {
9981
10328
  return Object.values(this.data.instances);
9982
10329
  }
10330
+ /** Returns `baseId` if available, otherwise appends `-2`, `-3`, etc. until unique. */
9983
10331
  uniqueId(baseId) {
9984
10332
  if (!this.data.instances[baseId]) return baseId;
9985
10333
  let n = 2;
@@ -10485,6 +10833,7 @@ var init_connection_manager = __esm({
10485
10833
  "use strict";
10486
10834
  ConnectionManager = class {
10487
10835
  connections = /* @__PURE__ */ new Map();
10836
+ // Secondary index: sessionId → Set of connection IDs for O(1) broadcast targeting
10488
10837
  sessionIndex = /* @__PURE__ */ new Map();
10489
10838
  maxConnectionsPerSession;
10490
10839
  maxTotalConnections;
@@ -10492,6 +10841,14 @@ var init_connection_manager = __esm({
10492
10841
  this.maxConnectionsPerSession = opts?.maxPerSession ?? 10;
10493
10842
  this.maxTotalConnections = opts?.maxTotal ?? 100;
10494
10843
  }
10844
+ /**
10845
+ * Registers a new SSE connection for the given session.
10846
+ *
10847
+ * Wires a `close` listener on the response so the connection is automatically
10848
+ * removed when the client disconnects (browser tab closed, network drop, etc.).
10849
+ *
10850
+ * @throws if the global or per-session connection limit is reached.
10851
+ */
10495
10852
  addConnection(sessionId, tokenId, response) {
10496
10853
  if (this.connections.size >= this.maxTotalConnections) {
10497
10854
  throw new Error("Maximum total connections reached");
@@ -10512,6 +10869,7 @@ var init_connection_manager = __esm({
10512
10869
  response.on("close", () => this.removeConnection(id));
10513
10870
  return connection;
10514
10871
  }
10872
+ /** Remove a connection from both indexes. Called automatically on client disconnect. */
10515
10873
  removeConnection(connectionId) {
10516
10874
  const conn = this.connections.get(connectionId);
10517
10875
  if (!conn) return;
@@ -10522,11 +10880,20 @@ var init_connection_manager = __esm({
10522
10880
  if (sessionConns.size === 0) this.sessionIndex.delete(conn.sessionId);
10523
10881
  }
10524
10882
  }
10883
+ /** Returns all active connections for a session. */
10525
10884
  getConnectionsBySession(sessionId) {
10526
10885
  const connIds = this.sessionIndex.get(sessionId);
10527
10886
  if (!connIds) return [];
10528
10887
  return Array.from(connIds).map((id) => this.connections.get(id)).filter((c3) => c3 !== void 0);
10529
10888
  }
10889
+ /**
10890
+ * Writes a serialized SSE event to all connections for the given session.
10891
+ *
10892
+ * Backpressure handling: if `response.write()` returns false (OS send buffer full),
10893
+ * the connection is flagged as `backpressured`. On the next write attempt, if it is
10894
+ * still backpressured, the connection is forcibly closed to prevent unbounded memory
10895
+ * growth from queuing writes on a slow or stalled client.
10896
+ */
10530
10897
  broadcast(sessionId, serializedEvent) {
10531
10898
  for (const conn of this.getConnectionsBySession(sessionId)) {
10532
10899
  if (conn.response.writableEnded) continue;
@@ -10548,6 +10915,10 @@ var init_connection_manager = __esm({
10548
10915
  }
10549
10916
  }
10550
10917
  }
10918
+ /**
10919
+ * Force-close all connections associated with a given auth token.
10920
+ * Called when a token is revoked to immediately terminate those streams.
10921
+ */
10551
10922
  disconnectByToken(tokenId) {
10552
10923
  for (const [id, conn] of this.connections) {
10553
10924
  if (conn.tokenId === tokenId) {
@@ -10556,9 +10927,11 @@ var init_connection_manager = __esm({
10556
10927
  }
10557
10928
  }
10558
10929
  }
10930
+ /** Returns a snapshot of all active connections (used by the admin endpoint). */
10559
10931
  listConnections() {
10560
10932
  return Array.from(this.connections.values());
10561
10933
  }
10934
+ /** Close all connections and clear all indexes. Called on plugin teardown. */
10562
10935
  cleanup() {
10563
10936
  for (const [, conn] of this.connections) {
10564
10937
  if (!conn.response.writableEnded) conn.response.end();
@@ -10576,10 +10949,15 @@ var init_event_buffer = __esm({
10576
10949
  "src/plugins/sse-adapter/event-buffer.ts"() {
10577
10950
  "use strict";
10578
10951
  EventBuffer = class {
10952
+ /**
10953
+ * @param maxSize Maximum events retained per session. Older events are evicted when
10954
+ * this limit is exceeded. Defaults to 100.
10955
+ */
10579
10956
  constructor(maxSize = 100) {
10580
10957
  this.maxSize = maxSize;
10581
10958
  }
10582
10959
  buffers = /* @__PURE__ */ new Map();
10960
+ /** Append an event to the session's buffer, evicting the oldest entry if at capacity. */
10583
10961
  push(sessionId, event) {
10584
10962
  let buffer = this.buffers.get(sessionId);
10585
10963
  if (!buffer) {
@@ -10591,6 +10969,14 @@ var init_event_buffer = __esm({
10591
10969
  buffer.shift();
10592
10970
  }
10593
10971
  }
10972
+ /**
10973
+ * Returns events that occurred after `lastEventId`.
10974
+ *
10975
+ * - If `lastEventId` is `undefined`, returns all buffered events (fresh connection).
10976
+ * - If `lastEventId` is not found in the buffer, returns `null` — the event has been
10977
+ * evicted and the client must be informed that a gap may exist.
10978
+ * - Otherwise returns the slice after the matching event.
10979
+ */
10594
10980
  getSince(sessionId, lastEventId) {
10595
10981
  const buffer = this.buffers.get(sessionId);
10596
10982
  if (!buffer || buffer.length === 0) return [];
@@ -10599,6 +10985,7 @@ var init_event_buffer = __esm({
10599
10985
  if (index === -1) return null;
10600
10986
  return buffer.slice(index + 1);
10601
10987
  }
10988
+ /** Remove the buffer for a session — called when the session ends to free memory. */
10602
10989
  cleanup(sessionId) {
10603
10990
  this.buffers.delete(sessionId);
10604
10991
  }
@@ -10680,6 +11067,12 @@ var init_adapter = __esm({
10680
11067
  voice: false
10681
11068
  };
10682
11069
  heartbeatTimer;
11070
+ /**
11071
+ * Starts the heartbeat timer that keeps idle SSE connections alive.
11072
+ *
11073
+ * `.unref()` prevents the timer from blocking the Node.js event loop from
11074
+ * exiting if this is the only remaining async operation (e.g. during tests).
11075
+ */
10683
11076
  async start() {
10684
11077
  this.heartbeatTimer = setInterval(() => {
10685
11078
  const heartbeat = serializeHeartbeat();
@@ -10696,6 +11089,7 @@ var init_adapter = __esm({
10696
11089
  this.heartbeatTimer.unref();
10697
11090
  }
10698
11091
  }
11092
+ /** Stops the heartbeat timer and closes all active connections. */
10699
11093
  async stop() {
10700
11094
  if (this.heartbeatTimer) {
10701
11095
  clearInterval(this.heartbeatTimer);
@@ -10703,18 +11097,32 @@ var init_adapter = __esm({
10703
11097
  }
10704
11098
  this.connectionManager.cleanup();
10705
11099
  }
11100
+ /**
11101
+ * Serializes an outgoing agent message, pushes it to the event buffer,
11102
+ * then broadcasts it to all active connections for the session.
11103
+ *
11104
+ * Buffering before broadcast ensures that a client reconnecting immediately
11105
+ * after this call can still replay the event via `Last-Event-ID`.
11106
+ */
10706
11107
  async sendMessage(sessionId, content) {
10707
11108
  const eventId = generateEventId();
10708
11109
  const serialized = serializeOutgoingMessage(sessionId, eventId, content);
10709
11110
  this.eventBuffer.push(sessionId, { id: eventId, data: serialized });
10710
11111
  this.connectionManager.broadcast(sessionId, serialized);
10711
11112
  }
11113
+ /** Serializes and delivers a permission request UI to the session's SSE clients. */
10712
11114
  async sendPermissionRequest(sessionId, request) {
10713
11115
  const eventId = generateEventId();
10714
11116
  const serialized = serializePermissionRequest(sessionId, eventId, request);
10715
11117
  this.eventBuffer.push(sessionId, { id: eventId, data: serialized });
10716
11118
  this.connectionManager.broadcast(sessionId, serialized);
10717
11119
  }
11120
+ /**
11121
+ * Delivers a cross-session notification to the target session's SSE clients.
11122
+ *
11123
+ * Notifications are always buffered in addition to being broadcast so that
11124
+ * a client reconnecting shortly after (e.g. page refresh) still sees the alert.
11125
+ */
10718
11126
  async sendNotification(notification) {
10719
11127
  if (notification.sessionId) {
10720
11128
  const eventId = generateEventId();
@@ -10723,9 +11131,11 @@ var init_adapter = __esm({
10723
11131
  this.connectionManager.broadcast(notification.sessionId, serialized);
10724
11132
  }
10725
11133
  }
11134
+ /** SSE has no concept of threads — return sessionId as the threadId */
10726
11135
  async createSessionThread(sessionId, _name) {
10727
11136
  return sessionId;
10728
11137
  }
11138
+ /** No-op for SSE — there are no named threads to rename. */
10729
11139
  async renameSessionThread(_sessionId, _newName) {
10730
11140
  }
10731
11141
  };
@@ -10765,6 +11175,7 @@ async function sseRoutes(app, deps) {
10765
11175
  "Content-Type": "text/event-stream",
10766
11176
  "Cache-Control": "no-cache",
10767
11177
  "Connection": "keep-alive",
11178
+ // Disable buffering in Nginx/Cloudflare so events arrive without delay
10768
11179
  "X-Accel-Buffering": "no"
10769
11180
  });
10770
11181
  raw.write(serializeConnected(connection.id, sessionId));
@@ -13694,13 +14105,16 @@ var init_settings_manager = __esm({
13694
14105
  constructor(basePath) {
13695
14106
  this.basePath = basePath;
13696
14107
  }
14108
+ /** Returns the base path for all plugin settings directories. */
13697
14109
  getBasePath() {
13698
14110
  return this.basePath;
13699
14111
  }
14112
+ /** Create a SettingsAPI instance scoped to a specific plugin. */
13700
14113
  createAPI(pluginName) {
13701
14114
  const settingsPath = this.getSettingsPath(pluginName);
13702
14115
  return new SettingsAPIImpl(settingsPath);
13703
14116
  }
14117
+ /** Load a plugin's settings from disk. Returns empty object if file doesn't exist. */
13704
14118
  async loadSettings(pluginName) {
13705
14119
  const settingsPath = this.getSettingsPath(pluginName);
13706
14120
  try {
@@ -13710,6 +14124,7 @@ var init_settings_manager = __esm({
13710
14124
  return {};
13711
14125
  }
13712
14126
  }
14127
+ /** Validate settings against a Zod schema. Returns valid if no schema is provided. */
13713
14128
  validateSettings(_pluginName, settings, schema) {
13714
14129
  if (!schema) return { valid: true };
13715
14130
  const result = schema.safeParse(settings);
@@ -13721,12 +14136,14 @@ var init_settings_manager = __esm({
13721
14136
  )
13722
14137
  };
13723
14138
  }
14139
+ /** Resolve the absolute path to a plugin's settings.json file. */
13724
14140
  getSettingsPath(pluginName) {
13725
14141
  return path30.join(this.basePath, pluginName, "settings.json");
13726
14142
  }
13727
14143
  async getPluginSettings(pluginName) {
13728
14144
  return this.loadSettings(pluginName);
13729
14145
  }
14146
+ /** Merge updates into existing settings (shallow merge). */
13730
14147
  async updatePluginSettings(pluginName, updates) {
13731
14148
  const api = this.createAPI(pluginName);
13732
14149
  const current = await api.getAll();
@@ -14297,6 +14714,12 @@ var init_doctor = __esm({
14297
14714
  this.dryRun = options?.dryRun ?? false;
14298
14715
  this.dataDir = options.dataDir;
14299
14716
  }
14717
+ /**
14718
+ * Executes all checks and returns an aggregated report.
14719
+ *
14720
+ * Safe fixes are applied inline (mutating CheckResult.message to show "Fixed").
14721
+ * Risky fixes are deferred to `report.pendingFixes` for user confirmation.
14722
+ */
14300
14723
  async runAll() {
14301
14724
  const ctx = await this.buildContext();
14302
14725
  const checks = [...ALL_CHECKS].sort((a, b) => a.order - b.order);
@@ -14344,6 +14767,7 @@ var init_doctor = __esm({
14344
14767
  }
14345
14768
  return { categories, summary, pendingFixes };
14346
14769
  }
14770
+ /** Constructs the shared context used by all checks — loads config if available. */
14347
14771
  async buildContext() {
14348
14772
  const dataDir = this.dataDir;
14349
14773
  const configPath = process.env.OPENACP_CONFIG_PATH || path35.join(dataDir, "config.json");
@@ -14991,6 +15415,14 @@ var init_permissions = __esm({
14991
15415
  this.sendNotification = sendNotification;
14992
15416
  }
14993
15417
  pending = /* @__PURE__ */ new Map();
15418
+ /**
15419
+ * Send a permission request to the session's topic as an inline keyboard message,
15420
+ * and fire a notification to the Notifications topic so the user is alerted.
15421
+ *
15422
+ * Each button encodes `p:<callbackKey>:<optionId>`. The callbackKey is a short
15423
+ * nanoid that maps to the full pending state stored in-memory, avoiding the
15424
+ * 64-byte Telegram callback_data limit.
15425
+ */
14994
15426
  async sendPermissionRequest(session, request) {
14995
15427
  const threadId = Number(session.threadId);
14996
15428
  const callbackKey = nanoid3(8);
@@ -15025,6 +15457,12 @@ ${escapeHtml4(request.description)}`,
15025
15457
  deepLink
15026
15458
  });
15027
15459
  }
15460
+ /**
15461
+ * Register the `p:` callback handler in the bot's middleware chain.
15462
+ *
15463
+ * Must be called during setup so grammY processes permission responses
15464
+ * before other generic callback handlers.
15465
+ */
15028
15466
  setupCallbackHandler() {
15029
15467
  this.bot.on("callback_query:data", async (ctx, next) => {
15030
15468
  const data = ctx.callbackQuery.data;
@@ -15072,6 +15510,9 @@ var init_tool_card_state = __esm({
15072
15510
  specs = [];
15073
15511
  planEntries;
15074
15512
  usage;
15513
+ // Lifecycle: active (first flush pending) → active (subsequent updates debounced) → finalized.
15514
+ // Once finalized, all updateFromSpec/updatePlan/appendUsage/finalize() calls are no-ops —
15515
+ // guards against events arriving after the session has ended or the tool has already completed.
15075
15516
  finalized = false;
15076
15517
  isFirstFlush = true;
15077
15518
  debounceTimer;
@@ -15079,6 +15520,7 @@ var init_tool_card_state = __esm({
15079
15520
  constructor(config) {
15080
15521
  this.onFlush = config.onFlush;
15081
15522
  }
15523
+ /** Adds or updates a tool spec. First call flushes immediately; subsequent calls are debounced. */
15082
15524
  updateFromSpec(spec) {
15083
15525
  if (this.finalized) return;
15084
15526
  const existingIdx = this.specs.findIndex((s) => s.id === spec.id);
@@ -15094,6 +15536,7 @@ var init_tool_card_state = __esm({
15094
15536
  this.scheduleFlush();
15095
15537
  }
15096
15538
  }
15539
+ /** Updates the plan entries displayed alongside tool cards. */
15097
15540
  updatePlan(entries) {
15098
15541
  if (this.finalized) return;
15099
15542
  this.planEntries = entries;
@@ -15104,17 +15547,20 @@ var init_tool_card_state = __esm({
15104
15547
  this.scheduleFlush();
15105
15548
  }
15106
15549
  }
15550
+ /** Appends token usage data to the tool card (typically at end of turn). */
15107
15551
  appendUsage(usage) {
15108
15552
  if (this.finalized) return;
15109
15553
  this.usage = usage;
15110
15554
  this.scheduleFlush();
15111
15555
  }
15556
+ /** Marks the turn as complete and flushes the final snapshot immediately. */
15112
15557
  finalize() {
15113
15558
  if (this.finalized) return;
15114
15559
  this.finalized = true;
15115
15560
  this.clearDebounce();
15116
15561
  this.flush();
15117
15562
  }
15563
+ /** Stops all pending flushes without emitting a final snapshot. */
15118
15564
  destroy() {
15119
15565
  this.finalized = true;
15120
15566
  this.clearDebounce();
@@ -15220,9 +15666,11 @@ var init_stream_accumulator = __esm({
15220
15666
  entry.diffStats = update.diffStats;
15221
15667
  }
15222
15668
  }
15669
+ /** Retrieves a tool entry by ID, or undefined if not yet tracked. */
15223
15670
  get(id) {
15224
15671
  return this.entries.get(id);
15225
15672
  }
15673
+ /** Resets all state between turns. */
15226
15674
  clear() {
15227
15675
  this.entries.clear();
15228
15676
  this.pendingUpdates.clear();
@@ -15231,20 +15679,24 @@ var init_stream_accumulator = __esm({
15231
15679
  ThoughtBuffer = class {
15232
15680
  chunks = [];
15233
15681
  sealed = false;
15682
+ /** Appends a thought text chunk. Ignored if already sealed. */
15234
15683
  append(chunk) {
15235
15684
  if (this.sealed) return;
15236
15685
  this.chunks.push(chunk);
15237
15686
  }
15687
+ /** Marks the thought as complete and returns the full accumulated text. */
15238
15688
  seal() {
15239
15689
  this.sealed = true;
15240
15690
  return this.chunks.join("");
15241
15691
  }
15692
+ /** Returns the text accumulated so far without sealing. */
15242
15693
  getText() {
15243
15694
  return this.chunks.join("");
15244
15695
  }
15245
15696
  isSealed() {
15246
15697
  return this.sealed;
15247
15698
  }
15699
+ /** Resets the buffer for reuse in a new turn. */
15248
15700
  reset() {
15249
15701
  this.chunks = [];
15250
15702
  this.sealed = false;
@@ -15416,6 +15868,13 @@ var init_display_spec_builder = __esm({
15416
15868
  constructor(tunnelService) {
15417
15869
  this.tunnelService = tunnelService;
15418
15870
  }
15871
+ /**
15872
+ * Builds a display spec for a single tool call entry.
15873
+ *
15874
+ * Deduplicates fields to avoid repeating the same info (e.g., if the title
15875
+ * was derived from the command, the command field is omitted). For long
15876
+ * output, generates a viewer link via the tunnel service when available.
15877
+ */
15419
15878
  buildToolSpec(entry, mode, sessionContext) {
15420
15879
  const effectiveKind = entry.displayKind ?? (isApplyPatchOtherTool(entry.kind, entry.name, entry.rawInput) ? "edit" : entry.kind);
15421
15880
  const icon = KIND_ICONS[effectiveKind] ?? KIND_ICONS["other"] ?? "\u{1F6E0}\uFE0F";
@@ -15474,6 +15933,7 @@ var init_display_spec_builder = __esm({
15474
15933
  isHidden
15475
15934
  };
15476
15935
  }
15936
+ /** Builds a display spec for an agent thought. Content is only included at high verbosity. */
15477
15937
  buildThoughtSpec(content, mode) {
15478
15938
  const indicator = "Thinking...";
15479
15939
  return {
@@ -15607,7 +16067,10 @@ var init_activity = __esm({
15607
16067
  this.sessionId = sessionId;
15608
16068
  this.state = new ToolCardState({
15609
16069
  onFlush: (snapshot) => {
15610
- this.flushPromise = this.flushPromise.then(() => this._sendOrEdit(snapshot)).catch(() => {
16070
+ this.flushPromise = this.flushPromise.then(() => {
16071
+ if (this.aborted) return;
16072
+ return this._sendOrEdit(snapshot);
16073
+ }).catch(() => {
15611
16074
  });
15612
16075
  }
15613
16076
  });
@@ -15617,6 +16080,7 @@ var init_activity = __esm({
15617
16080
  lastSentText;
15618
16081
  flushPromise = Promise.resolve();
15619
16082
  overflowMsgIds = [];
16083
+ aborted = false;
15620
16084
  tracer;
15621
16085
  sessionId;
15622
16086
  updateFromSpec(spec) {
@@ -15633,6 +16097,7 @@ var init_activity = __esm({
15633
16097
  await this.flushPromise;
15634
16098
  }
15635
16099
  destroy() {
16100
+ this.aborted = true;
15636
16101
  this.state.destroy();
15637
16102
  }
15638
16103
  hasContent() {
@@ -15864,6 +16329,13 @@ var init_send_queue = __esm({
15864
16329
  get pending() {
15865
16330
  return this.items.length;
15866
16331
  }
16332
+ /**
16333
+ * Queues an async operation for rate-limited execution.
16334
+ *
16335
+ * For text-type items with a key, replaces any existing queued item with
16336
+ * the same key (deduplication). This is used for streaming draft updates
16337
+ * where only the latest content matters.
16338
+ */
15867
16339
  enqueue(fn, opts) {
15868
16340
  const type = opts?.type ?? "other";
15869
16341
  const key = opts?.key;
@@ -15891,6 +16363,11 @@ var init_send_queue = __esm({
15891
16363
  this.scheduleProcess();
15892
16364
  return promise;
15893
16365
  }
16366
+ /**
16367
+ * Called when a platform rate limit is hit. Drops all pending text items
16368
+ * (draft updates) to reduce backlog, keeping only non-text items that
16369
+ * represent important operations (e.g., permission requests).
16370
+ */
15894
16371
  onRateLimited() {
15895
16372
  this.config.onRateLimited?.();
15896
16373
  const remaining2 = [];
@@ -15909,6 +16386,10 @@ var init_send_queue = __esm({
15909
16386
  }
15910
16387
  this.items = [];
15911
16388
  }
16389
+ /**
16390
+ * Schedules the next item for processing after the rate-limit delay.
16391
+ * Uses per-category timing when available, falling back to the global minInterval.
16392
+ */
15912
16393
  scheduleProcess() {
15913
16394
  if (this.processing) return;
15914
16395
  if (this.items.length === 0) return;
@@ -15981,6 +16462,7 @@ var init_streaming = __esm({
15981
16462
  lastSentHtml = "";
15982
16463
  displayTruncated = false;
15983
16464
  tracer;
16465
+ /** Append a text chunk to the buffer and schedule a throttled flush. */
15984
16466
  append(text5) {
15985
16467
  if (!text5) return;
15986
16468
  this.buffer += text5;
@@ -16060,6 +16542,16 @@ var init_streaming = __esm({
16060
16542
  }
16061
16543
  }
16062
16544
  }
16545
+ /**
16546
+ * Flush the complete buffer as the final message for this turn.
16547
+ *
16548
+ * Cancels any pending debounce timer, waits for in-flight flushes to settle,
16549
+ * then sends the full content. If the HTML exceeds 4096 bytes, the content is
16550
+ * split at markdown boundaries to avoid breaking HTML tags mid-tag.
16551
+ *
16552
+ * Returns the Telegram message ID of the sent/edited message, or undefined
16553
+ * if nothing was sent (empty buffer or all network calls failed).
16554
+ */
16063
16555
  async finalize() {
16064
16556
  this.tracer?.log("telegram", { action: "draft:finalize", sessionId: this.sessionId, bufferLen: this.buffer.length, msgId: this.messageId });
16065
16557
  if (this.flushTimer) {
@@ -16131,9 +16623,14 @@ var init_streaming = __esm({
16131
16623
  this.tracer?.log("telegram", { action: "draft:finalize:split", sessionId: this.sessionId, chunks: mdChunks.length });
16132
16624
  return this.messageId;
16133
16625
  }
16626
+ /** Returns the Telegram message ID for this draft, or undefined if not yet sent. */
16134
16627
  getMessageId() {
16135
16628
  return this.messageId;
16136
16629
  }
16630
+ /**
16631
+ * Strip occurrences of `pattern` from the buffer and edit the message in-place.
16632
+ * Used by the TTS plugin to remove [TTS]...[/TTS] blocks after audio is sent.
16633
+ */
16137
16634
  async stripPattern(pattern) {
16138
16635
  if (!this.messageId || !this.buffer) return;
16139
16636
  const stripped = this.buffer.replace(pattern, "").trim();
@@ -16171,6 +16668,10 @@ var init_draft_manager = __esm({
16171
16668
  drafts = /* @__PURE__ */ new Map();
16172
16669
  textBuffers = /* @__PURE__ */ new Map();
16173
16670
  finalizedDrafts = /* @__PURE__ */ new Map();
16671
+ /**
16672
+ * Return the active draft for a session, creating one if it doesn't exist yet.
16673
+ * Only one draft per session exists at a time.
16674
+ */
16174
16675
  getOrCreate(sessionId, threadId, tracer = null) {
16175
16676
  let draft = this.drafts.get(sessionId);
16176
16677
  if (!draft) {
@@ -16199,7 +16700,12 @@ var init_draft_manager = __esm({
16199
16700
  );
16200
16701
  }
16201
16702
  /**
16202
- * Finalize the current draft and return the message ID.
16703
+ * Finalize the active draft for a session and retain a short-lived reference for post-send edits.
16704
+ *
16705
+ * Removes the draft from the active map before awaiting to prevent concurrent calls from
16706
+ * double-finalizing the same draft. If the draft produces a message ID, stores it as a
16707
+ * `FinalizedDraft` so `stripPattern` (e.g. TTS block removal) can still edit the message
16708
+ * after it has been sent.
16203
16709
  */
16204
16710
  async finalize(sessionId, _assistantSessionId) {
16205
16711
  const draft = this.drafts.get(sessionId);
@@ -16226,6 +16732,12 @@ var init_draft_manager = __esm({
16226
16732
  await finalized.draft.stripPattern(pattern);
16227
16733
  }
16228
16734
  }
16735
+ /**
16736
+ * Discard all draft state for a session without sending anything.
16737
+ *
16738
+ * Removes the active draft, text buffer, and finalized draft reference. Called when a
16739
+ * session ends or is reset and any unsent content should be silently dropped.
16740
+ */
16229
16741
  cleanup(sessionId) {
16230
16742
  this.drafts.delete(sessionId);
16231
16743
  this.textBuffers.delete(sessionId);
@@ -16244,14 +16756,19 @@ var init_skill_command_manager = __esm({
16244
16756
  init_log();
16245
16757
  log28 = createChildLogger({ module: "skill-commands" });
16246
16758
  SkillCommandManager = class {
16247
- // sessionId → pinned msgId
16248
16759
  constructor(bot, chatId, sendQueue, sessionManager) {
16249
16760
  this.bot = bot;
16250
16761
  this.chatId = chatId;
16251
16762
  this.sendQueue = sendQueue;
16252
16763
  this.sessionManager = sessionManager;
16253
16764
  }
16765
+ // sessionId → Telegram message ID of the pinned skills message
16254
16766
  messages = /* @__PURE__ */ new Map();
16767
+ /**
16768
+ * Send or update the pinned skill commands message for a session.
16769
+ * Creates a new pinned message if none exists; edits the existing one otherwise.
16770
+ * Passing an empty `commands` array removes the pinned message.
16771
+ */
16255
16772
  async send(sessionId, threadId, commands) {
16256
16773
  if (!this.messages.has(sessionId)) {
16257
16774
  const record = this.sessionManager.getSessionRecord(sessionId);
@@ -16351,11 +16868,21 @@ var init_messaging_adapter = __esm({
16351
16868
  this.adapterConfig = adapterConfig;
16352
16869
  }
16353
16870
  // === Message dispatch flow ===
16871
+ /**
16872
+ * Entry point for all outbound messages from sessions to the platform.
16873
+ * Resolves the current verbosity, filters messages that should be hidden,
16874
+ * then dispatches to the appropriate type-specific handler.
16875
+ */
16354
16876
  async sendMessage(sessionId, content) {
16355
16877
  const verbosity = this.getVerbosity();
16356
16878
  if (!this.shouldDisplay(content, verbosity)) return;
16357
16879
  await this.dispatchMessage(sessionId, content, verbosity);
16358
16880
  }
16881
+ /**
16882
+ * Routes a message to its type-specific handler.
16883
+ * Subclasses can override this for custom dispatch logic, but typically
16884
+ * override individual handle* methods instead.
16885
+ */
16359
16886
  async dispatchMessage(sessionId, content, verbosity) {
16360
16887
  switch (content.type) {
16361
16888
  case "text":
@@ -16393,6 +16920,8 @@ var init_messaging_adapter = __esm({
16393
16920
  }
16394
16921
  }
16395
16922
  // === Default handlers — all protected, all overridable ===
16923
+ // Each handler is a no-op by default. Subclasses override only the message
16924
+ // types they support (e.g., Telegram overrides handleText, handleToolCall, etc.).
16396
16925
  async handleText(_sessionId, _content) {
16397
16926
  }
16398
16927
  async handleThought(_sessionId, _content, _verbosity) {
@@ -16426,6 +16955,10 @@ var init_messaging_adapter = __esm({
16426
16955
  async handleResourceLink(_sessionId, _content) {
16427
16956
  }
16428
16957
  // === Helpers ===
16958
+ /**
16959
+ * Resolves the current output verbosity by checking (in priority order):
16960
+ * per-channel config, global config, then adapter default. Falls back to "medium".
16961
+ */
16429
16962
  getVerbosity() {
16430
16963
  const config = this.context.configManager.get();
16431
16964
  const channelConfig = config.channels;
@@ -16434,6 +16967,13 @@ var init_messaging_adapter = __esm({
16434
16967
  if (v === "low" || v === "high") return v;
16435
16968
  return "medium";
16436
16969
  }
16970
+ /**
16971
+ * Determines whether a message should be displayed at the given verbosity.
16972
+ *
16973
+ * Noise filtering: tool calls matching noise rules (e.g., `ls`, `glob`, `grep`)
16974
+ * are hidden at medium/low verbosity to reduce clutter. Thoughts and usage
16975
+ * stats are hidden entirely at "low".
16976
+ */
16437
16977
  shouldDisplay(content, verbosity) {
16438
16978
  if (verbosity === "low" && HIDDEN_ON_LOW.has(content.type)) return false;
16439
16979
  if (content.type === "tool_call") {
@@ -16665,6 +17205,7 @@ var init_output_mode_resolver = __esm({
16665
17205
  "use strict";
16666
17206
  VALID_MODES = /* @__PURE__ */ new Set(["low", "medium", "high"]);
16667
17207
  OutputModeResolver = class {
17208
+ /** Resolves the effective output mode by walking the override cascade. */
16668
17209
  resolve(configManager, adapterName, sessionId, sessionManager) {
16669
17210
  const config = configManager.get();
16670
17211
  let mode = toOutputMode(config.outputMode) ?? "medium";
@@ -16763,7 +17304,11 @@ var init_adapter2 = __esm({
16763
17304
  _topicsInitialized = false;
16764
17305
  /** Background watcher timer — cancelled on stop() or when topics succeed */
16765
17306
  _prerequisiteWatcher = null;
16766
- /** Store control message ID in memory + persist to session record */
17307
+ /**
17308
+ * Persist the control message ID both in-memory and to the session record.
17309
+ * The control message is the pinned status card with bypass/TTS buttons; its ID
17310
+ * is needed after a restart to edit it when config changes.
17311
+ */
16767
17312
  storeControlMsgId(sessionId, msgId) {
16768
17313
  this.controlMsgIds.set(sessionId, msgId);
16769
17314
  const record = this.core.sessionManager.getSessionRecord(sessionId);
@@ -16828,6 +17373,12 @@ var init_adapter2 = __esm({
16828
17373
  this.telegramConfig = config;
16829
17374
  this.saveTopicIds = saveTopicIds;
16830
17375
  }
17376
+ /**
17377
+ * Set up the grammY bot, register all callback and message handlers, then perform
17378
+ * two-phase startup: Phase 1 starts polling immediately; Phase 2 checks group
17379
+ * prerequisites (bot is admin, topics are enabled) and creates/restores system topics.
17380
+ * If prerequisites are not met, a background watcher retries until they are.
17381
+ */
16831
17382
  async start() {
16832
17383
  this.bot = new Bot(this.telegramConfig.botToken, {
16833
17384
  client: {
@@ -17258,6 +17809,13 @@ OpenACP will automatically retry until this is resolved.`;
17258
17809
  };
17259
17810
  this._prerequisiteWatcher = setTimeout(retry, schedule[0]);
17260
17811
  }
17812
+ /**
17813
+ * Tear down the bot and release all associated resources.
17814
+ *
17815
+ * Cancels the background prerequisite watcher, destroys all per-session activity
17816
+ * trackers (which hold interval timers), removes eventBus listeners, clears the
17817
+ * send queue, and stops the grammY bot polling loop.
17818
+ */
17261
17819
  async stop() {
17262
17820
  if (this._prerequisiteWatcher !== null) {
17263
17821
  clearTimeout(this._prerequisiteWatcher);
@@ -17391,8 +17949,7 @@ ${lines.join("\n")}`;
17391
17949
  await this.draftManager.finalize(sessionId, assistantSession?.id);
17392
17950
  }
17393
17951
  if (sessionId) {
17394
- const tracker = this.sessionTrackers.get(sessionId);
17395
- if (tracker) await tracker.onNewPrompt();
17952
+ await this.drainAndResetTracker(sessionId);
17396
17953
  }
17397
17954
  ctx.replyWithChatAction("typing").catch(() => {
17398
17955
  });
@@ -17481,9 +18038,26 @@ ${lines.join("\n")}`;
17481
18038
  * its creation event. This queue ensures events are processed in the order they arrive.
17482
18039
  */
17483
18040
  _dispatchQueues = /* @__PURE__ */ new Map();
18041
+ /**
18042
+ * Drain pending event dispatches from the previous prompt, then reset the
18043
+ * activity tracker so late tool_call events don't leak into the new card.
18044
+ */
18045
+ async drainAndResetTracker(sessionId) {
18046
+ const pendingDispatch = this._dispatchQueues.get(sessionId);
18047
+ if (pendingDispatch) await pendingDispatch;
18048
+ const tracker = this.sessionTrackers.get(sessionId);
18049
+ if (tracker) await tracker.onNewPrompt();
18050
+ }
17484
18051
  getTracer(sessionId) {
17485
18052
  return this.core.sessionManager.getSession(sessionId)?.agentInstance?.debugTracer ?? null;
17486
18053
  }
18054
+ /**
18055
+ * Primary outbound dispatch method — routes an agent message to the session's Telegram topic.
18056
+ *
18057
+ * Wraps the base class `sendMessage` in a per-session promise chain (`_dispatchQueues`)
18058
+ * so that concurrent events fired from SessionBridge are serialized and delivered in the
18059
+ * order they arrive, preventing fast handlers from overtaking slower ones.
18060
+ */
17487
18061
  async sendMessage(sessionId, content) {
17488
18062
  const session = this.core.sessionManager.getSession(sessionId);
17489
18063
  if (!session) return;
@@ -17797,6 +18371,11 @@ Task completed.
17797
18371
  )
17798
18372
  );
17799
18373
  }
18374
+ /**
18375
+ * Render a PermissionRequest as an inline keyboard in the session topic and
18376
+ * notify the Notifications topic. Runs inside a sendQueue item, so
18377
+ * notification is fire-and-forget to avoid deadlock.
18378
+ */
17800
18379
  async sendPermissionRequest(sessionId, request) {
17801
18380
  this.getTracer(sessionId)?.log("telegram", { action: "permission:send", sessionId, requestId: request.id, description: request.description });
17802
18381
  log29.info({ sessionId, requestId: request.id }, "Permission request sent");
@@ -17806,6 +18385,11 @@ Task completed.
17806
18385
  () => this.permissionHandler.sendPermissionRequest(session, request)
17807
18386
  );
17808
18387
  }
18388
+ /**
18389
+ * Post a notification to the Notifications topic.
18390
+ * Assistant session notifications are suppressed — the assistant topic is
18391
+ * the user's primary interface and does not need a separate alert.
18392
+ */
17809
18393
  async sendNotification(notification) {
17810
18394
  this.getTracer(notification.sessionId)?.log("telegram", { action: "notification:send", sessionId: notification.sessionId, type: notification.type });
17811
18395
  if (notification.sessionId === this.core.assistantManager?.get("telegram")?.id) return;
@@ -17846,6 +18430,10 @@ Task completed.
17846
18430
  })
17847
18431
  );
17848
18432
  }
18433
+ /**
18434
+ * Create a new Telegram forum topic for a session and return its thread ID as a string.
18435
+ * Called by the core when a session is created via the API or CLI (not from the Telegram UI).
18436
+ */
17849
18437
  async createSessionThread(sessionId, name) {
17850
18438
  this.getTracer(sessionId)?.log("telegram", { action: "thread:create", sessionId, name });
17851
18439
  log29.info({ sessionId, name }, "Session topic created");
@@ -17853,6 +18441,10 @@ Task completed.
17853
18441
  await createSessionTopic(this.bot, this.telegramConfig.chatId, name)
17854
18442
  );
17855
18443
  }
18444
+ /**
18445
+ * Rename the forum topic for a session and update the session record's display name.
18446
+ * No-ops silently if the session doesn't have a threadId yet (e.g. still initializing).
18447
+ */
17856
18448
  async renameSessionThread(sessionId, newName) {
17857
18449
  this.getTracer(sessionId)?.log("telegram", { action: "thread:rename", sessionId, newName });
17858
18450
  const session = this.core.sessionManager.getSession(sessionId);
@@ -17870,6 +18462,7 @@ Task completed.
17870
18462
  );
17871
18463
  await this.core.sessionManager.patchRecord(sessionId, { name: newName });
17872
18464
  }
18465
+ /** Delete the forum topic associated with a session. */
17873
18466
  async deleteSessionThread(sessionId) {
17874
18467
  const record = this.core.sessionManager.getSessionRecord(sessionId);
17875
18468
  const platform2 = record?.platform;
@@ -17884,6 +18477,11 @@ Task completed.
17884
18477
  );
17885
18478
  }
17886
18479
  }
18480
+ /**
18481
+ * Display or update the pinned skill commands message for a session.
18482
+ * If the session's threadId is not yet set (e.g. session created from API),
18483
+ * the commands are queued and flushed once the thread becomes available.
18484
+ */
17887
18485
  async sendSkillCommands(sessionId, commands) {
17888
18486
  if (sessionId === this.core.assistantManager?.get("telegram")?.id) return;
17889
18487
  const session = this.core.sessionManager.getSession(sessionId);
@@ -17968,7 +18566,10 @@ Task completed.
17968
18566
  return;
17969
18567
  }
17970
18568
  const sid = await this.resolveSessionId(threadId);
17971
- if (sid) await this.draftManager.finalize(sid, this.core.assistantManager?.get("telegram")?.id);
18569
+ if (sid) {
18570
+ await this.draftManager.finalize(sid, this.core.assistantManager?.get("telegram")?.id);
18571
+ await this.drainAndResetTracker(sid);
18572
+ }
17972
18573
  this.core.handleMessage({
17973
18574
  channelId: "telegram",
17974
18575
  threadId: String(threadId),
@@ -17977,10 +18578,24 @@ Task completed.
17977
18578
  attachments: [att]
17978
18579
  }).catch((err) => log29.error({ err }, "handleMessage error"));
17979
18580
  }
18581
+ /**
18582
+ * Remove skill slash commands from the Telegram bot command list for a session.
18583
+ *
18584
+ * Clears any queued pending commands that hadn't been sent yet, then delegates
18585
+ * to `SkillCommandManager` to delete the commands from the Telegram API. Called
18586
+ * when a session with registered skill commands ends.
18587
+ */
17980
18588
  async cleanupSkillCommands(sessionId) {
17981
18589
  this._pendingSkillCommands.delete(sessionId);
17982
18590
  await this.skillManager.cleanup(sessionId);
17983
18591
  }
18592
+ /**
18593
+ * Clean up all adapter state associated with a session.
18594
+ *
18595
+ * Finalizes and discards any in-flight draft, destroys the activity tracker
18596
+ * (stopping ThinkingIndicator timers and finalizing any open ToolCard), and
18597
+ * clears pending skill commands. Called when a session ends or is reset.
18598
+ */
17984
18599
  async cleanupSessionState(sessionId) {
17985
18600
  this._pendingSkillCommands.delete(sessionId);
17986
18601
  await this.draftManager.finalize(sessionId, this.core.assistantManager?.get("telegram")?.id);
@@ -17991,9 +18606,22 @@ Task completed.
17991
18606
  this.sessionTrackers.delete(sessionId);
17992
18607
  }
17993
18608
  }
18609
+ /**
18610
+ * Remove `[TTS]...[/TTS]` blocks from the active or finalized draft for a session.
18611
+ *
18612
+ * The agent embeds these blocks so the speech plugin can extract the TTS text, but
18613
+ * they should never appear in the chat message. Called after TTS audio has been sent.
18614
+ */
17994
18615
  async stripTTSBlock(sessionId) {
17995
18616
  await this.draftManager.stripPattern(sessionId, /\[TTS\][\s\S]*?\[\/TTS\]/g);
17996
18617
  }
18618
+ /**
18619
+ * Archive a session by deleting its forum topic.
18620
+ *
18621
+ * Sets `session.archiving = true` to suppress any outgoing messages while the
18622
+ * topic is being torn down, finalizes pending drafts, cleans up all trackers,
18623
+ * then deletes the Telegram topic (which removes all messages).
18624
+ */
17997
18625
  async archiveSessionTopic(sessionId) {
17998
18626
  this.getTracer(sessionId)?.log("telegram", { action: "thread:archive", sessionId });
17999
18627
  const core = this.core;
@@ -19022,13 +19650,18 @@ var init_path_guard = __esm({
19022
19650
  "src/core/security/path-guard.ts"() {
19023
19651
  "use strict";
19024
19652
  DEFAULT_DENY_PATTERNS = [
19653
+ // Environment files — contain API keys, database URLs, etc.
19025
19654
  ".env",
19026
19655
  ".env.*",
19656
+ // Cryptographic keys
19027
19657
  "*.key",
19028
19658
  "*.pem",
19659
+ // SSH and cloud credentials
19029
19660
  ".ssh/",
19030
19661
  ".aws/",
19662
+ // OpenACP workspace — contains bot tokens and secrets
19031
19663
  ".openacp/",
19664
+ // Generic credential/secret files
19032
19665
  "**/credentials*",
19033
19666
  "**/secrets*",
19034
19667
  "**/*.secret"
@@ -19056,6 +19689,20 @@ var init_path_guard = __esm({
19056
19689
  this.ig.add(options.ignorePatterns);
19057
19690
  }
19058
19691
  }
19692
+ /**
19693
+ * Checks whether an agent is allowed to access the given path.
19694
+ *
19695
+ * Validation order:
19696
+ * 1. Write to .openacpignore is always blocked (prevents agents from weakening their own restrictions)
19697
+ * 2. Path must be within cwd or an explicitly allowlisted path
19698
+ * 3. If within cwd but not allowlisted, path must not match any deny pattern
19699
+ *
19700
+ * @param targetPath - The path the agent is attempting to access.
19701
+ * @param operation - The operation type. Write operations are subject to stricter
19702
+ * restrictions than reads — specifically, writing to `.openacpignore` is blocked
19703
+ * (to prevent agents from weakening their own restrictions), while reading it is allowed.
19704
+ * @returns `{ allowed: true }` or `{ allowed: false, reason: "..." }`
19705
+ */
19059
19706
  validatePath(targetPath, operation) {
19060
19707
  const resolved = path43.resolve(targetPath);
19061
19708
  let realPath;
@@ -19091,6 +19738,7 @@ var init_path_guard = __esm({
19091
19738
  }
19092
19739
  return { allowed: true, reason: "" };
19093
19740
  }
19741
+ /** Adds an additional allowed path at runtime (e.g. for file-service uploads). */
19094
19742
  addAllowedPath(p2) {
19095
19743
  try {
19096
19744
  this.allowedPaths.push(fs38.realpathSync(path43.resolve(p2)));
@@ -19098,6 +19746,10 @@ var init_path_guard = __esm({
19098
19746
  this.allowedPaths.push(path43.resolve(p2));
19099
19747
  }
19100
19748
  }
19749
+ /**
19750
+ * Loads additional deny patterns from .openacpignore in the workspace root.
19751
+ * Follows .gitignore syntax — blank lines and lines starting with # are skipped.
19752
+ */
19101
19753
  static loadIgnoreFile(cwd) {
19102
19754
  const ignorePath = path43.join(cwd, ".openacpignore");
19103
19755
  try {
@@ -19137,6 +19789,7 @@ var init_env_filter = __esm({
19137
19789
  "src/core/security/env-filter.ts"() {
19138
19790
  "use strict";
19139
19791
  DEFAULT_ENV_WHITELIST = [
19792
+ // Shell basics — agents need these to resolve commands and write temp files
19140
19793
  "PATH",
19141
19794
  "HOME",
19142
19795
  "SHELL",
@@ -19149,11 +19802,11 @@ var init_env_filter = __esm({
19149
19802
  "XDG_*",
19150
19803
  "NODE_ENV",
19151
19804
  "EDITOR",
19152
- // Git
19805
+ // Git — agents need git config and SSH access for code operations
19153
19806
  "GIT_*",
19154
19807
  "SSH_AUTH_SOCK",
19155
19808
  "SSH_AGENT_PID",
19156
- // Terminal rendering
19809
+ // Terminal rendering — ensures correct color output in agent responses
19157
19810
  "COLORTERM",
19158
19811
  "FORCE_COLOR",
19159
19812
  "NO_COLOR",
@@ -19221,12 +19874,14 @@ var init_stderr_capture = __esm({
19221
19874
  this.maxLines = maxLines;
19222
19875
  }
19223
19876
  lines = [];
19877
+ /** Append a chunk of stderr output, splitting on newlines and trimming to maxLines. */
19224
19878
  append(chunk) {
19225
19879
  this.lines.push(...chunk.split("\n").filter(Boolean));
19226
19880
  if (this.lines.length > this.maxLines) {
19227
19881
  this.lines = this.lines.slice(-this.maxLines);
19228
19882
  }
19229
19883
  }
19884
+ /** Return all captured lines joined as a single string. */
19230
19885
  getLastLines() {
19231
19886
  return this.lines.join("\n");
19232
19887
  }
@@ -19245,6 +19900,7 @@ var init_typed_emitter = __esm({
19245
19900
  listeners = /* @__PURE__ */ new Map();
19246
19901
  paused = false;
19247
19902
  buffer = [];
19903
+ /** Register a listener for the given event. Returns `this` for chaining. */
19248
19904
  on(event, listener) {
19249
19905
  let set = this.listeners.get(event);
19250
19906
  if (!set) {
@@ -19254,10 +19910,17 @@ var init_typed_emitter = __esm({
19254
19910
  set.add(listener);
19255
19911
  return this;
19256
19912
  }
19913
+ /** Remove a specific listener for the given event. */
19257
19914
  off(event, listener) {
19258
19915
  this.listeners.get(event)?.delete(listener);
19259
19916
  return this;
19260
19917
  }
19918
+ /**
19919
+ * Emit an event to all registered listeners.
19920
+ *
19921
+ * When paused, events are buffered (up to MAX_BUFFER_SIZE) unless
19922
+ * the passthrough filter allows them through immediately.
19923
+ */
19261
19924
  emit(event, ...args2) {
19262
19925
  if (this.paused) {
19263
19926
  if (this.passthroughFn?.(event, args2)) {
@@ -19301,6 +19964,7 @@ var init_typed_emitter = __esm({
19301
19964
  get bufferSize() {
19302
19965
  return this.buffer.length;
19303
19966
  }
19967
+ /** Remove all listeners for a specific event, or all events if none specified. */
19304
19968
  removeAllListeners(event) {
19305
19969
  if (event) {
19306
19970
  this.listeners.delete(event);
@@ -19308,6 +19972,7 @@ var init_typed_emitter = __esm({
19308
19972
  this.listeners.clear();
19309
19973
  }
19310
19974
  }
19975
+ /** Deliver an event to listeners, isolating errors so one broken listener doesn't break others. */
19311
19976
  deliver(event, args2) {
19312
19977
  const set = this.listeners.get(event);
19313
19978
  if (!set) return;
@@ -19379,6 +20044,11 @@ var init_terminal_manager = __esm({
19379
20044
  constructor(maxOutputBytes = 1024 * 1024) {
19380
20045
  this.maxOutputBytes = maxOutputBytes;
19381
20046
  }
20047
+ /**
20048
+ * Spawn a new terminal process. Runs terminal:beforeCreate middleware first
20049
+ * (which can modify command/args/env or block creation entirely).
20050
+ * Returns a terminalId for subsequent output/wait/kill operations.
20051
+ */
19382
20052
  async createTerminal(sessionId, params, middlewareChain) {
19383
20053
  let termCommand = params.command;
19384
20054
  let termArgs = params.args ?? [];
@@ -19460,6 +20130,7 @@ var init_terminal_manager = __esm({
19460
20130
  });
19461
20131
  return { terminalId };
19462
20132
  }
20133
+ /** Retrieve accumulated stdout/stderr output for a terminal. */
19463
20134
  getOutput(terminalId) {
19464
20135
  const state = this.terminals.get(terminalId);
19465
20136
  if (!state) {
@@ -19474,6 +20145,7 @@ var init_terminal_manager = __esm({
19474
20145
  } : void 0
19475
20146
  };
19476
20147
  }
20148
+ /** Block until the terminal process exits, returning exit code and signal. */
19477
20149
  async waitForExit(terminalId) {
19478
20150
  const state = this.terminals.get(terminalId);
19479
20151
  if (!state) {
@@ -19497,6 +20169,7 @@ var init_terminal_manager = __esm({
19497
20169
  }
19498
20170
  });
19499
20171
  }
20172
+ /** Send SIGTERM to a terminal process (graceful shutdown). */
19500
20173
  kill(terminalId) {
19501
20174
  const state = this.terminals.get(terminalId);
19502
20175
  if (!state) {
@@ -19504,6 +20177,7 @@ var init_terminal_manager = __esm({
19504
20177
  }
19505
20178
  state.process.kill("SIGTERM");
19506
20179
  }
20180
+ /** Force-kill (SIGKILL) and immediately remove a terminal from the registry. */
19507
20181
  release(terminalId) {
19508
20182
  const state = this.terminals.get(terminalId);
19509
20183
  if (!state) {
@@ -19512,6 +20186,7 @@ var init_terminal_manager = __esm({
19512
20186
  state.process.kill("SIGKILL");
19513
20187
  this.terminals.delete(terminalId);
19514
20188
  }
20189
+ /** Force-kill all terminals. Used during session/system teardown. */
19515
20190
  destroyAll() {
19516
20191
  for (const [, t] of this.terminals) {
19517
20192
  t.process.kill("SIGKILL");
@@ -19559,6 +20234,12 @@ var init_debug_tracer = __esm({
19559
20234
  }
19560
20235
  dirCreated = false;
19561
20236
  logDir;
20237
+ /**
20238
+ * Write a timestamped JSONL entry to the trace file for the given layer.
20239
+ *
20240
+ * Handles circular references gracefully and silently swallows errors —
20241
+ * debug logging must never crash the application.
20242
+ */
19562
20243
  log(layer, data) {
19563
20244
  try {
19564
20245
  if (!this.dirCreated) {
@@ -19716,9 +20397,13 @@ var init_agent_instance = __esm({
19716
20397
  connection;
19717
20398
  child;
19718
20399
  stderrCapture;
20400
+ /** Manages terminal subprocesses that agents can spawn for shell commands. */
19719
20401
  terminalManager = new TerminalManager();
20402
+ /** Shared across all instances — resolves MCP server configs for ACP sessions. */
19720
20403
  static mcpManager = new McpManager();
20404
+ /** Guards against emitting crash events during intentional shutdown. */
19721
20405
  _destroying = false;
20406
+ /** Restricts agent file I/O to the workspace directory and explicitly allowed paths. */
19722
20407
  pathGuard;
19723
20408
  sessionId;
19724
20409
  agentName;
@@ -19728,16 +20413,36 @@ var init_agent_instance = __esm({
19728
20413
  initialSessionResponse;
19729
20414
  middlewareChain;
19730
20415
  debugTracer = null;
19731
- /** Allow external callers (e.g. SessionFactory) to whitelist additional read paths */
20416
+ /**
20417
+ * Whitelist an additional filesystem path for agent read access.
20418
+ *
20419
+ * Called by SessionFactory to allow agents to read files outside the
20420
+ * workspace (e.g., the file-service upload directory for attachments).
20421
+ */
19732
20422
  addAllowedPath(p2) {
19733
20423
  this.pathGuard.addAllowedPath(p2);
19734
20424
  }
19735
- // Callback — set by core when wiring events
20425
+ // Callback — set by Session/Core when wiring events. Returns the selected
20426
+ // permission option ID. Default no-op auto-selects the first option.
19736
20427
  onPermissionRequest = async () => "";
19737
20428
  constructor(agentName) {
19738
20429
  super();
19739
20430
  this.agentName = agentName;
19740
20431
  }
20432
+ /**
20433
+ * Spawn the agent child process and complete the ACP protocol handshake.
20434
+ *
20435
+ * Steps:
20436
+ * 1. Resolve the agent command to a directly executable path
20437
+ * 2. Create a PathGuard scoped to the working directory
20438
+ * 3. Spawn the subprocess with a filtered environment (security: only whitelisted
20439
+ * env vars are passed to prevent leaking secrets like API keys)
20440
+ * 4. Wire stdin/stdout through debug-tracing Transform streams
20441
+ * 5. Convert Node streams → Web streams for the ACP SDK
20442
+ * 6. Perform the ACP `initialize` handshake and negotiate capabilities
20443
+ *
20444
+ * Does NOT create a session — callers must follow up with newSession or loadSession.
20445
+ */
19741
20446
  static async spawnSubprocess(agentDef, workingDirectory, allowedPaths = []) {
19742
20447
  const instance = new _AgentInstance(agentDef.name);
19743
20448
  const resolved = resolveAgentCommand(agentDef.command);
@@ -19836,6 +20541,13 @@ var init_agent_instance = __esm({
19836
20541
  );
19837
20542
  return instance;
19838
20543
  }
20544
+ /**
20545
+ * Monitor the subprocess for unexpected exits and emit error events.
20546
+ *
20547
+ * Distinguishes intentional shutdown (SIGTERM/SIGINT during destroy) from
20548
+ * crashes (non-zero exit code or unexpected signal). Crash events include
20549
+ * captured stderr output for diagnostic context.
20550
+ */
19839
20551
  setupCrashDetection() {
19840
20552
  this.child.on("exit", (code, signal) => {
19841
20553
  if (this._destroying) return;
@@ -19858,6 +20570,18 @@ ${stderr}`
19858
20570
  log33.debug({ sessionId: this.sessionId }, "ACP connection closed");
19859
20571
  });
19860
20572
  }
20573
+ /**
20574
+ * Spawn a new agent subprocess and create a fresh ACP session.
20575
+ *
20576
+ * This is the primary entry point for starting an agent. It spawns the
20577
+ * subprocess, completes the ACP handshake, and calls `newSession` to
20578
+ * initialize the agent's working context (cwd, MCP servers).
20579
+ *
20580
+ * @param agentDef - Agent definition (command, args, env) from the catalog
20581
+ * @param workingDirectory - Workspace root the agent operates in
20582
+ * @param mcpServers - Optional MCP server configs to extend agent capabilities
20583
+ * @param allowedPaths - Extra filesystem paths the agent may access
20584
+ */
19861
20585
  static async spawn(agentDef, workingDirectory, mcpServers, allowedPaths) {
19862
20586
  log33.debug(
19863
20587
  { agentName: agentDef.name, command: agentDef.command },
@@ -19891,6 +20615,15 @@ ${stderr}`
19891
20615
  );
19892
20616
  return instance;
19893
20617
  }
20618
+ /**
20619
+ * Spawn a new subprocess and restore an existing agent session.
20620
+ *
20621
+ * Tries loadSession first (preferred, stable API), falls back to the
20622
+ * unstable resumeSession, and finally falls back to creating a brand-new
20623
+ * session if resume fails entirely (e.g., agent lost its state).
20624
+ *
20625
+ * @param agentSessionId - The agent-side session ID to restore
20626
+ */
19894
20627
  static async resume(agentDef, workingDirectory, agentSessionId, mcpServers, allowedPaths) {
19895
20628
  log33.debug({ agentName: agentDef.name, agentSessionId }, "Resuming agent");
19896
20629
  const spawnStart = Date.now();
@@ -19957,12 +20690,26 @@ ${stderr}`
19957
20690
  instance.setupCrashDetection();
19958
20691
  return instance;
19959
20692
  }
19960
- // createClient — implemented in Task 6b
20693
+ /**
20694
+ * Build the ACP Client callback object.
20695
+ *
20696
+ * The ACP SDK invokes these callbacks when the agent sends notifications
20697
+ * or requests. Each callback maps an ACP protocol message to either:
20698
+ * - An internal AgentEvent (emitted for Session/adapters to consume)
20699
+ * - A filesystem or terminal operation (executed on the agent's behalf)
20700
+ * - A permission request (proxied to the user via the adapter)
20701
+ */
19961
20702
  createClient(_agent) {
19962
20703
  const self = this;
19963
20704
  const MAX_OUTPUT_BYTES = 1024 * 1024;
19964
20705
  return {
19965
20706
  // ── Session updates ──────────────────────────────────────────────────
20707
+ // The agent streams its response as a series of session update events.
20708
+ // Each event type maps to an internal AgentEvent that Session relays
20709
+ // to adapters for rendering (text chunks, tool calls, usage stats, etc.).
20710
+ // Chunks are forwarded to Session individually as they arrive — no buffering
20711
+ // happens at this layer. If buffering is needed (e.g., to avoid rate limits),
20712
+ // it is the responsibility of the Session or adapter layer.
19966
20713
  async sessionUpdate(params) {
19967
20714
  const update = params.update;
19968
20715
  let event = null;
@@ -20091,6 +20838,10 @@ ${stderr}`
20091
20838
  }
20092
20839
  },
20093
20840
  // ── Permission requests ──────────────────────────────────────────────
20841
+ // The agent needs user approval before performing sensitive operations
20842
+ // (e.g., file writes, shell commands). This proxies the request up
20843
+ // through Session → PermissionGate → adapter → user, then returns
20844
+ // the user's chosen option ID back to the agent.
20094
20845
  async requestPermission(params) {
20095
20846
  const permissionRequest = {
20096
20847
  id: params.toolCall.toolCallId,
@@ -20107,6 +20858,9 @@ ${stderr}`
20107
20858
  };
20108
20859
  },
20109
20860
  // ── File operations ──────────────────────────────────────────────────
20861
+ // The agent reads/writes files through these callbacks rather than
20862
+ // accessing the filesystem directly. This allows PathGuard to enforce
20863
+ // workspace boundaries and middleware hooks to intercept I/O.
20110
20864
  async readTextFile(params) {
20111
20865
  const p2 = params;
20112
20866
  const pathCheck = self.pathGuard.validatePath(p2.path, "read");
@@ -20142,6 +20896,8 @@ ${stderr}`
20142
20896
  return {};
20143
20897
  },
20144
20898
  // ── Terminal operations (delegated to TerminalManager) ─────────────
20899
+ // Agents can spawn shell commands via terminal operations. TerminalManager
20900
+ // handles subprocess lifecycle, output capture, and byte-limit enforcement.
20145
20901
  async createTerminal(params) {
20146
20902
  return self.terminalManager.createTerminal(
20147
20903
  self.sessionId,
@@ -20171,6 +20927,13 @@ ${stderr}`
20171
20927
  };
20172
20928
  }
20173
20929
  // ── New ACP methods ──────────────────────────────────────────────────
20930
+ /**
20931
+ * Update a session config option (mode, model, etc.) on the agent.
20932
+ *
20933
+ * Falls back to legacy `setSessionMode`/`unstable_setSessionModel` methods
20934
+ * for agents that haven't adopted the unified `session/set_config_option`
20935
+ * ACP method (detected via JSON-RPC -32601 "Method Not Found" error).
20936
+ */
20174
20937
  async setConfigOption(configId, value) {
20175
20938
  try {
20176
20939
  return await this.connection.setSessionConfigOption({
@@ -20198,12 +20961,14 @@ ${stderr}`
20198
20961
  throw err;
20199
20962
  }
20200
20963
  }
20964
+ /** List the agent's known sessions, optionally filtered by working directory. */
20201
20965
  async listSessions(cwd, cursor) {
20202
20966
  return await this.connection.listSessions({
20203
20967
  cwd: cwd ?? null,
20204
20968
  cursor: cursor ?? null
20205
20969
  });
20206
20970
  }
20971
+ /** Load an existing agent session by ID into this subprocess. */
20207
20972
  async loadSession(sessionId, cwd, mcpServers) {
20208
20973
  const resolvedMcp = _AgentInstance.mcpManager.resolve(mcpServers);
20209
20974
  return await this.connection.loadSession({
@@ -20212,9 +20977,11 @@ ${stderr}`
20212
20977
  mcpServers: resolvedMcp
20213
20978
  });
20214
20979
  }
20980
+ /** Trigger agent-managed authentication (e.g., OAuth flow). */
20215
20981
  async authenticate(methodId) {
20216
20982
  await this.connection.authenticate({ methodId });
20217
20983
  }
20984
+ /** Fork an existing session, creating a new branch with shared history. */
20218
20985
  async forkSession(sessionId, cwd, mcpServers) {
20219
20986
  const resolvedMcp = _AgentInstance.mcpManager.resolve(mcpServers);
20220
20987
  return await this.connection.unstable_forkSession({
@@ -20223,10 +20990,25 @@ ${stderr}`
20223
20990
  mcpServers: resolvedMcp
20224
20991
  });
20225
20992
  }
20993
+ /** Close a session on the agent side (cleanup agent-internal state). */
20226
20994
  async closeSession(sessionId) {
20227
20995
  await this.connection.unstable_closeSession({ sessionId });
20228
20996
  }
20229
20997
  // ── Prompt & lifecycle ──────────────────────────────────────────────
20998
+ /**
20999
+ * Send a user prompt to the agent and wait for the complete response.
21000
+ *
21001
+ * Builds ACP content blocks from the text and any attachments (images
21002
+ * are base64-encoded if the agent supports them, otherwise appended as
21003
+ * file paths). The promise resolves when the agent finishes responding;
21004
+ * streaming events arrive via the `agent_event` emitter during execution.
21005
+ *
21006
+ * Attachments that exceed size limits or use unsupported formats are
21007
+ * skipped with a note appended to the prompt text.
21008
+ *
21009
+ * Call `cancel()` to interrupt a running prompt; the agent will stop and
21010
+ * the promise resolves with partial results.
21011
+ */
20230
21012
  async prompt(text5, attachments) {
20231
21013
  const contentBlocks = [{ type: "text", text: text5 }];
20232
21014
  const capabilities = this.promptCapabilities ?? {};
@@ -20276,9 +21058,18 @@ ${skipNote}`;
20276
21058
  prompt: contentBlocks
20277
21059
  });
20278
21060
  }
21061
+ /** Cancel the currently running prompt. The agent should stop and return partial results. */
20279
21062
  async cancel() {
20280
21063
  await this.connection.cancel({ sessionId: this.sessionId });
20281
21064
  }
21065
+ /**
21066
+ * Gracefully shut down the agent subprocess.
21067
+ *
21068
+ * Sends SIGTERM first, giving the agent up to 10 seconds to clean up.
21069
+ * If the process hasn't exited by then, SIGKILL forces termination.
21070
+ * The timer is unref'd so it doesn't keep the Node process alive
21071
+ * during shutdown.
21072
+ */
20282
21073
  async destroy() {
20283
21074
  this._destroying = true;
20284
21075
  this.terminalManager.destroyAll();
@@ -20313,6 +21104,7 @@ var init_agent_manager = __esm({
20313
21104
  constructor(catalog) {
20314
21105
  this.catalog = catalog;
20315
21106
  }
21107
+ /** Return definitions for all installed agents. */
20316
21108
  getAvailableAgents() {
20317
21109
  const installed = this.catalog.getInstalledEntries();
20318
21110
  return Object.entries(installed).map(([key, agent]) => ({
@@ -20322,14 +21114,25 @@ var init_agent_manager = __esm({
20322
21114
  env: agent.env
20323
21115
  }));
20324
21116
  }
21117
+ /** Look up a single agent definition by its short name (e.g., "claude", "gemini"). */
20325
21118
  getAgent(name) {
20326
21119
  return this.catalog.resolve(name);
20327
21120
  }
21121
+ /**
21122
+ * Spawn a new agent subprocess with a fresh session.
21123
+ *
21124
+ * @throws If the agent is not installed — includes install instructions in the error message.
21125
+ */
20328
21126
  async spawn(agentName, workingDirectory, allowedPaths) {
20329
21127
  const agentDef = this.getAgent(agentName);
20330
21128
  if (!agentDef) throw new Error(`Agent "${agentName}" is not installed. Run "openacp agents install ${agentName}" to add it.`);
20331
21129
  return AgentInstance.spawn(agentDef, workingDirectory, void 0, allowedPaths);
20332
21130
  }
21131
+ /**
21132
+ * Spawn a subprocess and resume an existing agent session.
21133
+ *
21134
+ * Falls back to a new session if the agent cannot restore the given session ID.
21135
+ */
20333
21136
  async resume(agentName, workingDirectory, agentSessionId, allowedPaths) {
20334
21137
  const agentDef = this.getAgent(agentName);
20335
21138
  if (!agentDef) throw new Error(`Agent "${agentName}" is not installed. Run "openacp agents install ${agentName}" to add it.`);
@@ -20354,6 +21157,11 @@ var init_prompt_queue = __esm({
20354
21157
  abortController = null;
20355
21158
  /** Set when abort is triggered; drainNext waits for the current processor to settle before starting the next item. */
20356
21159
  processorSettled = null;
21160
+ /**
21161
+ * Add a prompt to the queue. If no prompt is currently processing, it runs
21162
+ * immediately. Otherwise, it's buffered and the returned promise resolves
21163
+ * only after the prompt finishes processing.
21164
+ */
20357
21165
  async enqueue(text5, attachments, routing, turnId) {
20358
21166
  if (this.processing) {
20359
21167
  return new Promise((resolve9) => {
@@ -20362,6 +21170,7 @@ var init_prompt_queue = __esm({
20362
21170
  }
20363
21171
  await this.process(text5, attachments, routing, turnId);
20364
21172
  }
21173
+ /** Run a single prompt through the processor, then drain the next queued item. */
20365
21174
  async process(text5, attachments, routing, turnId) {
20366
21175
  this.processing = true;
20367
21176
  this.abortController = new AbortController();
@@ -20389,12 +21198,17 @@ var init_prompt_queue = __esm({
20389
21198
  this.drainNext();
20390
21199
  }
20391
21200
  }
21201
+ /** Dequeue and process the next pending prompt, if any. Called after each prompt completes. */
20392
21202
  drainNext() {
20393
21203
  const next = this.queue.shift();
20394
21204
  if (next) {
20395
21205
  this.process(next.text, next.attachments, next.routing, next.turnId).then(next.resolve);
20396
21206
  }
20397
21207
  }
21208
+ /**
21209
+ * Abort the in-flight prompt and discard all queued prompts.
21210
+ * Pending promises are resolved (not rejected) so callers don't see unhandled rejections.
21211
+ */
20398
21212
  clear() {
20399
21213
  if (this.abortController) {
20400
21214
  this.abortController.abort();
@@ -20430,6 +21244,10 @@ var init_permission_gate = __esm({
20430
21244
  constructor(timeoutMs) {
20431
21245
  this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
20432
21246
  }
21247
+ /**
21248
+ * Register a new permission request and return a promise that resolves with the
21249
+ * chosen option ID when the user responds, or rejects on timeout / supersession.
21250
+ */
20433
21251
  setPending(request) {
20434
21252
  if (!this.settled && this.rejectFn) {
20435
21253
  this.rejectFn(new Error("Superseded by new permission request"));
@@ -20448,6 +21266,7 @@ var init_permission_gate = __esm({
20448
21266
  }
20449
21267
  });
20450
21268
  }
21269
+ /** Approve the pending request with the given option ID. No-op if already settled. */
20451
21270
  resolve(optionId) {
20452
21271
  if (this.settled || !this.resolveFn) return;
20453
21272
  this.settled = true;
@@ -20455,6 +21274,7 @@ var init_permission_gate = __esm({
20455
21274
  this.resolveFn(optionId);
20456
21275
  this.cleanup();
20457
21276
  }
21277
+ /** Deny the pending request. No-op if already settled. */
20458
21278
  reject(reason) {
20459
21279
  if (this.settled || !this.rejectFn) return;
20460
21280
  this.settled = true;
@@ -20563,6 +21383,8 @@ Additionally, include a [TTS]...[/TTS] block with a spoken-friendly summary of y
20563
21383
  get agentInstance() {
20564
21384
  return this._agentInstance;
20565
21385
  }
21386
+ /** Setting agentInstance wires the agent→session event relay and commands buffer.
21387
+ * This happens both at construction and on agent switch (switchAgent). */
20566
21388
  set agentInstance(agent) {
20567
21389
  this._agentInstance = agent;
20568
21390
  this.wireAgentRelay();
@@ -20665,7 +21487,7 @@ Additionally, include a [TTS]...[/TTS] block with a spoken-friendly summary of y
20665
21487
  this.transition("finished");
20666
21488
  this.emit(SessionEv.SESSION_END, reason ?? "completed");
20667
21489
  }
20668
- /** Transition to cancelled — from active only (terminal session cancel) */
21490
+ /** Transition to cancelled — from active or error (terminal session cancel) */
20669
21491
  markCancelled() {
20670
21492
  this.transition("cancelled");
20671
21493
  }
@@ -20685,19 +21507,29 @@ Additionally, include a [TTS]...[/TTS] block with a spoken-friendly summary of y
20685
21507
  get queueDepth() {
20686
21508
  return this.queue.pending;
20687
21509
  }
21510
+ /** Whether a prompt is currently being processed by the agent */
20688
21511
  get promptRunning() {
20689
21512
  return this.queue.isProcessing;
20690
21513
  }
20691
21514
  // --- Context Injection ---
21515
+ /** Store context markdown to be prepended to the next prompt (used for session resume with history). */
20692
21516
  setContext(markdown) {
20693
21517
  this.pendingContext = markdown;
20694
21518
  }
20695
21519
  // --- Voice Mode ---
21520
+ /** Set TTS mode: "off" = disabled, "next" = one-shot (auto-resets after prompt), "on" = persistent. */
20696
21521
  setVoiceMode(mode) {
20697
21522
  this.voiceMode = mode;
20698
21523
  this.log.info({ voiceMode: mode }, "TTS mode changed");
20699
21524
  }
20700
21525
  // --- Public API ---
21526
+ /**
21527
+ * Enqueue a user prompt for serial processing.
21528
+ *
21529
+ * Runs the prompt through agent:beforePrompt middleware (which can modify or block),
21530
+ * then adds it to the PromptQueue. Returns a turnId that callers can use to correlate
21531
+ * queued/processing events before the prompt actually runs.
21532
+ */
20701
21533
  async enqueuePrompt(text5, attachments, routing, externalTurnId) {
20702
21534
  const turnId = externalTurnId ?? nanoid5(8);
20703
21535
  if (this.middlewareChain) {
@@ -20807,6 +21639,10 @@ ${text5}`;
20807
21639
  await this.autoName();
20808
21640
  }
20809
21641
  }
21642
+ /**
21643
+ * Transcribe audio attachments to text if the agent doesn't support audio natively.
21644
+ * Audio attachments are removed and their transcriptions are appended to the prompt text.
21645
+ */
20810
21646
  async maybeTranscribeAudio(text5, attachments) {
20811
21647
  if (!attachments?.length || !this.speechService) {
20812
21648
  return { text: text5, attachments };
@@ -20852,6 +21688,7 @@ ${result.text}` : result.text;
20852
21688
  attachments: remainingAttachments.length > 0 ? remainingAttachments : void 0
20853
21689
  };
20854
21690
  }
21691
+ /** Extract [TTS] block from agent response, synthesize speech, and emit audio_content event. */
20855
21692
  async processTTSResponse(responseText) {
20856
21693
  const match = TTS_BLOCK_REGEX.exec(responseText);
20857
21694
  if (!match?.[1]) {
@@ -20888,7 +21725,9 @@ ${result.text}` : result.text;
20888
21725
  this.log.warn({ err }, "TTS synthesis failed, skipping");
20889
21726
  }
20890
21727
  }
20891
- // NOTE: This injects a summary prompt into the agent's conversation history.
21728
+ // Sends a special prompt to the agent to generate a short session title.
21729
+ // The session emitter is paused (excluding non-agent_event emissions) so the naming
21730
+ // prompt's output is intercepted by a capture handler instead of being forwarded to adapters.
20892
21731
  async autoName() {
20893
21732
  let title = "";
20894
21733
  const captureHandler = (event) => {
@@ -21047,6 +21886,7 @@ ${result.text}` : result.text;
21047
21886
  this.applySpawnResponse(newAgent.initialSessionResponse, newAgent.agentCapabilities);
21048
21887
  this.log.info({ from: this.agentSwitchHistory.at(-1).agentName, to: agentName }, "Agent switched");
21049
21888
  }
21889
+ /** Tear down the session: reject pending permissions, clear queue, destroy agent subprocess. */
21050
21890
  async destroy() {
21051
21891
  this.log.info("Session destroyed");
21052
21892
  if (this.permissionGate.isPending) {
@@ -21072,12 +21912,17 @@ var init_session_manager = __esm({
21072
21912
  store;
21073
21913
  eventBus;
21074
21914
  middlewareChain;
21915
+ /**
21916
+ * Inject the EventBus after construction. Deferred because EventBus is created
21917
+ * after SessionManager during bootstrap, so it cannot be passed to the constructor.
21918
+ */
21075
21919
  setEventBus(eventBus) {
21076
21920
  this.eventBus = eventBus;
21077
21921
  }
21078
21922
  constructor(store = null) {
21079
21923
  this.store = store;
21080
21924
  }
21925
+ /** Create a new session by spawning an agent and persisting the initial record. */
21081
21926
  async createSession(channelId, agentName, workingDirectory, agentManager) {
21082
21927
  const agentInstance = await agentManager.spawn(agentName, workingDirectory);
21083
21928
  const session = new Session({
@@ -21105,9 +21950,11 @@ var init_session_manager = __esm({
21105
21950
  }
21106
21951
  return session;
21107
21952
  }
21953
+ /** Look up a live session by its OpenACP session ID. */
21108
21954
  getSession(sessionId) {
21109
21955
  return this.sessions.get(sessionId);
21110
21956
  }
21957
+ /** Look up a live session by adapter channel and thread ID (checks per-adapter threadIds map first, then legacy fields). */
21111
21958
  getSessionByThread(channelId, threadId) {
21112
21959
  for (const session of this.sessions.values()) {
21113
21960
  const adapterThread = session.threadIds.get(channelId);
@@ -21118,6 +21965,7 @@ var init_session_manager = __esm({
21118
21965
  }
21119
21966
  return void 0;
21120
21967
  }
21968
+ /** Look up a live session by the agent's internal session ID (assigned by the ACP subprocess). */
21121
21969
  getSessionByAgentSessionId(agentSessionId) {
21122
21970
  for (const session of this.sessions.values()) {
21123
21971
  if (session.agentSessionId === agentSessionId) {
@@ -21126,18 +21974,26 @@ var init_session_manager = __esm({
21126
21974
  }
21127
21975
  return void 0;
21128
21976
  }
21977
+ /** Look up the persisted SessionRecord by the agent's internal session ID. */
21129
21978
  getRecordByAgentSessionId(agentSessionId) {
21130
21979
  return this.store?.findByAgentSessionId(agentSessionId);
21131
21980
  }
21981
+ /** Look up the persisted SessionRecord by channel and thread ID. */
21132
21982
  getRecordByThread(channelId, threadId) {
21133
21983
  return this.store?.findByPlatform(
21134
21984
  channelId,
21135
21985
  (p2) => String(p2.topicId) === threadId || p2.threadId === threadId
21136
21986
  );
21137
21987
  }
21988
+ /** Register a session that was created externally (e.g. restored from store on startup). */
21138
21989
  registerSession(session) {
21139
21990
  this.sessions.set(session.id, session);
21140
21991
  }
21992
+ /**
21993
+ * Merge a partial update into the stored SessionRecord. If no record exists yet and
21994
+ * the patch includes `sessionId`, it is treated as an initial save.
21995
+ * Pass `{ immediate: true }` to flush the store to disk synchronously.
21996
+ */
21141
21997
  async patchRecord(sessionId, patch, options) {
21142
21998
  if (!this.store) return;
21143
21999
  const record = this.store.get(sessionId);
@@ -21150,9 +22006,11 @@ var init_session_manager = __esm({
21150
22006
  this.store.flush();
21151
22007
  }
21152
22008
  }
22009
+ /** Retrieve the persisted SessionRecord for a given session ID. Returns undefined if no store or record not found. */
21153
22010
  getSessionRecord(sessionId) {
21154
22011
  return this.store?.get(sessionId);
21155
22012
  }
22013
+ /** Cancel a session: abort in-flight prompt, transition to cancelled, destroy agent, and persist. */
21156
22014
  async cancelSession(sessionId) {
21157
22015
  const session = this.sessions.get(sessionId);
21158
22016
  if (session) {
@@ -21175,11 +22033,16 @@ var init_session_manager = __esm({
21175
22033
  });
21176
22034
  }
21177
22035
  }
22036
+ /** List live (in-memory) sessions, optionally filtered by channel. Excludes assistant sessions. */
21178
22037
  listSessions(channelId) {
21179
22038
  const all = Array.from(this.sessions.values()).filter((s) => !s.isAssistant);
21180
22039
  if (channelId) return all.filter((s) => s.channelId === channelId);
21181
22040
  return all;
21182
22041
  }
22042
+ /**
22043
+ * List all sessions (live + stored) as SessionSummary. Live sessions take precedence
22044
+ * over stored records — their real-time state (queueDepth, promptRunning) is used.
22045
+ */
21183
22046
  listAllSessions(channelId) {
21184
22047
  if (this.store) {
21185
22048
  let records = this.store.list().filter((r) => !r.isAssistant);
@@ -21241,6 +22104,7 @@ var init_session_manager = __esm({
21241
22104
  isLive: true
21242
22105
  }));
21243
22106
  }
22107
+ /** List all stored SessionRecords, optionally filtered by status. Excludes assistant sessions. */
21244
22108
  listRecords(filter) {
21245
22109
  if (!this.store) return [];
21246
22110
  let records = this.store.list().filter((r) => !r.isAssistant);
@@ -21249,6 +22113,7 @@ var init_session_manager = __esm({
21249
22113
  }
21250
22114
  return records;
21251
22115
  }
22116
+ /** Remove a session's stored record and emit a SESSION_DELETED event. */
21252
22117
  async removeRecord(sessionId) {
21253
22118
  if (!this.store) return;
21254
22119
  await this.store.remove(sessionId);
@@ -21359,7 +22224,10 @@ var init_session_bridge = __esm({
21359
22224
  log34.error({ err, sessionId }, "Error in sendMessage middleware");
21360
22225
  }
21361
22226
  }
21362
- /** Determine if this bridge should forward the given event based on turn routing. */
22227
+ /**
22228
+ * Determine if this bridge should forward the given event based on turn routing.
22229
+ * System events are always forwarded; turn events are routed only to the target adapter.
22230
+ */
21363
22231
  shouldForward(event) {
21364
22232
  if (isSystemEvent(event)) return true;
21365
22233
  const ctx = this.session.activeTurnContext;
@@ -21368,6 +22236,13 @@ var init_session_bridge = __esm({
21368
22236
  if (target === null) return false;
21369
22237
  return this.adapterId === target;
21370
22238
  }
22239
+ /**
22240
+ * Subscribe to session events and start forwarding them to the adapter.
22241
+ *
22242
+ * Wires: agent events → adapter dispatch, permission UI, lifecycle persistence
22243
+ * (status changes, naming, prompt count), and EventBus notifications.
22244
+ * Also replays any commands or config options that arrived before the bridge connected.
22245
+ */
21371
22246
  connect() {
21372
22247
  if (this.connected) return;
21373
22248
  this.connected = true;
@@ -21444,6 +22319,7 @@ var init_session_bridge = __esm({
21444
22319
  this.session.emit(SessionEv.AGENT_EVENT, { type: "config_option_update", options: this.session.configOptions });
21445
22320
  }
21446
22321
  }
22322
+ /** Unsubscribe all session event listeners and clean up adapter state. */
21447
22323
  disconnect() {
21448
22324
  if (!this.connected) return;
21449
22325
  this.connected = false;
@@ -21976,6 +22852,12 @@ var init_message_transformer = __esm({
21976
22852
  constructor(tunnelService) {
21977
22853
  this.tunnelService = tunnelService;
21978
22854
  }
22855
+ /**
22856
+ * Convert an agent event to an outgoing message for adapter delivery.
22857
+ *
22858
+ * For tool events, enriches the metadata with diff stats and viewer links
22859
+ * when a tunnel service is available.
22860
+ */
21979
22861
  transform(event, sessionContext) {
21980
22862
  switch (event.type) {
21981
22863
  case "text":
@@ -22250,6 +23132,11 @@ var init_session_store = __esm({
22250
23132
  }
22251
23133
  return void 0;
22252
23134
  }
23135
+ /**
23136
+ * Find a session by its ACP agent session ID.
23137
+ * Checks current, original, and historical agent session IDs (from agent switches)
23138
+ * since the agent session ID changes on each switch.
23139
+ */
22253
23140
  findByAgentSessionId(agentSessionId) {
22254
23141
  for (const record of this.records.values()) {
22255
23142
  if (record.agentSessionId === agentSessionId || record.originalAgentSessionId === agentSessionId) {
@@ -22294,6 +23181,7 @@ var init_session_store = __esm({
22294
23181
  if (!fs42.existsSync(dir)) fs42.mkdirSync(dir, { recursive: true });
22295
23182
  fs42.writeFileSync(this.filePath, JSON.stringify(data, null, 2));
22296
23183
  }
23184
+ /** Clean up timers and process listeners. Call on shutdown to prevent leaks. */
22297
23185
  destroy() {
22298
23186
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
22299
23187
  if (this.cleanupInterval) clearInterval(this.cleanupInterval);
@@ -22329,7 +23217,11 @@ var init_session_store = __esm({
22329
23217
  }
22330
23218
  }
22331
23219
  }
22332
- /** Migrate old SessionRecord format to new multi-adapter format. */
23220
+ /**
23221
+ * Migrate old SessionRecord format to new multi-adapter format.
23222
+ * Converts single-adapter `platform` field to per-adapter `platforms` map,
23223
+ * and initializes `attachedAdapters` for records created before multi-adapter support.
23224
+ */
22333
23225
  migrateRecord(record) {
22334
23226
  if (!record.platforms && record.platform && typeof record.platform === "object") {
22335
23227
  const platformData = record.platform;
@@ -22342,6 +23234,7 @@ var init_session_store = __esm({
22342
23234
  }
22343
23235
  return record;
22344
23236
  }
23237
+ /** Remove expired session records (past TTL). Active and assistant sessions are preserved. */
22345
23238
  cleanup() {
22346
23239
  const cutoff = Date.now() - this.ttlDays * 24 * 60 * 60 * 1e3;
22347
23240
  let removed = 0;
@@ -22412,6 +23305,10 @@ var init_session_factory = __esm({
22412
23305
  get speechService() {
22413
23306
  return typeof this.speechServiceAccessor === "function" ? this.speechServiceAccessor() : this.speechServiceAccessor;
22414
23307
  }
23308
+ /**
23309
+ * Create a new Session: spawn agent → create Session instance → hydrate ACP state → register.
23310
+ * Runs session:beforeCreate middleware (which can modify params or block creation).
23311
+ */
22415
23312
  async create(params) {
22416
23313
  let createParams = params;
22417
23314
  if (this.middlewareChain) {
@@ -22595,6 +23492,11 @@ var init_session_factory = __esm({
22595
23492
  this.resumeLocks.set(sessionId, resumePromise);
22596
23493
  return resumePromise;
22597
23494
  }
23495
+ /**
23496
+ * Attempt to resume a session from disk when a message arrives on a thread with
23497
+ * no live session. Deduplicates concurrent resume attempts for the same thread
23498
+ * via resumeLocks to avoid spawning multiple agents.
23499
+ */
22598
23500
  async lazyResume(channelId, threadId) {
22599
23501
  const store = this.sessionStore;
22600
23502
  if (!store || !this.createFullSession) return null;
@@ -22698,6 +23600,7 @@ var init_session_factory = __esm({
22698
23600
  this.resumeLocks.set(lockKey, resumePromise);
22699
23601
  return resumePromise;
22700
23602
  }
23603
+ /** Create a brand-new session, resolving agent name and workspace from config if not provided. */
22701
23604
  async handleNewSession(channelId, agentName, workspacePath, options) {
22702
23605
  if (!this.configManager || !this.agentCatalog || !this.createFullSession) {
22703
23606
  throw new Error("SessionFactory not fully initialized");
@@ -22742,6 +23645,7 @@ var init_session_factory = __esm({
22742
23645
  record.workingDir
22743
23646
  );
22744
23647
  }
23648
+ /** Create a session and inject conversation context from a ContextProvider (e.g., history from a previous session). */
22745
23649
  async createSessionWithContext(params) {
22746
23650
  if (!this.createFullSession) throw new Error("SessionFactory not fully initialized");
22747
23651
  let contextResult = null;
@@ -22768,6 +23672,7 @@ var init_session_factory = __esm({
22768
23672
  }
22769
23673
  return { session, contextResult };
22770
23674
  }
23675
+ /** Wire session-level side effects: usage tracking (via EventBus) and tunnel cleanup on session end. */
22771
23676
  wireSideEffects(session, deps) {
22772
23677
  session.on(SessionEv.AGENT_EVENT, (event) => {
22773
23678
  if (event.type !== "usage") return;
@@ -22814,7 +23719,12 @@ var init_agent_switch_handler = __esm({
22814
23719
  constructor(deps) {
22815
23720
  this.deps = deps;
22816
23721
  }
23722
+ /** Prevents concurrent switch operations on the same session */
22817
23723
  switchingLocks = /* @__PURE__ */ new Set();
23724
+ /**
23725
+ * Switch a session to a different agent. Returns whether the previous
23726
+ * agent session was resumed or a new one was spawned.
23727
+ */
22818
23728
  async switch(sessionId, toAgent) {
22819
23729
  if (this.switchingLocks.has(sessionId)) {
22820
23730
  throw new Error("Switch already in progress");
@@ -23025,20 +23935,29 @@ var init_agent_catalog = __esm({
23025
23935
  DEFAULT_TTL_HOURS = 24;
23026
23936
  AgentCatalog = class {
23027
23937
  store;
23938
+ /** Agents available in the remote registry (cached in memory after load). */
23028
23939
  registryAgents = [];
23029
23940
  cachePath;
23941
+ /** Directory where binary agent archives are extracted to. */
23030
23942
  agentsDir;
23031
23943
  constructor(store, cachePath, agentsDir) {
23032
23944
  this.store = store;
23033
23945
  this.cachePath = cachePath;
23034
23946
  this.agentsDir = agentsDir;
23035
23947
  }
23948
+ /**
23949
+ * Load installed agents from disk and hydrate the registry from cache/snapshot.
23950
+ *
23951
+ * Also enriches installed agents with registry metadata — fixes agents that
23952
+ * were migrated from older config formats with incomplete data.
23953
+ */
23036
23954
  load() {
23037
23955
  this.store.load();
23038
23956
  this.loadRegistryFromCacheOrSnapshot();
23039
23957
  this.enrichInstalledFromRegistry();
23040
23958
  }
23041
23959
  // --- Registry ---
23960
+ /** Fetch the latest agent registry from the CDN and update the local cache. */
23042
23961
  async fetchRegistry() {
23043
23962
  try {
23044
23963
  log39.info("Fetching agent registry from CDN...");
@@ -23058,6 +23977,7 @@ var init_agent_catalog = __esm({
23058
23977
  log39.warn({ err }, "Failed to fetch registry, using cached data");
23059
23978
  }
23060
23979
  }
23980
+ /** Re-fetch registry only if the local cache has expired (24-hour TTL). */
23061
23981
  async refreshRegistryIfStale() {
23062
23982
  if (this.isCacheStale()) {
23063
23983
  await this.fetchRegistry();
@@ -23069,6 +23989,7 @@ var init_agent_catalog = __esm({
23069
23989
  getRegistryAgent(registryId) {
23070
23990
  return this.registryAgents.find((a) => a.id === registryId);
23071
23991
  }
23992
+ /** Find a registry agent by registry ID or by its short alias (e.g., "claude"). */
23072
23993
  findRegistryAgent(keyOrId) {
23073
23994
  const byId = this.registryAgents.find((a) => a.id === keyOrId);
23074
23995
  if (byId) return byId;
@@ -23085,6 +24006,15 @@ var init_agent_catalog = __esm({
23085
24006
  return this.store.getAgent(key);
23086
24007
  }
23087
24008
  // --- Discovery ---
24009
+ /**
24010
+ * Build the unified list of all agents (installed + registry-only).
24011
+ *
24012
+ * Installed agents appear first with their live availability status.
24013
+ * Registry agents that aren't installed yet show whether a distribution
24014
+ * exists for the current platform. Missing external dependencies
24015
+ * (e.g., claude CLI) are surfaced as `missingDeps` for UI display
24016
+ * but do NOT block installation.
24017
+ */
23088
24018
  getAvailable() {
23089
24019
  const installed = this.getInstalledEntries();
23090
24020
  const items = [];
@@ -23125,6 +24055,7 @@ var init_agent_catalog = __esm({
23125
24055
  }
23126
24056
  return items;
23127
24057
  }
24058
+ /** Check if an agent can be installed on this system (platform + dependencies). */
23128
24059
  checkAvailability(keyOrId) {
23129
24060
  const agent = this.findRegistryAgent(keyOrId);
23130
24061
  if (!agent) return { available: false, reason: "Not found in the agent registry." };
@@ -23135,6 +24066,12 @@ var init_agent_catalog = __esm({
23135
24066
  return checkDependencies(agent.id);
23136
24067
  }
23137
24068
  // --- Install/Uninstall ---
24069
+ /**
24070
+ * Install an agent from the registry.
24071
+ *
24072
+ * Resolves the distribution (npx/uvx/binary), downloads binary archives
24073
+ * if needed, and persists the agent definition in the store.
24074
+ */
23138
24075
  async install(keyOrId, progress, force) {
23139
24076
  const agent = this.findRegistryAgent(keyOrId);
23140
24077
  if (!agent) {
@@ -23159,6 +24096,7 @@ var init_agent_catalog = __esm({
23159
24096
  registerFallbackAgent(key, data) {
23160
24097
  this.store.addAgent(key, data);
23161
24098
  }
24099
+ /** Remove an installed agent and delete its binary directory if applicable. */
23162
24100
  async uninstall(key) {
23163
24101
  if (this.store.hasAgent(key)) {
23164
24102
  await uninstallAgent(key, this.store);
@@ -23167,6 +24105,7 @@ var init_agent_catalog = __esm({
23167
24105
  return { ok: false, error: `"${key}" is not installed.` };
23168
24106
  }
23169
24107
  // --- Resolution (for AgentManager) ---
24108
+ /** Convert an installed agent's short key to an AgentDefinition for spawning. */
23170
24109
  resolve(key) {
23171
24110
  const agent = this.store.getAgent(key);
23172
24111
  if (!agent) return void 0;
@@ -23310,6 +24249,7 @@ var init_agent_store = __esm({
23310
24249
  constructor(filePath) {
23311
24250
  this.filePath = filePath;
23312
24251
  }
24252
+ /** Load and validate the store from disk. Starts fresh if file is missing or invalid. */
23313
24253
  load() {
23314
24254
  if (!fs44.existsSync(this.filePath)) {
23315
24255
  this.data = { version: 1, installed: {} };
@@ -23349,6 +24289,11 @@ var init_agent_store = __esm({
23349
24289
  hasAgent(key) {
23350
24290
  return key in this.data.installed;
23351
24291
  }
24292
+ /**
24293
+ * Persist the store to disk using atomic write (write to .tmp, then rename).
24294
+ * File permissions are restricted to owner-only (0o600) since the store
24295
+ * may contain agent binary paths and environment variables.
24296
+ */
23352
24297
  save() {
23353
24298
  fs44.mkdirSync(path49.dirname(this.filePath), { recursive: true });
23354
24299
  const tmpPath = this.filePath + ".tmp";
@@ -23446,6 +24391,10 @@ var init_service_registry = __esm({
23446
24391
  "use strict";
23447
24392
  ServiceRegistry = class {
23448
24393
  services = /* @__PURE__ */ new Map();
24394
+ /**
24395
+ * Register a service. Throws if the service name is already taken.
24396
+ * Use `registerOverride` to intentionally replace an existing service.
24397
+ */
23449
24398
  register(name, implementation, pluginName) {
23450
24399
  if (this.services.has(name)) {
23451
24400
  const existing = this.services.get(name);
@@ -23453,21 +24402,27 @@ var init_service_registry = __esm({
23453
24402
  }
23454
24403
  this.services.set(name, { implementation, pluginName });
23455
24404
  }
24405
+ /** Register a service, replacing any existing registration (used by override plugins). */
23456
24406
  registerOverride(name, implementation, pluginName) {
23457
24407
  this.services.set(name, { implementation, pluginName });
23458
24408
  }
24409
+ /** Retrieve a service by name. Returns undefined if not registered. */
23459
24410
  get(name) {
23460
24411
  return this.services.get(name)?.implementation;
23461
24412
  }
24413
+ /** Check whether a service is registered. */
23462
24414
  has(name) {
23463
24415
  return this.services.has(name);
23464
24416
  }
24417
+ /** List all registered services with their owning plugin names. */
23465
24418
  list() {
23466
24419
  return [...this.services.entries()].map(([name, { pluginName }]) => ({ name, pluginName }));
23467
24420
  }
24421
+ /** Remove a single service by name. */
23468
24422
  unregister(name) {
23469
24423
  this.services.delete(name);
23470
24424
  }
24425
+ /** Remove all services owned by a specific plugin (called during plugin unload). */
23471
24426
  unregisterByPlugin(pluginName) {
23472
24427
  for (const [name, entry] of this.services) {
23473
24428
  if (entry.pluginName === pluginName) {
@@ -23489,6 +24444,7 @@ var init_middleware_chain = __esm({
23489
24444
  chains = /* @__PURE__ */ new Map();
23490
24445
  errorHandler;
23491
24446
  errorTracker;
24447
+ /** Register a middleware handler for a hook. Handlers are kept sorted by priority. */
23492
24448
  add(hook, pluginName, opts) {
23493
24449
  const entry = {
23494
24450
  pluginName,
@@ -23503,6 +24459,15 @@ var init_middleware_chain = __esm({
23503
24459
  this.chains.set(hook, [entry]);
23504
24460
  }
23505
24461
  }
24462
+ /**
24463
+ * Execute the middleware chain for a hook, ending with the core handler.
24464
+ *
24465
+ * The chain is built recursively: each handler calls `next()` to invoke the
24466
+ * next handler, with the core handler at the end. If no middleware is registered,
24467
+ * the core handler runs directly.
24468
+ *
24469
+ * @returns The final payload, or `null` if any handler short-circuited.
24470
+ */
23506
24471
  async execute(hook, payload, coreHandler) {
23507
24472
  const handlers = this.chains.get(hook);
23508
24473
  if (!handlers || handlers.length === 0) {
@@ -23572,6 +24537,7 @@ var init_middleware_chain = __esm({
23572
24537
  const start = buildNext(0, payload);
23573
24538
  return start();
23574
24539
  }
24540
+ /** Remove all middleware handlers registered by a specific plugin. */
23575
24541
  removeAll(pluginName) {
23576
24542
  for (const [hook, handlers] of this.chains.entries()) {
23577
24543
  const filtered = handlers.filter((h) => h.pluginName !== pluginName);
@@ -23582,9 +24548,11 @@ var init_middleware_chain = __esm({
23582
24548
  }
23583
24549
  }
23584
24550
  }
24551
+ /** Set a callback for middleware errors (e.g., logging). */
23585
24552
  setErrorHandler(fn) {
23586
24553
  this.errorHandler = fn;
23587
24554
  }
24555
+ /** Attach an ErrorTracker for circuit-breaking misbehaving plugins. */
23588
24556
  setErrorTracker(tracker) {
23589
24557
  this.errorTracker = tracker;
23590
24558
  }
@@ -23602,10 +24570,15 @@ var init_error_tracker = __esm({
23602
24570
  disabled = /* @__PURE__ */ new Set();
23603
24571
  exempt = /* @__PURE__ */ new Set();
23604
24572
  config;
24573
+ /** Callback fired when a plugin is auto-disabled due to error budget exhaustion. */
23605
24574
  onDisabled;
23606
24575
  constructor(config) {
23607
24576
  this.config = { maxErrors: config?.maxErrors ?? 10, windowMs: config?.windowMs ?? 36e5 };
23608
24577
  }
24578
+ /**
24579
+ * Record an error for a plugin. If the error budget is exceeded,
24580
+ * the plugin is disabled and the `onDisabled` callback fires.
24581
+ */
23609
24582
  increment(pluginName) {
23610
24583
  if (this.exempt.has(pluginName)) return;
23611
24584
  const now = Date.now();
@@ -23622,13 +24595,16 @@ var init_error_tracker = __esm({
23622
24595
  this.onDisabled?.(pluginName, reason);
23623
24596
  }
23624
24597
  }
24598
+ /** Check if a plugin has been disabled due to errors. */
23625
24599
  isDisabled(pluginName) {
23626
24600
  return this.disabled.has(pluginName);
23627
24601
  }
24602
+ /** Re-enable a plugin and clear its error history. */
23628
24603
  reset(pluginName) {
23629
24604
  this.disabled.delete(pluginName);
23630
24605
  this.errors.delete(pluginName);
23631
24606
  }
24607
+ /** Mark a plugin as exempt from circuit-breaking (e.g., essential plugins). */
23632
24608
  setExempt(pluginName) {
23633
24609
  this.exempt.add(pluginName);
23634
24610
  }
@@ -23646,6 +24622,7 @@ var init_plugin_storage = __esm({
23646
24622
  PluginStorageImpl = class {
23647
24623
  kvPath;
23648
24624
  dataDir;
24625
+ /** Serializes writes to prevent concurrent file corruption */
23649
24626
  writeChain = Promise.resolve();
23650
24627
  constructor(baseDir) {
23651
24628
  this.dataDir = path50.join(baseDir, "data");
@@ -23686,6 +24663,7 @@ var init_plugin_storage = __esm({
23686
24663
  async list() {
23687
24664
  return Object.keys(this.readKv());
23688
24665
  }
24666
+ /** Returns the plugin's data directory, creating it lazily on first access. */
23689
24667
  getDataDir() {
23690
24668
  fs45.mkdirSync(this.dataDir, { recursive: true });
23691
24669
  return this.dataDir;
@@ -23861,6 +24839,11 @@ function createPluginContext(opts) {
23861
24839
  return core;
23862
24840
  },
23863
24841
  instanceRoot,
24842
+ /**
24843
+ * Called by LifecycleManager during plugin teardown.
24844
+ * Unregisters all event handlers, middleware, commands, and services
24845
+ * registered by this plugin, preventing leaks across reloads.
24846
+ */
23864
24847
  cleanup() {
23865
24848
  for (const { event, handler } of registeredListeners) {
23866
24849
  eventBus.off(event, handler);
@@ -23967,12 +24950,15 @@ var init_lifecycle_manager = __esm({
23967
24950
  loadOrder = [];
23968
24951
  _loaded = /* @__PURE__ */ new Set();
23969
24952
  _failed = /* @__PURE__ */ new Set();
24953
+ /** Names of plugins that successfully completed setup(). */
23970
24954
  get loadedPlugins() {
23971
24955
  return [...this._loaded];
23972
24956
  }
24957
+ /** Names of plugins whose setup() threw an error. These plugins are skipped but don't crash the system. */
23973
24958
  get failedPlugins() {
23974
24959
  return [...this._failed];
23975
24960
  }
24961
+ /** The PluginRegistry tracking installed and enabled plugin state. */
23976
24962
  get registry() {
23977
24963
  return this.pluginRegistry;
23978
24964
  }
@@ -24019,6 +25005,12 @@ var init_lifecycle_manager = __esm({
24019
25005
  return this;
24020
25006
  } };
24021
25007
  }
25008
+ /**
25009
+ * Boot a set of plugins in dependency order.
25010
+ *
25011
+ * Can be called multiple times (e.g., core plugins first, then dev plugins later).
25012
+ * Already-loaded plugins are included in dependency resolution but not re-booted.
25013
+ */
24022
25014
  async boot(plugins) {
24023
25015
  const newNames = new Set(plugins.map((p2) => p2.name));
24024
25016
  const allForResolution = [...this.loadOrder.filter((p2) => !newNames.has(p2.name)), ...plugins];
@@ -24130,6 +25122,11 @@ var init_lifecycle_manager = __esm({
24130
25122
  }
24131
25123
  }
24132
25124
  }
25125
+ /**
25126
+ * Unload a single plugin: call teardown(), clean up its context
25127
+ * (listeners, middleware, services), and remove from tracked state.
25128
+ * Used for hot-reload: unload → rebuild → re-boot.
25129
+ */
24133
25130
  async unloadPlugin(name) {
24134
25131
  if (!this._loaded.has(name)) return;
24135
25132
  const plugin2 = this.loadOrder.find((p2) => p2.name === name);
@@ -24149,6 +25146,10 @@ var init_lifecycle_manager = __esm({
24149
25146
  this.loadOrder = this.loadOrder.filter((p2) => p2.name !== name);
24150
25147
  this.eventBus?.emit(BusEvent.PLUGIN_UNLOADED, { name });
24151
25148
  }
25149
+ /**
25150
+ * Gracefully shut down all loaded plugins.
25151
+ * Teardown runs in reverse boot order so that dependencies outlive their dependents.
25152
+ */
24152
25153
  async shutdown() {
24153
25154
  const reversed = [...this.loadOrder].reverse();
24154
25155
  for (const plugin2 of reversed) {
@@ -24182,16 +25183,19 @@ var init_menu_registry = __esm({
24182
25183
  log41 = createChildLogger({ module: "menu-registry" });
24183
25184
  MenuRegistry = class {
24184
25185
  items = /* @__PURE__ */ new Map();
25186
+ /** Register or replace a menu item by its unique ID. */
24185
25187
  register(item) {
24186
25188
  this.items.set(item.id, item);
24187
25189
  }
25190
+ /** Remove a menu item by ID. */
24188
25191
  unregister(id) {
24189
25192
  this.items.delete(id);
24190
25193
  }
25194
+ /** Look up a single menu item by ID. */
24191
25195
  getItem(id) {
24192
25196
  return this.items.get(id);
24193
25197
  }
24194
- /** Get all visible items sorted by priority */
25198
+ /** Get all visible items sorted by priority (lower number = shown first). */
24195
25199
  getItems() {
24196
25200
  return [...this.items.values()].filter((item) => {
24197
25201
  if (!item.visible) return true;
@@ -24281,19 +25285,31 @@ var init_assistant_registry = __esm({
24281
25285
  AssistantRegistry = class {
24282
25286
  sections = /* @__PURE__ */ new Map();
24283
25287
  _instanceRoot = "";
24284
- /** Set the instance root path used in assistant guidelines */
25288
+ /** Set the instance root path used in assistant guidelines. */
24285
25289
  setInstanceRoot(root) {
24286
25290
  this._instanceRoot = root;
24287
25291
  }
25292
+ /** Register a prompt section. Overwrites any existing section with the same id. */
24288
25293
  register(section) {
24289
25294
  if (this.sections.has(section.id)) {
24290
25295
  log42.warn({ id: section.id }, "Assistant section overwritten");
24291
25296
  }
24292
25297
  this.sections.set(section.id, section);
24293
25298
  }
25299
+ /** Remove a previously registered section by id. */
24294
25300
  unregister(id) {
24295
25301
  this.sections.delete(id);
24296
25302
  }
25303
+ /**
25304
+ * Compose the full system prompt from all registered sections.
25305
+ *
25306
+ * Sections are sorted by priority (ascending), each contributing a titled
25307
+ * markdown block. If a section's `buildContext()` throws, it is skipped
25308
+ * gracefully so one broken section doesn't break the entire prompt.
25309
+ *
25310
+ * If `channelId` is provided, a "Current Channel" block is injected at the
25311
+ * top of the prompt so the assistant can adapt its behavior to the platform.
25312
+ */
24297
25313
  buildSystemPrompt(channelId) {
24298
25314
  const sorted = [...this.sections.values()].sort((a, b) => a.priority - b.priority);
24299
25315
  const parts = [ASSISTANT_PREAMBLE];
@@ -24336,6 +25352,13 @@ var init_assistant_manager = __esm({
24336
25352
  }
24337
25353
  sessions = /* @__PURE__ */ new Map();
24338
25354
  pendingSystemPrompts = /* @__PURE__ */ new Map();
25355
+ /**
25356
+ * Returns the assistant session for a channel, creating one if needed.
25357
+ *
25358
+ * If a persisted assistant session exists in the store, it is reused
25359
+ * (same session ID) to preserve conversation history. The system prompt
25360
+ * is always rebuilt fresh and deferred until the first user message.
25361
+ */
24339
25362
  async getOrSpawn(channelId, threadId) {
24340
25363
  const existing = this.core.sessionStore?.findAssistant(channelId);
24341
25364
  const session = await this.core.createSession({
@@ -24356,6 +25379,7 @@ var init_assistant_manager = __esm({
24356
25379
  );
24357
25380
  return session;
24358
25381
  }
25382
+ /** Returns the active assistant session for a channel, or null if none exists. */
24359
25383
  get(channelId) {
24360
25384
  return this.sessions.get(channelId) ?? null;
24361
25385
  }
@@ -24368,6 +25392,7 @@ var init_assistant_manager = __esm({
24368
25392
  if (prompt) this.pendingSystemPrompts.delete(channelId);
24369
25393
  return prompt;
24370
25394
  }
25395
+ /** Checks whether a given session ID belongs to the built-in assistant. */
24371
25396
  isAssistant(sessionId) {
24372
25397
  for (const s of this.sessions.values()) {
24373
25398
  if (s.id === sessionId) return true;
@@ -24666,30 +25691,49 @@ var init_core = __esm({
24666
25691
  menuRegistry = new MenuRegistry();
24667
25692
  assistantRegistry = new AssistantRegistry();
24668
25693
  assistantManager;
24669
- // --- Lazy getters: resolve from ServiceRegistry (populated by plugins during boot) ---
25694
+ // Services (security, notifications, speech, etc.) are provided by plugins that
25695
+ // register during boot. Core accesses them lazily via ServiceRegistry so it doesn't
25696
+ // need compile-time dependencies on plugin implementations.
25697
+ /** @throws if the service hasn't been registered by its plugin yet */
24670
25698
  getService(name) {
24671
25699
  const svc = this.lifecycleManager.serviceRegistry.get(name);
24672
25700
  if (!svc) throw new Error(`Service '${name}' not registered \u2014 is the ${name} plugin loaded?`);
24673
25701
  return svc;
24674
25702
  }
25703
+ /** Access control and rate-limiting guard (provided by security plugin). */
24675
25704
  get securityGuard() {
24676
25705
  return this.getService("security");
24677
25706
  }
25707
+ /** Cross-session notification delivery (provided by notifications plugin). */
24678
25708
  get notificationManager() {
24679
25709
  return this.getService("notifications");
24680
25710
  }
25711
+ /** File I/O service for agent attachment storage (provided by file-service plugin). */
24681
25712
  get fileService() {
24682
25713
  return this.getService("file-service");
24683
25714
  }
25715
+ /** Text-to-speech / speech-to-text engine (provided by speech plugin). */
24684
25716
  get speechService() {
24685
25717
  return this.getService("speech");
24686
25718
  }
25719
+ /** Conversation history builder for context injection (provided by context plugin). */
24687
25720
  get contextManager() {
24688
25721
  return this.getService("context");
24689
25722
  }
25723
+ /** Per-plugin persistent settings (e.g. API keys). */
24690
25724
  get settingsManager() {
24691
25725
  return this.lifecycleManager.settingsManager;
24692
25726
  }
25727
+ /**
25728
+ * Bootstrap all core subsystems. The boot order matters:
25729
+ * 1. AgentCatalog + AgentManager (agent definitions)
25730
+ * 2. SessionStore + SessionManager (session persistence and lookup)
25731
+ * 3. EventBus (inter-module communication)
25732
+ * 4. SessionFactory (session creation pipeline)
25733
+ * 5. LifecycleManager (plugin infrastructure)
25734
+ * 6. Wire middleware chain into factory + manager
25735
+ * 7. AgentSwitchHandler, config listeners, menu/assistant registries
25736
+ */
24693
25737
  constructor(configManager, ctx) {
24694
25738
  this.configManager = configManager;
24695
25739
  this.instanceContext = ctx;
@@ -24800,16 +25844,28 @@ var init_core = __esm({
24800
25844
  this.lifecycleManager.serviceRegistry.register("menu-registry", this.menuRegistry, "core");
24801
25845
  this.lifecycleManager.serviceRegistry.register("assistant-registry", this.assistantRegistry, "core");
24802
25846
  }
25847
+ /** Optional tunnel for generating public URLs (code viewer links, etc.). */
24803
25848
  get tunnelService() {
24804
25849
  return this._tunnelService;
24805
25850
  }
25851
+ /** Propagate tunnel service to MessageTransformer so it can generate viewer links. */
24806
25852
  set tunnelService(service) {
24807
25853
  this._tunnelService = service;
24808
25854
  this.messageTransformer.tunnelService = service;
24809
25855
  }
25856
+ /**
25857
+ * Register a messaging adapter (e.g. Telegram, Slack, SSE).
25858
+ *
25859
+ * Adapters must be registered before `start()`. The adapter name serves as its
25860
+ * channel ID throughout the system — used in session records, bridge keys, and routing.
25861
+ */
24810
25862
  registerAdapter(name, adapter) {
24811
25863
  this.adapters.set(name, adapter);
24812
25864
  }
25865
+ /**
25866
+ * Start all registered adapters. Adapters that fail are logged but do not
25867
+ * prevent others from starting. Throws only if ALL adapters fail.
25868
+ */
24813
25869
  async start() {
24814
25870
  this.agentCatalog.refreshRegistryIfStale().catch((err) => {
24815
25871
  log44.warn({ err }, "Background registry refresh failed");
@@ -24829,6 +25885,10 @@ var init_core = __esm({
24829
25885
  );
24830
25886
  }
24831
25887
  }
25888
+ /**
25889
+ * Graceful shutdown: notify users, persist session state, stop adapters.
25890
+ * Agent subprocesses are not explicitly killed — they exit with the parent process.
25891
+ */
24832
25892
  async stop() {
24833
25893
  try {
24834
25894
  const nm = this.lifecycleManager.serviceRegistry.get("notifications");
@@ -24847,6 +25907,13 @@ var init_core = __esm({
24847
25907
  }
24848
25908
  }
24849
25909
  // --- Archive ---
25910
+ /**
25911
+ * Archive a session: delete its adapter topic/thread and cancel the session.
25912
+ *
25913
+ * Only sessions in archivable states (active, cancelled, error) can be archived —
25914
+ * initializing and finished sessions are excluded.
25915
+ * The adapter handles platform-side cleanup (e.g. deleting a Telegram topic).
25916
+ */
24850
25917
  async archiveSession(sessionId) {
24851
25918
  const session = this.sessionManager.getSession(sessionId);
24852
25919
  if (!session) return { ok: false, error: "Session not found (must be in memory)" };
@@ -24866,6 +25933,18 @@ var init_core = __esm({
24866
25933
  }
24867
25934
  }
24868
25935
  // --- Message Routing ---
25936
+ /**
25937
+ * Route an incoming platform message to the appropriate session.
25938
+ *
25939
+ * Flow:
25940
+ * 1. Run `message:incoming` middleware (plugins can modify or block)
25941
+ * 2. SecurityGuard checks user access and per-user session limits
25942
+ * 3. Find session by channel+thread (in-memory lookup, then lazy resume from disk)
25943
+ * 4. For assistant sessions, prepend any deferred system prompt
25944
+ * 5. Emit `message:queued` for SSE clients, then enqueue the prompt on the session
25945
+ *
25946
+ * If no session is found, the user is told to start one with /new.
25947
+ */
24869
25948
  async handleMessage(message) {
24870
25949
  log44.debug(
24871
25950
  {
@@ -24947,6 +26026,17 @@ ${text5}`;
24947
26026
  }
24948
26027
  }
24949
26028
  // --- Unified Session Creation Pipeline ---
26029
+ /**
26030
+ * Create (or resume) a session with full wiring: agent, adapter thread, bridge, persistence.
26031
+ *
26032
+ * This is the single entry point for session creation. The pipeline:
26033
+ * 1. SessionFactory spawns/resumes the agent process
26034
+ * 2. Adapter creates a thread/topic if requested
26035
+ * 3. Initial session record is persisted (so lazy resume can find it by threadId)
26036
+ * 4. SessionBridge connects agent events to the adapter
26037
+ * 5. For headless sessions (no adapter), fallback event handlers are wired inline
26038
+ * 6. Side effects (usage tracking, tunnel cleanup) are attached
26039
+ */
24950
26040
  async createSession(params) {
24951
26041
  const session = await this.sessionFactory.create(params);
24952
26042
  if (params.threadId) {
@@ -25069,9 +26159,16 @@ ${text5}`;
25069
26159
  );
25070
26160
  return session;
25071
26161
  }
26162
+ /** Convenience wrapper: create a new session with default agent/workspace resolution. */
25072
26163
  async handleNewSession(channelId, agentName, workspacePath, options) {
25073
26164
  return this.sessionFactory.handleNewSession(channelId, agentName, workspacePath, options);
25074
26165
  }
26166
+ /**
26167
+ * Adopt an externally-started agent session (e.g. from a CLI `openacp adopt` command).
26168
+ *
26169
+ * Validates that the agent supports resume, checks session limits, avoids duplicates,
26170
+ * then creates a full session via the unified pipeline with resume semantics.
26171
+ */
25075
26172
  async adoptSession(agentName, agentSessionId, cwd, channelId) {
25076
26173
  const caps = getAgentCapabilities(agentName);
25077
26174
  if (!caps.supportsResume) {
@@ -25182,22 +26279,33 @@ ${text5}`;
25182
26279
  status: "adopted"
25183
26280
  };
25184
26281
  }
26282
+ /** Start a new chat within the same agent and workspace as the current session's thread. */
25185
26283
  async handleNewChat(channelId, currentThreadId) {
25186
26284
  return this.sessionFactory.handleNewChat(channelId, currentThreadId);
25187
26285
  }
26286
+ /** Create a session and inject conversation context from a prior session or repo. */
25188
26287
  async createSessionWithContext(params) {
25189
26288
  return this.sessionFactory.createSessionWithContext(params);
25190
26289
  }
25191
26290
  // --- Agent Switch ---
26291
+ /** Switch a session's active agent. Delegates to AgentSwitchHandler for state coordination. */
25192
26292
  async switchSessionAgent(sessionId, toAgent) {
25193
26293
  return this.agentSwitchHandler.switch(sessionId, toAgent);
25194
26294
  }
26295
+ /** Find a session by channel+thread, resuming from disk if not in memory. */
25195
26296
  async getOrResumeSession(channelId, threadId) {
25196
26297
  return this.sessionFactory.getOrResume(channelId, threadId);
25197
26298
  }
26299
+ /** Find a session by ID, resuming from disk if not in memory. */
25198
26300
  async getOrResumeSessionById(sessionId) {
25199
26301
  return this.sessionFactory.getOrResumeById(sessionId);
25200
26302
  }
26303
+ /**
26304
+ * Attach an additional adapter to an existing session (multi-adapter support).
26305
+ *
26306
+ * Creates a thread on the target adapter and connects a SessionBridge so the
26307
+ * session's agent events are forwarded to both the primary and attached adapters.
26308
+ */
25201
26309
  async attachAdapter(sessionId, adapterId) {
25202
26310
  const session = this.sessionManager.getSession(sessionId);
25203
26311
  if (!session) throw new Error(`Session ${sessionId} not found`);
@@ -25221,6 +26329,10 @@ ${text5}`;
25221
26329
  });
25222
26330
  return { threadId };
25223
26331
  }
26332
+ /**
26333
+ * Detach a secondary adapter from a session. The primary adapter (channelId) cannot
26334
+ * be detached. Disconnects the bridge and cleans up thread mappings.
26335
+ */
25224
26336
  async detachAdapter(sessionId, adapterId) {
25225
26337
  const session = this.sessionManager.getSession(sessionId);
25226
26338
  if (!session) throw new Error(`Session ${sessionId} not found`);
@@ -25253,6 +26365,7 @@ ${text5}`;
25253
26365
  platforms: this.buildPlatformsFromSession(session)
25254
26366
  });
25255
26367
  }
26368
+ /** Build the platforms map (adapter → thread/topic IDs) for persistence. */
25256
26369
  buildPlatformsFromSession(session) {
25257
26370
  const platforms = {};
25258
26371
  for (const [adapterId, threadId] of session.threadIds) {
@@ -25284,8 +26397,13 @@ ${text5}`;
25284
26397
  const bridge = this.createBridge(session, adapter, session.channelId);
25285
26398
  bridge.connect();
25286
26399
  }
25287
- /** Create a SessionBridge for the given session and adapter.
25288
- * Disconnects any existing bridge for the same adapter+session first. */
26400
+ /**
26401
+ * Create a SessionBridge for the given session and adapter.
26402
+ *
26403
+ * The bridge subscribes to Session events (agent output, status changes, naming)
26404
+ * and forwards them to the adapter for platform delivery. Disconnects any existing
26405
+ * bridge for the same adapter+session first to avoid duplicate event handlers.
26406
+ */
25289
26407
  createBridge(session, adapter, adapterId) {
25290
26408
  const id = adapterId ?? adapter.name;
25291
26409
  const key = this.bridgeKey(id, session.id);
@@ -25395,10 +26513,15 @@ var init_command_registry = __esm({
25395
26513
  return this.getAll().filter((cmd) => cmd.category === category);
25396
26514
  }
25397
26515
  /**
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
26516
+ * Parse and execute a command string (e.g. "/greet hello world").
26517
+ *
26518
+ * Resolution order:
26519
+ * 1. Adapter-specific override (e.g. Telegram's version of /new)
26520
+ * 2. Short name or qualified name in the main registry
26521
+ *
26522
+ * Strips Telegram-style bot mentions (e.g. "/help@MyBot" → "help").
26523
+ * Returns `{ type: 'delegated' }` if the handler returns null/undefined
26524
+ * (meaning it handled the response itself, e.g. via assistant).
25402
26525
  */
25403
26526
  async execute(commandString, baseArgs) {
25404
26527
  const trimmed = commandString.trim();
@@ -26193,12 +27316,15 @@ var init_plugin_field_registry = __esm({
26193
27316
  "use strict";
26194
27317
  PluginFieldRegistry = class {
26195
27318
  fields = /* @__PURE__ */ new Map();
27319
+ /** Register (or replace) the editable fields for a plugin. */
26196
27320
  register(pluginName, fields) {
26197
27321
  this.fields.set(pluginName, fields);
26198
27322
  }
27323
+ /** Get the editable fields for a specific plugin. */
26199
27324
  getForPlugin(pluginName) {
26200
27325
  return this.fields.get(pluginName) ?? [];
26201
27326
  }
27327
+ /** Get all fields grouped by plugin name. */
26202
27328
  getAll() {
26203
27329
  return new Map(this.fields);
26204
27330
  }
@@ -27555,6 +28681,10 @@ var init_dev_loader = __esm({
27555
28681
  constructor(pluginPath) {
27556
28682
  this.pluginPath = path55.resolve(pluginPath);
27557
28683
  }
28684
+ /**
28685
+ * Import the plugin's default export from dist/index.js.
28686
+ * Each call uses a unique URL query to bypass Node's ESM cache.
28687
+ */
27558
28688
  async load() {
27559
28689
  const distIndex = path55.join(this.pluginPath, "dist", "index.js");
27560
28690
  const srcIndex = path55.join(this.pluginPath, "src", "index.ts");
@@ -27572,9 +28702,11 @@ var init_dev_loader = __esm({
27572
28702
  }
27573
28703
  return plugin2;
27574
28704
  }
28705
+ /** Returns the resolved absolute path to the plugin's root directory. */
27575
28706
  getPluginPath() {
27576
28707
  return this.pluginPath;
27577
28708
  }
28709
+ /** Returns the path to the plugin's dist directory. */
27578
28710
  getDistPath() {
27579
28711
  return path55.join(this.pluginPath, "dist");
27580
28712
  }