@ganglion/xacpx 0.9.3 → 0.10.1

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/README.md CHANGED
@@ -258,6 +258,7 @@ xacpx doctor
258
258
  xacpx doctor --verbose
259
259
  xacpx doctor --smoke
260
260
  xacpx doctor --smoke --agent codex --workspace backend
261
+ xacpx doctor --fix
261
262
  ```
262
263
 
263
264
  Notes:
@@ -266,6 +267,7 @@ Notes:
266
267
  - `--smoke` additionally runs a minimal real transport-level prompt check
267
268
  - `--agent` / `--workspace` only affect `--smoke`
268
269
  - Without `--smoke`, the related checks show as `SKIP`
270
+ - `--fix` applies safe local repairs (runtime dir permissions, stale locks, invalid state records) and re-checks; state-mutating repairs are withheld while the daemon runs — see [docs/doctor-command.md](docs/doctor-command.md)
269
271
 
270
272
  ### How to use `update`
271
273
 
@@ -210,6 +210,54 @@ var init_spawn_command = __esm(() => {
210
210
  SCRIPT_FILE_PATTERN = /\.(c|m)?js$/i;
211
211
  });
212
212
 
213
+ // src/process/terminate-process-tree.ts
214
+ import { spawn } from "node:child_process";
215
+ async function terminateProcessTree(pid, options = {}, platform = process.platform, runCommand = defaultRunProcessCommand, killProcess = (targetPid, signal) => {
216
+ process.kill(targetPid, signal);
217
+ }, isProcessRunning = defaultIsProcessRunning) {
218
+ if (pid <= 0) {
219
+ return;
220
+ }
221
+ if (platform === "win32") {
222
+ try {
223
+ await runCommand("taskkill", ["/PID", String(pid), "/T", "/F"]);
224
+ } catch {}
225
+ return;
226
+ }
227
+ const targetPid = options.detachedProcessGroup ? -pid : pid;
228
+ try {
229
+ killProcess(targetPid, "SIGTERM");
230
+ } catch {
231
+ return;
232
+ }
233
+ const deadline = Date.now() + 5000;
234
+ while (Date.now() < deadline) {
235
+ if (!isProcessRunning(targetPid)) {
236
+ return;
237
+ }
238
+ await new Promise((resolve) => setTimeout(resolve, 100));
239
+ }
240
+ try {
241
+ killProcess(targetPid, "SIGKILL");
242
+ } catch {}
243
+ }
244
+ function defaultIsProcessRunning(pid) {
245
+ try {
246
+ process.kill(pid, 0);
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+ async function defaultRunProcessCommand(command, args) {
253
+ return await new Promise((resolve, reject) => {
254
+ const child = spawn(command, args, { stdio: "ignore" });
255
+ child.on("error", reject);
256
+ child.on("close", (code) => resolve(code ?? 1));
257
+ });
258
+ }
259
+ var init_terminate_process_tree = () => {};
260
+
213
261
  // src/transport/prompt-media.ts
214
262
  import { mkdtemp, open, rm, writeFile } from "node:fs/promises";
215
263
  import { tmpdir as defaultTmpdir } from "node:os";
@@ -645,7 +693,7 @@ var init_streaming_prompt = __esm(() => {
645
693
  });
646
694
 
647
695
  // src/recovery/discover-parent-package-paths.ts
648
- import { spawn } from "node:child_process";
696
+ import { spawn as spawn2 } from "node:child_process";
649
697
  import { createRequire as createRequire2 } from "node:module";
650
698
  import { access } from "node:fs/promises";
651
699
  import { homedir } from "node:os";
@@ -739,7 +787,7 @@ async function defaultQueryPackageManagerRoot(tool) {
739
787
  };
740
788
  let child;
741
789
  try {
742
- child = spawn(spec.cmd, spec.args, {
790
+ child = spawn2(spec.cmd, spec.args, {
743
791
  stdio: ["ignore", "pipe", "pipe"],
744
792
  shell: process.platform === "win32"
745
793
  });
@@ -778,54 +826,6 @@ var init_discover_parent_package_paths = __esm(() => {
778
826
  require2 = createRequire2(import.meta.url);
779
827
  });
780
828
 
781
- // src/process/terminate-process-tree.ts
782
- import { spawn as spawn2 } from "node:child_process";
783
- async function terminateProcessTree(pid, options = {}, platform = process.platform, runCommand = defaultRunProcessCommand, killProcess = (targetPid, signal) => {
784
- process.kill(targetPid, signal);
785
- }, isProcessRunning = defaultIsProcessRunning) {
786
- if (pid <= 0) {
787
- return;
788
- }
789
- if (platform === "win32") {
790
- try {
791
- await runCommand("taskkill", ["/PID", String(pid), "/T", "/F"]);
792
- } catch {}
793
- return;
794
- }
795
- const targetPid = options.detachedProcessGroup ? -pid : pid;
796
- try {
797
- killProcess(targetPid, "SIGTERM");
798
- } catch {
799
- return;
800
- }
801
- const deadline = Date.now() + 5000;
802
- while (Date.now() < deadline) {
803
- if (!isProcessRunning(targetPid)) {
804
- return;
805
- }
806
- await new Promise((resolve) => setTimeout(resolve, 100));
807
- }
808
- try {
809
- killProcess(targetPid, "SIGKILL");
810
- } catch {}
811
- }
812
- function defaultIsProcessRunning(pid) {
813
- try {
814
- process.kill(pid, 0);
815
- return true;
816
- } catch {
817
- return false;
818
- }
819
- }
820
- async function defaultRunProcessCommand(command, args) {
821
- return await new Promise((resolve, reject) => {
822
- const child = spawn2(command, args, { stdio: "ignore" });
823
- child.on("error", reject);
824
- child.on("close", (code) => resolve(code ?? 1));
825
- });
826
- }
827
- var init_terminate_process_tree = () => {};
828
-
829
829
  // src/util/text.ts
830
830
  function truncateText(text, maxLength, ellipsis = "…") {
831
831
  if (text.length <= maxLength)
@@ -860,6 +860,11 @@ var init_session = __esm(() => {
860
860
  currentLabel: "[current]",
861
861
  sessionListItem: (alias, agent, workspace) => `- ${alias} (${agent} @ ${workspace})`,
862
862
  sessionCreated: (alias) => `Session "${alias}" created and switched.`,
863
+ sessionAlreadyExists: (alias, agent, workspace) => [
864
+ `Session "${alias}" already exists (${agent} @ ${workspace}).`,
865
+ `Switch to it with /use ${alias}, or remove it first with /session rm ${alias}.`
866
+ ].join(`
867
+ `),
863
868
  sessionAttachNotFound: (alias, agent, workspace) => [
864
869
  "No existing session found to attach.",
865
870
  `Check the session name and retry: /session attach ${alias} --agent ${agent} --ws ${workspace} --name <session-name>`
@@ -896,6 +901,7 @@ var init_session = __esm(() => {
896
901
  sessionBlockedByTasksHint: "Use /tasks to list tasks, or /task cancel <id> to cancel one.",
897
902
  sessionRemoved: (alias) => `Session "${alias}" removed.`,
898
903
  sessionRemovedWasActive: "This was the active session. Its chat context has been cleared.",
904
+ sessionRemovedWasActivePromoted: (alias) => `This was the active session. Switched back to the previous session "${alias}".`,
899
905
  sessionTransportShared: (transportSession, count) => `Note: backend session "${transportSession}" is still referenced by ${count} other session(s) and was not closed.`,
900
906
  sessionOrchestrationPurgeFailed: (warning) => `Note: failed to purge orchestration references (${warning}). Run /tasks clean manually to clean up.`,
901
907
  sessionTransportTeardownFailed: (warning) => `Note: backend session could not be closed automatically (${warning}). Run acpx sessions close manually if needed.`,
@@ -1189,7 +1195,7 @@ var init_later = __esm(() => {
1189
1195
  helpNote2: "Time must be at least 10 seconds and at most 7 days away",
1190
1196
  helpNote3: "By default runs in a new temporary session that is destroyed after completion",
1191
1197
  helpNote4: "Use --bind to send to the session that was current when the task was created (configurable via later.defaultMode)",
1192
- helpNote5: "/lt list shows all pending tasks globally; in group chats only the owner can cancel",
1198
+ helpNote5: "/lt list shows only this chat's pending tasks; in group chats only the owner can cancel",
1193
1199
  helpNote6: "Scheduling slash-prefixed xacpx commands is not supported",
1194
1200
  helpNote7: "Full time format reference: docs/later-command.md"
1195
1201
  };
@@ -1682,6 +1688,8 @@ var init_plugin_cli = __esm(() => {
1682
1688
  noPlugins: "No plugins installed yet.",
1683
1689
  pluginListHeader: "Plugins:",
1684
1690
  unrecognizedArgs: (args) => `Unrecognized arguments: ${args}`,
1691
+ pluginSpecHasDoubleQuote: (spec) => `Invalid plugin spec ${spec}: double quotes (") are never valid in an npm package spec.`,
1692
+ pluginSpecHasPercentOnWindows: (spec) => `Invalid plugin spec ${spec}: "%" would be mangled by cmd.exe on Windows. Install the package with npm directly instead.`,
1685
1693
  pluginInstallFailed: (packageSpec, error) => `Plugin ${packageSpec} install failed: ${error}`,
1686
1694
  pluginValidateFailed: (recordedName, error) => `Plugin ${recordedName} validation failed: ${error}`,
1687
1695
  pluginInstalled: (recordedName) => `Plugin ${recordedName} installed`,
@@ -1793,8 +1801,6 @@ var init_weixin = __esm(() => {
1793
1801
  debugEnabled: "Debug mode enabled",
1794
1802
  debugDisabled: "Debug mode disabled",
1795
1803
  sessionCleared: "✅ Session cleared. Starting a fresh conversation.",
1796
- noAccountsLoggedIn: "No accounts are currently logged in.",
1797
- logoutSuccess: "✅ Logged out. All account credentials cleared.",
1798
1804
  commandFailed: (detail) => `❌ Command failed: ${detail}`
1799
1805
  };
1800
1806
  });
@@ -1832,6 +1838,7 @@ var init_misc = __esm(() => {
1832
1838
  quotaOverflowSummary: (count) => `(${count} progress updates omitted due to message limit; see final result below)`,
1833
1839
  finalHeadsUp: (total, sentSoFar, remaining) => `—
1834
1840
  \uD83D\uDCC4 Result: ${total} parts total, ${sentSoFar} sent. Reply /jx to see the next ${remaining} parts.`,
1841
+ finalAllParked: (count) => `\uD83D\uDCC4 Message limit reached: the result (${count} parts) is parked. Reply /jx to receive it.`,
1835
1842
  quotedMessagePrefix: (parts) => `[Quote: ${parts}]`,
1836
1843
  scheduledTaskFailed: (message) => `Scheduled task failed: ${message}`,
1837
1844
  orchestrationTaskCompleted: (taskId, workerSession, result) => `Delegation task "${taskId}" completed
@@ -1860,6 +1867,8 @@ var init_misc = __esm(() => {
1860
1867
  delegateQPackageInstr3: "Do not forward the human's exact words to the worker",
1861
1868
  commandAccessDeniedSuffix: " is restricted to group owner only.",
1862
1869
  commandAccessDeniedHint: "To perform control operations, have the owner send them in the group, or use a private chat.",
1870
+ commandAccessDeniedChatTypeMissingSuffix: " was blocked: this channel did not report the chat type (direct or group), so control commands are disabled here.",
1871
+ commandAccessDeniedChatTypeMissingHint: "Read-only commands and prompts still work. This is a channel metadata issue — update or report the channel plugin.",
1863
1872
  commandLabelThisMessage: "This message",
1864
1873
  sessionResetNoCurrentSession: "No session is currently selected. Run /session new ... or /use <alias> first.",
1865
1874
  sessionResetFailed: (alias) => `Session "${alias}" reset failed. The new backend session was not created, please try again later.`,
@@ -1930,6 +1939,11 @@ var init_session2 = __esm(() => {
1930
1939
  currentLabel: "[当前]",
1931
1940
  sessionListItem: (alias, agent2, workspace2) => `- ${alias} (${agent2} @ ${workspace2})`,
1932
1941
  sessionCreated: (alias) => `会话「${alias}」已创建并切换`,
1942
+ sessionAlreadyExists: (alias, agent2, workspace2) => [
1943
+ `会话「${alias}」已存在(${agent2} @ ${workspace2})。`,
1944
+ `发送 /use ${alias} 切换到它,或先执行 /session rm ${alias} 删除后再创建。`
1945
+ ].join(`
1946
+ `),
1933
1947
  sessionAttachNotFound: (alias, agent2, workspace2) => [
1934
1948
  "没有找到可绑定的已有会话。",
1935
1949
  `请确认会话名是否正确,然后重新执行:/session attach ${alias} --agent ${agent2} --ws ${workspace2} --name <会话名>`
@@ -1966,6 +1980,7 @@ var init_session2 = __esm(() => {
1966
1980
  sessionBlockedByTasksHint: "使用 /tasks 查看任务列表,或 /task cancel <id> 取消任务。",
1967
1981
  sessionRemoved: (alias) => `已删除会话「${alias}」。`,
1968
1982
  sessionRemovedWasActive: "该会话是当前活跃会话,已自动清除相关聊天上下文。",
1983
+ sessionRemovedWasActivePromoted: (alias) => `该会话是当前活跃会话,已切换回上一个会话「${alias}」。`,
1969
1984
  sessionTransportShared: (transportSession, count) => `提示:后端会话「${transportSession}」仍被其他 ${count} 个会话引用,未关闭。`,
1970
1985
  sessionOrchestrationPurgeFailed: (warning) => `提示:清理任务编排引用失败(${warning}),请稍后执行 /tasks clean 手动清理。`,
1971
1986
  sessionTransportTeardownFailed: (warning) => `提示:后端会话未能自动关闭(${warning}),如有残留请手动执行 acpx sessions close。`,
@@ -2259,7 +2274,7 @@ var init_later2 = __esm(() => {
2259
2274
  helpNote2: "时间必须在 10 秒之后、7 天之内",
2260
2275
  helpNote3: "默认在为本次任务新建的临时会话里执行,跑完即销毁",
2261
2276
  helpNote4: "加 --bind 改为发送到创建时绑定的当前会话(默认模式可用 later.defaultMode 配置)",
2262
- helpNote5: "/lt list 显示全局待执行任务;群聊中只有群主可取消",
2277
+ helpNote5: "/lt list 只显示本聊天创建的待执行任务;群聊中只有群主可取消",
2263
2278
  helpNote6: "不支持延迟执行 / 开头的 xacpx 命令",
2264
2279
  helpNote7: "完整时间格式与说明见 docs/later-command.md"
2265
2280
  };
@@ -2752,6 +2767,8 @@ var init_plugin_cli2 = __esm(() => {
2752
2767
  noPlugins: "还没有安装插件。",
2753
2768
  pluginListHeader: "插件:",
2754
2769
  unrecognizedArgs: (args) => `未识别的参数:${args}`,
2770
+ pluginSpecHasDoubleQuote: (spec) => `非法插件 spec ${spec}:npm 包 spec 不允许包含双引号 (")。`,
2771
+ pluginSpecHasPercentOnWindows: (spec) => `非法插件 spec ${spec}:Windows 上 cmd.exe 会展开 %,无法安全传递。请改用 npm 直接安装该包。`,
2755
2772
  pluginInstallFailed: (packageSpec, error) => `插件 ${packageSpec} 安装失败:${error}`,
2756
2773
  pluginValidateFailed: (recordedName, error) => `插件 ${recordedName} 校验失败:${error}`,
2757
2774
  pluginInstalled: (recordedName) => `插件 ${recordedName} 已安装`,
@@ -2863,8 +2880,6 @@ var init_weixin2 = __esm(() => {
2863
2880
  debugEnabled: "Debug 模式已开启",
2864
2881
  debugDisabled: "Debug 模式已关闭",
2865
2882
  sessionCleared: "✅ 会话已清除,重新开始对话",
2866
- noAccountsLoggedIn: "当前没有已登录的账号",
2867
- logoutSuccess: "✅ 已退出登录,清除所有账号凭证",
2868
2883
  commandFailed: (detail) => `❌ 指令执行失败: ${detail}`
2869
2884
  };
2870
2885
  });
@@ -2902,6 +2917,7 @@ var init_misc2 = __esm(() => {
2902
2917
  quotaOverflowSummary: (count) => `(因消息次数限制省略 ${count} 条进度,请继续查看下方最终结果)`,
2903
2918
  finalHeadsUp: (total, sentSoFar, remaining) => `—
2904
2919
  \uD83D\uDCC4 结果共 ${total} 段,已发 ${sentSoFar} 段。回复 /jx 续看后 ${remaining} 段。`,
2920
+ finalAllParked: (count) => `\uD83D\uDCC4 已达消息上限:结果共 ${count} 段已暂存。回复 /jx 接收。`,
2905
2921
  quotedMessagePrefix: (parts) => `[引用: ${parts}]`,
2906
2922
  scheduledTaskFailed: (message) => `定时任务执行失败:${message}`,
2907
2923
  orchestrationTaskCompleted: (taskId, workerSession, result) => `委派任务「${taskId}」已完成
@@ -2930,6 +2946,8 @@ var init_misc2 = __esm(() => {
2930
2946
  delegateQPackageInstr3: "不要直接把 human 原话转发给 worker",
2931
2947
  commandAccessDeniedSuffix: " 仅限群创建者/频道 owner 使用。",
2932
2948
  commandAccessDeniedHint: "如果需要执行控制类操作,请由 owner 在群内发送,或改用私聊。",
2949
+ commandAccessDeniedChatTypeMissingSuffix: " 已被拦截:该频道未上报会话类型(直聊/群聊),控制类命令在此暂不可用。",
2950
+ commandAccessDeniedChatTypeMissingHint: "只读命令与普通对话不受影响。这是频道元数据问题,请升级或反馈该频道插件。",
2933
2951
  commandLabelThisMessage: "该消息",
2934
2952
  sessionResetNoCurrentSession: "当前还没有选中的会话。请先执行 /session new ... 或 /use <alias>。",
2935
2953
  sessionResetFailed: (alias) => `会话「${alias}」重置失败。
@@ -3351,6 +3369,19 @@ function normalizeBridgePermissionMode(value) {
3351
3369
  function normalizeBridgeNonInteractivePermissions(value) {
3352
3370
  return value === "deny" || value === "fail" ? value : "deny";
3353
3371
  }
3372
+ function normalizeBridgePermissionPolicy(value) {
3373
+ if (typeof value !== "string" || value.trim().length === 0) {
3374
+ return;
3375
+ }
3376
+ return value;
3377
+ }
3378
+ function normalizeBridgeSessionInitTimeoutMs(value) {
3379
+ if (value === undefined || value.trim().length === 0) {
3380
+ return;
3381
+ }
3382
+ const parsed = Number(value);
3383
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
3384
+ }
3354
3385
  function normalizeBridgeQueueOwnerTtlSeconds(value) {
3355
3386
  if (value === undefined) {
3356
3387
  return;
@@ -3396,6 +3427,7 @@ class BridgeRequestScheduler {
3396
3427
 
3397
3428
  // src/bridge/bridge-runtime.ts
3398
3429
  init_spawn_command();
3430
+ init_terminate_process_tree();
3399
3431
  init_prompt_output();
3400
3432
  init_prompt_media();
3401
3433
  init_streaming_prompt();
@@ -3431,6 +3463,16 @@ class EnsureSessionFailedError extends Error {
3431
3463
  this.data = data;
3432
3464
  }
3433
3465
  }
3466
+ var DEFAULT_SESSION_INIT_TIMEOUT_MS = 120000;
3467
+
3468
+ class CommandTimeoutError extends Error {
3469
+ timeoutMs;
3470
+ constructor(timeoutMs, command) {
3471
+ super(`acpx command timed out after ${timeoutMs / 1000}s: ${command}`);
3472
+ this.timeoutMs = timeoutMs;
3473
+ this.name = "CommandTimeoutError";
3474
+ }
3475
+ }
3434
3476
 
3435
3477
  class BridgeRuntime {
3436
3478
  command;
@@ -3483,12 +3525,25 @@ class BridgeRuntime {
3483
3525
  "--resume-session",
3484
3526
  input.agentSessionId
3485
3527
  ], { format: "quiet" }));
3486
- const result = await this.runSessionCreate(spawnSpec.command, spawnSpec.args, input.cwd);
3528
+ const timeoutMs = this.sessionInitTimeoutMs();
3529
+ let result;
3530
+ try {
3531
+ result = await this.runSessionCreate(spawnSpec.command, spawnSpec.args, input.cwd, { timeoutMs });
3532
+ } catch (error) {
3533
+ if (error instanceof CommandTimeoutError) {
3534
+ throw new Error(`session initialization timed out after ${timeoutMs / 1000}s`);
3535
+ }
3536
+ throw error;
3537
+ }
3487
3538
  if (result.code !== 0) {
3488
3539
  throw new Error(result.stderr || result.stdout || "sessions resume failed");
3489
3540
  }
3490
3541
  return {};
3491
3542
  }
3543
+ sessionInitTimeoutMs() {
3544
+ const value = this.options.sessionInitTimeoutMs;
3545
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : DEFAULT_SESSION_INIT_TIMEOUT_MS;
3546
+ }
3492
3547
  async hasSession(input) {
3493
3548
  const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, [
3494
3549
  "sessions",
@@ -3520,6 +3575,11 @@ class BridgeRuntime {
3520
3575
  }
3521
3576
  async ensureSession(input, onProgress) {
3522
3577
  onProgress?.("spawn");
3578
+ const timeoutMs = this.sessionInitTimeoutMs();
3579
+ const now = this.options.now ?? Date.now;
3580
+ const deadline = now() + timeoutMs;
3581
+ const remainingTimeoutMs = () => Math.max(deadline - now(), 1);
3582
+ const sessionInitTimedOutError = () => new EnsureSessionFailedError(`session initialization timed out after ${timeoutMs / 1000}s`, "generic");
3523
3583
  const onStderrLine = onProgress ? (line) => {
3524
3584
  const trimmed = line.replace(/\r$/, "").trimEnd();
3525
3585
  if (trimmed.length === 0)
@@ -3527,41 +3587,58 @@ class BridgeRuntime {
3527
3587
  onProgress({ kind: "note", text: trimmed });
3528
3588
  } : undefined;
3529
3589
  const runWithVerboseFallback = async (tailArgs, runner) => {
3530
- const useVerbose = this.acpxVerboseSupported !== false;
3531
- const spec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, tailArgs, { verbose: useVerbose }));
3532
- const result = await runner(spec.command, spec.args);
3533
- if (result.code === 0) {
3534
- if (useVerbose)
3535
- this.acpxVerboseSupported = true;
3590
+ try {
3591
+ const useVerbose = this.acpxVerboseSupported !== false;
3592
+ const spec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, tailArgs, { verbose: useVerbose }));
3593
+ const result = await runner(spec.command, spec.args);
3594
+ if (result.code === 0) {
3595
+ if (useVerbose)
3596
+ this.acpxVerboseSupported = true;
3597
+ return result;
3598
+ }
3599
+ if (useVerbose && isUnknownVerboseOption(result.stderr, result.stdout)) {
3600
+ this.acpxVerboseSupported = false;
3601
+ const retrySpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, tailArgs, { verbose: false }));
3602
+ return await runner(retrySpec.command, retrySpec.args);
3603
+ }
3536
3604
  return result;
3605
+ } catch (error) {
3606
+ if (error instanceof CommandTimeoutError) {
3607
+ throw sessionInitTimedOutError();
3608
+ }
3609
+ throw error;
3537
3610
  }
3538
- if (useVerbose && isUnknownVerboseOption(result.stderr, result.stdout)) {
3539
- this.acpxVerboseSupported = false;
3540
- const retrySpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, tailArgs, { verbose: false }));
3541
- return await runner(retrySpec.command, retrySpec.args);
3542
- }
3543
- return result;
3544
3611
  };
3545
- const ensured = await runWithVerboseFallback(["sessions", "ensure", "--name", input.name], (command, args) => this.run(command, args, { onStderrLine }));
3612
+ const ensured = await runWithVerboseFallback(["sessions", "ensure", "--name", input.name], (command, args) => this.run(command, args, { onStderrLine, timeoutMs: remainingTimeoutMs() }));
3546
3613
  if (ensured.code === 0) {
3547
3614
  onProgress?.("ready");
3548
3615
  return {};
3549
3616
  }
3550
3617
  const existingSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, ["sessions", "show", input.name]));
3551
- const existing = await this.run(existingSpec.command, existingSpec.args);
3618
+ const runShowProbe = async () => {
3619
+ try {
3620
+ return await this.run(existingSpec.command, existingSpec.args, { timeoutMs: remainingTimeoutMs() });
3621
+ } catch (error) {
3622
+ if (error instanceof CommandTimeoutError) {
3623
+ throw sessionInitTimedOutError();
3624
+ }
3625
+ throw error;
3626
+ }
3627
+ };
3628
+ const existing = await runShowProbe();
3552
3629
  if (existing.code === 0) {
3553
3630
  onProgress?.("ready");
3554
3631
  return {};
3555
3632
  }
3556
3633
  onProgress?.("initializing");
3557
- const created = await runWithVerboseFallback(["sessions", "new", "--name", input.name], (command, args) => this.runSessionCreate(command, args, input.cwd, { onStderrLine }));
3634
+ const created = await runWithVerboseFallback(["sessions", "new", "--name", input.name], (command, args) => this.runSessionCreate(command, args, input.cwd, { onStderrLine, timeoutMs: remainingTimeoutMs() }));
3558
3635
  if (created.code === 0) {
3559
3636
  onProgress?.("ready");
3560
3637
  return {};
3561
3638
  }
3562
3639
  const output = created.stderr || created.stdout || "";
3563
3640
  if (output.includes("EPERM") && await this.repairSessionIndex()) {
3564
- const repaired = await this.run(existingSpec.command, existingSpec.args);
3641
+ const repaired = await runShowProbe();
3565
3642
  if (repaired.code === 0) {
3566
3643
  onProgress?.("ready");
3567
3644
  return {};
@@ -3762,12 +3839,18 @@ class BridgeRuntime {
3762
3839
  }
3763
3840
  function spawnCapture(command, args, options) {
3764
3841
  return new Promise((resolve, reject) => {
3765
- const child = spawn4(command, args, { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"] });
3842
+ const spawnFn = options?.spawnFn ?? spawn4;
3843
+ const child = spawnFn(command, args, { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"] });
3766
3844
  child.stdout.setEncoding("utf8");
3767
3845
  child.stderr.setEncoding("utf8");
3768
3846
  let stdout = "";
3769
3847
  let stderr = "";
3770
3848
  let stderrTail = "";
3849
+ const timeoutId = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? setTimeout(() => {
3850
+ const kill = options.killProcessTreeFn ?? ((pid) => terminateProcessTree(pid, { detachedProcessGroup: false }));
3851
+ kill(child.pid ?? 0);
3852
+ reject(new CommandTimeoutError(options.timeoutMs, [command, ...args].join(" ")));
3853
+ }, options.timeoutMs) : undefined;
3771
3854
  child.stdout.on("data", (chunk) => {
3772
3855
  stdout += String(chunk);
3773
3856
  });
@@ -3783,8 +3866,14 @@ function spawnCapture(command, args, options) {
3783
3866
  options.onStderrLine(line);
3784
3867
  }
3785
3868
  });
3786
- child.on("error", reject);
3869
+ child.on("error", (error) => {
3870
+ if (timeoutId)
3871
+ clearTimeout(timeoutId);
3872
+ reject(error);
3873
+ });
3787
3874
  child.on("close", (code) => {
3875
+ if (timeoutId)
3876
+ clearTimeout(timeoutId);
3788
3877
  if (options?.onStderrLine && stderrTail.length > 0) {
3789
3878
  options.onStderrLine(stderrTail);
3790
3879
  }
@@ -3857,7 +3946,7 @@ async function defaultPromptRunner(command, args, onEvent, options) {
3857
3946
  return await runStreamingPrompt(command, args, onEvent, options);
3858
3947
  }
3859
3948
  async function shellSessionCreateRunner(command, args, cwd, options) {
3860
- return await spawnCapture(command, args, { cwd, onStderrLine: options?.onStderrLine });
3949
+ return await spawnCapture(command, args, { ...options, cwd });
3861
3950
  }
3862
3951
  function selectLatestAcpxSessionIndexTmp(files) {
3863
3952
  let latestTmp = null;
@@ -4006,7 +4095,8 @@ class BridgeServer {
4006
4095
  case "updatePermissionPolicy":
4007
4096
  return await this.runtime.updatePermissionPolicy({
4008
4097
  permissionMode: requirePermissionMode(params, "permissionMode"),
4009
- nonInteractivePermissions: requireNonInteractivePermissions(params, "nonInteractivePermissions")
4098
+ nonInteractivePermissions: requireNonInteractivePermissions(params, "nonInteractivePermissions"),
4099
+ permissionPolicy: asOptionalString(params.permissionPolicy)
4010
4100
  });
4011
4101
  case "hasSession":
4012
4102
  return await this.runtime.hasSession({
@@ -4315,7 +4405,9 @@ async function runBridgeMain() {
4315
4405
  const server = new BridgeServer(new BridgeRuntime(coreEnv("BRIDGE_ACPX_COMMAND") ?? "acpx", undefined, undefined, {
4316
4406
  permissionMode: normalizeBridgePermissionMode(coreEnv("BRIDGE_PERMISSION_MODE")),
4317
4407
  nonInteractivePermissions: normalizeBridgeNonInteractivePermissions(coreEnv("BRIDGE_NON_INTERACTIVE_PERMISSIONS")),
4318
- queueOwnerTtlSeconds: normalizeBridgeQueueOwnerTtlSeconds(coreEnv("BRIDGE_QUEUE_OWNER_TTL_SECONDS"))
4408
+ permissionPolicy: normalizeBridgePermissionPolicy(coreEnv("BRIDGE_PERMISSION_POLICY")),
4409
+ queueOwnerTtlSeconds: normalizeBridgeQueueOwnerTtlSeconds(coreEnv("BRIDGE_QUEUE_OWNER_TTL_SECONDS")),
4410
+ sessionInitTimeoutMs: normalizeBridgeSessionInitTimeoutMs(coreEnv("BRIDGE_SESSION_INIT_TIMEOUT_MS"))
4319
4411
  }));
4320
4412
  const input = createInterface({
4321
4413
  input: process.stdin,
@@ -96,8 +96,19 @@ export interface MessageChannelRuntime {
96
96
  id: string;
97
97
  isLoggedIn(): boolean;
98
98
  login(): Promise<string>;
99
+ /**
100
+ * Destructive credential removal. Reached only via the explicit
101
+ * `xacpx logout` CLI path — never as part of a normal shutdown.
102
+ */
99
103
  logout(): void;
100
104
  start(input: ChannelStartInput): Promise<void>;
105
+ /**
106
+ * Non-destructive shutdown: release runtime resources without touching
107
+ * stored credentials. Optional for compatibility with already-published
108
+ * plugin channels; when absent, the registry falls back to `logout()`
109
+ * (which for those plugins is a benign client stop).
110
+ */
111
+ stop?(): void | Promise<void>;
101
112
  createConsumerLock?(options?: ConsumerLockOptions): ConsumerLock;
102
113
  configureOrchestration?(callbacks: OrchestrationDeliveryCallbacks): void;
103
114
  notifyTaskCompletion(task: OrchestrationTaskRecord): Promise<void>;
@@ -15,6 +15,14 @@ export declare class WeixinChannel implements MessageChannelRuntime {
15
15
  isLoggedIn(): boolean;
16
16
  login(): Promise<string>;
17
17
  logout(): void;
18
+ /**
19
+ * Non-destructive shutdown. The monitor loop is already stopped via the
20
+ * abort signal passed to start(); this only drops runtime references and
21
+ * MUST NOT touch the credential files on disk (a graceful daemon stop or
22
+ * restart must not force a QR re-login). Destructive credential removal
23
+ * happens only through logout() (the explicit `xacpx logout` CLI path).
24
+ */
25
+ stop(): void;
18
26
  createConsumerLock(options?: ConsumerLockOptions): ConsumerLock;
19
27
  configureOrchestration(callbacks: OrchestrationDeliveryCallbacks): void;
20
28
  start(input: ChannelStartInput): Promise<void>;