@ganglion/xacpx 0.20.3 → 0.22.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.
@@ -8,6 +8,12 @@ export declare function executableExtensions(platform: NodeJS.Platform, env: Nod
8
8
  * `opencode` matches `opencode.cmd`/`.exe`. `env`/`isExecutableFile` are injectable for tests.
9
9
  */
10
10
  export declare function isExecutableOnPath(name: string, env?: NodeJS.ProcessEnv, isExecutableFile?: (p: string) => boolean): boolean;
11
+ /**
12
+ * Is `driver` listed as a local-fallback agent in LOCAL_AGENT_BINS? Use this — not a
13
+ * hand-copied allow-list — for drift guards that need to exempt drivers xacpx itself
14
+ * supplies at runtime (e.g. `agent-catalog.test.ts`'s acpx-registry drift guard).
15
+ */
16
+ export declare function isLocalAgentBinDriver(driver: string): boolean;
11
17
  /**
12
18
  * If `driver` is a known npx-fallback agent AND its native CLI is on PATH, return the
13
19
  * structured argv (e.g. `["opencode", "acp"]`); otherwise undefined (let acpx fall back
@@ -31,7 +31,7 @@ export interface ResolveAgentLaunchOptions {
31
31
  * 2. user `argv`: content-addressed overlay alias + canonical identity.
32
32
  * 3. managed codex/claude: structured pinned npx argv (alias launch).
33
33
  * 4. hermes: ACP shim argv (alias launch).
34
- * 5. local fallback (opencode/kilocode on PATH): structured argv (alias launch).
34
+ * 5. local fallback (opencode/kilocode/reasonix/omp on PATH): structured argv (alias launch).
35
35
  * 6. anything else: bare built-in driver positional.
36
36
  */
37
37
  export declare function resolveConfiguredAgentLaunch(agent: Pick<AgentConfig, "driver" | "command" | "argv">, transport?: Pick<TransportConfig, "preferLocalAgents" | "adapterVersions" | "adapterRegistry">, options?: ResolveAgentLaunchOptions): AgentLaunchSpec;
@@ -37,10 +37,12 @@ export interface TransportConfig {
37
37
  queueOwnerTtlSeconds?: number;
38
38
  /**
39
39
  * Prefer a locally-installed native agent CLI over acpx's `npx -y <pkg>` fallback
40
- * when one is on PATH (currently the unpinned-npx drivers: opencode, kilocode). This
41
- * avoids a per-cold-start npm-registry fetch — faster and immune to network blips
42
- * (e.g. ECONNRESET during agent init). Defaults to `true`; set `false` to always use
43
- * acpx's default resolution. A per-agent `command` override still takes precedence.
40
+ * when one is on PATH (currently opencode, kilocode, plus the local-fallback drivers
41
+ * reasonix and omp that aren't in acpx's registry but speak ACP via `<bin> acp`).
42
+ * This avoids a per-cold-start npm-registry fetch — faster and immune to network
43
+ * blips (e.g. ECONNRESET during agent init). Defaults to `true`; set `false` to
44
+ * always use acpx's default resolution. A per-agent `command` override still takes
45
+ * precedence.
44
46
  */
45
47
  preferLocalAgents?: boolean;
46
48
  /** Exact local overrides for xacpx-managed ACP adapter versions. Omitted entries
@@ -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.22.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",