@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.
@@ -3,10 +3,10 @@ import type { ScheduledSessionMode } from "../../scheduled/scheduled-types";
3
3
  import type { HelpTopicMetadata } from "../help/help-types";
4
4
  export declare function laterHelp(): HelpTopicMetadata;
5
5
  export declare function handleLaterHelp(): RouterResponse;
6
- export declare function handleLaterCreate(tokens: string[], scheduled: ScheduledRouterOps, chatKey: string, currentSession: {
6
+ export declare function handleLaterCreate(tokens: string[], tails: string[], scheduled: ScheduledRouterOps, chatKey: string, currentSession: {
7
7
  alias: string;
8
8
  agent: string;
9
9
  workspace: string;
10
10
  } | null, defaultMode: ScheduledSessionMode, accountId?: string, replyContextToken?: string): Promise<RouterResponse>;
11
- export declare function handleLaterList(scheduled: ScheduledRouterOps): RouterResponse;
12
- export declare function handleLaterCancel(id: string, scheduled: ScheduledRouterOps): Promise<RouterResponse>;
11
+ export declare function handleLaterList(scheduled: ScheduledRouterOps, chatKey: string): RouterResponse;
12
+ export declare function handleLaterCancel(id: string, scheduled: ScheduledRouterOps, chatKey: string): Promise<RouterResponse>;
@@ -162,6 +162,7 @@ export type ParsedCommand = {
162
162
  } | {
163
163
  kind: "later.create";
164
164
  tokens: string[];
165
+ tails: string[];
165
166
  } | {
166
167
  kind: "later.list";
167
168
  } | {
@@ -41,7 +41,7 @@ export interface ActiveHumanQuestionPackageContext {
41
41
  }>;
42
42
  queuedCount: number;
43
43
  }
44
- export type WritableConfigStore = Pick<ConfigStore, "load" | "save" | "upsertWorkspace" | "removeWorkspace" | "upsertAgent" | "removeAgent" | "updateTransport" | "updateChannel">;
44
+ export type WritableConfigStore = Pick<ConfigStore, "load" | "upsertWorkspace" | "removeWorkspace" | "upsertAgent" | "removeAgent" | "updateTransport" | "updateChannel" | "getRawValue" | "setRawValue" | "unsetRawValue">;
45
45
  export interface CommandRouterContext {
46
46
  sessions: SessionService;
47
47
  transport: SessionTransport;
@@ -1,13 +1,52 @@
1
- import type { AppConfig } from "./types";
1
+ import type { AgentConfig, AppConfig, ChannelRuntimeConfig, PluginConfig } from "./types";
2
+ /**
3
+ * Raw-patch config persistence.
4
+ *
5
+ * The parsed `AppConfig` is a READ model only: parsing drops unknown keys
6
+ * (e.g. `workspaces.*.allowed_agents`), expands `~` in workspace cwds, and
7
+ * materializes every default. Serializing it back would destroy a hand-edited
8
+ * config.json. Every mutation therefore patches the raw JSON document read
9
+ * straight from disk, touching only the targeted subtree, and never writes a
10
+ * parsed config object.
11
+ */
12
+ export type RawConfigPathSegment = string | {
13
+ /** Addresses the entry of a JSON array whose `id` property equals this value. */
14
+ id: string;
15
+ /** When set, a missing entry is materialized from this template on writes. */
16
+ createWith?: Record<string, unknown>;
17
+ };
18
+ export type RawConfigPath = readonly RawConfigPathSegment[];
19
+ export type RawConfigLookup = {
20
+ present: true;
21
+ value: unknown;
22
+ } | {
23
+ present: false;
24
+ };
2
25
  export declare class ConfigStore {
3
26
  private readonly path;
4
27
  constructor(path: string);
5
28
  load(): Promise<AppConfig>;
6
- save(config: AppConfig): Promise<void>;
29
+ /** Reads the raw (unparsed) value at `path`, e.g. to capture it for a rollback. */
30
+ getRawValue(path: RawConfigPath): Promise<RawConfigLookup>;
31
+ setRawValue(path: RawConfigPath, value: unknown): Promise<AppConfig>;
32
+ unsetRawValue(path: RawConfigPath): Promise<AppConfig>;
7
33
  upsertWorkspace(name: string, cwd: string, description?: string): Promise<AppConfig>;
8
34
  removeWorkspace(name: string): Promise<AppConfig>;
9
- upsertAgent(name: string, agent: AppConfig["agents"][string]): Promise<AppConfig>;
35
+ upsertAgent(name: string, agent: AgentConfig): Promise<AppConfig>;
10
36
  removeAgent(name: string): Promise<AppConfig>;
11
- updateTransport(transport: Partial<AppConfig["transport"]>): Promise<AppConfig>;
12
- updateChannel(channel: Partial<AppConfig["channel"]>): Promise<AppConfig>;
37
+ /** Sets only the given transport keys; a key explicitly set to `undefined` is removed. */
38
+ updateTransport(patch: Partial<AppConfig["transport"]>): Promise<AppConfig>;
39
+ /** Sets only the given channel keys; a key explicitly set to `undefined` is removed. */
40
+ updateChannel(patch: Partial<AppConfig["channel"]>): Promise<AppConfig>;
41
+ /** Replaces the tool-managed `plugins` array; everything else stays untouched. */
42
+ replacePlugins(plugins: PluginConfig[]): Promise<AppConfig>;
43
+ /** Replaces the tool-managed `channels` array; everything else stays untouched. */
44
+ replaceChannels(channels: ChannelRuntimeConfig[]): Promise<AppConfig>;
45
+ private patchRaw;
46
+ private readRaw;
13
47
  }
48
+ export declare function serializeRawConfig(raw: Record<string, unknown>): string;
49
+ export declare function readRawConfigValue(root: Record<string, unknown>, path: RawConfigPath): RawConfigLookup;
50
+ export declare function setRawConfigValue(root: Record<string, unknown>, path: RawConfigPath, value: unknown): void;
51
+ export declare function unsetRawConfigValue(root: Record<string, unknown>, path: RawConfigPath): void;
52
+ export declare function assertSafeConfigKey(key: string): void;
@@ -7,6 +7,12 @@ export type WechatReplyMode = ReplyMode;
7
7
  export interface ChannelConfig {
8
8
  type: string;
9
9
  replyMode: ReplyMode;
10
+ /**
11
+ * Sender ids the operator trusts as channel owners. Group turns from these
12
+ * senders pass owner-gated command authorization even when the channel
13
+ * protocol carries no group-role information (e.g. WeChat).
14
+ */
15
+ ownerIds?: string[];
10
16
  options?: Record<string, unknown>;
11
17
  }
12
18
  /** @deprecated Legacy input shape only. Use ChannelConfig. */
@@ -67,6 +73,8 @@ export interface ChannelRuntimeConfig {
67
73
  type: string;
68
74
  enabled: boolean;
69
75
  replyMode?: ReplyMode;
76
+ /** See ChannelConfig.ownerIds — per-channel trusted owner sender ids. */
77
+ ownerIds?: string[];
70
78
  options?: Record<string, unknown>;
71
79
  }
72
80
  export interface PluginConfig {
@@ -11,6 +11,7 @@ export interface SessionMessages {
11
11
  currentLabel: string;
12
12
  sessionListItem: (alias: string, agent: string, workspace: string) => string;
13
13
  sessionCreated: (alias: string) => string;
14
+ sessionAlreadyExists: (alias: string, agent: string, workspace: string) => string;
14
15
  sessionAttachNotFound: (alias: string, agent: string, workspace: string) => string;
15
16
  sessionAttached: (alias: string) => string;
16
17
  switched: (alias: string, agent: string, workspace: string) => string;
@@ -43,6 +44,7 @@ export interface SessionMessages {
43
44
  sessionBlockedByTasksHint: string;
44
45
  sessionRemoved: (alias: string) => string;
45
46
  sessionRemovedWasActive: string;
47
+ sessionRemovedWasActivePromoted: (alias: string) => string;
46
48
  sessionTransportShared: (transportSession: string, count: number) => string;
47
49
  sessionOrchestrationPurgeFailed: (warning: string) => string;
48
50
  sessionTransportTeardownFailed: (warning: string) => string;
@@ -689,6 +691,8 @@ export interface PluginCliMessages {
689
691
  noPlugins: string;
690
692
  pluginListHeader: string;
691
693
  unrecognizedArgs: (args: string) => string;
694
+ pluginSpecHasDoubleQuote: (spec: string) => string;
695
+ pluginSpecHasPercentOnWindows: (spec: string) => string;
692
696
  pluginInstallFailed: (packageSpec: string, error: string) => string;
693
697
  pluginValidateFailed: (recordedName: string, error: string) => string;
694
698
  pluginInstalled: (recordedName: string) => string;
@@ -775,8 +779,6 @@ export interface WeixinMessages {
775
779
  debugEnabled: string;
776
780
  debugDisabled: string;
777
781
  sessionCleared: string;
778
- noAccountsLoggedIn: string;
779
- logoutSuccess: string;
780
782
  commandFailed: (detail: string) => string;
781
783
  }
782
784
  export interface MigrateMessages {
@@ -802,6 +804,7 @@ export interface MiscMessages {
802
804
  quotaHeadsUp: string;
803
805
  quotaOverflowSummary: (count: number) => string;
804
806
  finalHeadsUp: (total: number, sentSoFar: number, remaining: number) => string;
807
+ finalAllParked: (count: number) => string;
805
808
  quotedMessagePrefix: (parts: string) => string;
806
809
  scheduledTaskFailed: (message: string) => string;
807
810
  orchestrationTaskCompleted: (taskId: string, workerSession: string, result: string) => string;
@@ -824,6 +827,8 @@ export interface MiscMessages {
824
827
  delegateQPackageInstr3: string;
825
828
  commandAccessDeniedSuffix: string;
826
829
  commandAccessDeniedHint: string;
830
+ commandAccessDeniedChatTypeMissingSuffix: string;
831
+ commandAccessDeniedChatTypeMissingHint: string;
827
832
  commandLabelThisMessage: string;
828
833
  sessionResetNoCurrentSession: string;
829
834
  sessionResetFailed: (alias: string) => string;
@@ -13,6 +13,7 @@ export interface CreatePerfLogWriterOptions {
13
13
  appendImpl?: (path: string, data: string) => Promise<void>;
14
14
  mkdirImpl?: (path: string, opts: {
15
15
  recursive: true;
16
+ mode?: number;
16
17
  }) => Promise<void>;
17
18
  now?: () => Date;
18
19
  }
@@ -68,6 +68,11 @@ var init_session = __esm(() => {
68
68
  currentLabel: "[current]",
69
69
  sessionListItem: (alias, agent, workspace) => `- ${alias} (${agent} @ ${workspace})`,
70
70
  sessionCreated: (alias) => `Session "${alias}" created and switched.`,
71
+ sessionAlreadyExists: (alias, agent, workspace) => [
72
+ `Session "${alias}" already exists (${agent} @ ${workspace}).`,
73
+ `Switch to it with /use ${alias}, or remove it first with /session rm ${alias}.`
74
+ ].join(`
75
+ `),
71
76
  sessionAttachNotFound: (alias, agent, workspace) => [
72
77
  "No existing session found to attach.",
73
78
  `Check the session name and retry: /session attach ${alias} --agent ${agent} --ws ${workspace} --name <session-name>`
@@ -104,6 +109,7 @@ var init_session = __esm(() => {
104
109
  sessionBlockedByTasksHint: "Use /tasks to list tasks, or /task cancel <id> to cancel one.",
105
110
  sessionRemoved: (alias) => `Session "${alias}" removed.`,
106
111
  sessionRemovedWasActive: "This was the active session. Its chat context has been cleared.",
112
+ sessionRemovedWasActivePromoted: (alias) => `This was the active session. Switched back to the previous session "${alias}".`,
107
113
  sessionTransportShared: (transportSession, count) => `Note: backend session "${transportSession}" is still referenced by ${count} other session(s) and was not closed.`,
108
114
  sessionOrchestrationPurgeFailed: (warning) => `Note: failed to purge orchestration references (${warning}). Run /tasks clean manually to clean up.`,
109
115
  sessionTransportTeardownFailed: (warning) => `Note: backend session could not be closed automatically (${warning}). Run acpx sessions close manually if needed.`,
@@ -397,7 +403,7 @@ var init_later = __esm(() => {
397
403
  helpNote2: "Time must be at least 10 seconds and at most 7 days away",
398
404
  helpNote3: "By default runs in a new temporary session that is destroyed after completion",
399
405
  helpNote4: "Use --bind to send to the session that was current when the task was created (configurable via later.defaultMode)",
400
- helpNote5: "/lt list shows all pending tasks globally; in group chats only the owner can cancel",
406
+ helpNote5: "/lt list shows only this chat's pending tasks; in group chats only the owner can cancel",
401
407
  helpNote6: "Scheduling slash-prefixed xacpx commands is not supported",
402
408
  helpNote7: "Full time format reference: docs/later-command.md"
403
409
  };
@@ -890,6 +896,8 @@ var init_plugin_cli = __esm(() => {
890
896
  noPlugins: "No plugins installed yet.",
891
897
  pluginListHeader: "Plugins:",
892
898
  unrecognizedArgs: (args) => `Unrecognized arguments: ${args}`,
899
+ pluginSpecHasDoubleQuote: (spec) => `Invalid plugin spec ${spec}: double quotes (") are never valid in an npm package spec.`,
900
+ pluginSpecHasPercentOnWindows: (spec) => `Invalid plugin spec ${spec}: "%" would be mangled by cmd.exe on Windows. Install the package with npm directly instead.`,
893
901
  pluginInstallFailed: (packageSpec, error) => `Plugin ${packageSpec} install failed: ${error}`,
894
902
  pluginValidateFailed: (recordedName, error) => `Plugin ${recordedName} validation failed: ${error}`,
895
903
  pluginInstalled: (recordedName) => `Plugin ${recordedName} installed`,
@@ -1001,8 +1009,6 @@ var init_weixin = __esm(() => {
1001
1009
  debugEnabled: "Debug mode enabled",
1002
1010
  debugDisabled: "Debug mode disabled",
1003
1011
  sessionCleared: "✅ Session cleared. Starting a fresh conversation.",
1004
- noAccountsLoggedIn: "No accounts are currently logged in.",
1005
- logoutSuccess: "✅ Logged out. All account credentials cleared.",
1006
1012
  commandFailed: (detail) => `❌ Command failed: ${detail}`
1007
1013
  };
1008
1014
  });
@@ -1040,6 +1046,7 @@ var init_misc = __esm(() => {
1040
1046
  quotaOverflowSummary: (count) => `(${count} progress updates omitted due to message limit; see final result below)`,
1041
1047
  finalHeadsUp: (total, sentSoFar, remaining) => `—
1042
1048
  \uD83D\uDCC4 Result: ${total} parts total, ${sentSoFar} sent. Reply /jx to see the next ${remaining} parts.`,
1049
+ finalAllParked: (count) => `\uD83D\uDCC4 Message limit reached: the result (${count} parts) is parked. Reply /jx to receive it.`,
1043
1050
  quotedMessagePrefix: (parts) => `[Quote: ${parts}]`,
1044
1051
  scheduledTaskFailed: (message) => `Scheduled task failed: ${message}`,
1045
1052
  orchestrationTaskCompleted: (taskId, workerSession, result) => `Delegation task "${taskId}" completed
@@ -1068,6 +1075,8 @@ var init_misc = __esm(() => {
1068
1075
  delegateQPackageInstr3: "Do not forward the human's exact words to the worker",
1069
1076
  commandAccessDeniedSuffix: " is restricted to group owner only.",
1070
1077
  commandAccessDeniedHint: "To perform control operations, have the owner send them in the group, or use a private chat.",
1078
+ commandAccessDeniedChatTypeMissingSuffix: " was blocked: this channel did not report the chat type (direct or group), so control commands are disabled here.",
1079
+ commandAccessDeniedChatTypeMissingHint: "Read-only commands and prompts still work. This is a channel metadata issue — update or report the channel plugin.",
1071
1080
  commandLabelThisMessage: "This message",
1072
1081
  sessionResetNoCurrentSession: "No session is currently selected. Run /session new ... or /use <alias> first.",
1073
1082
  sessionResetFailed: (alias) => `Session "${alias}" reset failed. The new backend session was not created, please try again later.`,
@@ -1138,6 +1147,11 @@ var init_session2 = __esm(() => {
1138
1147
  currentLabel: "[当前]",
1139
1148
  sessionListItem: (alias, agent2, workspace2) => `- ${alias} (${agent2} @ ${workspace2})`,
1140
1149
  sessionCreated: (alias) => `会话「${alias}」已创建并切换`,
1150
+ sessionAlreadyExists: (alias, agent2, workspace2) => [
1151
+ `会话「${alias}」已存在(${agent2} @ ${workspace2})。`,
1152
+ `发送 /use ${alias} 切换到它,或先执行 /session rm ${alias} 删除后再创建。`
1153
+ ].join(`
1154
+ `),
1141
1155
  sessionAttachNotFound: (alias, agent2, workspace2) => [
1142
1156
  "没有找到可绑定的已有会话。",
1143
1157
  `请确认会话名是否正确,然后重新执行:/session attach ${alias} --agent ${agent2} --ws ${workspace2} --name <会话名>`
@@ -1174,6 +1188,7 @@ var init_session2 = __esm(() => {
1174
1188
  sessionBlockedByTasksHint: "使用 /tasks 查看任务列表,或 /task cancel <id> 取消任务。",
1175
1189
  sessionRemoved: (alias) => `已删除会话「${alias}」。`,
1176
1190
  sessionRemovedWasActive: "该会话是当前活跃会话,已自动清除相关聊天上下文。",
1191
+ sessionRemovedWasActivePromoted: (alias) => `该会话是当前活跃会话,已切换回上一个会话「${alias}」。`,
1177
1192
  sessionTransportShared: (transportSession, count) => `提示:后端会话「${transportSession}」仍被其他 ${count} 个会话引用,未关闭。`,
1178
1193
  sessionOrchestrationPurgeFailed: (warning) => `提示:清理任务编排引用失败(${warning}),请稍后执行 /tasks clean 手动清理。`,
1179
1194
  sessionTransportTeardownFailed: (warning) => `提示:后端会话未能自动关闭(${warning}),如有残留请手动执行 acpx sessions close。`,
@@ -1467,7 +1482,7 @@ var init_later2 = __esm(() => {
1467
1482
  helpNote2: "时间必须在 10 秒之后、7 天之内",
1468
1483
  helpNote3: "默认在为本次任务新建的临时会话里执行,跑完即销毁",
1469
1484
  helpNote4: "加 --bind 改为发送到创建时绑定的当前会话(默认模式可用 later.defaultMode 配置)",
1470
- helpNote5: "/lt list 显示全局待执行任务;群聊中只有群主可取消",
1485
+ helpNote5: "/lt list 只显示本聊天创建的待执行任务;群聊中只有群主可取消",
1471
1486
  helpNote6: "不支持延迟执行 / 开头的 xacpx 命令",
1472
1487
  helpNote7: "完整时间格式与说明见 docs/later-command.md"
1473
1488
  };
@@ -1960,6 +1975,8 @@ var init_plugin_cli2 = __esm(() => {
1960
1975
  noPlugins: "还没有安装插件。",
1961
1976
  pluginListHeader: "插件:",
1962
1977
  unrecognizedArgs: (args) => `未识别的参数:${args}`,
1978
+ pluginSpecHasDoubleQuote: (spec) => `非法插件 spec ${spec}:npm 包 spec 不允许包含双引号 (")。`,
1979
+ pluginSpecHasPercentOnWindows: (spec) => `非法插件 spec ${spec}:Windows 上 cmd.exe 会展开 %,无法安全传递。请改用 npm 直接安装该包。`,
1963
1980
  pluginInstallFailed: (packageSpec, error) => `插件 ${packageSpec} 安装失败:${error}`,
1964
1981
  pluginValidateFailed: (recordedName, error) => `插件 ${recordedName} 校验失败:${error}`,
1965
1982
  pluginInstalled: (recordedName) => `插件 ${recordedName} 已安装`,
@@ -2071,8 +2088,6 @@ var init_weixin2 = __esm(() => {
2071
2088
  debugEnabled: "Debug 模式已开启",
2072
2089
  debugDisabled: "Debug 模式已关闭",
2073
2090
  sessionCleared: "✅ 会话已清除,重新开始对话",
2074
- noAccountsLoggedIn: "当前没有已登录的账号",
2075
- logoutSuccess: "✅ 已退出登录,清除所有账号凭证",
2076
2091
  commandFailed: (detail) => `❌ 指令执行失败: ${detail}`
2077
2092
  };
2078
2093
  });
@@ -2110,6 +2125,7 @@ var init_misc2 = __esm(() => {
2110
2125
  quotaOverflowSummary: (count) => `(因消息次数限制省略 ${count} 条进度,请继续查看下方最终结果)`,
2111
2126
  finalHeadsUp: (total, sentSoFar, remaining) => `—
2112
2127
  \uD83D\uDCC4 结果共 ${total} 段,已发 ${sentSoFar} 段。回复 /jx 续看后 ${remaining} 段。`,
2128
+ finalAllParked: (count) => `\uD83D\uDCC4 已达消息上限:结果共 ${count} 段已暂存。回复 /jx 接收。`,
2113
2129
  quotedMessagePrefix: (parts) => `[引用: ${parts}]`,
2114
2130
  scheduledTaskFailed: (message) => `定时任务执行失败:${message}`,
2115
2131
  orchestrationTaskCompleted: (taskId, workerSession, result) => `委派任务「${taskId}」已完成
@@ -2138,6 +2154,8 @@ var init_misc2 = __esm(() => {
2138
2154
  delegateQPackageInstr3: "不要直接把 human 原话转发给 worker",
2139
2155
  commandAccessDeniedSuffix: " 仅限群创建者/频道 owner 使用。",
2140
2156
  commandAccessDeniedHint: "如果需要执行控制类操作,请由 owner 在群内发送,或改用私聊。",
2157
+ commandAccessDeniedChatTypeMissingSuffix: " 已被拦截:该频道未上报会话类型(直聊/群聊),控制类命令在此暂不可用。",
2158
+ commandAccessDeniedChatTypeMissingHint: "只读命令与普通对话不受影响。这是频道元数据问题,请升级或反馈该频道插件。",
2141
2159
  commandLabelThisMessage: "该消息",
2142
2160
  sessionResetNoCurrentSession: "当前还没有选中的会话。请先执行 /session new ... 或 /use <alias>。",
2143
2161
  sessionResetFailed: (alias) => `会话「${alias}」重置失败。
@@ -28,8 +28,11 @@ export declare class ScheduledTaskService {
28
28
  private readonly claimedInThisSession;
29
29
  constructor(state: AppState, stateStore: Pick<StateStore, "save">, options?: ScheduledTaskServiceOptions);
30
30
  createTask(input: CreateScheduledTaskInput): Promise<ScheduledTaskRecord>;
31
- listPending(): ScheduledTaskRecord[];
32
- cancelPending(inputId: string): Promise<boolean>;
31
+ listPending(chatKey: string): ScheduledTaskRecord[];
32
+ listPendingAllChats(): ScheduledTaskRecord[];
33
+ cancelPending(inputId: string, chatKey: string): Promise<boolean>;
34
+ cancelPendingAnyChat(inputId: string): Promise<boolean>;
35
+ private cancelPendingWhere;
33
36
  markStartupMissed(): Promise<void>;
34
37
  claimDueTasks(): Promise<ScheduledTaskRecord[]>;
35
38
  markExecuted(id: string): Promise<void>;
@@ -67,9 +67,13 @@ export declare class SessionService {
67
67
  createSession(alias: string, agent: string, workspace: string): Promise<ResolvedSession>;
68
68
  /**
69
69
  * All currently-known logical sessions resolved to transport sessions, deduped by
70
- * transport session. Sessions whose agent or workspace is no longer registered are
71
- * skipped (toResolvedSession would throw). Used by shutdown cleanup to reap warm
72
- * acpx queue owners; never throws.
70
+ * the composite identity acpx keys its session records on (agent + agent command +
71
+ * cwd + transport session name). Two aliases sharing a transport-session *name*
72
+ * but differing in agent/cwd (possible via /session attach) resolve to different
73
+ * acpx records with their own warm queue owners, so both must survive. Sessions
74
+ * whose agent or workspace is no longer registered are skipped (toResolvedSession
75
+ * would throw). Used by startup/shutdown cleanup to reap warm acpx queue owners;
76
+ * never throws.
73
77
  */
74
78
  listAllResolvedSessions(): ResolvedSession[];
75
79
  resolveSession(alias: string, agent: string, workspace: string, transportSession: string): ResolvedSession;
@@ -1,8 +1,75 @@
1
1
  import { type AppState } from "./types";
2
- export declare function parseState(raw: unknown, path: string): AppState;
2
+ /**
3
+ * Format version written on every save. The parser tolerates files without it
4
+ * (older releases) and ignores it on load; no migration chain exists yet.
5
+ */
6
+ export declare const STATE_FILE_VERSION = 1;
7
+ /** A record or section that was skipped/repaired while loading state.json. */
8
+ export interface StateLoadDroppedRecord {
9
+ section: string;
10
+ key: string;
11
+ reason: string;
12
+ }
13
+ export interface StateLoadReport {
14
+ dropped: StateLoadDroppedRecord[];
15
+ /** Backup copy of the original file, written because records were dropped. */
16
+ quarantinePath?: string;
17
+ /** Unreadable original renamed aside (whole-file JSON corruption). */
18
+ corruptPath?: string;
19
+ /** Best-effort backup/rename failure; load still returned the cleaned state. */
20
+ backupError?: string;
21
+ }
22
+ /**
23
+ * Lenient state parser: a malformed record (or wrong-typed section) is skipped
24
+ * and collected in `dropped` instead of throwing, so one bad record can never
25
+ * brick daemon startup. The per-record shape checks themselves stay strict — an
26
+ * invalid record is quarantined, it never flows into dispatch logic. Only a
27
+ * non-object top level still throws (StateStore.load treats that as a corrupt
28
+ * file and renames it aside).
29
+ */
30
+ export declare function parseState(raw: unknown, path: string, dropped?: StateLoadDroppedRecord[]): AppState;
31
+ export interface StateStoreOptions {
32
+ /** Injectable clock used for quarantine/corrupt backup file names. */
33
+ now?: () => Date;
34
+ /**
35
+ * Injectable backup writer (tests simulate backup failures). May return the
36
+ * path actually written when it differs from the requested one (the default
37
+ * writer suffix-retries instead of overwriting an existing backup).
38
+ */
39
+ writeBackup?: (targetPath: string, content: string) => Promise<string | void>;
40
+ }
41
+ /** Result of a side-effect-free {@link StateStore.inspect}. */
42
+ export interface StateLoadInspection {
43
+ state: AppState;
44
+ /** Null when the file is fully valid (or missing/empty). */
45
+ report: StateLoadReport | null;
46
+ }
3
47
  export declare class StateStore {
4
48
  private readonly path;
5
- constructor(path: string);
49
+ private readonly options;
50
+ private loadReport;
51
+ constructor(path: string, options?: StateStoreOptions);
52
+ /**
53
+ * Report of the most recent load(): null when the file was fully valid (or
54
+ * missing/empty), otherwise the dropped/repaired records plus the quarantine
55
+ * or corrupt backup path. Callers log/print this so silent repair is visible.
56
+ */
57
+ get lastLoadReport(): StateLoadReport | null;
6
58
  load(): Promise<AppState>;
59
+ /**
60
+ * 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}.
64
+ */
65
+ inspect(): Promise<StateLoadInspection>;
66
+ private readAndParse;
7
67
  save(state: AppState): Promise<void>;
68
+ private fileTimestamp;
69
+ /**
70
+ * Whole-file corruption (JSON syntax error / non-object top level): rename
71
+ * the file aside — not copy — so the next save does not fight the corrupt
72
+ * bytes, then start from an empty state.
73
+ */
74
+ private recoverFromCorruptFile;
8
75
  }
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Runs `fn` while holding the file's proper-lockfile lock, so a caller can make
3
+ * a whole read→modify→write span atomic with respect to other xacpx-aware
4
+ * processes (writePrivateFileAtomic alone only serializes the write).
5
+ *
6
+ * proper-lockfile is NOT reentrant: inside `fn`, write through the provided
7
+ * `writeLocked` (same atomic semantics, no re-lock) — never call
8
+ * writePrivateFileAtomic on the same path or it will deadlock until the lock
9
+ * goes stale. `realpath: false` keeps locking working for a not-yet-existing
10
+ * target file (the lock lives at `<path>.lock`).
11
+ */
12
+ export declare function withPrivateFileLock<T>(path: string, fn: (writeLocked: (content: string) => Promise<void>) => Promise<T>): Promise<T>;
1
13
  export declare function writePrivateFileAtomic(path: string, content: string): Promise<void>;
2
14
  /**
3
15
  * Synchronous private-file write for hot-path callers that cannot await
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganglion/xacpx",
3
- "version": "0.9.3",
3
+ "version": "0.10.1",
4
4
  "description": "随时随地通过聊天频道(微信 / 飞书 / 元宝等)远程控制 `acpx` 上的 Claude Code、Codex 等 Agents。",
5
5
  "keywords": [
6
6
  "acpx",