@ganglion/xacpx 0.20.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4889,6 +4889,7 @@ var init_channel_cli = __esm(() => {
4889
4889
  channelAdded: (type) => `Channel ${type} added`,
4890
4890
  cannotRemoveLastEnabled: "Cannot remove the last enabled channel.",
4891
4891
  channelRemoved: (id) => `Channel ${id} removed`,
4892
+ channelRetirementFailed: (id, error) => `Channel ${id} retirement cleanup failed: ${error}`,
4892
4893
  channelCredentialsCleared: (id) => `Removed stored credentials for channel ${id}`,
4893
4894
  channelCredentialsClearFailed: (id, error) => `Channel ${id} removed, but clearing its stored credentials failed: ${error}`,
4894
4895
  channelCredentialsKept: (id) => `Kept stored credentials for channel ${id} (--keep-credentials)`,
@@ -6015,6 +6016,7 @@ var init_channel_cli2 = __esm(() => {
6015
6016
  channelAdded: (type) => `频道 ${type} 已添加`,
6016
6017
  cannotRemoveLastEnabled: "不能删除最后一个启用的频道。",
6017
6018
  channelRemoved: (id) => `频道 ${id} 已删除`,
6019
+ channelRetirementFailed: (id, error) => `频道 ${id} 退役清理失败:${error}`,
6018
6020
  channelCredentialsCleared: (id) => `已移除频道 ${id} 的存储凭证`,
6019
6021
  channelCredentialsClearFailed: (id, error) => `频道 ${id} 已删除,但清除其存储凭证失败:${error}`,
6020
6022
  channelCredentialsKept: (id) => `已保留频道 ${id} 的存储凭证(--keep-credentials)`,
@@ -22,6 +22,18 @@ export interface ChannelCliIo {
22
22
  promptText: (message: string) => Promise<string>;
23
23
  promptSecret: (message: string) => Promise<string>;
24
24
  }
25
+ /**
26
+ * Structured doctor finding from an optional channel/plugin diagnostic hook.
27
+ * Core renders these verbatim and must not interpret RMUX-specific codes.
28
+ */
29
+ export type ChannelDoctorFindingLevel = "ok" | "warn" | "error" | "skip";
30
+ export interface ChannelDoctorFinding {
31
+ level: ChannelDoctorFindingLevel;
32
+ code: string;
33
+ message: string;
34
+ suggestion?: string;
35
+ details?: Record<string, string | number | boolean | null>;
36
+ }
25
37
  export interface ChannelCliProvider {
26
38
  type: string;
27
39
  displayName: string;
@@ -31,6 +43,11 @@ export interface ChannelCliProvider {
31
43
  validateConfig(config: ChannelRuntimeConfig): ChannelCliValidationIssue[];
32
44
  renderSummary(config: ChannelRuntimeConfig): string[];
33
45
  promptForMissingFields(input: ChannelCliInput, io: ChannelCliIo): Promise<ChannelCliInput>;
46
+ /**
47
+ * Optional read-only health probe for `xacpx doctor` / `xacpx plugin doctor`.
48
+ * Must not mutate registry, credentials, or kill resources.
49
+ */
50
+ diagnose?(config: ChannelRuntimeConfig): Promise<ChannelDoctorFinding[]> | ChannelDoctorFinding[];
34
51
  /**
35
52
  * Optional: declares this plugin supports the `xacpx channel ... --account <id>`
36
53
  * multi-bot CLI surface. Plugins that opt in must also implement
@@ -1,9 +1,30 @@
1
1
  import type { ChannelFactory } from "./create-channel.js";
2
2
  import type { ChannelCliProvider } from "./cli/provider.js";
3
+ import type { ChannelRuntimeConfig } from "../config/types.js";
4
+ export type ChannelRetireDaemonState = "running" | "stopped" | "indeterminate";
5
+ /** Context passed to a plugin `retireChannel` hook from `channel disable|rm`. */
6
+ export interface ChannelRetireContext {
7
+ channel: ChannelRuntimeConfig;
8
+ reason: "disabled" | "removed";
9
+ print: (line: string) => void;
10
+ getDaemonStatus: () => Promise<{
11
+ state: ChannelRetireDaemonState;
12
+ }>;
13
+ }
14
+ export type ChannelRetireHook = (ctx: ChannelRetireContext) => Promise<void>;
3
15
  export interface ChannelPluginDefinition {
4
16
  /** Stable channel type used in channels[].type and chatKey prefix. */
5
17
  type: string;
6
18
  factory: ChannelFactory;
7
19
  cliProvider?: ChannelCliProvider;
20
+ /**
21
+ * Optional CLI lifecycle hook for `xacpx channel disable|rm`.
22
+ * Invoked after plugins are loaded, via the per-type registry — core must
23
+ * not import the plugin npm package to retire resources.
24
+ */
25
+ retireChannel?: ChannelRetireHook;
8
26
  }
9
27
  export declare function registerChannelPlugin(plugin: ChannelPluginDefinition): void;
28
+ export declare function getChannelRetireHook(type: string): ChannelRetireHook | undefined;
29
+ /** Dispatch `channel disable|rm` retirement to the loaded plugin for this type. */
30
+ export declare function invokeChannelRetireHook(ctx: ChannelRetireContext): Promise<void>;
@@ -5,6 +5,7 @@ import type { AppLogger } from "../logging/app-logger.js";
5
5
  import type { PendingFinalChunk } from "../weixin/messaging/quota-manager.js";
6
6
  import type { PerfTracer } from "../perf/perf-tracer.js";
7
7
  import type { SessionService } from "../sessions/session-service.js";
8
+ import type { SessionResourceCatalog } from "../sessions/session-resource-catalog.js";
8
9
  import type { ActiveTurnRegistry } from "../sessions/active-turn-registry.js";
9
10
  import type { Locale } from "../i18n/index.js";
10
11
  import type { ControlService } from "../control/control-service.js";
@@ -79,6 +80,12 @@ export interface ChannelStartInput {
79
80
  * channels ignore it.
80
81
  */
81
82
  control?: ControlService;
83
+ /**
84
+ * Generic catalog of logical-session resources: immutable logical session
85
+ * IDs, internal/display aliases, authoritative workspace cwd, archived flag,
86
+ * and a lifecycle-event subscription. Optional: text-only channels ignore it.
87
+ */
88
+ sessionResources?: SessionResourceCatalog;
82
89
  }
83
90
  export interface OrchestrationDeliveryCallbacks {
84
91
  markTaskNoticeDelivered: (taskId: string, accountId: string) => Promise<void>;
@@ -114,6 +121,7 @@ export interface ConsumerLockOptions {
114
121
  lockFilePath?: string;
115
122
  onDiagnostic?: (event: string, context: Record<string, string | number | boolean | undefined>) => void | Promise<void>;
116
123
  }
124
+ export type ChannelStopReason = "shutdown" | "disabled" | "removed" | "logout";
117
125
  export interface MessageChannelRuntime {
118
126
  id: string;
119
127
  isLoggedIn(): boolean;
@@ -122,15 +130,21 @@ export interface MessageChannelRuntime {
122
130
  * Destructive credential removal. Reached only via the explicit
123
131
  * `xacpx logout` CLI path — never as part of a normal shutdown.
124
132
  */
125
- logout(): void;
133
+ logout(): void | Promise<void>;
126
134
  start(input: ChannelStartInput): Promise<void>;
127
135
  /**
128
136
  * Non-destructive shutdown: release runtime resources without touching
129
137
  * stored credentials. Optional for compatibility with already-published
130
138
  * plugin channels; when absent, the registry falls back to `logout()`
131
139
  * (which for those plugins is a benign client stop).
140
+ *
141
+ * @param reason - The reason for stopping:
142
+ * - "shutdown": normal signal/startup-error cleanup
143
+ * - "disabled": channel disabled via CLI
144
+ * - "removed": channel removed via CLI
145
+ * - "logout": explicit logout command
132
146
  */
133
- stop?(): void | Promise<void>;
147
+ stop?(reason?: ChannelStopReason): void | Promise<void>;
134
148
  createConsumerLock?(options?: ConsumerLockOptions): ConsumerLock;
135
149
  configureOrchestration?(callbacks: OrchestrationDeliveryCallbacks): void;
136
150
  notifyTaskCompletion(task: OrchestrationTaskRecord): Promise<void>;
package/dist/cli.js CHANGED
@@ -927,6 +927,7 @@ var init_channel_cli = __esm(() => {
927
927
  channelAdded: (type) => `Channel ${type} added`,
928
928
  cannotRemoveLastEnabled: "Cannot remove the last enabled channel.",
929
929
  channelRemoved: (id) => `Channel ${id} removed`,
930
+ channelRetirementFailed: (id, error) => `Channel ${id} retirement cleanup failed: ${error}`,
930
931
  channelCredentialsCleared: (id) => `Removed stored credentials for channel ${id}`,
931
932
  channelCredentialsClearFailed: (id, error) => `Channel ${id} removed, but clearing its stored credentials failed: ${error}`,
932
933
  channelCredentialsKept: (id) => `Kept stored credentials for channel ${id} (--keep-credentials)`,
@@ -2053,6 +2054,7 @@ var init_channel_cli2 = __esm(() => {
2053
2054
  channelAdded: (type) => `频道 ${type} 已添加`,
2054
2055
  cannotRemoveLastEnabled: "不能删除最后一个启用的频道。",
2055
2056
  channelRemoved: (id) => `频道 ${id} 已删除`,
2057
+ channelRetirementFailed: (id, error) => `频道 ${id} 退役清理失败:${error}`,
2056
2058
  channelCredentialsCleared: (id) => `已移除频道 ${id} 的存储凭证`,
2057
2059
  channelCredentialsClearFailed: (id, error) => `频道 ${id} 已删除,但清除其存储凭证失败:${error}`,
2058
2060
  channelCredentialsKept: (id) => `已保留频道 ${id} 的存储凭证(--keep-credentials)`,
@@ -8203,7 +8205,7 @@ function createCoreRuntimeLock(options) {
8203
8205
  try {
8204
8206
  if (isBunRuntime() && !options.loadFsExt) {
8205
8207
  await handle.close();
8206
- posixHelper = await acquireFlockHelper(options.lockFilePath, options.helperAcquireTimeoutMs, options.helperHandshakeDelayMs);
8208
+ posixHelper = await acquireFlockHelper(options.lockFilePath, options.helperAcquireTimeoutMs, options.helperHandshakeDelayMs, onDiagnostic);
8207
8209
  await emitDiagnostic(onDiagnostic, "lock_helper_started", {
8208
8210
  lockFilePath: options.lockFilePath,
8209
8211
  helperPid: posixHelper.pid
@@ -8301,11 +8303,17 @@ async function releasePosixLock(options, handle, helper) {
8301
8303
  function isBunRuntime() {
8302
8304
  return Boolean(globalThis.Bun);
8303
8305
  }
8304
- async function acquireFlockHelper(lockFilePath, timeoutMs = 1e4, handshakeDelayMs = 0) {
8306
+ async function acquireFlockHelper(lockFilePath, timeoutMs = 1e4, handshakeDelayMs = 0, onDiagnostic) {
8305
8307
  const fsExtPath = require2.resolve("fs-ext");
8306
8308
  const child = spawn7("node", ["-e", FLOCK_HELPER_SOURCE, fsExtPath, lockFilePath, String(handshakeDelayMs)], {
8307
8309
  stdio: ["pipe", "pipe", "pipe"]
8308
8310
  });
8311
+ if (child.pid) {
8312
+ await emitDiagnostic(onDiagnostic, "lock_helper_spawned", {
8313
+ lockFilePath,
8314
+ helperPid: child.pid
8315
+ });
8316
+ }
8309
8317
  const line = await new Promise((resolve5, reject) => {
8310
8318
  let stdout = "";
8311
8319
  let stderr = "";
@@ -29367,6 +29375,7 @@ function createEmptyState() {
29367
29375
  var init_types4 = () => {};
29368
29376
 
29369
29377
  // src/state/state-store.ts
29378
+ import { randomUUID as randomUUID6 } from "node:crypto";
29370
29379
  import { readFile as readFile10, rename as rename3, writeFile as writeFile4 } from "node:fs/promises";
29371
29380
  function isRecord3(value) {
29372
29381
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -29619,6 +29628,9 @@ function parseOrchestrationState(raw, dropped) {
29619
29628
  function isReplyMode2(value) {
29620
29629
  return value === "stream" || value === "final" || value === "verbose";
29621
29630
  }
29631
+ function isLogicalSessionId(value) {
29632
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
29633
+ }
29622
29634
  function isSessionSource(value) {
29623
29635
  return value === undefined || value === "weacpx" || value === "xacpx" || value === "agent-side";
29624
29636
  }
@@ -29626,16 +29638,26 @@ function isSessionRecord(value) {
29626
29638
  if (!isRecord3(value)) {
29627
29639
  return false;
29628
29640
  }
29629
- return isString(value.alias) && isString(value.agent) && isString(value.workspace) && isString(value.transport_session) && isSessionSource(value.source) && isOptionalString(value.agent_session_id) && isOptionalString(value.agent_session_title) && isOptionalString(value.agent_session_updated_at) && isOptionalString(value.attached_at) && isOptionalString(value.transport_agent_command) && isOptionalString(value.transport_acpx_agent) && (value.transport_agent_argv === undefined || isStringArray(value.transport_agent_argv)) && isOptionalString(value.mode_id) && isOptionalString(value.effort) && (value.reply_mode === undefined || isReplyMode2(value.reply_mode)) && isString(value.created_at) && isString(value.last_used_at);
29641
+ return isString(value.alias) && isString(value.agent) && isString(value.workspace) && isString(value.transport_session) && (value.logical_session_id === undefined || isLogicalSessionId(value.logical_session_id)) && isSessionSource(value.source) && isOptionalString(value.agent_session_id) && isOptionalString(value.agent_session_title) && isOptionalString(value.agent_session_updated_at) && isOptionalString(value.attached_at) && isOptionalString(value.transport_agent_command) && isOptionalString(value.transport_acpx_agent) && (value.transport_agent_argv === undefined || isStringArray(value.transport_agent_argv)) && isOptionalString(value.mode_id) && isOptionalString(value.effort) && (value.reply_mode === undefined || isReplyMode2(value.reply_mode)) && isString(value.created_at) && isString(value.last_used_at);
29630
29642
  }
29631
- function parseSessions(raw, dropped) {
29643
+ function parseSessions(raw, dropped, migrated) {
29632
29644
  const sessions = {};
29633
29645
  for (const [alias, value] of Object.entries(raw)) {
29634
29646
  if (!isSessionRecord(value)) {
29635
29647
  dropped.push({ section: "sessions", key: alias, reason: "malformed session record" });
29636
29648
  continue;
29637
29649
  }
29638
- sessions[alias] = value;
29650
+ if (value.logical_session_id === undefined) {
29651
+ migrated.push({
29652
+ section: "sessions",
29653
+ key: alias,
29654
+ reason: "legacy record missing logical_session_id; assigned a new UUIDv4"
29655
+ });
29656
+ }
29657
+ sessions[alias] = {
29658
+ ...value,
29659
+ logical_session_id: value.logical_session_id ?? randomUUID6()
29660
+ };
29639
29661
  }
29640
29662
  return sessions;
29641
29663
  }
@@ -29700,11 +29722,11 @@ function parseScheduledTasks(raw, dropped) {
29700
29722
  }
29701
29723
  return tasks;
29702
29724
  }
29703
- function parseState(raw, path3, dropped = []) {
29725
+ function parseState(raw, path3, dropped = [], migrated = []) {
29704
29726
  if (!isRecord3(raw)) {
29705
29727
  throw new Error(`state file "${path3}" must contain a JSON object`);
29706
29728
  }
29707
- const parsedSessions = parseSessions(sectionRecord(raw.sessions, "sessions", dropped), dropped);
29729
+ const parsedSessions = parseSessions(sectionRecord(raw.sessions, "sessions", dropped), dropped, migrated);
29708
29730
  const orchestration3 = parseOrchestrationState(raw.orchestration, dropped);
29709
29731
  repairExternalCoordinatorIdentityCollisions(parsedSessions, orchestration3, dropped);
29710
29732
  return {
@@ -29765,20 +29787,31 @@ class StateStore {
29765
29787
  if (read.kind === "corrupt") {
29766
29788
  return await this.recoverFromCorruptFile(read.reason);
29767
29789
  }
29768
- if (read.dropped.length === 0) {
29790
+ if (read.dropped.length === 0 && read.migrated.length === 0) {
29769
29791
  return read.state;
29770
29792
  }
29771
29793
  const report = { dropped: read.dropped };
29772
- const quarantinePath = `${this.path}.quarantine-${this.fileTimestamp()}`;
29773
- try {
29774
- const written = await (this.options.writeBackup ?? defaultWriteBackup)(quarantinePath, read.content);
29775
- report.quarantinePath = typeof written === "string" ? written : quarantinePath;
29776
- } catch (error2) {
29777
- report.backupError = error2 instanceof Error ? error2.message : String(error2);
29794
+ if (read.migrated.length > 0) {
29795
+ report.migrated = read.migrated;
29796
+ }
29797
+ if (read.dropped.length > 0) {
29798
+ const quarantinePath = `${this.path}.quarantine-${this.fileTimestamp()}`;
29799
+ try {
29800
+ const written = await (this.options.writeBackup ?? defaultWriteBackup)(quarantinePath, read.content);
29801
+ report.quarantinePath = typeof written === "string" ? written : quarantinePath;
29802
+ } catch (error2) {
29803
+ report.backupError = error2 instanceof Error ? error2.message : String(error2);
29804
+ }
29805
+ }
29806
+ if (read.migrated.length > 0) {
29807
+ await this.persistMigration(read.state);
29778
29808
  }
29779
29809
  this.loadReport = report;
29780
29810
  return read.state;
29781
29811
  }
29812
+ async persistMigration(state) {
29813
+ await (this.options.writeMigration ?? ((next) => this.save(next)))(state);
29814
+ }
29782
29815
  async inspect() {
29783
29816
  const read = await this.readAndParse();
29784
29817
  if (read.kind === "absent") {
@@ -29792,7 +29825,10 @@ class StateStore {
29792
29825
  }
29793
29826
  return {
29794
29827
  state: read.state,
29795
- report: read.dropped.length > 0 ? { dropped: read.dropped } : null
29828
+ report: read.dropped.length > 0 || read.migrated.length > 0 ? {
29829
+ dropped: read.dropped,
29830
+ ...read.migrated.length > 0 ? { migrated: read.migrated } : {}
29831
+ } : null
29796
29832
  };
29797
29833
  }
29798
29834
  async readAndParse() {
@@ -29821,7 +29857,8 @@ class StateStore {
29821
29857
  return { kind: "corrupt", reason: "top-level value is not an object" };
29822
29858
  }
29823
29859
  const dropped = [];
29824
- return { kind: "parsed", state: parseState(parsed, this.path, dropped), dropped, content };
29860
+ const migrated = [];
29861
+ return { kind: "parsed", state: parseState(parsed, this.path, dropped, migrated), dropped, migrated, content };
29825
29862
  }
29826
29863
  async save(state) {
29827
29864
  await writePrivateFileAtomic(this.path, JSON.stringify({ version: STATE_FILE_VERSION, ...state }, null, 2));
@@ -32272,7 +32309,7 @@ var require_main = __commonJS((exports, module) => {
32272
32309
  });
32273
32310
 
32274
32311
  // src/weixin/auth/login-qr.ts
32275
- import { randomUUID as randomUUID6 } from "node:crypto";
32312
+ import { randomUUID as randomUUID7 } from "node:crypto";
32276
32313
  function isLoginFresh(login3) {
32277
32314
  return Date.now() - login3.startedAt < ACTIVE_LOGIN_TTL_MS;
32278
32315
  }
@@ -32375,7 +32412,7 @@ async function readVerifyCodeFromStdin(prompt) {
32375
32412
  });
32376
32413
  }
32377
32414
  async function startWeixinLoginWithQr(opts) {
32378
- const sessionKey = opts.accountId || randomUUID6();
32415
+ const sessionKey = opts.accountId || randomUUID7();
32379
32416
  purgeExpiredLogins();
32380
32417
  const existing = activeLogins.get(sessionKey);
32381
32418
  if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
@@ -32398,7 +32435,7 @@ async function startWeixinLoginWithQr(opts) {
32398
32435
  });
32399
32436
  const login3 = {
32400
32437
  sessionKey,
32401
- id: randomUUID6(),
32438
+ id: randomUUID7(),
32402
32439
  qrcode: qrResponse.qrcode,
32403
32440
  qrcodeUrl: qrResponse.qrcode_img_content,
32404
32441
  startedAt: Date.now()
@@ -36388,7 +36425,7 @@ var init_scheduled_turn = __esm(() => {
36388
36425
  });
36389
36426
 
36390
36427
  // src/weixin/monitor/consumer-lock.ts
36391
- import { randomUUID as randomUUID7 } from "node:crypto";
36428
+ import { randomUUID as randomUUID8 } from "node:crypto";
36392
36429
  import { mkdir as mkdir10, open as open6, readFile as readFile12, rm as rm9, writeFile as writeFile7 } from "node:fs/promises";
36393
36430
  import { dirname as dirname15, join as join17 } from "node:path";
36394
36431
  import { homedir as homedir6 } from "node:os";
@@ -36412,7 +36449,7 @@ function createWeixinConsumerLock(options = {}) {
36412
36449
  const existing = await loadLockMetadata(lockFilePath) ?? meta2;
36413
36450
  throw new ActiveWeixinConsumerLockError(lockFilePath, existing);
36414
36451
  }
36415
- windowsLockId = meta2.lockId ?? randomUUID7();
36452
+ windowsLockId = meta2.lockId ?? randomUUID8();
36416
36453
  const metadata = {
36417
36454
  ...meta2,
36418
36455
  schemaVersion: 2,
@@ -36432,7 +36469,7 @@ function createWeixinConsumerLock(options = {}) {
36432
36469
  return;
36433
36470
  }
36434
36471
  const requestedIdentity = await (options.probeProcessIdentity ?? probePosixProcessIdentity)(meta2.pid);
36435
- posixLockId = meta2.lockId ?? randomUUID7();
36472
+ posixLockId = meta2.lockId ?? randomUUID8();
36436
36473
  const requested = requestedIdentity.status === "found" ? { ...meta2, schemaVersion: 2, lockId: posixLockId, processStartedAtMs: requestedIdentity.identity.startedAtMs } : { ...meta2, schemaVersion: 2, lockId: posixLockId };
36437
36474
  while (true) {
36438
36475
  try {
@@ -36916,6 +36953,12 @@ var init_registry = __esm(() => {
36916
36953
  });
36917
36954
 
36918
36955
  // src/channels/plugin.ts
36956
+ var exports_plugin = {};
36957
+ __export(exports_plugin, {
36958
+ registerChannelPlugin: () => registerChannelPlugin,
36959
+ invokeChannelRetireHook: () => invokeChannelRetireHook,
36960
+ getChannelRetireHook: () => getChannelRetireHook
36961
+ });
36919
36962
  function registerChannelPlugin(plugin) {
36920
36963
  bootstrapBuiltinChannelFactories();
36921
36964
  bootstrapBuiltinChannelCliProviders();
@@ -36930,10 +36973,23 @@ function registerChannelPlugin(plugin) {
36930
36973
  registerChannelFactory(plugin.type, plugin.factory);
36931
36974
  if (plugin.cliProvider)
36932
36975
  registerChannelCliProvider(plugin.cliProvider);
36976
+ if (plugin.retireChannel)
36977
+ retireHooks.set(channelType, plugin.retireChannel);
36933
36978
  }
36979
+ function getChannelRetireHook(type) {
36980
+ return retireHooks.get(type);
36981
+ }
36982
+ async function invokeChannelRetireHook(ctx) {
36983
+ const hook = retireHooks.get(ctx.channel.type);
36984
+ if (!hook)
36985
+ return;
36986
+ await hook(ctx);
36987
+ }
36988
+ var retireHooks;
36934
36989
  var init_plugin = __esm(() => {
36935
36990
  init_create_channel();
36936
36991
  init_registry();
36992
+ retireHooks = new Map;
36937
36993
  });
36938
36994
 
36939
36995
  // src/plugins/compatibility.ts
@@ -38373,6 +38429,13 @@ async function removeChannel(type, rawArgs, deps) {
38373
38429
  deps.print(t().channelCli.cannotRemoveLastEnabled);
38374
38430
  return 1;
38375
38431
  }
38432
+ if (deps.retireChannel) {
38433
+ try {
38434
+ await deps.retireChannel(channel, "removed");
38435
+ } catch (error2) {
38436
+ deps.print(t().channelCli.channelRetirementFailed(channel.id, error2 instanceof Error ? error2.message : String(error2)));
38437
+ }
38438
+ }
38376
38439
  config4.channels = config4.channels.filter((entry) => entry.id !== channel.id);
38377
38440
  await deps.saveChannels(config4.channels);
38378
38441
  deps.print(t().channelCli.channelRemoved(channel.id));
@@ -38405,6 +38468,13 @@ async function setChannelEnabled(type, enabled, rawArgs, deps) {
38405
38468
  deps.print(t().channelCli.cannotDisableLastEnabled);
38406
38469
  return 1;
38407
38470
  }
38471
+ if (!enabled && channel.enabled && deps.retireChannel) {
38472
+ try {
38473
+ await deps.retireChannel(channel, "disabled");
38474
+ } catch (error2) {
38475
+ deps.print(t().channelCli.channelRetirementFailed(channel.id, error2 instanceof Error ? error2.message : String(error2)));
38476
+ }
38477
+ }
38408
38478
  channel.enabled = enabled;
38409
38479
  await deps.saveChannels(config4.channels);
38410
38480
  deps.print(t().channelCli.channelEnabledToggled(channel.id, enabled));
@@ -38770,6 +38840,16 @@ async function inspectPlugins(input) {
38770
38840
  message: configPlugin.enabled ? `plugin is installed and valid; channels: ${channelTypes.length > 0 ? channelTypes.join(", ") : "none"}` : `plugin is installed and valid but disabled; run xacpx plugin enable ${configPlugin.name}`,
38771
38841
  ...configPlugin.enabled ? {} : { suggestion: `xacpx plugin enable ${configPlugin.name}` }
38772
38842
  });
38843
+ if (configPlugin.enabled) {
38844
+ for (const finding of await collectChannelDiagnoseFindings(plugin.channels ?? [], input.config)) {
38845
+ pushIfRelevant({
38846
+ level: finding.level === "skip" ? "ok" : finding.level,
38847
+ plugin: configPlugin.name,
38848
+ message: finding.level === "skip" ? `${finding.code}: ${finding.message}` : `${finding.code}: ${finding.message}`,
38849
+ ...finding.suggestion ? { suggestion: finding.suggestion } : {}
38850
+ });
38851
+ }
38852
+ }
38773
38853
  } catch (error2) {
38774
38854
  const message = error2 instanceof Error ? error2.message : String(error2);
38775
38855
  pushIfRelevant({ level: "error", plugin: configPlugin.name, message });
@@ -38804,6 +38884,28 @@ async function inspectPlugins(input) {
38804
38884
  }
38805
38885
  return issues;
38806
38886
  }
38887
+ async function collectChannelDiagnoseFindings(channelDefs, config4) {
38888
+ const findings = [];
38889
+ for (const def of channelDefs) {
38890
+ const diagnose = def.cliProvider?.diagnose;
38891
+ if (!diagnose)
38892
+ continue;
38893
+ const runtimeChannels = (config4.channels ?? []).filter((channel) => channel.type === def.type && channel.enabled !== false);
38894
+ for (const channel of runtimeChannels) {
38895
+ try {
38896
+ const result = await diagnose(channel);
38897
+ findings.push(...result);
38898
+ } catch (error2) {
38899
+ findings.push({
38900
+ level: "error",
38901
+ code: "channel-diagnose-failed",
38902
+ message: `channel ${channel.type} diagnose hook failed: ${error2 instanceof Error ? error2.message : String(error2)}`
38903
+ });
38904
+ }
38905
+ }
38906
+ }
38907
+ return findings;
38908
+ }
38807
38909
  var init_plugin_doctor = __esm(() => {
38808
38910
  init_channel_scope();
38809
38911
  init_plugin_loader();
@@ -49610,7 +49712,7 @@ function replaceRuntimeState(target, source) {
49610
49712
  }
49611
49713
 
49612
49714
  // src/sessions/session-service.ts
49613
- import { randomBytes as randomBytes2 } from "node:crypto";
49715
+ import { randomBytes as randomBytes2, randomUUID as randomUUID9 } from "node:crypto";
49614
49716
  import { dirname as dirname19 } from "node:path";
49615
49717
 
49616
49718
  class SessionService {
@@ -49622,6 +49724,7 @@ class SessionService {
49622
49724
  platform;
49623
49725
  runtimeRoot;
49624
49726
  pendingSessionAliasOperations = new Set;
49727
+ lifecyclePublisher;
49625
49728
  constructor(config4, stateStore, state, options = {}) {
49626
49729
  this.config = config4;
49627
49730
  this.stateStore = stateStore;
@@ -49667,6 +49770,7 @@ class SessionService {
49667
49770
  agent: agent3,
49668
49771
  workspace: workspace3,
49669
49772
  transport_session: transportSession,
49773
+ logical_session_id: existing?.logical_session_id ?? randomUUID9(),
49670
49774
  transport_agent_command: sameAgentExisting?.transport_agent_command,
49671
49775
  transport_acpx_agent: sameAgentExisting?.transport_acpx_agent,
49672
49776
  transport_agent_argv: sameAgentExisting?.transport_agent_argv,
@@ -49740,30 +49844,42 @@ class SessionService {
49740
49844
  if (!session3) {
49741
49845
  throw new Error(`session "${alias}" does not exist`);
49742
49846
  }
49743
- const prevCtx = this.state.chat_contexts[chatKey];
49744
- const previousCurrent = prevCtx?.current_session;
49745
- const carriedPrevious = previousCurrent && previousCurrent !== internalAlias ? previousCurrent : prevCtx?.previous_session;
49746
- session3.last_used_at = new Date().toISOString();
49747
49847
  if (session3.archived) {
49748
- delete session3.archived;
49749
- delete session3.archived_at;
49750
- }
49751
- const nextCtx = { ...prevCtx, current_session: internalAlias };
49752
- if (carriedPrevious) {
49753
- nextCtx.previous_session = carriedPrevious;
49754
- } else {
49755
- delete nextCtx.previous_session;
49848
+ const nextState = structuredClone(this.state);
49849
+ const result2 = this.applyUseSession(nextState, chatKey, internalAlias);
49850
+ await this.commitLifecycleTransition(nextState);
49851
+ this.publishLifecycleEvent("restored", nextState.sessions[internalAlias]);
49852
+ return result2;
49756
49853
  }
49757
- this.state.chat_contexts[chatKey] = nextCtx;
49854
+ const result = this.applyUseSession(this.state, chatKey, internalAlias);
49758
49855
  await this.persist();
49759
- return {
49760
- alias: toDisplaySessionAlias(session3.alias),
49761
- agent: session3.agent,
49762
- workspace: session3.workspace,
49763
- previousAlias: carriedPrevious ? toDisplaySessionAlias(carriedPrevious) : undefined
49764
- };
49856
+ return result;
49765
49857
  });
49766
49858
  }
49859
+ applyUseSession(state, chatKey, internalAlias) {
49860
+ const session3 = state.sessions[internalAlias];
49861
+ const prevCtx = state.chat_contexts[chatKey];
49862
+ const previousCurrent = prevCtx?.current_session;
49863
+ const carriedPrevious = previousCurrent && previousCurrent !== internalAlias ? previousCurrent : prevCtx?.previous_session;
49864
+ session3.last_used_at = new Date().toISOString();
49865
+ if (session3.archived) {
49866
+ delete session3.archived;
49867
+ delete session3.archived_at;
49868
+ }
49869
+ const nextCtx = { ...prevCtx, current_session: internalAlias };
49870
+ if (carriedPrevious) {
49871
+ nextCtx.previous_session = carriedPrevious;
49872
+ } else {
49873
+ delete nextCtx.previous_session;
49874
+ }
49875
+ state.chat_contexts[chatKey] = nextCtx;
49876
+ return {
49877
+ alias: toDisplaySessionAlias(session3.alias),
49878
+ agent: session3.agent,
49879
+ workspace: session3.workspace,
49880
+ previousAlias: carriedPrevious ? toDisplaySessionAlias(carriedPrevious) : undefined
49881
+ };
49882
+ }
49767
49883
  async usePreviousSession(chatKey) {
49768
49884
  return await this.mutate(async () => {
49769
49885
  const ctx = this.state.chat_contexts[chatKey];
@@ -49927,6 +50043,12 @@ class SessionService {
49927
50043
  listInternalAliases() {
49928
50044
  return Object.keys(this.state.sessions);
49929
50045
  }
50046
+ getLogicalSessionRecord(alias) {
50047
+ return this.state.sessions[alias] ?? null;
50048
+ }
50049
+ listLogicalSessionRecords() {
50050
+ return Object.values(this.state.sessions);
50051
+ }
49930
50052
  async setCurrentSessionMode(chatKey, modeId) {
49931
50053
  await this.mutate(async () => {
49932
50054
  const currentAlias = this.state.chat_contexts[chatKey]?.current_session;
@@ -50005,20 +50127,37 @@ class SessionService {
50005
50127
  }
50006
50128
  return count;
50007
50129
  }
50130
+ setSessionResourceLifecyclePublisher(publish) {
50131
+ this.lifecyclePublisher = publish;
50132
+ }
50133
+ publishLifecycleEvent(type, record3) {
50134
+ this.lifecyclePublisher?.({ type, record: record3 });
50135
+ }
50136
+ async commitLifecycleTransition(nextState) {
50137
+ await this.stateStore.saveNow(nextState);
50138
+ replaceRuntimeState(this.state, nextState);
50139
+ }
50008
50140
  async setArchived(alias, archived) {
50009
50141
  await this.mutate(async () => {
50010
50142
  const session3 = this.state.sessions[alias];
50011
50143
  if (!session3) {
50012
50144
  throw new Error(`session "${alias}" does not exist`);
50013
50145
  }
50146
+ const wasArchived = session3.archived === true;
50147
+ if (wasArchived === archived) {
50148
+ return;
50149
+ }
50150
+ const nextState = structuredClone(this.state);
50151
+ const nextSession = nextState.sessions[alias];
50014
50152
  if (archived) {
50015
- session3.archived = true;
50016
- session3.archived_at = new Date(this.now()).toISOString();
50153
+ nextSession.archived = true;
50154
+ nextSession.archived_at = new Date(this.now()).toISOString();
50017
50155
  } else {
50018
- delete session3.archived;
50019
- delete session3.archived_at;
50156
+ delete nextSession.archived;
50157
+ delete nextSession.archived_at;
50020
50158
  }
50021
- await this.persist();
50159
+ await this.commitLifecycleTransition(nextState);
50160
+ this.publishLifecycleEvent(archived ? "archived" : "restored", nextSession);
50022
50161
  });
50023
50162
  }
50024
50163
  async removeSession(alias) {
@@ -50027,9 +50166,11 @@ class SessionService {
50027
50166
  if (!session3) {
50028
50167
  throw new Error(`session "${alias}" does not exist`);
50029
50168
  }
50030
- const wasActive = Object.values(this.state.chat_contexts).some((ctx) => ctx.current_session === alias);
50031
- delete this.state.sessions[alias];
50032
- for (const [chatKey, ctx] of Object.entries(this.state.chat_contexts)) {
50169
+ const snapshot = structuredClone(session3);
50170
+ const nextState = structuredClone(this.state);
50171
+ const wasActive = Object.values(nextState.chat_contexts).some((ctx) => ctx.current_session === alias);
50172
+ delete nextState.sessions[alias];
50173
+ for (const [chatKey, ctx] of Object.entries(nextState.chat_contexts)) {
50033
50174
  if (ctx.previous_session === alias) {
50034
50175
  delete ctx.previous_session;
50035
50176
  }
@@ -50048,10 +50189,11 @@ class SessionService {
50048
50189
  }
50049
50190
  }
50050
50191
  if (!ctx.current_session && !ctx.previous_session && !ctx.background_results) {
50051
- delete this.state.chat_contexts[chatKey];
50192
+ delete nextState.chat_contexts[chatKey];
50052
50193
  }
50053
50194
  }
50054
- await this.persist();
50195
+ await this.commitLifecycleTransition(nextState);
50196
+ this.publishLifecycleEvent("removed", snapshot);
50055
50197
  return { wasActive };
50056
50198
  });
50057
50199
  }
@@ -50309,6 +50451,7 @@ class SessionService {
50309
50451
  agent: agent3,
50310
50452
  workspace: workspace3,
50311
50453
  transport_session: transportSession,
50454
+ logical_session_id: randomUUID9(),
50312
50455
  source: native?.source,
50313
50456
  agent_session_id: native?.agentSessionId,
50314
50457
  agent_session_title: native?.title ?? undefined,
@@ -50358,6 +50501,89 @@ var init_session_service = __esm(() => {
50358
50501
  init_channel_scope();
50359
50502
  });
50360
50503
 
50504
+ // src/sessions/session-resource-catalog.ts
50505
+ class CoreSessionResourceCatalog {
50506
+ deps;
50507
+ listeners = new Set;
50508
+ constructor(deps) {
50509
+ this.deps = deps;
50510
+ }
50511
+ async resolve(chatKey, alias) {
50512
+ let internalAlias;
50513
+ try {
50514
+ internalAlias = await this.deps.sessions.resolveAliasForChat(chatKey, alias);
50515
+ } catch {
50516
+ return null;
50517
+ }
50518
+ const record3 = this.deps.sessions.getLogicalSessionRecord(internalAlias);
50519
+ if (!record3) {
50520
+ return null;
50521
+ }
50522
+ const channelId = getChannelIdFromChatKey(chatKey);
50523
+ if (!isSessionAliasVisibleInChannel(record3.alias, channelId)) {
50524
+ return null;
50525
+ }
50526
+ return this.toDescriptor(record3);
50527
+ }
50528
+ async list(channelId) {
50529
+ const descriptors = [];
50530
+ for (const record3 of this.deps.sessions.listLogicalSessionRecords()) {
50531
+ if (!isSessionAliasVisibleInChannel(record3.alias, channelId)) {
50532
+ continue;
50533
+ }
50534
+ const descriptor = this.toDescriptor(record3);
50535
+ if (descriptor) {
50536
+ descriptors.push(descriptor);
50537
+ }
50538
+ }
50539
+ return descriptors;
50540
+ }
50541
+ subscribe(listener) {
50542
+ this.listeners.add(listener);
50543
+ return () => {
50544
+ this.listeners.delete(listener);
50545
+ };
50546
+ }
50547
+ publishLifecycleEvent(input) {
50548
+ const descriptor = this.toDescriptor(input.record);
50549
+ if (!descriptor) {
50550
+ return;
50551
+ }
50552
+ this.emit({ type: input.type, session: descriptor });
50553
+ }
50554
+ emit(event) {
50555
+ for (const listener of [...this.listeners]) {
50556
+ try {
50557
+ listener(event);
50558
+ } catch (error2) {
50559
+ this.deps.logger.error("sessions.resource_catalog.listener_failed", "session resource listener threw; delivery continues", {
50560
+ message: error2 instanceof Error ? error2.message : String(error2),
50561
+ eventType: event.type,
50562
+ internalAlias: event.session.internalAlias
50563
+ }).catch(() => {});
50564
+ }
50565
+ }
50566
+ }
50567
+ toDescriptor(record3) {
50568
+ const workspace3 = this.deps.config.workspaces[record3.workspace];
50569
+ if (!workspace3) {
50570
+ return null;
50571
+ }
50572
+ return {
50573
+ logicalSessionId: record3.logical_session_id,
50574
+ channelId: getChannelIdFromChatKey(record3.alias),
50575
+ internalAlias: record3.alias,
50576
+ displayAlias: toDisplaySessionAlias(record3.alias),
50577
+ workspace: record3.workspace,
50578
+ cwd: workspace3.cwd,
50579
+ archived: record3.archived === true
50580
+ };
50581
+ }
50582
+ }
50583
+ var init_session_resource_catalog = __esm(() => {
50584
+ init_channel_scope();
50585
+ });
50586
+
50361
50587
  // src/sessions/active-turn-registry.ts
50362
50588
  function createActiveTurnRegistry() {
50363
50589
  const byChat = new Map;
@@ -50619,7 +50845,7 @@ async function deleteAcpxSessionFiles(options) {
50619
50845
  var init_acpx_session_files = () => {};
50620
50846
 
50621
50847
  // src/transport/acpx-queue-owner-launcher.ts
50622
- import { createHash as createHash9, randomUUID as randomUUID8 } from "node:crypto";
50848
+ import { createHash as createHash9, randomUUID as randomUUID10 } from "node:crypto";
50623
50849
  import { spawn as spawn13 } from "node:child_process";
50624
50850
  import { access as access6, readFile as readFile18, unlink as unlink2 } from "node:fs/promises";
50625
50851
  import { join as join27 } from "node:path";
@@ -50694,7 +50920,7 @@ class AcpxQueueOwnerLauncher {
50694
50920
  this.probeSpawnedProcess = options.probeSpawnedProcess ?? defaultProbeSpawnedProcess;
50695
50921
  this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 1e4;
50696
50922
  this.handshakePollMs = options.handshakePollMs ?? 50;
50697
- this.uuid = options.uuid ?? randomUUID8;
50923
+ this.uuid = options.uuid ?? randomUUID10;
50698
50924
  this.sleep = options.sleep ?? ((ms) => new Promise((resolve7) => setTimeout(resolve7, ms)));
50699
50925
  }
50700
50926
  async launch(input) {
@@ -56556,7 +56782,7 @@ var init_session_turn_runner = __esm(() => {
56556
56782
  });
56557
56783
 
56558
56784
  // src/control/turn-queue.ts
56559
- import { randomUUID as randomUUID9 } from "node:crypto";
56785
+ import { randomUUID as randomUUID11 } from "node:crypto";
56560
56786
 
56561
56787
  class TurnQueue {
56562
56788
  deps;
@@ -56600,7 +56826,7 @@ class TurnQueue {
56600
56826
  if (q.length >= QUEUE_MAX_DEPTH) {
56601
56827
  return { ok: false, errorMessage: "queue-full" };
56602
56828
  }
56603
- const id = randomUUID9();
56829
+ const id = randomUUID11();
56604
56830
  const item = {
56605
56831
  id,
56606
56832
  text: params.text,
@@ -57403,7 +57629,7 @@ function warmthKey(session3) {
57403
57629
  }
57404
57630
 
57405
57631
  // src/control/terminal-service.ts
57406
- import { randomUUID as randomUUID10 } from "node:crypto";
57632
+ import { randomUUID as randomUUID12 } from "node:crypto";
57407
57633
  import { createRequire as createRequire8 } from "node:module";
57408
57634
  import { statSync as statSync4 } from "node:fs";
57409
57635
  import { spawn as spawnPty2 } from "node-pty";
@@ -57514,7 +57740,7 @@ function createTerminalService(deps) {
57514
57740
  return {
57515
57741
  create({ cwd, cols, rows }) {
57516
57742
  const shell = resolveShell({ platform, env: process.env, shellOverride: deps.shell?.() });
57517
- const terminalId = randomUUID10();
57743
+ const terminalId = randomUUID12();
57518
57744
  const handle = spawn18(shell, [], { name: "xterm-256color", cols, rows, cwd, env: scrubEnv() });
57519
57745
  const session3 = { handle, seq: 0, idleTimer: null, buffer: "", bufBytes: 0, pending: "", pendingBytes: 0, flushTimer: null };
57520
57746
  sessions.set(terminalId, session3);
@@ -58260,7 +58486,7 @@ __export(exports_main, {
58260
58486
  main: () => main,
58261
58487
  buildApp: () => buildApp
58262
58488
  });
58263
- import { randomUUID as randomUUID11 } from "node:crypto";
58489
+ import { randomUUID as randomUUID13 } from "node:crypto";
58264
58490
  import { homedir as homedir17 } from "node:os";
58265
58491
  import { dirname as dirname24, join as join35 } from "node:path";
58266
58492
  import { fileURLToPath as fileURLToPath6 } from "node:url";
@@ -58376,6 +58602,14 @@ async function buildApp(paths, deps = {}) {
58376
58602
  message: stateLoadReport.backupError
58377
58603
  });
58378
58604
  }
58605
+ for (const record3 of stateLoadReport.migrated ?? []) {
58606
+ await logger.info("state.session_id_migrated", "assigned a new logical_session_id to a legacy session record", {
58607
+ statePath: paths.statePath,
58608
+ section: record3.section,
58609
+ key: record3.key,
58610
+ reason: record3.reason
58611
+ });
58612
+ }
58379
58613
  }
58380
58614
  const stateMutex = new AsyncMutex;
58381
58615
  const debouncedStateStore = new DebouncedStateStore({
@@ -58389,11 +58623,13 @@ async function buildApp(paths, deps = {}) {
58389
58623
  });
58390
58624
  const runtimeRoot = dirname24(paths.configPath);
58391
58625
  const sessions = new SessionService(config4, debouncedStateStore, state, { stateMutex, runtimeRoot });
58626
+ const sessionResources = new CoreSessionResourceCatalog({ sessions, config: config4, logger });
58627
+ sessions.setSessionResourceLifecyclePublisher((transition) => sessionResources.publishLifecycleEvent(transition));
58392
58628
  const launchIntentCoordinator = new LaunchIntentCoordinator({
58393
58629
  platform: process.platform,
58394
58630
  runtimeRoot,
58395
58631
  configRoot: runtimeRoot,
58396
- generationId: deps.daemonIdentity?.generationId ?? randomUUID11(),
58632
+ generationId: deps.daemonIdentity?.generationId ?? randomUUID13(),
58397
58633
  ...deps.orphanRegistry ? { registry: deps.orphanRegistry } : {},
58398
58634
  classifyAdapter: (command) => classifyPreinstalledAdapterCommandShape(command),
58399
58635
  resolveAdapter: async (command) => (await validateAndReResolveAdapterCommand(runtimeRoot, command)).agentCommand,
@@ -58735,7 +58971,7 @@ async function buildApp(paths, deps = {}) {
58735
58971
  };
58736
58972
  orchestration3 = new OrchestrationService({
58737
58973
  now: deps.loggerNow ?? (() => new Date),
58738
- createId: () => randomUUID11(),
58974
+ createId: () => randomUUID13(),
58739
58975
  config: config4,
58740
58976
  loadState: async () => JSON.parse(JSON.stringify(state)),
58741
58977
  saveState: async (nextState) => {
@@ -59008,6 +59244,7 @@ async function buildApp(paths, deps = {}) {
59008
59244
  agent: agent3,
59009
59245
  router: router3,
59010
59246
  sessions,
59247
+ sessionResources,
59011
59248
  activeTurns,
59012
59249
  stateStore,
59013
59250
  configStore,
@@ -59144,6 +59381,7 @@ var init_main = __esm(() => {
59144
59381
  init_scheduled_route_create();
59145
59382
  init_scheduled_route_manage();
59146
59383
  init_session_service();
59384
+ init_session_resource_catalog();
59147
59385
  init_state_store();
59148
59386
  init_acpx_bridge_client();
59149
59387
  init_acpx_bridge_transport();
@@ -59344,6 +59582,7 @@ async function runConsole(paths, deps) {
59344
59582
  abortSignal: shutdownController.signal,
59345
59583
  quota: runtime.quota,
59346
59584
  sessions: runtime.sessions,
59585
+ sessionResources: runtime.sessionResources,
59347
59586
  activeTurns: runtime.activeTurns,
59348
59587
  logger: runtime.logger,
59349
59588
  perfTracer: runtime.perfTracer,
@@ -59509,14 +59748,14 @@ class MessageChannelRegistry {
59509
59748
  throw new Error("all channels failed to start");
59510
59749
  }
59511
59750
  }
59512
- async stopAll() {
59751
+ async stopAll(reason = "shutdown") {
59513
59752
  let firstError;
59514
59753
  for (const channel of this.channels.values()) {
59515
59754
  try {
59516
59755
  if (channel.stop) {
59517
- await channel.stop();
59756
+ await channel.stop(reason);
59518
59757
  } else {
59519
- channel.logout();
59758
+ await channel.logout();
59520
59759
  }
59521
59760
  } catch (error2) {
59522
59761
  firstError ??= error2;
@@ -61232,11 +61471,20 @@ function applyStateInspectionReport(result, report, statePath, daemonRunning, is
61232
61471
  if (!report) {
61233
61472
  return result;
61234
61473
  }
61474
+ const migrated = report.migrated ?? [];
61475
+ const migrationDetails = migrated.map((record3) => `legacy session record ${record3.section}["${record3.key}"]: ${record3.reason} (applied automatically at next daemon startup)`);
61476
+ if (report.dropped.length === 0) {
61477
+ return {
61478
+ ...result,
61479
+ details: [...result.details ?? [], `state path: ${statePath}`, ...migrationDetails]
61480
+ };
61481
+ }
61235
61482
  const fileCorrupt = report.dropped.some((record3) => record3.section === "file");
61236
61483
  const details = [
61237
61484
  ...result.details ?? [],
61238
61485
  `state path: ${statePath}`,
61239
- ...report.dropped.map((record3) => record3.section === "file" ? `state.json is unreadable: ${record3.reason}` : `invalid state record ${record3.section}["${record3.key}"]: ${record3.reason}`)
61486
+ ...report.dropped.map((record3) => record3.section === "file" ? `state.json is unreadable: ${record3.reason}` : `invalid state record ${record3.section}["${record3.key}"]: ${record3.reason}`),
61487
+ ...migrationDetails
61240
61488
  ];
61241
61489
  return {
61242
61490
  ...result,
@@ -61272,9 +61520,11 @@ function createStateQuarantineFix(statePath, daemonRunning, isDaemonRunning) {
61272
61520
  return { ok: true, message: `state.json was unreadable; renamed to ${report.corruptPath} and reset` };
61273
61521
  }
61274
61522
  const backup = report.quarantinePath ? ` (original backed up to ${report.quarantinePath})` : "";
61523
+ const migratedCount = report.migrated?.length ?? 0;
61524
+ const migration = migratedCount > 0 ? `; assigned logical_session_id to ${migratedCount} legacy session record(s)` : "";
61275
61525
  return {
61276
61526
  ok: true,
61277
- message: `quarantined ${report.dropped.length} invalid state.json record(s)${backup}`
61527
+ message: `quarantined ${report.dropped.length} invalid state.json record(s)${backup}${migration}`
61278
61528
  };
61279
61529
  }
61280
61530
  };
@@ -61354,7 +61604,7 @@ __export(exports_cli, {
61354
61604
  getUsageText: () => getUsageText,
61355
61605
  createMcpStdioIdentityResolver: () => createMcpStdioIdentityResolver
61356
61606
  });
61357
- import { randomUUID as randomUUID12 } from "node:crypto";
61607
+ import { randomUUID as randomUUID14 } from "node:crypto";
61358
61608
  import { homedir as homedir23 } from "node:os";
61359
61609
  import { dirname as dirname26, join as join39, sep as sep6 } from "node:path";
61360
61610
  import { fileURLToPath as fileURLToPath8 } from "node:url";
@@ -61400,7 +61650,7 @@ async function prepareMcpCoordinatorStartup(input) {
61400
61650
  return { kind: "external-coordinator" };
61401
61651
  }
61402
61652
  function createMcpStdioIdentityResolver(input) {
61403
- const instanceId = randomUUID12().slice(0, 8);
61653
+ const instanceId = randomUUID14().slice(0, 8);
61404
61654
  return async (context) => {
61405
61655
  const parsedCoordinatorSession = input.parsedCoordinatorSession?.trim() || null;
61406
61656
  const workspace3 = input.workspace?.trim() || null;
@@ -61745,6 +61995,10 @@ function warnStateLoadReport(store, writeStderr = (text) => process.stderr.write
61745
61995
  }
61746
61996
  if (report.backupError) {
61747
61997
  writeStderr(`[xacpx] state.quarantine_backup_failed ${report.backupError}
61998
+ `);
61999
+ }
62000
+ for (const record3 of report.migrated ?? []) {
62001
+ writeStderr(`[xacpx] state.session_id_migrated section=${record3.section}${record3.key ? ` key=${record3.key}` : ""} reason=${record3.reason}
61748
62002
  `);
61749
62003
  }
61750
62004
  }
@@ -62064,7 +62318,7 @@ async function defaultLogin() {
62064
62318
  }
62065
62319
  async function defaultLogout() {
62066
62320
  const channel = await resolveLoginChannelForCli();
62067
- channel.logout();
62321
+ await channel.logout();
62068
62322
  }
62069
62323
  async function defaultLoadConfiguredPluginsForChannelCli() {
62070
62324
  const store = await createCliConfigStore();
@@ -62294,7 +62548,23 @@ async function createChannelCliDeps(input) {
62294
62548
  restartDaemon: async () => await restartDaemonCli(controller, input.print),
62295
62549
  clearChannelCredentials: async (channel) => {
62296
62550
  const { createMessageChannel: createMessageChannel2 } = await Promise.resolve().then(() => (init_create_channel(), exports_create_channel));
62297
- createMessageChannel2(channel.type, channel).logout();
62551
+ await createMessageChannel2(channel.type, channel).logout();
62552
+ },
62553
+ retireChannel: async (channel, reason) => {
62554
+ const { invokeChannelRetireHook: invokeChannelRetireHook2 } = await Promise.resolve().then(() => (init_plugin(), exports_plugin));
62555
+ await invokeChannelRetireHook2({
62556
+ channel,
62557
+ reason,
62558
+ print: input.print,
62559
+ getDaemonStatus: async () => {
62560
+ const status = await controller.getStatus();
62561
+ if (status.state === "running")
62562
+ return { state: "running" };
62563
+ if (status.state === "indeterminate")
62564
+ return { state: "indeterminate" };
62565
+ return { state: "stopped" };
62566
+ }
62567
+ });
62298
62568
  }
62299
62569
  };
62300
62570
  return { ...base, ...input.overrides };
@@ -700,6 +700,7 @@ export interface ChannelCliMessages {
700
700
  channelAdded: (type: string) => string;
701
701
  cannotRemoveLastEnabled: string;
702
702
  channelRemoved: (id: string) => string;
703
+ channelRetirementFailed: (id: string, error: string) => string;
703
704
  channelCredentialsCleared: (id: string) => string;
704
705
  channelCredentialsClearFailed: (id: string, error: string) => string;
705
706
  channelCredentialsKept: (id: string) => string;
@@ -1,9 +1,9 @@
1
- export type { ChannelPluginDefinition } from "./channels/plugin.js";
1
+ export type { ChannelPluginDefinition, ChannelRetireContext, ChannelRetireDaemonState, ChannelRetireHook, } from "./channels/plugin.js";
2
2
  export type { ChannelFactory, CreateChannelDeps } from "./channels/create-channel.js";
3
- export type { ChannelStartInput, ConsumerLock, ConsumerLockMetadata, ConsumerLockOptions, CoordinatorMessageInput, MessageChannelRuntime, ScheduledChannelMessageInput, OrchestrationDeliveryCallbacks, OutboundQuota, PlanEntry, PlanEntryStatus, ToolUseEvent, ToolUseKind, ToolUseStatus, } from "./channels/types.js";
3
+ export type { ChannelStartInput, ConsumerLock, ConsumerLockMetadata, ConsumerLockOptions, ChannelStopReason, CoordinatorMessageInput, MessageChannelRuntime, ScheduledChannelMessageInput, OrchestrationDeliveryCallbacks, OutboundQuota, PlanEntry, PlanEntryStatus, ToolUseEvent, ToolUseKind, ToolUseStatus, } from "./channels/types.js";
4
4
  export { formatSubagentNotice, SubagentNoticeTracker } from "./channels/subagent-notice.js";
5
5
  export type { AgentCommand, PromptUsage, UsageBreakdown, UsageCost, } from "./transport/types.js";
6
- export type { ChannelCliInput, ChannelCliIo, ChannelCliParseResult, ChannelCliProvider, ChannelCliValidationIssue, } from "./channels/cli/provider.js";
6
+ export type { ChannelCliInput, ChannelCliIo, ChannelCliParseResult, ChannelCliProvider, ChannelCliValidationIssue, ChannelDoctorFinding, ChannelDoctorFindingLevel, } from "./channels/cli/provider.js";
7
7
  export type { ChannelRuntimeConfig } from "./config/types.js";
8
8
  export type { CommandHint } from "./commands/command-hints.js";
9
9
  export type { AppLogger } from "./logging/app-logger.js";
@@ -22,4 +22,5 @@ export { getLocale } from "./i18n/index.js";
22
22
  export type { Locale } from "./i18n/index.js";
23
23
  export type { ControlExecuteCommandInput, ControlPromptInput, ControlPromptResult, ControlService, ControlSessionInfo, } from "./control/control-service.js";
24
24
  export type { ControlEvent, ControlEventBus, ControlEventListener } from "./control/control-event-bus.js";
25
+ export type { SessionResourceCatalog, SessionResourceDescriptor, SessionResourceLifecycleEvent, } from "./sessions/session-resource-catalog.js";
25
26
  export { coreHomeDir } from "./runtime/core-home.js";
@@ -927,6 +927,7 @@ var init_channel_cli = __esm(() => {
927
927
  channelAdded: (type) => `Channel ${type} added`,
928
928
  cannotRemoveLastEnabled: "Cannot remove the last enabled channel.",
929
929
  channelRemoved: (id) => `Channel ${id} removed`,
930
+ channelRetirementFailed: (id, error) => `Channel ${id} retirement cleanup failed: ${error}`,
930
931
  channelCredentialsCleared: (id) => `Removed stored credentials for channel ${id}`,
931
932
  channelCredentialsClearFailed: (id, error) => `Channel ${id} removed, but clearing its stored credentials failed: ${error}`,
932
933
  channelCredentialsKept: (id) => `Kept stored credentials for channel ${id} (--keep-credentials)`,
@@ -2053,6 +2054,7 @@ var init_channel_cli2 = __esm(() => {
2053
2054
  channelAdded: (type) => `频道 ${type} 已添加`,
2054
2055
  cannotRemoveLastEnabled: "不能删除最后一个启用的频道。",
2055
2056
  channelRemoved: (id) => `频道 ${id} 已删除`,
2057
+ channelRetirementFailed: (id, error) => `频道 ${id} 退役清理失败:${error}`,
2056
2058
  channelCredentialsCleared: (id) => `已移除频道 ${id} 的存储凭证`,
2057
2059
  channelCredentialsClearFailed: (id, error) => `频道 ${id} 已删除,但清除其存储凭证失败:${error}`,
2058
2060
  channelCredentialsKept: (id) => `已保留频道 ${id} 的存储凭证(--keep-credentials)`,
@@ -0,0 +1,96 @@
1
+ import type { AppConfig } from "../config/types";
2
+ import type { AppLogger } from "../logging/app-logger";
3
+ import type { LogicalSession } from "../state/types";
4
+ import type { SessionService } from "./session-service";
5
+ /**
6
+ * A point-in-time snapshot of one logical session's resource identity. The
7
+ * immutable `logicalSessionId` is the ONLY stable key: aliases, display names
8
+ * and transport bindings can all change under the same id. `cwd` is resolved
9
+ * authoritatively from the core workspace config — callers (e.g. browsers)
10
+ * never contribute a cwd. Descriptors are snapshots: a previously returned
11
+ * descriptor never mutates when the underlying session changes.
12
+ */
13
+ export interface SessionResourceDescriptor {
14
+ logicalSessionId: string;
15
+ channelId: string;
16
+ internalAlias: string;
17
+ displayAlias: string;
18
+ workspace: string;
19
+ cwd: string;
20
+ archived: boolean;
21
+ }
22
+ export type SessionResourceLifecycleEvent = {
23
+ type: "archived";
24
+ session: SessionResourceDescriptor;
25
+ } | {
26
+ type: "restored";
27
+ session: SessionResourceDescriptor;
28
+ } | {
29
+ type: "removed";
30
+ session: SessionResourceDescriptor;
31
+ };
32
+ /**
33
+ * Write-side seam handed to SessionService: after a lifecycle transition has
34
+ * been durably persisted (never before), the service reports the transition
35
+ * type plus the persisted record; the catalog maps the record to a descriptor
36
+ * and publishes the event to subscribers. For `removed` the record is the
37
+ * pre-delete snapshot, so the event carries the session's final identity.
38
+ */
39
+ export interface SessionResourceLifecyclePublishInput {
40
+ type: SessionResourceLifecycleEvent["type"];
41
+ record: LogicalSession;
42
+ }
43
+ /**
44
+ * Generic, channel-agnostic catalog over logical session resources. Contains
45
+ * no channel- or consumer-specific vocabulary; structured channels (and their
46
+ * plugins) use it to enumerate and resolve the sessions they own.
47
+ */
48
+ export interface SessionResourceCatalog {
49
+ /**
50
+ * Resolve a chat-scoped alias (display or internal form) to a descriptor.
51
+ * Uses the same chat-scope resolution as ControlService: a caller can never
52
+ * reach another channel's sessions. Returns null for unknown aliases and
53
+ * for sessions whose workspace is no longer registered (their cwd cannot be
54
+ * authoritatively resolved).
55
+ */
56
+ resolve(chatKey: string, alias: string): Promise<SessionResourceDescriptor | null>;
57
+ /** All sessions of one channel, active AND archived, for reconciliation. */
58
+ list(channelId: string): Promise<SessionResourceDescriptor[]>;
59
+ /**
60
+ * Register a lifecycle listener; returns an unsubscribe function. Listener
61
+ * exceptions are caught and logged to the app log: they never roll back the
62
+ * underlying session operation and never block other listeners.
63
+ */
64
+ subscribe(listener: (event: SessionResourceLifecycleEvent) => void): () => void;
65
+ }
66
+ interface SessionResourceCatalogDeps {
67
+ sessions: SessionService;
68
+ config: AppConfig;
69
+ logger: AppLogger;
70
+ }
71
+ /**
72
+ * Production catalog assembled by the core runtime over the live SessionService
73
+ * and workspace config. Plugin/channel tests should use their own in-memory
74
+ * implementation of the interface instead of this adapter.
75
+ */
76
+ export declare class CoreSessionResourceCatalog implements SessionResourceCatalog {
77
+ private readonly deps;
78
+ private readonly listeners;
79
+ constructor(deps: SessionResourceCatalogDeps);
80
+ resolve(chatKey: string, alias: string): Promise<SessionResourceDescriptor | null>;
81
+ list(channelId: string): Promise<SessionResourceDescriptor[]>;
82
+ subscribe(listener: (event: SessionResourceLifecycleEvent) => void): () => void;
83
+ /**
84
+ * Publish one durably-persisted lifecycle transition. SessionService calls
85
+ * this (via the publisher it was handed at runtime wiring) strictly AFTER
86
+ * saveNow succeeded and the runtime state was published; the catalog itself
87
+ * never persists anything. Records whose workspace is no longer registered
88
+ * produce no event: such sessions are invisible in resolve()/list(), so
89
+ * consumers never tracked a resource for them.
90
+ */
91
+ publishLifecycleEvent(input: SessionResourceLifecyclePublishInput): void;
92
+ /** Fan an event out to all subscribers; listener throws are isolated. */
93
+ private emit;
94
+ private toDescriptor;
95
+ }
96
+ export {};
@@ -1,7 +1,8 @@
1
1
  import type { AppConfig } from "../config/types";
2
2
  import { AsyncMutex } from "../orchestration/async-mutex";
3
3
  import type { StateStore } from "../state/state-store";
4
- import type { AppState, BackgroundResult } from "../state/types";
4
+ import type { AppState, BackgroundResult, LogicalSession } from "../state/types";
5
+ import type { SessionResourceLifecyclePublishInput } from "./session-resource-catalog";
5
6
  import type { AgentSession, ResolvedSession } from "../transport/types";
6
7
  interface SessionListItem {
7
8
  alias: string;
@@ -79,6 +80,7 @@ export declare class SessionService {
79
80
  private readonly platform;
80
81
  private readonly runtimeRoot;
81
82
  private readonly pendingSessionAliasOperations;
83
+ private lifecyclePublisher;
82
84
  constructor(config: AppConfig, stateStore: SessionStateWriter, state: AppState, options?: SessionServiceOptions);
83
85
  createSession(alias: string, agent: string, workspace: string): Promise<ResolvedSession>;
84
86
  /**
@@ -109,6 +111,8 @@ export declare class SessionService {
109
111
  getPreferredSessionForTransport(transportSession: string): Promise<ResolvedSession | null>;
110
112
  findAttachedNativeSession(chatKey: string, agent: string, agentSessionId: string): Promise<ResolvedSession | null>;
111
113
  useSession(chatKey: string, alias: string): Promise<SessionSwitchResult>;
114
+ /** Apply the use-session mutation (switch, plus restore when archived). */
115
+ private applyUseSession;
112
116
  usePreviousSession(chatKey: string): Promise<SessionSwitchResult | null>;
113
117
  setBackgroundResult(chatKey: string, alias: string, result: BackgroundResult): Promise<void>;
114
118
  takeBackgroundResult(chatKey: string, alias: string): Promise<BackgroundResult | null>;
@@ -155,11 +159,36 @@ export declare class SessionService {
155
159
  */
156
160
  buildFreshTransportSession(stableTransportSession: string): string;
157
161
  listInternalAliases(): string[];
162
+ /**
163
+ * Read-only access to the persisted logical session record by internal alias.
164
+ * Returns the LIVE record — callers must treat it as immutable. Exists for
165
+ * consumers (the session resource catalog) that need fields ResolvedSession
166
+ * does not carry (immutable logical_session_id, archived flag).
167
+ */
168
+ getLogicalSessionRecord(alias: string): LogicalSession | null;
169
+ /** Read-only view of every persisted logical session record, across all channels. */
170
+ listLogicalSessionRecords(): LogicalSession[];
158
171
  setCurrentSessionMode(chatKey: string, modeId: string | undefined): Promise<void>;
159
172
  setCurrentSessionReplyMode(chatKey: string, replyMode: "stream" | "final" | "verbose" | undefined): Promise<void>;
160
173
  getCurrentSession(chatKey: string): Promise<ResolvedSession | null>;
161
174
  listSessions(chatKey: string): Promise<SessionListItem[]>;
162
175
  countAliasesSharingTransport(transportSession: string, excludeAlias?: string): number;
176
+ /**
177
+ * Wire the sink for resource lifecycle events (production: the core
178
+ * SessionResourceCatalog; the runtime calls this once after constructing
179
+ * the catalog). Archive/restore/remove transitions report through it only
180
+ * AFTER their state has been durably persisted — never before.
181
+ */
182
+ setSessionResourceLifecyclePublisher(publish: (input: SessionResourceLifecyclePublishInput) => void): void;
183
+ private publishLifecycleEvent;
184
+ /**
185
+ * Durability gate for lifecycle transitions: persist the copy-on-write
186
+ * snapshot first (saveNow rejects on write failure, so the live state, chat
187
+ * contexts and event stream all stay untouched), then publish the persisted
188
+ * snapshot to the runtime state. Only after this resolves may the lifecycle
189
+ * event be emitted.
190
+ */
191
+ private commitLifecycleTransition;
163
192
  setArchived(alias: string, archived: boolean): Promise<void>;
164
193
  removeSession(alias: string): Promise<{
165
194
  wasActive: boolean;
@@ -12,6 +12,12 @@ export interface StateLoadDroppedRecord {
12
12
  }
13
13
  export interface StateLoadReport {
14
14
  dropped: StateLoadDroppedRecord[];
15
+ /**
16
+ * Legacy session records that were assigned a fresh logical_session_id
17
+ * during load. Kept separate from {@link dropped}: a migrated record is
18
+ * healthy and survives, a dropped record is corrupt and was removed.
19
+ */
20
+ migrated?: StateLoadDroppedRecord[];
15
21
  /** Backup copy of the original file, written because records were dropped. */
16
22
  quarantinePath?: string;
17
23
  /** Unreadable original renamed aside (whole-file JSON corruption). */
@@ -19,6 +25,12 @@ export interface StateLoadReport {
19
25
  /** Best-effort backup/rename failure; load still returned the cleaned state. */
20
26
  backupError?: string;
21
27
  }
28
+ /**
29
+ * A logical_session_id is always a UUIDv4 (randomUUID). Present-but-invalid
30
+ * values are treated as corruption and quarantined with the record; only a
31
+ * genuinely MISSING field (legacy record) enters the load-time migration.
32
+ */
33
+ export declare function isLogicalSessionId(value: unknown): value is string;
22
34
  /**
23
35
  * Lenient state parser: a malformed record (or wrong-typed section) is skipped
24
36
  * and collected in `dropped` instead of throwing, so one bad record can never
@@ -27,7 +39,7 @@ export interface StateLoadReport {
27
39
  * non-object top level still throws (StateStore.load treats that as a corrupt
28
40
  * file and renames it aside).
29
41
  */
30
- export declare function parseState(raw: unknown, path: string, dropped?: StateLoadDroppedRecord[]): AppState;
42
+ export declare function parseState(raw: unknown, path: string, dropped?: StateLoadDroppedRecord[], migrated?: StateLoadDroppedRecord[]): AppState;
31
43
  export interface StateStoreOptions {
32
44
  /** Injectable clock used for quarantine/corrupt backup file names. */
33
45
  now?: () => Date;
@@ -37,6 +49,11 @@ export interface StateStoreOptions {
37
49
  * writer suffix-retries instead of overwriting an existing backup).
38
50
  */
39
51
  writeBackup?: (targetPath: string, content: string) => Promise<string | void>;
52
+ /**
53
+ * Injectable durable writer for the legacy logical_session_id migration
54
+ * (tests simulate write failures). Defaults to the regular atomic save.
55
+ */
56
+ writeMigration?: (state: AppState) => Promise<void>;
40
57
  }
41
58
  /** Result of a side-effect-free {@link StateStore.inspect}. */
42
59
  export interface StateLoadInspection {
@@ -56,11 +73,13 @@ export declare class StateStore {
56
73
  */
57
74
  get lastLoadReport(): StateLoadReport | null;
58
75
  load(): Promise<AppState>;
76
+ /** Synchronous (awaited) atomic write of a migrated state, via the private-file writer. */
77
+ private persistMigration;
59
78
  /**
60
79
  * Side-effect-free variant of load() for diagnostic callers (doctor): parses
61
- * and reports exactly what load() would drop/repair, but never writes a
62
- * quarantine backup, never renames a corrupt file, and does not touch
63
- * {@link lastLoadReport}.
80
+ * and reports exactly what load() would drop/repair/migrate, but never writes
81
+ * a quarantine backup, never renames a corrupt file, never persists pending
82
+ * logical_session_id migrations, and does not touch {@link lastLoadReport}.
64
83
  */
65
84
  inspect(): Promise<StateLoadInspection>;
66
85
  private readAndParse;
@@ -20,6 +20,14 @@ export interface LogicalSession {
20
20
  agent: string;
21
21
  workspace: string;
22
22
  transport_session: string;
23
+ /**
24
+ * Immutable identity of this logical session (UUIDv4), assigned once at
25
+ * create/attach time. Never changes across rename, display-name, agent,
26
+ * workspace, or transport-binding updates; never reused after the alias is
27
+ * deleted. Legacy records missing the field are migrated once at load time
28
+ * and persisted before startup proceeds.
29
+ */
30
+ logical_session_id: string;
23
31
  source?: LogicalSessionSource;
24
32
  agent_session_id?: string;
25
33
  agent_session_title?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganglion/xacpx",
3
- "version": "0.20.3",
3
+ "version": "0.21.0",
4
4
  "description": "随时随地通过聊天频道(微信 / 飞书 / 元宝等)远程控制 `acpx` 上的 Claude Code、Codex 等 Agents。",
5
5
  "keywords": [
6
6
  "acpx",
@@ -54,6 +54,7 @@
54
54
  "build:relay": "bun run build:relay-web && bun run clean:relay && bun build ./packages/relay/src/cli.ts --outdir ./packages/relay/dist --target node --external ws --external hono --external @hono/node-server --external @ganglion/xacpx-relay-protocol && tsc -p packages/relay/tsconfig.json && bun run bundle:relay-web",
55
55
  "clean:channel-relay": "node -e \"require('node:fs').rmSync('packages/channel-relay/dist', { recursive: true, force: true })\"",
56
56
  "build:channel-relay": "bun run build:plugin-api && bun run build:relay-protocol && bun run clean:channel-relay && bun build ./packages/channel-relay/src/index.ts --outdir ./packages/channel-relay/dist --target node --external xacpx --external ws --external @ganglion/xacpx-relay-protocol && tsc -p packages/channel-relay/tsconfig.json",
57
+ "pack:rmux-bridge": "node ./scripts/pack-rmux-bridge-platform.mjs",
57
58
  "clean:relay-web": "node -e \"require('node:fs').rmSync('packages/relay-web/dist',{recursive:true,force:true})\"",
58
59
  "build:relay-web": "bun run build:relay-protocol && bun run clean:relay-web && bun run --cwd packages/relay-web build",
59
60
  "test:web": "bun run --cwd packages/relay-web test",