@lesliechan721/aw 0.1.0-beta.10 → 0.1.0-beta.12

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.
Files changed (227) hide show
  1. package/dist/authoring/assets/aw-config.d.mts +1 -50
  2. package/dist/authoring/assets/aw-config.mjs +58 -928
  3. package/dist/authoring/assets/aw-config.mjs.map +1 -1
  4. package/dist/authoring/scripts/init-plugin.mjs +28 -33
  5. package/dist/authoring/scripts/init-plugin.mjs.map +1 -1
  6. package/dist/authoring/scripts/lib/plugin-version-check.d.mts +1 -0
  7. package/dist/authoring/scripts/lib/plugin-version-check.mjs +229 -8
  8. package/dist/authoring/scripts/lib/plugin-version-check.mjs.map +1 -1
  9. package/dist/authoring/scripts/validate-plugin.mjs +159 -48
  10. package/dist/authoring/scripts/validate-plugin.mjs.map +1 -1
  11. package/dist/scripts/bundle-plugins.js +18 -4
  12. package/dist/scripts/bundle-plugins.js.map +1 -1
  13. package/dist/scripts/test-plugin-scripts.d.ts +2 -0
  14. package/dist/scripts/test-plugin-scripts.js +21 -5
  15. package/dist/scripts/test-plugin-scripts.js.map +1 -1
  16. package/dist/src/commands/aw-capabilities.d.ts +1 -1
  17. package/dist/src/commands/aw-capabilities.js +1 -1
  18. package/dist/src/commands/aw-capabilities.js.map +1 -1
  19. package/dist/src/commands/catalog-dev-source.d.ts +6 -3
  20. package/dist/src/commands/catalog-dev-source.js +96 -35
  21. package/dist/src/commands/catalog-dev-source.js.map +1 -1
  22. package/dist/src/commands/catalog-publication-inspection.d.ts +1 -1
  23. package/dist/src/commands/catalog-publication-inspection.js +54 -8
  24. package/dist/src/commands/catalog-publication-inspection.js.map +1 -1
  25. package/dist/src/commands/catalog-publication-journal.js +1 -1
  26. package/dist/src/commands/catalog-publication-journal.js.map +1 -1
  27. package/dist/src/commands/catalog-source.d.ts +19 -4
  28. package/dist/src/commands/catalog-source.js +121 -12
  29. package/dist/src/commands/catalog-source.js.map +1 -1
  30. package/dist/src/commands/catalog-store.d.ts +26 -7
  31. package/dist/src/commands/catalog-store.js +361 -42
  32. package/dist/src/commands/catalog-store.js.map +1 -1
  33. package/dist/src/commands/console.js +28 -17
  34. package/dist/src/commands/console.js.map +1 -1
  35. package/dist/src/commands/operation-plan.d.ts +2 -1
  36. package/dist/src/commands/operation-plan.js +24 -0
  37. package/dist/src/commands/operation-plan.js.map +1 -1
  38. package/dist/src/commands/operation-planner.d.ts +2 -2
  39. package/dist/src/commands/operation-planner.js +22 -20
  40. package/dist/src/commands/operation-planner.js.map +1 -1
  41. package/dist/src/commands/options.d.ts +2 -2
  42. package/dist/src/commands/options.js +18 -4
  43. package/dist/src/commands/options.js.map +1 -1
  44. package/dist/src/commands/plugin-capabilities.js +2 -2
  45. package/dist/src/commands/plugin-capabilities.js.map +1 -1
  46. package/dist/src/commands/rebuild-command.d.ts +19 -0
  47. package/dist/src/commands/rebuild-command.js +828 -283
  48. package/dist/src/commands/rebuild-command.js.map +1 -1
  49. package/dist/src/commands/rebuild-integration.d.ts +25 -47
  50. package/dist/src/commands/rebuild-integration.js +1176 -1016
  51. package/dist/src/commands/rebuild-integration.js.map +1 -1
  52. package/dist/src/commands/rebuild-operations.d.ts +50 -12
  53. package/dist/src/commands/rebuild-operations.js +207 -101
  54. package/dist/src/commands/rebuild-operations.js.map +1 -1
  55. package/dist/src/commands/status-v6.d.ts +21 -2
  56. package/dist/src/commands/status-v6.js +163 -31
  57. package/dist/src/commands/status-v6.js.map +1 -1
  58. package/dist/src/commands/sync-command.d.ts +17 -0
  59. package/dist/src/commands/sync-command.js +45 -37
  60. package/dist/src/commands/sync-command.js.map +1 -1
  61. package/dist/src/commands/write-commands.d.ts +5 -0
  62. package/dist/src/commands/write-commands.js +50 -3
  63. package/dist/src/commands/write-commands.js.map +1 -1
  64. package/dist/src/generators/local-plugin.js +14 -4
  65. package/dist/src/generators/local-plugin.js.map +1 -1
  66. package/dist/src/index.js +260 -91
  67. package/dist/src/index.js.map +1 -1
  68. package/dist/src/output/command-receipt.js +1 -3
  69. package/dist/src/output/command-receipt.js.map +1 -1
  70. package/dist/src/output/rebuild-progress.js +1 -3
  71. package/dist/src/output/rebuild-progress.js.map +1 -1
  72. package/dist/src/portable/materializer.js +10 -8
  73. package/dist/src/portable/materializer.js.map +1 -1
  74. package/dist/src/tui/catalog-generation.js +41 -1
  75. package/dist/src/tui/catalog-generation.js.map +1 -1
  76. package/dist/src/tui/catalog-sources/session.d.ts +1 -0
  77. package/dist/src/tui/catalog-sources/session.js +16 -3
  78. package/dist/src/tui/catalog-sources/session.js.map +1 -1
  79. package/dist/src/tui/catalog-sources/types.d.ts +3 -2
  80. package/dist/src/tui/components.d.ts +4 -0
  81. package/dist/src/tui/components.js +36 -24
  82. package/dist/src/tui/components.js.map +1 -1
  83. package/dist/src/tui/console-app-v4.d.ts +13 -4
  84. package/dist/src/tui/console-app-v4.js +2065 -857
  85. package/dist/src/tui/console-app-v4.js.map +1 -1
  86. package/dist/src/tui/controller-runtime.js +731 -62
  87. package/dist/src/tui/controller-runtime.js.map +1 -1
  88. package/dist/src/tui/controller.d.ts +156 -10
  89. package/dist/src/tui/controller.js +1187 -163
  90. package/dist/src/tui/controller.js.map +1 -1
  91. package/dist/src/tui/diagnostic-projection.d.ts +22 -0
  92. package/dist/src/tui/diagnostic-projection.js +437 -0
  93. package/dist/src/tui/diagnostic-projection.js.map +1 -0
  94. package/dist/src/tui/diagnostics.d.ts +1 -1
  95. package/dist/src/tui/diagnostics.js +7 -2
  96. package/dist/src/tui/diagnostics.js.map +1 -1
  97. package/dist/src/tui/glossary.d.ts +23 -0
  98. package/dist/src/tui/glossary.js +42 -0
  99. package/dist/src/tui/glossary.js.map +1 -0
  100. package/dist/src/tui/index.d.ts +4 -1
  101. package/dist/src/tui/index.js +4 -1
  102. package/dist/src/tui/index.js.map +1 -1
  103. package/dist/src/tui/keys.d.ts +44 -0
  104. package/dist/src/tui/keys.js +43 -15
  105. package/dist/src/tui/keys.js.map +1 -1
  106. package/dist/src/tui/lifecycle-action-view.d.ts +6 -0
  107. package/dist/src/tui/lifecycle-action-view.js +34 -0
  108. package/dist/src/tui/lifecycle-action-view.js.map +1 -0
  109. package/dist/src/tui/model.d.ts +12 -2
  110. package/dist/src/tui/model.js +128 -52
  111. package/dist/src/tui/model.js.map +1 -1
  112. package/dist/src/tui/operation-diagnostic-screens.d.ts +21 -0
  113. package/dist/src/tui/operation-diagnostic-screens.js +101 -0
  114. package/dist/src/tui/operation-diagnostic-screens.js.map +1 -0
  115. package/dist/src/tui/operation-history.d.ts +80 -0
  116. package/dist/src/tui/operation-history.js +130 -0
  117. package/dist/src/tui/operation-history.js.map +1 -0
  118. package/dist/src/tui/operation-review-planner.js +366 -99
  119. package/dist/src/tui/operation-review-planner.js.map +1 -1
  120. package/dist/src/tui/router.d.ts +82 -17
  121. package/dist/src/tui/router.js +186 -38
  122. package/dist/src/tui/router.js.map +1 -1
  123. package/dist/src/tui/screen-registry.d.ts +546 -0
  124. package/dist/src/tui/screen-registry.js +179 -0
  125. package/dist/src/tui/screen-registry.js.map +1 -0
  126. package/dist/src/tui/session-console-app.d.ts +6 -0
  127. package/dist/src/tui/session-console-app.js +13 -0
  128. package/dist/src/tui/session-console-app.js.map +1 -0
  129. package/dist/src/tui/session-host.d.ts +44 -0
  130. package/dist/src/tui/session-host.js +197 -0
  131. package/dist/src/tui/session-host.js.map +1 -0
  132. package/dist/src/types.d.ts +64 -3
  133. package/dist/src/types.js +1 -1
  134. package/dist/src/types.js.map +1 -1
  135. package/dist/src/utils/agent-version.js +1 -1
  136. package/dist/src/utils/agent-version.js.map +1 -1
  137. package/dist/src/utils/artifact-plan.d.ts +2 -2
  138. package/dist/src/utils/artifact-plan.js +8 -6
  139. package/dist/src/utils/artifact-plan.js.map +1 -1
  140. package/dist/src/utils/artifact-renderer.d.ts +2 -1
  141. package/dist/src/utils/artifact-renderer.js +139 -120
  142. package/dist/src/utils/artifact-renderer.js.map +1 -1
  143. package/dist/src/utils/artifact-request.d.ts +5 -0
  144. package/dist/src/utils/artifact-request.js +40 -0
  145. package/dist/src/utils/artifact-request.js.map +1 -1
  146. package/dist/src/utils/bundle-layout.js +4 -4
  147. package/dist/src/utils/bundle-layout.js.map +1 -1
  148. package/dist/src/utils/catalog-capability-projection.d.ts +8 -0
  149. package/dist/src/utils/catalog-capability-projection.js +14 -0
  150. package/dist/src/utils/catalog-capability-projection.js.map +1 -0
  151. package/dist/src/utils/catalog-generation-resolver.d.ts +14 -2
  152. package/dist/src/utils/catalog-generation-resolver.js +49 -27
  153. package/dist/src/utils/catalog-generation-resolver.js.map +1 -1
  154. package/dist/src/utils/catalog-generation-service.d.ts +8 -1
  155. package/dist/src/utils/catalog-generation-service.js +81 -47
  156. package/dist/src/utils/catalog-generation-service.js.map +1 -1
  157. package/dist/src/utils/catalog-generation-store.d.ts +1 -0
  158. package/dist/src/utils/catalog-generation-store.js +60 -5
  159. package/dist/src/utils/catalog-generation-store.js.map +1 -1
  160. package/dist/src/utils/catalog-operation-context.js +1 -0
  161. package/dist/src/utils/catalog-operation-context.js.map +1 -1
  162. package/dist/src/utils/catalog-public-error.js +4 -0
  163. package/dist/src/utils/catalog-public-error.js.map +1 -1
  164. package/dist/src/utils/catalog-source-impact.d.ts +3 -2
  165. package/dist/src/utils/catalog-source-impact.js +142 -20
  166. package/dist/src/utils/catalog-source-impact.js.map +1 -1
  167. package/dist/src/utils/catalog-source-references.js +95 -9
  168. package/dist/src/utils/catalog-source-references.js.map +1 -1
  169. package/dist/src/utils/catalog-sources.d.ts +1 -1
  170. package/dist/src/utils/catalog-sources.js +19 -3
  171. package/dist/src/utils/catalog-sources.js.map +1 -1
  172. package/dist/src/utils/codex-manager.js +8 -0
  173. package/dist/src/utils/codex-manager.js.map +1 -1
  174. package/dist/src/utils/codex-native-observation.d.ts +34 -1
  175. package/dist/src/utils/codex-native-observation.js +132 -0
  176. package/dist/src/utils/codex-native-observation.js.map +1 -1
  177. package/dist/src/utils/codex-reconciler.d.ts +0 -2
  178. package/dist/src/utils/codex-reconciler.js.map +1 -1
  179. package/dist/src/utils/codex-shared-native.d.ts +2 -26
  180. package/dist/src/utils/codex-shared-native.js +8 -82
  181. package/dist/src/utils/codex-shared-native.js.map +1 -1
  182. package/dist/src/utils/git-catalog-provider.d.ts +3 -0
  183. package/dist/src/utils/git-catalog-provider.js +40 -1
  184. package/dist/src/utils/git-catalog-provider.js.map +1 -1
  185. package/dist/src/utils/local-marketplace.d.ts +1 -0
  186. package/dist/src/utils/local-marketplace.js +69 -73
  187. package/dist/src/utils/local-marketplace.js.map +1 -1
  188. package/dist/src/utils/multi-source-catalog.js +1 -0
  189. package/dist/src/utils/multi-source-catalog.js.map +1 -1
  190. package/dist/src/utils/native-cli.d.ts +9 -4
  191. package/dist/src/utils/native-cli.js +280 -34
  192. package/dist/src/utils/native-cli.js.map +1 -1
  193. package/dist/src/utils/path-catalog-provider.js +2 -0
  194. package/dist/src/utils/path-catalog-provider.js.map +1 -1
  195. package/dist/src/utils/plugin-config-impact.d.ts +33 -0
  196. package/dist/src/utils/plugin-config-impact.js +298 -0
  197. package/dist/src/utils/plugin-config-impact.js.map +1 -0
  198. package/dist/src/utils/plugin-config-write.d.ts +36 -14
  199. package/dist/src/utils/plugin-config-write.js +709 -225
  200. package/dist/src/utils/plugin-config-write.js.map +1 -1
  201. package/dist/src/utils/plugin-config.d.ts +58 -4
  202. package/dist/src/utils/plugin-config.js +328 -60
  203. package/dist/src/utils/plugin-config.js.map +1 -1
  204. package/dist/src/utils/plugin-scripts.d.ts +20 -12
  205. package/dist/src/utils/plugin-scripts.js +389 -114
  206. package/dist/src/utils/plugin-scripts.js.map +1 -1
  207. package/dist/src/utils/process-tree.d.ts +2 -2
  208. package/dist/src/utils/process-tree.js +1 -1
  209. package/dist/src/utils/process-tree.js.map +1 -1
  210. package/dist/src/utils/project-boundary.d.ts +11 -1
  211. package/dist/src/utils/project-boundary.js +33 -11
  212. package/dist/src/utils/project-boundary.js.map +1 -1
  213. package/dist/src/utils/safe-operation-diagnostic.d.ts +15 -0
  214. package/dist/src/utils/safe-operation-diagnostic.js +33 -0
  215. package/dist/src/utils/safe-operation-diagnostic.js.map +1 -0
  216. package/dist/src/utils/sensitive-content.d.ts +4 -0
  217. package/dist/src/utils/sensitive-content.js +36 -0
  218. package/dist/src/utils/sensitive-content.js.map +1 -0
  219. package/dist/src/utils/windows-claude-shim.d.ts +9 -0
  220. package/dist/src/utils/windows-claude-shim.js +141 -0
  221. package/dist/src/utils/windows-claude-shim.js.map +1 -0
  222. package/dist/vitest.config.js +91 -10
  223. package/dist/vitest.config.js.map +1 -1
  224. package/package.json +9 -9
  225. package/dist/authoring/templates/aw-config-runtime.d.mts +0 -2
  226. package/dist/authoring/templates/aw-config-runtime.mjs +0 -407
  227. package/dist/authoring/templates/aw-config-runtime.mjs.map +0 -1
@@ -1,14 +1,25 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { CatalogGenerationError } from '../utils/catalog-generation-error.js';
3
+ import { readSafeOperationError } from '../utils/safe-operation-diagnostic.js';
3
4
  import { buildRebuildReceiptJson, requestedIntentAction } from '../output/command-receipt.js';
4
5
  import { buildOperationResultModel } from '../commands/operation-planner.js';
5
6
  import { buildOperationReviewProjection } from './operation-review-planner.js';
6
7
  import { buildTuiViewModel, dependencyPlacementTargets, hasInstalledPlacement, } from './model.js';
7
- import { safeDiagnosticCode } from './diagnostics.js';
8
+ import { safeDiagnosticCode, sanitizeDiagnosticText } from './diagnostics.js';
9
+ import { projectExecutionDiagnostics, projectThrownOperationDiagnostic } from './diagnostic-projection.js';
10
+ import { OperationHistoryStore, } from './operation-history.js';
8
11
  import { targetHasArtifactCollision } from '../utils/target-eligibility.js';
9
12
  import { TuiRouter, } from './router.js';
13
+ import { normalizeTuiRoute } from './screen-registry.js';
10
14
  import { CatalogSourcesSession } from './catalog-sources/session.js';
11
15
  import { catalogSourceAutoCheckOnlyValue, projectCatalogSourceMutationWarnings, } from './catalog-sources/review.js';
16
+ export const PLUGIN_WRITE_BATCH_ACTIONS = [
17
+ 'install',
18
+ 'sync',
19
+ 'reinstall',
20
+ 'uninstall',
21
+ 'cleanup',
22
+ ];
12
23
  function reviewPreparationStageLabel(stage) {
13
24
  return {
14
25
  refresh: '刷新相关来源',
@@ -48,6 +59,179 @@ function stableValue(value) {
48
59
  .sort(([left], [right]) => left.localeCompare(right, 'en'))
49
60
  .map(([key, entry]) => [key, stableValue(entry)]));
50
61
  }
62
+ function canonicalPluginIds(pluginIds) {
63
+ return [...new Set(pluginIds)].sort((left, right) => left.localeCompare(right, 'en'));
64
+ }
65
+ function canonicalBatchTargets(targets) {
66
+ const byKey = new Map();
67
+ for (const target of targets) {
68
+ byKey.set([target.pluginId, target.platform, target.scope].join('\0'), target);
69
+ }
70
+ return [...byKey.values()].sort((left, right) => [left.pluginId, left.platform, left.scope].join('\0')
71
+ .localeCompare([right.pluginId, right.platform, right.scope].join('\0'), 'en'));
72
+ }
73
+ function normalizeOperationIntent(intent) {
74
+ if (intent.kind === 'configSave' && intent.retryApply && !intent.apply) {
75
+ return { ...intent, apply: true };
76
+ }
77
+ if (intent.kind !== 'pluginWriteBatch')
78
+ return intent;
79
+ if (intent.selection.kind === 'targets') {
80
+ return {
81
+ ...intent,
82
+ selection: { kind: 'targets', targets: canonicalBatchTargets(intent.selection.targets) },
83
+ };
84
+ }
85
+ if (intent.selection.kind !== 'selected')
86
+ return intent;
87
+ return {
88
+ ...intent,
89
+ selection: {
90
+ kind: 'selected',
91
+ pluginIds: canonicalPluginIds(intent.selection.pluginIds),
92
+ },
93
+ };
94
+ }
95
+ function assertPluginWriteBatchEvidence(evidence) {
96
+ const candidate = evidence;
97
+ if (!Array.isArray(candidate.requestedPluginIds) || !Array.isArray(candidate.groups)) {
98
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量确认信息不完整。');
99
+ }
100
+ const requestedPluginIds = canonicalPluginIds(candidate.requestedPluginIds);
101
+ if (requestedPluginIds.length === 0
102
+ || JSON.stringify(requestedPluginIds) !== JSON.stringify(candidate.requestedPluginIds)) {
103
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量根插件集合无效。');
104
+ }
105
+ const requested = new Set(requestedPluginIds);
106
+ const groupIds = new Set();
107
+ const groupedRoots = new Set();
108
+ for (const group of candidate.groups) {
109
+ if (!group || typeof group.groupId !== 'string' || group.groupId.length === 0
110
+ || typeof group.sourceId !== 'string' || group.sourceId.length === 0
111
+ || groupIds.has(group.groupId)
112
+ || !Array.isArray(group.requestedPluginIds)
113
+ || group.requestedPluginIds.length === 0
114
+ || JSON.stringify(canonicalPluginIds(group.requestedPluginIds))
115
+ !== JSON.stringify(group.requestedPluginIds)
116
+ || group.requestedPluginIds.some((pluginId) => !requested.has(pluginId))
117
+ || group.executable !== Boolean(group.preview?.operationPlan)) {
118
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量分组确认信息无效。');
119
+ }
120
+ groupIds.add(group.groupId);
121
+ group.requestedPluginIds.forEach((pluginId) => groupedRoots.add(pluginId));
122
+ }
123
+ if (requestedPluginIds.some((pluginId) => !groupedRoots.has(pluginId))) {
124
+ throw new TuiOperationBlockedError('batchEvidenceInvalid', '批量分组缺少请求根插件。');
125
+ }
126
+ }
127
+ export function pluginWriteBatchFailedRequestedTargets(execution) {
128
+ const failed = [];
129
+ for (const group of execution.groupResults) {
130
+ const roots = new Set(group.requestedPluginIds);
131
+ const targetsByPlugin = new Map();
132
+ for (const target of group.result.targetResults) {
133
+ const pluginId = target.target.pluginId;
134
+ if (!pluginId)
135
+ continue;
136
+ const entries = targetsByPlugin.get(pluginId) ?? [];
137
+ entries.push(target);
138
+ targetsByPlugin.set(pluginId, entries);
139
+ }
140
+ const successfulRootKeys = new Set(group.result.targetResults.flatMap((target) => {
141
+ const pluginId = target.target.pluginId;
142
+ return pluginId && roots.has(pluginId)
143
+ && target.state !== 'failed' && target.action !== 'skipped'
144
+ ? [[pluginId, target.target.platform, target.target.scope].join('\0')]
145
+ : [];
146
+ }));
147
+ const unsuccessful = group.result.targetResults.filter((target) => target.state === 'failed' || target.action === 'skipped');
148
+ for (const target of unsuccessful) {
149
+ const pending = target.target.pluginId
150
+ ? [{ pluginId: target.target.pluginId, platform: target.target.platform }]
151
+ : [];
152
+ const visited = new Set();
153
+ while (pending.length > 0) {
154
+ const current = pending.shift();
155
+ const visitKey = [current.pluginId, current.platform].join('\0');
156
+ if (visited.has(visitKey))
157
+ continue;
158
+ visited.add(visitKey);
159
+ const candidates = (targetsByPlugin.get(current.pluginId) ?? [])
160
+ .filter((candidate) => candidate.target.platform === current.platform);
161
+ if (roots.has(current.pluginId)) {
162
+ if (candidates.length === 0) {
163
+ const retryTarget = {
164
+ pluginId: current.pluginId,
165
+ platform: current.platform,
166
+ scope: group.scope,
167
+ };
168
+ const key = [retryTarget.pluginId, retryTarget.platform, retryTarget.scope].join('\0');
169
+ if (!successfulRootKeys.has(key))
170
+ failed.push(retryTarget);
171
+ }
172
+ for (const candidate of candidates) {
173
+ const retryTarget = {
174
+ pluginId: current.pluginId,
175
+ platform: candidate.target.platform,
176
+ scope: candidate.target.scope,
177
+ };
178
+ const key = [current.pluginId, retryTarget.platform, retryTarget.scope].join('\0');
179
+ if (!successfulRootKeys.has(key))
180
+ failed.push(retryTarget);
181
+ }
182
+ continue;
183
+ }
184
+ for (const candidate of candidates) {
185
+ pending.push(...(candidate.requiredBy ?? []).map((pluginId) => ({
186
+ pluginId, platform: current.platform,
187
+ })));
188
+ }
189
+ }
190
+ }
191
+ }
192
+ return canonicalBatchTargets(failed);
193
+ }
194
+ export function pluginWriteBatchFailedRequestedPluginIds(execution) {
195
+ return canonicalPluginIds(pluginWriteBatchFailedRequestedTargets(execution)
196
+ .map((target) => target.pluginId));
197
+ }
198
+ function collectOperationSensitiveValues(intent) {
199
+ if (intent.kind !== 'configSave')
200
+ return [];
201
+ const sensitiveKeys = new Set([...(intent.sensitiveKeys ?? []), ...(intent.privateKeys ?? [])]);
202
+ const values = [];
203
+ const visit = (value) => {
204
+ if (typeof value === 'string')
205
+ values.push(value);
206
+ else if (Array.isArray(value))
207
+ value.forEach(visit);
208
+ else if (value && typeof value === 'object')
209
+ Object.values(value).forEach(visit);
210
+ };
211
+ for (const [key, value] of Object.entries(intent.updates ?? {})) {
212
+ if (sensitiveKeys.has(key))
213
+ visit(value);
214
+ }
215
+ return values;
216
+ }
217
+ function sanitizeOperationSummary(summary, sensitiveValues) {
218
+ const text = (value) => sanitizeDiagnosticText(value, [], sensitiveValues);
219
+ return {
220
+ ...summary,
221
+ message: text(summary.message),
222
+ ...(summary.messages ? { messages: summary.messages.map(text) } : {}),
223
+ ...(summary.changes ? { changes: summary.changes.map(text) } : {}),
224
+ ...(summary.warnings ? { warnings: summary.warnings.map(text) } : {}),
225
+ ...(summary.recoveryActions ? { recoveryActions: summary.recoveryActions.map(text) } : {}),
226
+ ...(summary.quarantineBasename ? { quarantineBasename: text(summary.quarantineBasename) } : {}),
227
+ ...(summary.portable ? {
228
+ portable: {
229
+ ...summary.portable,
230
+ ...(summary.portable.recovery ? { recovery: text(summary.portable.recovery) } : {}),
231
+ },
232
+ } : {}),
233
+ };
234
+ }
51
235
  function deepFreeze(value) {
52
236
  if (typeof value !== 'object' || value === null || Object.isFrozen(value))
53
237
  return value;
@@ -58,27 +242,33 @@ function deepFreeze(value) {
58
242
  function cloneFrozen(value) {
59
243
  return deepFreeze(structuredClone(value));
60
244
  }
61
- function publicReviewPreparation(snapshot) {
62
- if (snapshot.intent.kind !== 'catalogGeneration' || !snapshot.intent.sourceMutation)
63
- return snapshot;
64
- const mutation = snapshot.intent.sourceMutation;
65
- const sourceMutation = mutation.action === 'add'
66
- ? {
67
- ...mutation,
68
- source: mutation.source.type === 'path'
69
- ? { ...mutation.source, path: '<private>' }
70
- : { ...mutation.source, url: '<private>' },
71
- }
72
- : {
73
- ...mutation,
74
- update: 'url' in mutation.update
75
- ? { ...mutation.update, ...(mutation.update.url ? { url: '<private>' } : {}) }
76
- : { ...mutation.update, ...('path' in mutation.update && mutation.update.path
77
- ? { path: '<private>' } : {}) },
78
- };
79
- return { ...snapshot, intent: { ...snapshot.intent, sourceMutation } };
80
- }
81
245
  export function operationEvidenceFingerprint(evidence) {
246
+ if ('groups' in evidence && 'requestedPluginIds' in evidence) {
247
+ const batch = evidence;
248
+ return crypto.createHash('sha256')
249
+ .update(JSON.stringify(stableValue({
250
+ requestedPluginIds: batch.requestedPluginIds,
251
+ groups: batch.groups.map((group) => ({
252
+ groupId: group.groupId,
253
+ sourceId: group.sourceId,
254
+ platform: group.platform,
255
+ requestedPluginIds: group.requestedPluginIds,
256
+ executable: group.executable,
257
+ planFingerprint: group.preview.operationPlan?.planFingerprint,
258
+ overallState: group.preview.overallState,
259
+ targetResults: group.preview.targetResults.map((target) => ({
260
+ pluginId: target.target.pluginId,
261
+ platform: target.target.platform,
262
+ scope: target.target.scope,
263
+ state: target.state,
264
+ action: target.action,
265
+ errorCode: target.errorCode,
266
+ requiredBy: target.requiredBy,
267
+ })),
268
+ })),
269
+ })))
270
+ .digest('hex');
271
+ }
82
272
  if (evidence.operationPlan)
83
273
  return evidence.operationPlan.planFingerprint;
84
274
  if (evidence.resetTarget) {
@@ -90,8 +280,14 @@ export function operationEvidenceFingerprint(evidence) {
90
280
  .digest('hex');
91
281
  }
92
282
  const { codexManager: _runtimeSelection, ...staleEvidence } = evidence;
283
+ const config = staleEvidence.config
284
+ ? (({ shareability: _shareability, ...identity }) => identity)(staleEvidence.config)
285
+ : undefined;
93
286
  return crypto.createHash('sha256')
94
- .update(JSON.stringify(stableValue(staleEvidence)))
287
+ .update(JSON.stringify(stableValue({
288
+ ...staleEvidence,
289
+ ...(config ? { config } : {}),
290
+ })))
95
291
  .digest('hex');
96
292
  }
97
293
  export function operationEvidenceMatches(expected, current) {
@@ -100,11 +296,22 @@ export function operationEvidenceMatches(expected, current) {
100
296
  !== JSON.stringify(stableValue(current.resetTarget))) {
101
297
  return false;
102
298
  }
103
- if (!expected.source)
299
+ if (expected.source && (current.source === undefined
300
+ || JSON.stringify(stableValue(expected.source))
301
+ !== JSON.stringify(stableValue(current.source)))) {
302
+ return false;
303
+ }
304
+ if (expected.targetFingerprint !== undefined
305
+ && expected.targetFingerprint !== current.targetFingerprint) {
306
+ return false;
307
+ }
308
+ if (expected.config?.impactState !== 'known')
104
309
  return true;
105
- return current.source !== undefined
106
- && JSON.stringify(stableValue(expected.source))
107
- === JSON.stringify(stableValue(current.source));
310
+ return JSON.stringify(stableValue({
311
+ config: expected.config,
312
+ })) === JSON.stringify(stableValue({
313
+ config: current.config,
314
+ }));
108
315
  }
109
316
  return operationEvidenceFingerprint(expected) === operationEvidenceFingerprint(current);
110
317
  }
@@ -125,6 +332,9 @@ export function userIssueKey(issue) {
125
332
  .join('\0');
126
333
  }
127
334
  function issueCode(error) {
335
+ const safeOperationError = readSafeOperationError(error);
336
+ if (safeOperationError)
337
+ return safeDiagnosticCode(safeOperationError.code, 'operationBlocked');
128
338
  if (error instanceof TuiOperationBlockedError)
129
339
  return error.code;
130
340
  if (error instanceof CatalogGenerationError)
@@ -160,12 +370,15 @@ export class TuiController {
160
370
  #listeners = new Set();
161
371
  #health = new Map();
162
372
  #tickets = new Map();
373
+ #reviewPreparationIntents = new Map();
163
374
  #inflightOperations = new Map();
375
+ #pendingReads = new Set();
164
376
  #abort = new AbortController();
165
377
  #progressUnsubscribe;
166
378
  #progressFeed;
167
379
  #phase = 'idle';
168
380
  #status;
381
+ #statusReadAtMs;
169
382
  #catalogGenerations = new Map();
170
383
  #catalogFreshness = new Map();
171
384
  #catalogSourcesSession;
@@ -177,18 +390,28 @@ export class TuiController {
177
390
  #reviewPreparation;
178
391
  #reviewPreparationToken = 0;
179
392
  #result;
393
+ #resultAnchor;
394
+ #portableResultSkills;
180
395
  #loadError;
181
396
  #portableDraft;
397
+ #recoverablePortableDraft;
182
398
  #portableRepreparePluginId;
183
399
  #portableRequestToken = 0;
184
400
  #portableRequestAbort;
185
401
  #portablePending;
186
402
  #installPlacementDraft;
403
+ #recoverableInstallPlacementDraft;
187
404
  #installPlacementRequestToken = 0;
188
405
  #installPlacementRequestAbort;
189
406
  #generation = 0;
190
407
  #disposed = false;
191
- #externalDraft;
408
+ #retiring = false;
409
+ #externalDrafts = [];
410
+ #batchRetryIntent;
411
+ #operationHistory = new OperationHistoryStore();
412
+ #sessionEpoch = 0;
413
+ #lastRecordedResultId;
414
+ #operationDiagnostics = new Map();
192
415
  constructor(dependencies, progressFeed) {
193
416
  this.dependencies = dependencies;
194
417
  this.#progressFeed = progressFeed;
@@ -211,6 +434,8 @@ export class TuiController {
211
434
  });
212
435
  if (progressFeed) {
213
436
  this.#progressUnsubscribe = progressFeed.subscribe((progress) => {
437
+ if (this.#router.snapshot.current.route.name === 'result')
438
+ return;
214
439
  this.#progress = {
215
440
  ...structuredClone(progress),
216
441
  messages: progress.messages.slice(-8),
@@ -239,6 +464,7 @@ export class TuiController {
239
464
  get snapshot() {
240
465
  return cloneFrozen({
241
466
  phase: this.#phase,
467
+ ...(this.#statusReadAtMs !== undefined ? { statusReadAtMs: this.#statusReadAtMs } : {}),
242
468
  ...(this.#status?.cliChannel ? { cliChannel: this.#status.cliChannel } : {}),
243
469
  router: this.#router.snapshot,
244
470
  ...(this.#viewModel ? { viewModel: this.#viewModel } : {}),
@@ -252,9 +478,11 @@ export class TuiController {
252
478
  progressTargets: this.#progressTargets,
253
479
  ...(this.#activeReview ? { activeReview: this.#activeReview } : {}),
254
480
  ...(this.#reviewPreparation
255
- ? { reviewPreparation: publicReviewPreparation(this.#reviewPreparation.snapshot) }
481
+ ? { reviewPreparation: this.#reviewPreparation.snapshot }
256
482
  : {}),
257
483
  ...(this.#result ? { result: this.#result } : {}),
484
+ operationHistorySummaries: this.#operationHistory.list(),
485
+ ...(this.#portableResultSkills ? { portableResultSkills: this.#portableResultSkills } : {}),
258
486
  ...(this.#loadError ? { loadError: this.#loadError } : {}),
259
487
  ...(this.#portableDraft ? { portableDraft: this.#portableDraft } : {}),
260
488
  ...(this.#installPlacementDraft
@@ -278,13 +506,222 @@ export class TuiController {
278
506
  listener(this.snapshot);
279
507
  return () => this.#listeners.delete(listener);
280
508
  }
509
+ get hasActiveWrite() {
510
+ return this.#inflightOperations.size > 0;
511
+ }
512
+ get retiring() {
513
+ return this.#retiring;
514
+ }
515
+ get hasSessionDraft() {
516
+ return this.#externalDrafts.length > 0
517
+ || this.#portableDraft !== undefined
518
+ || this.#installPlacementDraft !== undefined;
519
+ }
520
+ get externalDrafts() {
521
+ return structuredClone(this.#externalDrafts);
522
+ }
523
+ attachOperationHistory(history, epoch) {
524
+ this.#operationHistory = history;
525
+ this.#sessionEpoch = epoch;
526
+ }
527
+ listOperationHistory() {
528
+ return this.#operationHistory.list();
529
+ }
530
+ getOperationDiagnostic(recordId) {
531
+ return this.#operationHistory.get(recordId);
532
+ }
533
+ previewOperationReport(recordId) {
534
+ return this.#operationHistory.preview(recordId);
535
+ }
536
+ saveOperationReport(recordId, filePath) {
537
+ return this.#operationHistory.save(recordId, filePath);
538
+ }
539
+ /** @internal Test-only observability for bounded private diagnostic ownership. */
540
+ operationDiagnosticRetentionCountForTests() {
541
+ return this.#operationDiagnostics.size;
542
+ }
543
+ recordUiFailure(error, context) {
544
+ const recordId = this.#recordStandaloneFailure(context.code, context.stage, error);
545
+ this.#publish();
546
+ return recordId;
547
+ }
548
+ #recordStandaloneFailure(code, stage, error) {
549
+ const operationId = `ui-${crypto.randomUUID()}`;
550
+ const recordId = `${this.#sessionEpoch}:${operationId}`;
551
+ const diagnostic = projectThrownOperationDiagnostic({
552
+ error,
553
+ id: `${recordId}:failure`,
554
+ stage: safeDiagnosticCode(stage, 'ui'),
555
+ fallbackCode: safeDiagnosticCode(code, 'uiOperationFailed'),
556
+ });
557
+ this.#operationHistory.record({
558
+ summary: {
559
+ recordId,
560
+ epoch: this.#sessionEpoch,
561
+ operationId,
562
+ createdAtMs: this.dependencies.now?.() ?? Date.now(),
563
+ state: 'blocked',
564
+ code: diagnostic.code,
565
+ message: diagnostic.safeMessage,
566
+ diagnosticCount: 1,
567
+ effects: {
568
+ configCommit: 'notApplicable', awWrite: 'notStarted',
569
+ preApplyExternalEffect: 'notApplicable', finalVerification: 'notStarted',
570
+ },
571
+ },
572
+ diagnostics: [diagnostic],
573
+ targets: [],
574
+ });
575
+ return recordId;
576
+ }
577
+ #trackRead(promise) {
578
+ this.#pendingReads.add(promise);
579
+ void promise.finally(() => this.#pendingReads.delete(promise)).catch(() => undefined);
580
+ return promise;
581
+ }
582
+ beginRetirement() {
583
+ this.#retiring = true;
584
+ }
585
+ async waitForWrites() {
586
+ this.beginRetirement();
587
+ while (this.#inflightOperations.size > 0) {
588
+ await Promise.allSettled([...this.#inflightOperations.values()].map((operation) => operation.promise));
589
+ }
590
+ }
591
+ async cancelAndWaitForReads() {
592
+ if (this.hasActiveWrite)
593
+ return false;
594
+ this.beginRetirement();
595
+ this.#generation += 1;
596
+ this.#cancelReviewPreparation();
597
+ this.#cancelPortableRequest();
598
+ this.#cancelInstallPlacementRequest();
599
+ this.#abort.abort();
600
+ const catalogDrain = this.#catalogSourcesSession.cancelAndWait();
601
+ while (this.#pendingReads.size > 0) {
602
+ await Promise.allSettled([...this.#pendingReads]);
603
+ }
604
+ await catalogDrain;
605
+ return !this.hasActiveWrite;
606
+ }
607
+ #assertAcceptingActions() {
608
+ if (this.#retiring || this.#disposed) {
609
+ throw new DOMException('Controller session is retiring.', 'AbortError');
610
+ }
611
+ }
612
+ goHome(options = {}) {
613
+ if (this.hasSessionDraft && !options.discardDrafts)
614
+ return false;
615
+ this.#cancelReviewPreparation();
616
+ this.#cancelPortableRequest();
617
+ this.#cancelInstallPlacementRequest();
618
+ this.#tickets.clear();
619
+ this.#activeReview = undefined;
620
+ this.#portableDraft = undefined;
621
+ this.#recoverablePortableDraft = undefined;
622
+ this.#installPlacementDraft = undefined;
623
+ this.#recoverableInstallPlacementDraft = undefined;
624
+ this.#batchRetryIntent = undefined;
625
+ this.#externalDrafts = [];
626
+ this.#result = undefined;
627
+ this.#resultAnchor = undefined;
628
+ this.#portableResultSkills = undefined;
629
+ this.#progress = { messages: [] };
630
+ this.#progressTargets = [];
631
+ this.#router.resetToRoot();
632
+ this.#publish();
633
+ return true;
634
+ }
281
635
  #publish() {
282
636
  if (this.#disposed)
283
637
  return;
638
+ this.#recordTerminalResult();
284
639
  const snapshot = this.snapshot;
285
640
  for (const listener of this.#listeners)
286
641
  listener(snapshot);
287
642
  }
643
+ #recordTerminalResult() {
644
+ const result = this.#result;
645
+ if (!result || result.state === 'stale')
646
+ return;
647
+ const recordId = `${this.#sessionEpoch}:${result.operationId}`;
648
+ if (this.#lastRecordedResultId === recordId)
649
+ return;
650
+ const projected = this.#operationDiagnostics.get(result.operationId) ?? {
651
+ diagnostics: [],
652
+ effects: {
653
+ configCommit: 'notApplicable',
654
+ awWrite: result.state === 'succeeded' ? 'completed' : 'unknown',
655
+ preApplyExternalEffect: 'notApplicable',
656
+ finalVerification: result.state === 'succeeded' ? 'completed' : 'unknown',
657
+ },
658
+ };
659
+ if ('summary' in result && result.summary) {
660
+ Object.assign(result.summary, sanitizeOperationSummary(result.summary, projected.sensitiveValues ?? []));
661
+ }
662
+ const diagnosticIds = projected.diagnostics.map((diagnostic) => diagnostic.id);
663
+ Object.assign(result, { operationRecordId: recordId, diagnosticIds, effects: projected.effects });
664
+ if ('summary' in result && result.summary) {
665
+ result.summary.diagnosticIds = diagnosticIds;
666
+ result.summary.effects = projected.effects;
667
+ for (const target of result.summary.targets ?? []) {
668
+ const targetDiagnostics = projected.diagnostics.filter((diagnostic) => diagnostic.target
669
+ && diagnostic.target.platform === target.platform
670
+ && diagnostic.target.scope === target.scope
671
+ && (diagnostic.target.pluginId === undefined || diagnostic.target.pluginId === target.pluginId));
672
+ target.diagnosticIds = targetDiagnostics.map((diagnostic) => diagnostic.id);
673
+ target.effects = projected.targetEffects?.get(`${target.pluginId ?? ''}\0${target.platform}\0${target.scope}`);
674
+ }
675
+ }
676
+ const code = result.state === 'succeeded' || result.state === 'partial'
677
+ ? result.summary.code : result.summary?.code ?? result.code;
678
+ const message = result.state === 'succeeded' || result.state === 'partial'
679
+ ? result.summary.message : result.summary?.message ?? result.message;
680
+ this.#operationHistory.record({
681
+ summary: {
682
+ recordId,
683
+ epoch: this.#sessionEpoch,
684
+ operationId: result.operationId,
685
+ createdAtMs: this.dependencies.now?.() ?? Date.now(),
686
+ state: result.state,
687
+ code,
688
+ message,
689
+ ...('operationKind' in result && result.operationKind ? { operationKind: result.operationKind } : {}),
690
+ ...('pluginId' in result && result.pluginId ? { pluginId: result.pluginId } : {}),
691
+ diagnosticCount: projected.diagnostics.length,
692
+ effects: projected.effects,
693
+ },
694
+ diagnostics: projected.diagnostics,
695
+ targets: 'summary' in result && result.summary
696
+ ? structuredClone((result.summary.targets ?? []).map((target) => ({
697
+ ...target,
698
+ diagnosticIds: target.diagnosticIds ?? [],
699
+ })))
700
+ : structuredClone(projected.targets ?? []),
701
+ }, { sensitiveValues: projected.sensitiveValues });
702
+ this.#operationDiagnostics.delete(result.operationId);
703
+ this.#lastRecordedResultId = recordId;
704
+ }
705
+ #captureThrownProjection(operationId, error, code, stage, intent, effects) {
706
+ const sensitiveValues = intent ? collectOperationSensitiveValues(intent) : [];
707
+ this.#operationDiagnostics.set(operationId, {
708
+ diagnostics: [projectThrownOperationDiagnostic({
709
+ error,
710
+ id: `${this.#sessionEpoch}:${operationId}:failure`,
711
+ stage,
712
+ fallbackCode: code,
713
+ sensitiveValues,
714
+ ...(intent && 'pluginId' in intent && 'platform' in intent && intent.platform !== 'both'
715
+ ? { target: { pluginId: intent.pluginId, platform: intent.platform, scope: intent.scope } }
716
+ : {}),
717
+ })],
718
+ effects: effects ?? {
719
+ configCommit: 'notApplicable', awWrite: 'notStarted',
720
+ preApplyExternalEffect: 'notApplicable', finalVerification: 'notStarted',
721
+ },
722
+ sensitiveValues,
723
+ });
724
+ }
288
725
  async #logErrorSafe(error, code, context) {
289
726
  try {
290
727
  await this.dependencies.logError?.(error, code, context);
@@ -306,64 +743,152 @@ export class TuiController {
306
743
  catalogGenerations: this.#catalogGenerations,
307
744
  dependencyOverviews: this.#status.dependencyOverviews,
308
745
  portablePluginIds: this.#status.portablePluginIds,
746
+ configStoreInventory: this.#status.configStoreInventory,
309
747
  });
310
748
  }
311
749
  #normalizeRoute(route) {
312
- if (route.name === 'pluginList' && route.sourceId
313
- && !this.#viewModel?.sourceTabs.some((source) => source.id === route.sourceId)) {
314
- return { name: 'pluginList', mode: route.mode };
750
+ return normalizeTuiRoute(route, this.#routeEntityState());
751
+ }
752
+ #routeEntityState() {
753
+ return {
754
+ hasPlugin: (pluginId) => this.#viewModel?.plugins.some((plugin) => plugin.pluginId === pluginId) ?? false,
755
+ hasSource: (sourceId) => this.#viewModel?.sourceTabs.some((source) => source.id === sourceId) ?? false,
756
+ hasIssue: (issueId) => this.#viewModel?.issues.some((issue) => issue.issueId === issueId) ?? false,
757
+ hasHistoryRecord: (recordId) => this.#operationHistory.get(recordId) !== undefined,
758
+ hasActiveOperation: (candidate) => candidate.name === 'reviewPreparing'
759
+ ? this.#reviewPreparation?.snapshot.id === candidate.preparationId
760
+ : candidate.name === 'result'
761
+ ? this.#result?.operationId === candidate.operationId
762
+ : this.#activeReview?.operationId === candidate.operationId,
763
+ };
764
+ }
765
+ #normalizeCurrentRoute() {
766
+ this.#router.normalizeRoutes((route) => this.#normalizeRoute(route));
767
+ if (this.#resultAnchor)
768
+ this.#resultAnchor = this.#normalizeRoute(this.#resultAnchor);
769
+ for (const ticket of this.#tickets.values())
770
+ ticket.returnRoute = this.#normalizeRoute(ticket.returnRoute);
771
+ if (this.#reviewPreparation) {
772
+ this.#reviewPreparation.returnRoute = this.#normalizeRoute(this.#reviewPreparation.returnRoute);
773
+ }
774
+ }
775
+ #ensureOperationFlow(intent) {
776
+ const active = this.#router.activeFlow();
777
+ if (intent.kind === 'pluginWriteBatch') {
778
+ if (active?.kind === 'batchWrite')
779
+ return;
780
+ this.#router.beginFlow('batchWrite', { name: 'pluginList', mode: 'installed' });
781
+ return;
315
782
  }
316
- const pluginRoute = route.name === 'pluginPreview'
317
- || route.name === 'portableSkillSelect'
318
- || route.name === 'portableDependencies'
319
- || route.name === 'portableAgentMode'
320
- || route.name === 'portableAgentInput'
321
- || route.name === 'portableScope'
322
- || route.name === 'portableBlocker'
323
- || route.name === 'pluginDependencies'
324
- || route.name === 'installPlacement'
325
- || route.name === 'installTargets'
326
- || route.name === 'dependencyPlacement'
327
- || route.name === 'dependencyPlacementEditor'
328
- || route.name === 'pluginDetail'
329
- || route.name === 'platform'
330
- || route.name === 'pluginAbout'
331
- || route.name === 'pluginTechnical'
332
- || route.name === 'configLayer'
333
- || route.name === 'configFields'
334
- || route.name === 'configFieldEditor'
335
- || route.name === 'configUnavailable';
336
- if (pluginRoute && !this.#viewModel?.plugins.some((plugin) => plugin.pluginId === route.pluginId)) {
337
- return {
338
- name: 'pluginList',
339
- mode: route.name === 'pluginPreview' || route.name === 'pluginDependencies'
340
- || route.name === 'installPlacement' || route.name === 'installTargets'
341
- || route.name === 'dependencyPlacement' || route.name === 'dependencyPlacementEditor'
342
- ? 'browse'
343
- : 'installed',
344
- };
783
+ if (intent.kind === 'pluginWrite') {
784
+ const kind = intent.action === 'install' ? 'pluginInstall' : 'pluginMaintenance';
785
+ if (active?.kind === kind)
786
+ return;
787
+ this.#router.beginPluginFlow(kind, intent.pluginId);
788
+ return;
345
789
  }
346
- if (route.name === 'issueDetail'
347
- && !this.#viewModel?.issues.some((issue) => issue.issueId === route.issueId)) {
348
- return { name: 'issueList' };
790
+ if (intent.kind === 'portableInstall') {
791
+ if (active?.kind === 'portableInstall')
792
+ return;
793
+ this.#router.beginPortableFlow(intent.pluginId);
794
+ return;
349
795
  }
350
- if ((route.name === 'sourceDetail'
351
- || route.name === 'sourceTechnical'
352
- || route.name === 'sourceEdit'
353
- || route.name === 'sourceRemoveBlocked')
354
- && !this.#viewModel?.sourceTabs.some((source) => source.id === route.sourceId)) {
355
- return { name: 'sourceList' };
796
+ if (intent.kind === 'configSave' || intent.kind === 'configReset') {
797
+ if (active?.kind === 'configEdit')
798
+ return;
799
+ const route = this.#router.snapshot.current.route;
800
+ if ((route.name === 'pluginPreview' || route.name === 'pluginDetail')
801
+ && route.pluginId === intent.pluginId) {
802
+ this.#router.beginConfigFlow(intent.pluginId);
803
+ }
804
+ else {
805
+ this.#router.beginFlow('configEdit', { name: 'pluginDetail', pluginId: intent.pluginId });
806
+ }
807
+ return;
356
808
  }
357
- return route;
358
- }
359
- #normalizeCurrentRoute() {
360
- const route = this.#router.snapshot.current.route;
361
- const normalized = this.#normalizeRoute(route);
362
- if (normalized !== route)
363
- this.#router.replace(normalized);
809
+ const kind = intent.kind === 'catalogSourcesRecovery' ? 'sourceRecovery' : 'sourceEdit';
810
+ if (active?.kind === 'sourceRecovery'
811
+ && (intent.kind === 'catalogSourcesRecovery'
812
+ || intent.kind === 'catalogGeneration'
813
+ || intent.kind === 'sourceMutation'))
814
+ return;
815
+ if (active?.kind === kind)
816
+ return;
817
+ this.#router.beginFlow(kind, active ? this.#router.snapshot.current.route
818
+ : intent.kind === 'sourceMutation' && intent.action !== 'remove'
819
+ ? { name: 'sourceDetail', sourceId: intent.sourceId }
820
+ : intent.kind === 'catalogGeneration'
821
+ ? { name: 'sourceDetail', sourceId: intent.sourceId }
822
+ : { name: 'sourceList' });
823
+ }
824
+ #stableResultRoute(intent, result) {
825
+ const active = this.#router.activeFlow();
826
+ if (active?.parentFlowId)
827
+ return this.#normalizeRoute(active.returnTarget);
828
+ if (active && (intent.kind === 'portableInstall'
829
+ || intent.kind === 'configSave' || intent.kind === 'configReset')) {
830
+ return this.#normalizeRoute(active.returnTarget);
831
+ }
832
+ if (intent.kind === 'pluginWrite') {
833
+ if (intent.originIssue && result.state === 'succeeded')
834
+ return { name: 'issueList' };
835
+ return this.#normalizeRoute({ name: 'pluginDetail', pluginId: intent.pluginId });
836
+ }
837
+ if (intent.kind === 'pluginWriteBatch')
838
+ return { name: 'pluginList', mode: 'installed' };
839
+ if (intent.kind === 'portableInstall' || intent.kind === 'configSave' || intent.kind === 'configReset') {
840
+ return this.#normalizeRoute({ name: 'pluginDetail', pluginId: intent.pluginId });
841
+ }
842
+ if (intent.kind === 'sourceMutation' && intent.action === 'update') {
843
+ return this.#normalizeRoute({ name: 'sourceDetail', sourceId: intent.sourceId });
844
+ }
845
+ if (intent.kind === 'catalogGeneration') {
846
+ return this.#normalizeRoute({ name: 'sourceDetail', sourceId: intent.sourceId });
847
+ }
848
+ return { name: 'sourceList' };
849
+ }
850
+ #showResult(result, intent) {
851
+ const terminal = { name: 'result', operationId: result.operationId };
852
+ const target = this.#stableResultRoute(intent, result);
853
+ this.#resultAnchor = structuredClone(target);
854
+ this.#cancelReviewPreparation();
855
+ this.#progress = { messages: [] };
856
+ this.#progressTargets = [];
857
+ this.#recoverableInstallPlacementDraft = result.state === 'blocked'
858
+ && intent.kind === 'pluginWrite' && intent.action === 'install'
859
+ && this.#installPlacementDraft?.pluginId === intent.pluginId
860
+ ? structuredClone(this.#installPlacementDraft)
861
+ : undefined;
862
+ this.#installPlacementDraft = undefined;
863
+ this.#recoverablePortableDraft = result.state !== 'succeeded'
864
+ && intent.kind === 'portableInstall'
865
+ && this.#portableDraft?.pluginId === intent.pluginId
866
+ ? structuredClone(this.#portableDraft)
867
+ : undefined;
868
+ this.#portableResultSkills = intent.kind === 'portableInstall'
869
+ ? this.#portableDraft?.candidates
870
+ .filter((candidate) => candidate.selected)
871
+ .map((candidate) => candidate.name)
872
+ : undefined;
873
+ this.#portableDraft = undefined;
874
+ this.#batchRetryIntent = intent.kind === 'pluginWriteBatch'
875
+ && 'summary' in result
876
+ && result.summary?.batch
877
+ && (result.summary.batch.retryTargets?.length ?? 0) > 0
878
+ ? {
879
+ ...structuredClone(intent),
880
+ selection: {
881
+ kind: 'targets',
882
+ targets: structuredClone(result.summary.batch.retryTargets),
883
+ },
884
+ }
885
+ : undefined;
886
+ if (!this.#router.finishFlow(target, terminal))
887
+ this.#router.replace(terminal);
364
888
  }
365
889
  #applyStatus(status) {
366
890
  this.#status = structuredClone(status);
891
+ this.#statusReadAtMs = this.dependencies.now?.() ?? Date.now();
367
892
  if (status.catalogGenerations) {
368
893
  this.#catalogGenerations.clear();
369
894
  for (const source of status.catalogGenerations) {
@@ -421,13 +946,14 @@ export class TuiController {
421
946
  this.#normalizeCurrentRoute();
422
947
  }
423
948
  async load() {
949
+ this.#assertAcceptingActions();
424
950
  this.dependencies.beginCatalogSession?.();
425
951
  const generation = ++this.#generation;
426
952
  this.#phase = 'loading';
427
953
  this.#publish();
428
954
  let status;
429
955
  try {
430
- status = await this.dependencies.loadStatus(this.#abort.signal);
956
+ status = await this.#trackRead(this.dependencies.loadStatus(this.#abort.signal));
431
957
  }
432
958
  catch (error) {
433
959
  if (this.#disposed || generation !== this.#generation)
@@ -436,6 +962,7 @@ export class TuiController {
436
962
  await this.#logErrorSafe(error, code);
437
963
  this.#phase = 'error';
438
964
  this.#loadError = { code, message: '无法安全读取插件状态。请检查诊断后重试或退出。' };
965
+ this.#recordStandaloneFailure(code, 'statusReload', error);
439
966
  this.#publish();
440
967
  return;
441
968
  }
@@ -451,6 +978,10 @@ export class TuiController {
451
978
  this.#cancelPortableRequest();
452
979
  this.#cancelInstallPlacementRequest();
453
980
  this.#discardPortableDraftForRoute(route);
981
+ if ((route.name === 'sourceAdd' || route.name === 'sourceEdit' || route.name === 'sourceSave')
982
+ && this.#router.activeFlow()?.kind !== 'sourceEdit') {
983
+ this.#router.beginFlow('sourceEdit', this.#router.snapshot.current.route);
984
+ }
454
985
  this.#router.push(route, context);
455
986
  this.#publish();
456
987
  }
@@ -458,6 +989,7 @@ export class TuiController {
458
989
  return this.#status?.portablePluginIds?.includes(pluginId) ?? false;
459
990
  }
460
991
  preparePortableInstall(pluginId, navigation = 'push') {
992
+ this.#assertAcceptingActions();
461
993
  if (!this.hasPortableSkills(pluginId)) {
462
994
  throw new TuiOperationBlockedError('portableUnsupported', '插件不提供通用 Skills。');
463
995
  }
@@ -473,6 +1005,7 @@ export class TuiController {
473
1005
  });
474
1006
  }
475
1007
  refreshPortableInstallDraft(pluginId) {
1008
+ this.#assertAcceptingActions();
476
1009
  const expectedRoute = this.#router.snapshot.current.route;
477
1010
  return this.#runPortableRequest(`refresh:${pluginId}`, async (signal, token) => {
478
1011
  const draft = await this.#buildPortableDraft(pluginId, true, signal);
@@ -488,12 +1021,12 @@ export class TuiController {
488
1021
  : undefined;
489
1022
  const prepared = await Promise.allSettled(['project', 'user'].map(async (scope) => ({
490
1023
  scope,
491
- evidence: await this.dependencies.captureEvidence({
1024
+ evidence: await this.#trackRead(this.dependencies.captureEvidence({
492
1025
  kind: 'portableInstall',
493
1026
  pluginId,
494
1027
  scope,
495
1028
  selectedSkills: [],
496
- }, signal),
1029
+ }, signal)),
497
1030
  })));
498
1031
  const available = prepared.flatMap((entry) => entry.status === 'fulfilled' && entry.value.evidence.portable ? [entry.value] : []);
499
1032
  if (available.length === 0) {
@@ -639,8 +1172,91 @@ export class TuiController {
639
1172
  });
640
1173
  this.#publish();
641
1174
  }
1175
+ beginSavedConfigRecovery(pluginId) {
1176
+ this.#assertAcceptingActions();
1177
+ const plugin = this.#viewModel?.plugins.find((entry) => entry.pluginId === pluginId);
1178
+ if (!plugin?.configOnly || plugin.savedConfigScopes.length === 0) {
1179
+ throw new TuiOperationBlockedError('savedConfigUnavailable', '没有可识别的已保存设置。');
1180
+ }
1181
+ this.#router.beginConfigFlow(pluginId);
1182
+ this.#router.push({
1183
+ name: 'configUnavailable',
1184
+ pluginId,
1185
+ storedScopes: plugin.savedConfigScopes,
1186
+ ...(plugin.savedConfigScopes.length === 1
1187
+ ? { layer: { scope: plugin.savedConfigScopes[0] } }
1188
+ : {}),
1189
+ diagnosticCode: 'catalogSourceUnavailable',
1190
+ diagnosticStage: 'read-config',
1191
+ });
1192
+ this.#publish();
1193
+ }
1194
+ beginConfigSourceRecovery(sourceId) {
1195
+ this.#assertAcceptingActions();
1196
+ const route = this.#router.snapshot.current.route;
1197
+ const active = this.#router.activeFlow();
1198
+ if (route.name !== 'configUnavailable'
1199
+ || active?.kind !== 'configEdit'
1200
+ || route.storedScopes !== undefined
1201
+ || sourceIdFromPluginId(route.pluginId) !== sourceId
1202
+ || !this.#viewModel?.sourceTabs.some((source) => source.id === sourceId)) {
1203
+ throw new TuiOperationBlockedError('configSourceRecoveryUnavailable', '来源恢复只能从不可用的设置流程开始。');
1204
+ }
1205
+ const layer = route.layer?.scope ?? route.resumeRoute?.layer.scope ?? 'effective';
1206
+ const returnTarget = route.resumeRoute ?? {
1207
+ name: 'configFields',
1208
+ pluginId: route.pluginId,
1209
+ layer: { scope: layer },
1210
+ };
1211
+ this.#router.beginFlow('sourceRecovery', returnTarget, undefined, route.resumeContext);
1212
+ this.#router.push({ name: 'sourceDetail', sourceId });
1213
+ this.#publish();
1214
+ }
1215
+ async getForceCleanupCandidate(pluginId, platform, scope) {
1216
+ this.#assertAcceptingActions();
1217
+ const intent = {
1218
+ kind: 'pluginWrite', action: 'cleanup', pluginId, platform, scope, force: true,
1219
+ };
1220
+ try {
1221
+ const evidence = await this.#trackRead(this.dependencies.captureEvidence(intent, this.#abort.signal));
1222
+ const candidate = evidence.offlineCleanup;
1223
+ if (candidate?.forceExclusive !== true
1224
+ || candidate.pluginId !== pluginId
1225
+ || candidate.platform !== platform
1226
+ || candidate.scope !== scope
1227
+ || candidate.plan.actions.length === 0)
1228
+ return undefined;
1229
+ return cloneFrozen({ pluginId, platform, scope, actionCount: candidate.plan.actions.length });
1230
+ }
1231
+ catch (error) {
1232
+ await this.#logOperationError(error, issueCode(error), intent);
1233
+ return undefined;
1234
+ }
1235
+ }
1236
+ startForceCleanupReview(candidate) {
1237
+ if (candidate.actionCount <= 0) {
1238
+ throw new TuiOperationBlockedError('forceCleanupPathUnavailable', '没有可验证的精确清理目标。');
1239
+ }
1240
+ return this.startReviewPreparation({
1241
+ kind: 'pluginWrite',
1242
+ action: 'cleanup',
1243
+ pluginId: candidate.pluginId,
1244
+ platform: candidate.platform,
1245
+ scope: candidate.scope,
1246
+ force: true,
1247
+ });
1248
+ }
642
1249
  configOrigin(pluginId) {
643
- return this.#router.configOrigin(pluginId);
1250
+ const origin = this.#router.configOrigin(pluginId);
1251
+ if (origin)
1252
+ return origin;
1253
+ if (this.#router.snapshot.current.route.name === 'result'
1254
+ && this.#resultAnchor
1255
+ && (this.#resultAnchor.name === 'pluginPreview' || this.#resultAnchor.name === 'pluginDetail')
1256
+ && this.#resultAnchor.pluginId === pluginId) {
1257
+ return structuredClone(this.#resultAnchor);
1258
+ }
1259
+ return undefined;
644
1260
  }
645
1261
  updatePageContext(context) {
646
1262
  this.#router.updateContext(context);
@@ -648,6 +1264,7 @@ export class TuiController {
648
1264
  }
649
1265
  back() {
650
1266
  const route = this.#router.snapshot.current.route;
1267
+ const activeFlow = this.#router.activeFlow();
651
1268
  this.#cancelReviewPreparation();
652
1269
  this.#cancelPortableRequest();
653
1270
  this.#cancelInstallPlacementRequest();
@@ -656,6 +1273,14 @@ export class TuiController {
656
1273
  this.#activeReview = undefined;
657
1274
  }
658
1275
  this.#router.back();
1276
+ if (activeFlow
1277
+ && this.#router.snapshot.currentFrameId === activeFlow.originFrameId) {
1278
+ this.#router.abandonFlow(activeFlow.id);
1279
+ if (activeFlow.kind === 'pluginInstall')
1280
+ this.#installPlacementDraft = undefined;
1281
+ if (activeFlow.kind === 'portableInstall')
1282
+ this.#portableDraft = undefined;
1283
+ }
659
1284
  this.#normalizeCurrentRoute();
660
1285
  this.#discardPortableDraftForRoute(this.#router.snapshot.current.route);
661
1286
  this.#publish();
@@ -666,13 +1291,34 @@ export class TuiController {
666
1291
  this.#activeReview = undefined;
667
1292
  }
668
1293
  this.#result = undefined;
669
- this.#router.discardSourceFlow(owner, returnRoute);
1294
+ const activeFlow = this.#router.activeFlow();
1295
+ if (activeFlow?.kind === 'sourceEdit') {
1296
+ this.#router.abandonFlow(activeFlow.id);
1297
+ if (JSON.stringify(this.#router.snapshot.current.route) !== JSON.stringify(returnRoute)) {
1298
+ this.#router.replace(returnRoute);
1299
+ }
1300
+ }
1301
+ else {
1302
+ this.#router.discardSourceFlow(owner, returnRoute);
1303
+ }
670
1304
  this.#normalizeCurrentRoute();
671
1305
  this.#publish();
672
1306
  }
673
1307
  leaveResult(target) {
674
1308
  if (this.#router.snapshot.current.route.name !== 'result')
675
1309
  return;
1310
+ if (target?.name === 'home') {
1311
+ this.#router.resetToRoot();
1312
+ this.#portableDraft = undefined;
1313
+ this.#recoverablePortableDraft = undefined;
1314
+ this.#installPlacementDraft = undefined;
1315
+ this.#recoverableInstallPlacementDraft = undefined;
1316
+ this.#result = undefined;
1317
+ this.#resultAnchor = undefined;
1318
+ this.#portableResultSkills = undefined;
1319
+ this.#publish();
1320
+ return;
1321
+ }
676
1322
  if (this.#result?.operationKind === 'portableInstall'
677
1323
  && 'pluginId' in this.#result && this.#result.pluginId) {
678
1324
  const preserveDraft = target?.name === 'portableSkillSelect'
@@ -688,19 +1334,55 @@ export class TuiController {
688
1334
  this.#publish();
689
1335
  return;
690
1336
  }
691
- const route = target ?? (this.#result && 'returnRoute' in this.#result
692
- ? this.#result.returnRoute
693
- : undefined);
694
1337
  this.#router.back();
695
- if (route)
696
- this.#router.replace(this.#normalizeRoute(route));
1338
+ if (target)
1339
+ this.#router.replace(this.#normalizeRoute(target));
697
1340
  else
698
1341
  this.#normalizeCurrentRoute();
699
1342
  this.#discardPortableDraftForRoute(this.#router.snapshot.current.route);
700
1343
  this.#result = undefined;
1344
+ this.#resultAnchor = undefined;
1345
+ this.#portableResultSkills = undefined;
1346
+ this.#recoverableInstallPlacementDraft = undefined;
1347
+ this.#recoverablePortableDraft = undefined;
1348
+ this.#publish();
1349
+ }
1350
+ resumeResultDraft() {
1351
+ const result = this.#result;
1352
+ if (this.#router.snapshot.current.route.name !== 'result' || !result
1353
+ || !('returnRoute' in result) || !result.returnRoute)
1354
+ return;
1355
+ const returnRoute = this.#normalizeRoute(result.returnRoute);
1356
+ this.#router.back();
1357
+ const kind = result.operationKind === 'configSave' || result.operationKind === 'configReset'
1358
+ ? 'configEdit'
1359
+ : result.operationKind === 'portableInstall'
1360
+ ? 'portableInstall'
1361
+ : result.operationKind === 'sourceMutation' || result.operationKind === 'catalogGeneration'
1362
+ || result.operationKind === 'catalogSourcesRecovery'
1363
+ ? 'sourceEdit'
1364
+ : 'pluginMaintenance';
1365
+ this.#router.beginFlow(kind, this.#router.snapshot.current.route);
1366
+ if (result.operationKind === 'pluginWrite') {
1367
+ this.#installPlacementDraft = this.#recoverableInstallPlacementDraft;
1368
+ }
1369
+ if (result.operationKind === 'portableInstall') {
1370
+ this.#portableDraft = this.#recoverablePortableDraft;
1371
+ }
1372
+ this.#router.push(returnRoute);
1373
+ this.#recoverableInstallPlacementDraft = undefined;
1374
+ this.#recoverablePortableDraft = undefined;
1375
+ this.#result = undefined;
1376
+ this.#resultAnchor = undefined;
1377
+ this.#portableResultSkills = undefined;
701
1378
  this.#publish();
702
1379
  }
703
1380
  #discardPortableDraftForRoute(route) {
1381
+ const readOnlyHistoryRoute = route.name === 'operationHistory'
1382
+ || route.name === 'operationDiagnostic'
1383
+ || route.name === 'operationReportPreview';
1384
+ if (readOnlyHistoryRoute && this.#router.activeFlow()?.kind === 'portableInstall')
1385
+ return;
704
1386
  const portableRoute = route.name === 'portableSkillSelect'
705
1387
  || route.name === 'portableDependencies'
706
1388
  || route.name === 'portableAgentMode'
@@ -709,7 +1391,8 @@ export class TuiController {
709
1391
  || route.name === 'portableBlocker';
710
1392
  const portableOperationRoute = (route.name === 'reviewPreparing' || route.name === 'review'
711
1393
  || route.name === 'progress' || route.name === 'result')
712
- && (this.#reviewPreparation?.snapshot.intent.kind === 'portableInstall'
1394
+ && (this.#reviewPreparation !== undefined
1395
+ && this.#reviewPreparationIntents.get(this.#reviewPreparation.snapshot.id)?.kind === 'portableInstall'
713
1396
  || this.#activeReview?.operationKind === 'portableInstall'
714
1397
  || this.#result?.operationKind === 'portableInstall');
715
1398
  if (!portableRoute && !portableOperationRoute) {
@@ -719,15 +1402,24 @@ export class TuiController {
719
1402
  }
720
1403
  resetPortableFlowToPreview(pluginId, target, preserveDraft = false) {
721
1404
  this.#cancelPortableRequest();
1405
+ if (preserveDraft && !this.#portableDraft) {
1406
+ this.#portableDraft = this.#recoverablePortableDraft;
1407
+ }
722
1408
  if (!preserveDraft)
723
1409
  this.#portableDraft = undefined;
1410
+ this.#recoverablePortableDraft = undefined;
724
1411
  if (this.#activeReview?.operationKind === 'portableInstall') {
725
1412
  this.#tickets.delete(this.#activeReview.reviewToken);
726
1413
  this.#activeReview = undefined;
727
1414
  }
728
1415
  if (this.#result?.operationKind === 'portableInstall')
729
1416
  this.#result = undefined;
730
- this.#router.resetPortableFlow(pluginId);
1417
+ this.#resultAnchor = undefined;
1418
+ this.#portableResultSkills = undefined;
1419
+ if (this.#router.snapshot.current.route.name === 'result')
1420
+ this.#router.back();
1421
+ else
1422
+ this.#router.resetPortableFlow(pluginId);
731
1423
  if (target && JSON.stringify(this.#router.snapshot.current.route) !== JSON.stringify(target)) {
732
1424
  this.#router.beginPortableFlow(pluginId);
733
1425
  this.#router.push(this.#normalizeRoute(target));
@@ -764,8 +1456,11 @@ export class TuiController {
764
1456
  throw new DOMException('Portable request was cancelled.', 'AbortError');
765
1457
  }
766
1458
  }
767
- beginInstallFlow(pluginId, rootScope, options = {}) {
1459
+ beginInstallFlow(pluginId, rootScope) {
768
1460
  this.#cancelInstallPlacementRequest();
1461
+ const active = this.#router.activeFlow();
1462
+ if (active?.kind !== 'pluginInstall')
1463
+ this.#router.beginPluginFlow('pluginInstall', pluginId);
769
1464
  this.#installPlacementDraft = {
770
1465
  pluginId,
771
1466
  rootScope,
@@ -774,7 +1469,6 @@ export class TuiController {
774
1469
  state: 'selecting',
775
1470
  targets: [],
776
1471
  canContinue: false,
777
- adjustingExisting: options.adjustingExisting === true,
778
1472
  };
779
1473
  this.#publish();
780
1474
  }
@@ -834,10 +1528,10 @@ export class TuiController {
834
1528
  ...(draft.dependencyScopeOverrides.length > 0
835
1529
  ? { dependencyScopeOverrides: draft.dependencyScopeOverrides }
836
1530
  : {}),
837
- ...(draft.adjustingExisting ? { dependencyPlacementAdjustment: true } : {}),
838
1531
  };
839
1532
  }
840
1533
  async prepareInstallDependencyPlacement() {
1534
+ this.#assertAcceptingActions();
841
1535
  const draft = this.#installPlacementDraft;
842
1536
  if (!draft)
843
1537
  throw new TuiOperationBlockedError('installPlacementMissing', '安装位置草稿不存在。');
@@ -859,7 +1553,7 @@ export class TuiController {
859
1553
  throw new DOMException('Dependency placement preview was cancelled.', 'AbortError');
860
1554
  }
861
1555
  };
862
- const plan = await preview(this.#installPlacementIntent());
1556
+ const plan = await this.#trackRead(preview(this.#installPlacementIntent()));
863
1557
  throwIfCancelled();
864
1558
  if (!plan) {
865
1559
  draft.state = 'ready';
@@ -925,7 +1619,7 @@ export class TuiController {
925
1619
  listPlugins(options) {
926
1620
  const query = options.query?.trim().toLocaleLowerCase('en');
927
1621
  return cloneFrozen((this.#viewModel?.plugins ?? []).filter((plugin) => (!options.sourceId || plugin.sourceId === options.sourceId)
928
- && (options.mode === 'browse' || hasInstalledPlacement(plugin))
1622
+ && (options.mode === 'browse' || hasInstalledPlacement(plugin) || plugin.savedConfigScopes.length > 0)
929
1623
  && (options.installFilter !== 'uninstalled' || !hasInstalledPlacement(plugin))
930
1624
  && (!query || `${plugin.displayName}\n${plugin.description}\n${plugin.sourceId}`.toLocaleLowerCase('en').includes(query))));
931
1625
  }
@@ -1026,6 +1720,10 @@ export class TuiController {
1026
1720
  health: this.#health.get(sourceId),
1027
1721
  });
1028
1722
  }
1723
+ getConfiguredSource(sourceId) {
1724
+ const source = this.#status?.configuredSources?.find((candidate) => candidate.id === sourceId);
1725
+ return source ? cloneFrozen(source) : undefined;
1726
+ }
1029
1727
  getCatalogGeneration(sourceId) {
1030
1728
  const source = this.#catalogGenerations.get(sourceId);
1031
1729
  return source ? cloneFrozen(source) : undefined;
@@ -1035,17 +1733,70 @@ export class TuiController {
1035
1733
  return freshness ? cloneFrozen(freshness) : undefined;
1036
1734
  }
1037
1735
  recheckCatalogSource(sourceId) {
1736
+ this.#assertAcceptingActions();
1038
1737
  return this.#catalogSourcesSession.recheck(sourceId);
1039
1738
  }
1739
+ async recheckAllCatalogSources() {
1740
+ this.#assertAcceptingActions();
1741
+ const sourceIds = [...this.listSources()]
1742
+ .sort((left, right) => Number(Boolean(right.primary)) - Number(Boolean(left.primary)))
1743
+ .map((source) => source.id);
1744
+ let next = 0;
1745
+ const worker = async () => {
1746
+ while (next < sourceIds.length) {
1747
+ const sourceId = sourceIds[next++];
1748
+ if (sourceId)
1749
+ await this.#catalogSourcesSession.recheck(sourceId);
1750
+ }
1751
+ };
1752
+ await Promise.all(Array.from({ length: Math.min(2, sourceIds.length) }, () => worker()));
1753
+ }
1040
1754
  fullValidateCatalogSource(sourceId) {
1755
+ this.#assertAcceptingActions();
1041
1756
  return this.#catalogSourcesSession.fullValidate(sourceId);
1042
1757
  }
1043
- setExternalDraftState(state) {
1044
- this.#externalDraft = state;
1758
+ setExternalDraftState(states = []) {
1759
+ const priority = { sourceDraft: 0, batchDraft: 1, configDraft: 2 };
1760
+ this.#externalDrafts = structuredClone(states).sort((left, right) => priority[left.kind] - priority[right.kind] || left.owner.localeCompare(right.owner, 'en'));
1761
+ this.#publish();
1762
+ }
1763
+ beginPluginWriteBatch() {
1764
+ this.#assertAcceptingActions();
1765
+ this.#router.beginBatchFlow();
1766
+ this.#router.push({ name: 'batchSelection' });
1767
+ this.#publish();
1768
+ }
1769
+ startPluginWriteBatchReview(intent) {
1770
+ return this.startReviewPreparation(intent, { name: 'batchPlacement' });
1771
+ }
1772
+ retryFailedPluginWriteBatch() {
1773
+ if (!this.#batchRetryIntent)
1774
+ return undefined;
1775
+ const intent = structuredClone(this.#batchRetryIntent);
1776
+ this.#batchRetryIntent = undefined;
1777
+ return this.startReviewPreparation(intent, { name: 'pluginList', mode: 'installed' });
1045
1778
  }
1046
1779
  catalogActivationBlockReason() {
1047
- if (this.#externalDraft)
1048
- return this.#externalDraft;
1780
+ if (this.#externalDrafts[0])
1781
+ return this.#externalDrafts[0].kind;
1782
+ if (this.#portableDraft)
1783
+ return 'portableDraft';
1784
+ if (this.#reviewPreparation)
1785
+ return 'reviewPreparation';
1786
+ if (this.#activeReview)
1787
+ return 'activeReview';
1788
+ if (this.#router.snapshot.current.route.name === 'progress'
1789
+ || this.#inflightOperations.size > 0)
1790
+ return 'writeProgress';
1791
+ return undefined;
1792
+ }
1793
+ catalogSourceRecoveryBlockReason() {
1794
+ const activeFlow = this.#router.activeFlow();
1795
+ const blockingExternalDraft = this.#externalDrafts.find((draft) => !(draft.kind === 'configDraft'
1796
+ && activeFlow?.kind === 'sourceRecovery'
1797
+ && activeFlow.parentFlowId !== undefined));
1798
+ if (blockingExternalDraft)
1799
+ return blockingExternalDraft.kind;
1049
1800
  if (this.#portableDraft)
1050
1801
  return 'portableDraft';
1051
1802
  if (this.#reviewPreparation)
@@ -1061,6 +1812,7 @@ export class TuiController {
1061
1812
  return this.#status?.payload.catalog.sources.find((source) => source.id === sourceId)?.catalogVersion;
1062
1813
  }
1063
1814
  async reloadCatalog(options) {
1815
+ this.#assertAcceptingActions();
1064
1816
  if (this.catalogActivationBlockReason())
1065
1817
  return false;
1066
1818
  this.dependencies.beginCatalogSession?.();
@@ -1068,7 +1820,7 @@ export class TuiController {
1068
1820
  this.#phase = 'loading';
1069
1821
  this.#publish();
1070
1822
  try {
1071
- const status = await this.dependencies.loadStatus(this.#abort.signal);
1823
+ const status = await this.#trackRead(this.dependencies.loadStatus(this.#abort.signal));
1072
1824
  if (this.#disposed || generation !== this.#generation)
1073
1825
  return false;
1074
1826
  this.#applyStatus(status);
@@ -1090,39 +1842,45 @@ export class TuiController {
1090
1842
  }
1091
1843
  }
1092
1844
  async inspectSourceRemoval(sourceId) {
1845
+ this.#assertAcceptingActions();
1093
1846
  if (!this.dependencies.inspectSourceRemoval) {
1094
1847
  throw new TuiOperationBlockedError('stateInventoryFailed', '来源引用清单不可用。');
1095
1848
  }
1096
- return cloneFrozen(await this.dependencies.inspectSourceRemoval(sourceId, this.#abort.signal));
1849
+ return cloneFrozen(await this.#trackRead(this.dependencies.inspectSourceRemoval(sourceId, this.#abort.signal)));
1097
1850
  }
1098
1851
  async getConfigGroups(pluginId, layer) {
1852
+ this.#assertAcceptingActions();
1099
1853
  if (!this.dependencies.getConfigGroups) {
1100
1854
  throw new TuiOperationBlockedError('configUnavailable', '设置读取入口不可用。');
1101
1855
  }
1102
- return cloneFrozen(await this.dependencies.getConfigGroups(pluginId, structuredClone(layer), this.#abort.signal));
1856
+ return cloneFrozen(await this.#trackRead(this.dependencies.getConfigGroups(pluginId, structuredClone(layer), this.#abort.signal)));
1103
1857
  }
1104
1858
  async getConfigPage(pluginId, layer, signal) {
1859
+ this.#assertAcceptingActions();
1105
1860
  if (!this.dependencies.getConfigPage) {
1106
1861
  throw new TuiOperationBlockedError('configUnavailable', '设置读取入口不可用。');
1107
1862
  }
1108
1863
  const combined = signal
1109
1864
  ? AbortSignal.any([this.#abort.signal, signal])
1110
1865
  : this.#abort.signal;
1111
- return cloneFrozen(await this.dependencies.getConfigPage(pluginId, structuredClone(layer), combined));
1866
+ return cloneFrozen(await this.#trackRead(this.dependencies.getConfigPage(pluginId, structuredClone(layer), combined)));
1112
1867
  }
1113
1868
  async getConfigResetAvailability(pluginId, layer) {
1869
+ this.#assertAcceptingActions();
1114
1870
  if (!this.dependencies.getConfigResetAvailability) {
1115
1871
  throw new TuiOperationBlockedError('configResetVerificationUnavailable', '设置层可信读取入口不可用。');
1116
1872
  }
1117
- return cloneFrozen(await this.dependencies.getConfigResetAvailability(pluginId, structuredClone(layer), this.#abort.signal));
1873
+ return cloneFrozen(await this.#trackRead(this.dependencies.getConfigResetAvailability(pluginId, structuredClone(layer), this.#abort.signal)));
1118
1874
  }
1119
1875
  listUserIssues() {
1120
1876
  return cloneFrozen(this.#viewModel?.issues ?? []);
1121
1877
  }
1122
1878
  async buildOperationReview(intent, returnRoute) {
1879
+ this.#assertAcceptingActions();
1880
+ intent = normalizeOperationIntent(intent);
1123
1881
  try {
1124
1882
  this.#validateReviewIntent(intent);
1125
- const evidence = await this.#prepareReviewEvidence(intent, this.#abort.signal);
1883
+ const evidence = await this.#trackRead(this.#prepareReviewEvidence(intent, this.#abort.signal));
1126
1884
  return this.#storeOperationReview(intent, evidence, 'push', returnRoute);
1127
1885
  }
1128
1886
  catch (error) {
@@ -1134,12 +1892,15 @@ export class TuiController {
1134
1892
  }
1135
1893
  }
1136
1894
  startReviewPreparation(intent, returnRoute) {
1895
+ this.#assertAcceptingActions();
1896
+ intent = normalizeOperationIntent(intent);
1137
1897
  if (intent.kind === 'catalogGeneration' && !intent.sourceMutation) {
1138
- const blockReason = this.catalogActivationBlockReason();
1898
+ const blockReason = this.catalogSourceRecoveryBlockReason();
1139
1899
  if (blockReason) {
1140
1900
  throw new TuiOperationBlockedError('catalogActivationBlocked', `Catalog Active 切换已阻止:${blockReason}。请先完成或放弃当前草稿或操作。`);
1141
1901
  }
1142
1902
  }
1903
+ this.#ensureOperationFlow(intent);
1143
1904
  const inheritedReturnRoute = this.#reviewPreparation?.returnRoute;
1144
1905
  const current = this.#router.snapshot.current.route;
1145
1906
  const navigation = current.name === 'result' || current.name === 'reviewPreparing'
@@ -1151,7 +1912,9 @@ export class TuiController {
1151
1912
  ? this.#result.returnRoute
1152
1913
  : current);
1153
1914
  const normalizedReturnRoute = this.#normalizeRoute(resolvedReturnRoute);
1154
- const key = JSON.stringify(stableValue({ intent, returnRoute: normalizedReturnRoute }));
1915
+ const key = crypto.createHash('sha256')
1916
+ .update(JSON.stringify(stableValue({ intent, returnRoute: normalizedReturnRoute })))
1917
+ .digest('hex');
1155
1918
  if (this.#reviewPreparation?.snapshot.state === 'running'
1156
1919
  && this.#reviewPreparation.key === key) {
1157
1920
  return cloneFrozen(this.#reviewPreparation.snapshot);
@@ -1162,14 +1925,16 @@ export class TuiController {
1162
1925
  const token = ++this.#reviewPreparationToken;
1163
1926
  const snapshot = {
1164
1927
  id,
1165
- intent: structuredClone(intent),
1928
+ operationKind: intent.kind,
1929
+ subject: this.#reviewPreparationLabel(intent),
1166
1930
  state: 'running',
1167
1931
  stages: this.dependencies.prepareReviewEvidence ? [] : [
1168
1932
  { id: 'validate', label: '验证当前状态', state: 'running' },
1169
1933
  { id: 'evidence', label: '准备确认信息', state: 'pending' },
1170
1934
  ],
1171
- currentLabel: this.#reviewPreparationLabel(intent),
1935
+ ...(this.dependencies.prepareReviewEvidence ? {} : { currentStage: 'validate' }),
1172
1936
  };
1937
+ this.#reviewPreparationIntents.set(id, structuredClone(intent));
1173
1938
  this.#reviewPreparation = {
1174
1939
  snapshot,
1175
1940
  key,
@@ -1187,9 +1952,25 @@ export class TuiController {
1187
1952
  const preparation = this.#reviewPreparation;
1188
1953
  if (!preparation || preparation.snapshot.state !== 'failed')
1189
1954
  return undefined;
1190
- return this.startReviewPreparation(preparation.snapshot.intent, preparation.returnRoute);
1955
+ const intent = this.#reviewPreparationIntents.get(preparation.snapshot.id);
1956
+ if (!intent)
1957
+ return undefined;
1958
+ return this.startReviewPreparation(structuredClone(intent), preparation.returnRoute);
1191
1959
  }
1192
1960
  #validateReviewIntent(intent) {
1961
+ if (intent.kind === 'pluginWriteBatch') {
1962
+ if (intent.selection.kind === 'currentProjectIntentRoots'
1963
+ && (intent.action !== 'sync' || intent.scope !== 'project')) {
1964
+ throw new TuiOperationBlockedError('batchSelectionInvalid', '当前项目全部同步只支持 project scope sync。');
1965
+ }
1966
+ if (intent.selection.kind === 'selected') {
1967
+ const canonical = canonicalPluginIds(intent.selection.pluginIds);
1968
+ if (canonical.length === 0) {
1969
+ throw new TuiOperationBlockedError('batchSelectionInvalid', '批量插件选择无效。');
1970
+ }
1971
+ canonical.forEach(sourceIdFromPluginId);
1972
+ }
1973
+ }
1193
1974
  const primaryGeneration = intent.kind === 'sourceMutation' && intent.action === 'setPrimary'
1194
1975
  ? this.#catalogGenerations.get(intent.sourceId) : undefined;
1195
1976
  if (primaryGeneration && !primaryGeneration.active && primaryGeneration.state !== 'ready') {
@@ -1199,6 +1980,11 @@ export class TuiController {
1199
1980
  #reviewPreparationLabel(intent) {
1200
1981
  if (intent.kind === 'catalogSourcesRecovery')
1201
1982
  return 'Catalog 来源配置';
1983
+ if (intent.kind === 'pluginWriteBatch') {
1984
+ return intent.selection.kind === 'selected'
1985
+ ? `${intent.selection.pluginIds.length} 个插件`
1986
+ : '当前项目全部插件';
1987
+ }
1202
1988
  if ('pluginId' in intent) {
1203
1989
  return this.#viewModel?.plugins.find((plugin) => plugin.pluginId === intent.pluginId)?.displayName
1204
1990
  ?? pluginNameFromId(intent.pluginId);
@@ -1217,13 +2003,17 @@ export class TuiController {
1217
2003
  const preparation = this.#reviewPreparation;
1218
2004
  if (!preparation || preparation.token !== token)
1219
2005
  return;
1220
- const intent = structuredClone(preparation.snapshot.intent);
2006
+ const storedIntent = this.#reviewPreparationIntents.get(preparation.snapshot.id);
2007
+ if (!storedIntent)
2008
+ return;
2009
+ const intent = structuredClone(storedIntent);
1221
2010
  const signal = AbortSignal.any([this.#abort.signal, preparation.abort.signal]);
1222
2011
  try {
1223
2012
  this.#validateReviewIntent(intent);
1224
- const evidence = await this.#prepareReviewEvidence(intent, signal, (stage) => {
2013
+ const evidence = await this.#trackRead(this.#prepareReviewEvidence(intent, signal, (stage) => {
1225
2014
  if (!this.#reviewPreparationIsCurrent(token))
1226
2015
  return;
2016
+ preparation.snapshot.currentStage = stage;
1227
2017
  const existing = preparation.snapshot.stages.find((entry) => entry.id === stage);
1228
2018
  preparation.snapshot.stages = preparation.snapshot.stages.map((entry) => entry.state === 'running' ? { ...entry, state: 'completed' } : entry);
1229
2019
  if (existing) {
@@ -1237,12 +2027,13 @@ export class TuiController {
1237
2027
  });
1238
2028
  }
1239
2029
  this.#publish();
1240
- });
2030
+ }));
1241
2031
  if (!this.#reviewPreparationIsCurrent(token))
1242
2032
  return;
1243
2033
  preparation.snapshot.stages = preparation.snapshot.stages.map((stage) => stage.state === 'running' ? { ...stage, state: 'completed' } : stage);
1244
2034
  this.#publish();
1245
2035
  const returnRoute = structuredClone(preparation.returnRoute);
2036
+ this.#reviewPreparationIntents.delete(preparation.snapshot.id);
1246
2037
  this.#reviewPreparation = undefined;
1247
2038
  this.#storeOperationReview(intent, evidence, 'replace', returnRoute);
1248
2039
  }
@@ -1269,7 +2060,10 @@ export class TuiController {
1269
2060
  : code === 'catalogGenerationActiveComparisonUnavailable'
1270
2061
  ? '损坏的当前 Active 无法提供可信版本,不能安全比较候选。当前 Active 保持不变。请明确编辑来源,或移除后重新添加。'
1271
2062
  : code === 'catalogReplaceDamagedRequired'
1272
- ? '无法重新取得或精确匹配旧 Active。当前 Active 保持不变。请返回并选择“使用当前候选替换损坏的 Active”。'
2063
+ ? intent.kind === 'catalogGeneration'
2064
+ && this.#catalogFreshness.get(intent.sourceId)?.state === 'incompatible'
2065
+ ? '无法重新取得或精确匹配旧 Active,且当前候选与此 AW 不兼容。当前 Active 保持不变。请升级 AW 或修改来源。'
2066
+ : '无法重新取得或精确匹配旧 Active。当前 Active 保持不变。请返回并选择“使用当前候选替换损坏的 Active”。'
1273
2067
  : code === 'catalogVersionConflict'
1274
2068
  ? '候选 Catalog 与当前 Active 版本相同,但内容摘要不同。普通更新已阻止。'
1275
2069
  : code === 'catalogRollbackDetected'
@@ -1277,6 +2071,34 @@ export class TuiController {
1277
2071
  : code === 'catalogGenerationUnavailable'
1278
2072
  ? '本次加载时 Catalog 来源不可用。请刷新来源并重新加载后重试。'
1279
2073
  : '无法准备确认信息。请返回检查选择,或重试。';
2074
+ const preparationRecordId = `${this.#sessionEpoch}:${active.snapshot.id}`;
2075
+ const preparationDiagnostic = projectThrownOperationDiagnostic({
2076
+ error,
2077
+ id: `${preparationRecordId}:failure`,
2078
+ stage: active.snapshot.currentStage ?? 'reviewPreparation',
2079
+ fallbackCode: code,
2080
+ sensitiveValues: collectOperationSensitiveValues(intent),
2081
+ });
2082
+ this.#operationHistory.record({
2083
+ summary: {
2084
+ recordId: preparationRecordId,
2085
+ epoch: this.#sessionEpoch,
2086
+ operationId: active.snapshot.id,
2087
+ createdAtMs: this.dependencies.now?.() ?? Date.now(),
2088
+ state: 'blocked',
2089
+ code: preparationDiagnostic.code,
2090
+ message: active.snapshot.message,
2091
+ operationKind: intent.kind,
2092
+ ...('pluginId' in intent ? { pluginId: intent.pluginId } : {}),
2093
+ diagnosticCount: 1,
2094
+ effects: {
2095
+ configCommit: 'notApplicable', awWrite: 'notStarted',
2096
+ preApplyExternalEffect: 'notApplicable', finalVerification: 'notStarted',
2097
+ },
2098
+ },
2099
+ diagnostics: [preparationDiagnostic],
2100
+ targets: [],
2101
+ }, { sensitiveValues: collectOperationSensitiveValues(intent) });
1280
2102
  this.#publish();
1281
2103
  await this.#logOperationError(error, code, intent);
1282
2104
  }
@@ -1300,6 +2122,7 @@ export class TuiController {
1300
2122
  return;
1301
2123
  this.#reviewPreparationToken += 1;
1302
2124
  this.#reviewPreparation.abort.abort();
2125
+ this.#reviewPreparationIntents.delete(this.#reviewPreparation.snapshot.id);
1303
2126
  this.#reviewPreparation = undefined;
1304
2127
  }
1305
2128
  async #logOperationError(error, code, intent) {
@@ -1322,6 +2145,9 @@ export class TuiController {
1322
2145
  }
1323
2146
  }
1324
2147
  #storeOperationReview(intent, evidence, navigation = 'push', returnRoute = this.#router.snapshot.current.route) {
2148
+ this.#ensureOperationFlow(intent);
2149
+ if (intent.kind === 'pluginWriteBatch')
2150
+ assertPluginWriteBatchEvidence(evidence);
1325
2151
  if (this.#router.snapshot.current.route.name === 'result')
1326
2152
  navigation = 'replace';
1327
2153
  const projection = buildOperationReviewProjection({
@@ -1338,7 +2164,10 @@ export class TuiController {
1338
2164
  const expiresAt = now
1339
2165
  + (this.dependencies.reviewTtlMs ?? 2 * 60_000);
1340
2166
  const pluginId = 'pluginId' in intent ? intent.pluginId
1341
- : 'sourceId' in intent ? intent.sourceId : 'Catalog 来源配置';
2167
+ : 'sourceId' in intent ? intent.sourceId
2168
+ : intent.kind === 'pluginWriteBatch'
2169
+ ? `${evidence.requestedPluginIds.length} 个插件`
2170
+ : 'Catalog 来源配置';
1342
2171
  if ('pluginId' in intent)
1343
2172
  sourceIdFromPluginId(intent.pluginId);
1344
2173
  const pluginVersion = 'pluginId' in intent
@@ -1354,6 +2183,7 @@ export class TuiController {
1354
2183
  : intent.kind === 'catalogGeneration' && intent.sourceMutation
1355
2184
  ? { sourceAction: intent.sourceMutation.action } : {}),
1356
2185
  ...(intent.kind === 'pluginWrite' ? { pluginAction: intent.action } : {}),
2186
+ ...(intent.kind === 'pluginWriteBatch' ? { pluginAction: intent.action } : {}),
1357
2187
  ...(intent.kind === 'pluginWrite' && intent.originIssue
1358
2188
  ? { originIssue: intent.originIssue }
1359
2189
  : {}),
@@ -1366,6 +2196,14 @@ export class TuiController {
1366
2196
  ...('pluginId' in intent ? { sourceLabel: sourceIdFromPluginId(intent.pluginId) } : {}),
1367
2197
  ...(pluginVersion ? { version: pluginVersion } : {}),
1368
2198
  },
2199
+ ...(intent.kind === 'pluginWriteBatch' ? {
2200
+ batch: {
2201
+ requestedPluginIds: [...evidence.requestedPluginIds],
2202
+ groupCount: evidence.groups.length,
2203
+ executableGroupCount: evidence.groups
2204
+ .filter((group) => group.executable).length,
2205
+ },
2206
+ } : {}),
1369
2207
  ...(evidence.source && 'catalogVersion' in evidence.source
1370
2208
  && typeof evidence.source.catalogVersion === 'string'
1371
2209
  && evidence.source.snapshotDigest
@@ -1389,6 +2227,14 @@ export class TuiController {
1389
2227
  return cloneFrozen(review);
1390
2228
  }
1391
2229
  executeOperation(reviewToken) {
2230
+ if (this.#retiring || this.#disposed) {
2231
+ return Promise.resolve(cloneFrozen({
2232
+ state: 'blocked',
2233
+ operationId: this.#tickets.get(reviewToken)?.review.operationId ?? 'unknown',
2234
+ code: 'sessionRetiring',
2235
+ message: '会话正在结束,未启动新操作。',
2236
+ }));
2237
+ }
1392
2238
  const inflight = this.#inflightOperations.get(reviewToken);
1393
2239
  if (inflight)
1394
2240
  return inflight.promise;
@@ -1409,6 +2255,7 @@ export class TuiController {
1409
2255
  const promise = this.#executeOperationTicket(ticket, operationId).finally(() => {
1410
2256
  if (this.#inflightOperations.get(reviewToken)?.promise === promise) {
1411
2257
  this.#inflightOperations.delete(reviewToken);
2258
+ this.#publish();
1412
2259
  }
1413
2260
  });
1414
2261
  this.#inflightOperations.set(reviewToken, { operationId, promise });
@@ -1418,8 +2265,10 @@ export class TuiController {
1418
2265
  let generation = this.#generation;
1419
2266
  const startedAtMs = this.dependencies.now?.() ?? Date.now();
1420
2267
  this.#progressFeed?.clearCurrent?.();
1421
- const dependencyPlan = ticket.evidence.dependencyPlan;
1422
- this.#progressTargets = dependencyPlan?.targets.map((target) => ({
2268
+ const dependencyTargets = ticket.intent.kind === 'pluginWriteBatch'
2269
+ ? ticket.evidence.groups.flatMap((group) => group.preview.dependencyPlan?.targets ?? [])
2270
+ : ticket.evidence.dependencyPlan?.targets ?? [];
2271
+ this.#progressTargets = dependencyTargets.map((target) => ({
1423
2272
  pluginId: target.pluginId,
1424
2273
  platform: target.platform,
1425
2274
  scope: target.resolvedScope,
@@ -1428,21 +2277,23 @@ export class TuiController {
1428
2277
  : target.depth === 1 ? 'direct' : 'transitive',
1429
2278
  requiredBy: target.requiredBy,
1430
2279
  state: target.action === 'keep' ? 'kept' : 'pending',
1431
- })) ?? [];
2280
+ }));
1432
2281
  this.#progress = {
1433
2282
  messages: [],
1434
2283
  startedAtMs,
1435
2284
  elapsedMs: 0,
1436
2285
  current: {
1437
- target: 'pluginId' in ticket.intent ? ticket.intent.pluginId
1438
- : 'sourceId' in ticket.intent ? ticket.intent.sourceId : 'Catalog 来源配置',
2286
+ target: ticket.intent.kind === 'pluginWriteBatch'
2287
+ ? `${ticket.evidence.requestedPluginIds.length} 个插件`
2288
+ : 'pluginId' in ticket.intent ? ticket.intent.pluginId
2289
+ : 'sourceId' in ticket.intent ? ticket.intent.sourceId : 'Catalog 来源配置',
1439
2290
  phase: '验证当前状态',
1440
2291
  },
1441
2292
  };
1442
2293
  this.#router.replace({ name: 'progress', operationId });
1443
2294
  this.#publish();
1444
2295
  try {
1445
- if (ticket.intent.kind !== 'pluginWrite') {
2296
+ if (ticket.intent.kind !== 'pluginWrite' && ticket.intent.kind !== 'pluginWriteBatch') {
1446
2297
  await this.dependencies.validateIntent?.(structuredClone(ticket.intent), this.#abort.signal);
1447
2298
  }
1448
2299
  }
@@ -1473,7 +2324,8 @@ export class TuiController {
1473
2324
  }
1474
2325
  if (!this.#operationIsCurrent(generation))
1475
2326
  return this.#detachedBlocked(operationId, ticket.intent);
1476
- if (ticket.intent.kind !== 'pluginWrite' && ticket.intent.kind !== 'catalogGeneration') {
2327
+ if (ticket.intent.kind !== 'pluginWrite' && ticket.intent.kind !== 'pluginWriteBatch'
2328
+ && ticket.intent.kind !== 'catalogGeneration') {
1477
2329
  let current;
1478
2330
  try {
1479
2331
  current = await this.dependencies.captureEvidence(structuredClone(ticket.intent), this.#abort.signal);
@@ -1501,6 +2353,11 @@ export class TuiController {
1501
2353
  const execution = ticket.intent.kind === 'catalogGeneration'
1502
2354
  ? await this.dependencies.executeIntent(structuredClone(ticket.intent), structuredClone(ticket.evidence), this.#abort.signal)
1503
2355
  : await this.dependencies.executeIntent(structuredClone(ticket.intent), structuredClone(ticket.evidence));
2356
+ const executionProjection = projectExecutionDiagnostics(execution, `${this.#sessionEpoch}:${operationId}`, collectOperationSensitiveValues(ticket.intent), ticket.intent.kind);
2357
+ this.#operationDiagnostics.set(operationId, {
2358
+ ...executionProjection,
2359
+ sensitiveValues: collectOperationSensitiveValues(ticket.intent),
2360
+ });
1504
2361
  if (ticket.intent.kind === 'sourceMutation' && ticket.intent.action === 'update') {
1505
2362
  const confirmedAutoCheck = catalogSourceAutoCheckOnlyValue(ticket.intent.update);
1506
2363
  if (confirmedAutoCheck !== undefined) {
@@ -1541,8 +2398,49 @@ export class TuiController {
1541
2398
  await this.#logErrorSafe(error, verificationCode, {
1542
2399
  privateConfigOperation: true,
1543
2400
  });
2401
+ const existingProjection = this.#operationDiagnostics.get(operationId);
2402
+ existingProjection.diagnostics.push(projectThrownOperationDiagnostic({
2403
+ error,
2404
+ id: `${this.#sessionEpoch}:${operationId}:status-reload`,
2405
+ stage: 'statusReload',
2406
+ fallbackCode: verificationCode,
2407
+ }));
2408
+ existingProjection.effects.finalVerification = 'failed';
1544
2409
  if (!this.#operationIsCurrent(generation))
1545
2410
  return this.#detachedBlocked(operationId, ticket.intent);
2411
+ const configCommitted = typeof execution === 'object'
2412
+ && execution !== null
2413
+ && execution.configWrite === 'succeeded';
2414
+ if ((ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset')
2415
+ && configCommitted) {
2416
+ const baseSummary = operationResultSummary(ticket.intent, execution);
2417
+ const elapsedMs = Math.max(0, (this.dependencies.now?.() ?? Date.now()) - startedAtMs);
2418
+ const summary = {
2419
+ ...baseSummary,
2420
+ code: 'operationPartial',
2421
+ message: '设置已保存,但最终状态无法刷新。',
2422
+ elapsedMs,
2423
+ messages: [
2424
+ ...this.#progress.messages,
2425
+ ...(baseSummary.messages ?? []),
2426
+ '状态刷新失败。配置写入已经提交;请稍后刷新状态。',
2427
+ ].slice(-8).map((message) => sanitizeDiagnosticText(message, [], collectOperationSensitiveValues(ticket.intent))),
2428
+ };
2429
+ this.#progress = { ...this.#progress, elapsedMs };
2430
+ const result = {
2431
+ state: 'partial',
2432
+ operationId,
2433
+ summary,
2434
+ operationKind: ticket.intent.kind,
2435
+ pluginId: ticket.intent.pluginId,
2436
+ returnRoute: structuredClone(this.#normalizeRoute(ticket.returnRoute)),
2437
+ };
2438
+ this.#result = structuredClone(result);
2439
+ this.#activeReview = undefined;
2440
+ this.#showResult(result, ticket.intent);
2441
+ this.#publish();
2442
+ return cloneFrozen(result);
2443
+ }
1546
2444
  return this.#setBlocked(operationId, verificationCode, {
1547
2445
  intent: ticket.intent,
1548
2446
  navigation: 'result',
@@ -1555,11 +2453,16 @@ export class TuiController {
1555
2453
  if (!this.#operationIsCurrent(generation))
1556
2454
  return this.#detachedBlocked(operationId, ticket.intent);
1557
2455
  this.#applyStatus(refreshed);
2456
+ const verifiedProjection = this.#operationDiagnostics.get(operationId);
2457
+ if (verifiedProjection && verifiedProjection.effects.finalVerification !== 'failed') {
2458
+ verifiedProjection.effects.finalVerification = 'completed';
2459
+ }
1558
2460
  if (ticket.intent.kind === 'catalogSourcesRecovery') {
1559
2461
  this.#phase = 'ready';
1560
2462
  this.#loadError = undefined;
1561
2463
  }
1562
2464
  const pluginSummary = ticket.intent.kind === 'pluginWrite'
2465
+ || ticket.intent.kind === 'pluginWriteBatch'
1563
2466
  ? operationResultSummary(ticket.intent, execution)
1564
2467
  : undefined;
1565
2468
  const hasFailedDependencyTarget = pluginSummary?.targets?.some((target) => target.relation !== undefined && target.relation !== 'requested'
@@ -1570,6 +2473,18 @@ export class TuiController {
1570
2473
  const dependencyFailure = pluginSummary?.code === 'operationFailed'
1571
2474
  && Boolean(hasFailedDependencyTarget);
1572
2475
  if (pluginSummary?.code === 'operationFailed') {
2476
+ const projected = this.#operationDiagnostics.get(operationId);
2477
+ if (projected) {
2478
+ projected.targets = (pluginSummary.targets ?? []).map((target) => ({
2479
+ ...target,
2480
+ diagnosticIds: projected.diagnostics.filter((diagnostic) => diagnostic.target
2481
+ && diagnostic.target.platform === target.platform
2482
+ && diagnostic.target.scope === target.scope
2483
+ && (diagnostic.target.pluginId === undefined || diagnostic.target.pluginId === target.pluginId))
2484
+ .map((diagnostic) => diagnostic.id),
2485
+ effects: projected.targetEffects?.get(`${target.pluginId ?? ''}\0${target.platform}\0${target.scope}`),
2486
+ }));
2487
+ }
1573
2488
  const unsuccessfulTargets = pluginSummary.targets?.filter((target) => target.state === 'failed' || target.state === 'skipped') ?? [];
1574
2489
  const managerFailureCode = unsuccessfulTargets.map((target) => target.errorCode).find((code) => code === 'codexSystemCliUnsupported'
1575
2490
  || code === 'managedCodexAuthorizationRequired'
@@ -1588,8 +2503,10 @@ export class TuiController {
1588
2503
  intent: ticket.intent,
1589
2504
  navigation: 'result',
1590
2505
  returnRoute: ticket.returnRoute,
1591
- ...(dependencyFailure || dependencyStillRequired ? { summary: pluginSummary } : {}),
1592
- ...(!dependencyFailure && !dependencyStillRequired ? { writeMayHaveCompleted: true } : {}),
2506
+ ...(ticket.intent.kind === 'pluginWriteBatch' || dependencyFailure || dependencyStillRequired
2507
+ ? { summary: pluginSummary } : {}),
2508
+ ...(ticket.intent.kind !== 'pluginWriteBatch'
2509
+ && !dependencyFailure && !dependencyStillRequired && ['started', 'partial', 'completed', 'failed', 'unknown'].includes(this.#operationDiagnostics.get(operationId)?.effects.awWrite ?? 'unknown') ? { writeMayHaveCompleted: true } : {}),
1593
2510
  });
1594
2511
  }
1595
2512
  const originIssue = ticket.intent.kind === 'pluginWrite' ? ticket.intent.originIssue : undefined;
@@ -1620,7 +2537,8 @@ export class TuiController {
1620
2537
  const summary = {
1621
2538
  ...baseSummary,
1622
2539
  elapsedMs: Math.max(0, (this.dependencies.now?.() ?? Date.now()) - startedAtMs),
1623
- messages: [...this.#progress.messages, ...(baseSummary.messages ?? [])].slice(-8),
2540
+ messages: [...this.#progress.messages, ...(baseSummary.messages ?? [])].slice(-8)
2541
+ .map((message) => sanitizeDiagnosticText(message, [], collectOperationSensitiveValues(ticket.intent))),
1624
2542
  };
1625
2543
  this.#progress = { ...this.#progress, elapsedMs: summary.elapsedMs };
1626
2544
  if (summary.code === 'operationFailed') {
@@ -1635,7 +2553,7 @@ export class TuiController {
1635
2553
  };
1636
2554
  this.#result = structuredClone(result);
1637
2555
  this.#activeReview = undefined;
1638
- this.#router.replace({ name: 'result', operationId });
2556
+ this.#showResult(result, ticket.intent);
1639
2557
  this.#publish();
1640
2558
  return cloneFrozen(result);
1641
2559
  }
@@ -1666,6 +2584,7 @@ export class TuiController {
1666
2584
  };
1667
2585
  if ((ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset')
1668
2586
  && this.#portableRepreparePluginId === ticket.intent.pluginId) {
2587
+ this.#operationDiagnostics.delete(operationId);
1669
2588
  this.#portableRepreparePluginId = undefined;
1670
2589
  this.#activeReview = undefined;
1671
2590
  this.#result = undefined;
@@ -1674,7 +2593,7 @@ export class TuiController {
1674
2593
  }
1675
2594
  this.#result = result;
1676
2595
  this.#activeReview = undefined;
1677
- this.#router.replace({ name: 'result', operationId });
2596
+ this.#showResult(result, ticket.intent);
1678
2597
  this.#publish();
1679
2598
  return cloneFrozen(result);
1680
2599
  }
@@ -1682,44 +2601,106 @@ export class TuiController {
1682
2601
  if (!this.#operationIsCurrent(generation))
1683
2602
  return this.#detachedBlocked(operationId, ticket.intent);
1684
2603
  const code = issueCode(error);
2604
+ const metadata = readSafeOperationError(error);
2605
+ const preApply = metadata?.stage === 'preApply';
2606
+ const configWrite = error && typeof error === 'object' && 'configWrite' in error
2607
+ ? error.configWrite : undefined;
2608
+ this.#captureThrownProjection(operationId, error, code, 'execute', ticket.intent, {
2609
+ configCommit: ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset'
2610
+ ? configWrite === 'succeeded' ? 'completed'
2611
+ : configWrite === 'failed' ? 'failed' : 'notStarted' : 'notApplicable',
2612
+ awWrite: preApply ? 'notStarted' : 'unknown',
2613
+ preApplyExternalEffect: preApply ? 'possible' : 'notApplicable',
2614
+ finalVerification: 'notStarted',
2615
+ });
1685
2616
  await this.#logOperationError(error, code, ticket.intent);
1686
2617
  if (!this.#operationIsCurrent(generation))
1687
2618
  return this.#detachedBlocked(operationId, ticket.intent);
1688
2619
  if (code === 'operationReviewStale' || code === 'catalogGenerationReviewStale') {
2620
+ this.#operationDiagnostics.delete(operationId);
1689
2621
  return this.#rebuildStaleReview(ticket, operationId, undefined, generation);
1690
2622
  }
1691
2623
  generation = ++this.#generation;
2624
+ let finalVerification = 'completed';
1692
2625
  try {
1693
2626
  const refreshed = await this.dependencies.loadStatus(this.#abort.signal);
1694
2627
  if (!this.#operationIsCurrent(generation))
1695
2628
  return this.#detachedBlocked(operationId, ticket.intent);
1696
2629
  this.#applyStatus(refreshed);
2630
+ const projection = this.#operationDiagnostics.get(operationId);
2631
+ if (projection)
2632
+ projection.effects.finalVerification = 'completed';
1697
2633
  if (ticket.intent.kind === 'catalogSourcesRecovery'
1698
2634
  && code === 'catalogSourcesConfigRecoveryStale') {
1699
2635
  this.#phase = 'ready';
1700
2636
  this.#loadError = undefined;
1701
- this.#activeReview = undefined;
1702
- this.#result = undefined;
1703
- this.#router.replace({ name: 'home' });
1704
- this.#publish();
1705
- return cloneFrozen({
1706
- state: 'blocked',
1707
- operationId,
1708
- code,
1709
- message: '来源配置已变化,已重新加载当前状态。',
1710
- operationKind: ticket.intent.kind,
2637
+ return this.#setBlocked(operationId, code, {
2638
+ intent: ticket.intent,
2639
+ navigation: 'result',
2640
+ returnRoute: ticket.returnRoute,
2641
+ writeMayHaveCompleted: false,
1711
2642
  });
1712
2643
  }
1713
2644
  }
1714
2645
  catch (reloadError) {
1715
2646
  if (!this.#operationIsCurrent(generation))
1716
2647
  return this.#detachedBlocked(operationId, ticket.intent);
2648
+ finalVerification = 'failed';
2649
+ const projection = this.#operationDiagnostics.get(operationId);
2650
+ if (projection)
2651
+ projection.effects.finalVerification = 'failed';
1717
2652
  await this.#logErrorSafe(reloadError, 'tuiStateReloadFailed', {
1718
2653
  privateConfigOperation: true,
1719
2654
  });
2655
+ if (ticket.intent.kind === 'catalogSourcesRecovery'
2656
+ && code === 'catalogSourcesConfigRecoveryStale') {
2657
+ this.#phase = 'error';
2658
+ this.#loadError = {
2659
+ code: issueCode(reloadError),
2660
+ message: '无法安全读取插件状态。请检查诊断后重试或退出。',
2661
+ };
2662
+ }
1720
2663
  }
1721
2664
  if (!this.#operationIsCurrent(generation))
1722
2665
  return this.#detachedBlocked(operationId, ticket.intent);
2666
+ if ((ticket.intent.kind === 'configSave' || ticket.intent.kind === 'configReset')
2667
+ && configWrite === 'succeeded') {
2668
+ const elapsedMs = Math.max(0, (this.dependencies.now?.() ?? Date.now()) - startedAtMs);
2669
+ const summary = {
2670
+ code: 'operationPartial',
2671
+ message: ticket.intent.kind === 'configReset'
2672
+ ? '设置层重置已提交,但后续步骤未完成。'
2673
+ : finalVerification === 'failed'
2674
+ ? ticket.intent.apply
2675
+ ? '设置已保存,但后续应用或状态复核失败。'
2676
+ : '设置已保存,但状态复核失败。'
2677
+ : ticket.intent.apply
2678
+ ? '设置已保存,但后续应用失败。'
2679
+ : '设置已保存,但后续步骤未完成。',
2680
+ pluginId: ticket.intent.pluginId,
2681
+ configScope: ticket.intent.scope,
2682
+ ...(ticket.intent.kind === 'configSave' && ticket.intent.apply
2683
+ ? { configApplyRetryAvailable: true } : {}),
2684
+ elapsedMs,
2685
+ messages: [ticket.intent.kind === 'configReset'
2686
+ ? '设置层重置已经提交;失败的后续步骤没有恢复旧设置。'
2687
+ : '配置写入已经提交;失败的后续步骤没有回滚设置。'],
2688
+ };
2689
+ this.#progress = { ...this.#progress, elapsedMs };
2690
+ const result = {
2691
+ state: 'partial',
2692
+ operationId,
2693
+ summary,
2694
+ operationKind: ticket.intent.kind,
2695
+ pluginId: ticket.intent.pluginId,
2696
+ returnRoute: structuredClone(this.#normalizeRoute(ticket.returnRoute)),
2697
+ };
2698
+ this.#result = structuredClone(result);
2699
+ this.#activeReview = undefined;
2700
+ this.#showResult(result, ticket.intent);
2701
+ this.#publish();
2702
+ return cloneFrozen(result);
2703
+ }
1723
2704
  return this.#setBlocked(operationId, code, {
1724
2705
  intent: ticket.intent,
1725
2706
  navigation: 'result',
@@ -1732,6 +2713,7 @@ export class TuiController {
1732
2713
  return !this.#disposed && !this.#abort.signal.aborted && generation === this.#generation;
1733
2714
  }
1734
2715
  #detachedBlocked(operationId, intent) {
2716
+ this.#operationDiagnostics.delete(operationId);
1735
2717
  return cloneFrozen({
1736
2718
  state: 'blocked',
1737
2719
  operationId,
@@ -1780,7 +2762,7 @@ export class TuiController {
1780
2762
  };
1781
2763
  this.#result = structuredClone(result);
1782
2764
  this.#activeReview = undefined;
1783
- this.#router.replace({ name: 'result', operationId });
2765
+ this.#showResult(result, ticket.intent);
1784
2766
  this.#publish();
1785
2767
  return cloneFrozen(result);
1786
2768
  }
@@ -1824,6 +2806,9 @@ export class TuiController {
1824
2806
  return cloneFrozen(result);
1825
2807
  }
1826
2808
  #setBlocked(operationId, code, options) {
2809
+ if (!this.#operationDiagnostics.has(operationId)) {
2810
+ this.#captureThrownProjection(operationId, undefined, code, 'execute', options?.intent);
2811
+ }
1827
2812
  const message = code === 'managedCodexAuthorizationRequired'
1828
2813
  ? '操作在插件写入前停止。请在 Review 中确认获取 Codex runtime,或在命令行传入 --allow-managed-codex-install。'
1829
2814
  : code === 'codexSystemCliUnsupported'
@@ -1856,6 +2841,8 @@ export class TuiController {
1856
2841
  this.#router.back();
1857
2842
  this.#normalizeCurrentRoute();
1858
2843
  }
2844
+ else if (options?.intent)
2845
+ this.#showResult(result, options.intent);
1859
2846
  else
1860
2847
  this.#router.replace({ name: 'result', operationId });
1861
2848
  this.#publish();
@@ -1886,7 +2873,7 @@ export class TuiController {
1886
2873
  this.#listeners.clear();
1887
2874
  }
1888
2875
  }
1889
- function operationResultSummary(intent, execution) {
2876
+ function operationResultSummaryUnsafe(intent, execution) {
1890
2877
  if (intent.kind === 'catalogSourcesRecovery') {
1891
2878
  const result = execution;
1892
2879
  return {
@@ -1918,14 +2905,14 @@ function operationResultSummary(intent, execution) {
1918
2905
  : '来源配置已保存,但 Catalog 尚未启用。可在 TUI 中立即重试。'
1919
2906
  : intent.action === 'repair' ? 'Catalog 已重新下载并修复。'
1920
2907
  : intent.action === 'replaceDamaged' ? '损坏的 Active Catalog 已由当前候选替换。'
1921
- : intent.action === 'recoverState' ? 'Catalog generation state 已恢复,目标来源已启用。'
2908
+ : intent.action === 'recoverState' ? '目录版本状态已恢复,目标来源已启用。'
1922
2909
  : updateActivated ? `Catalog 已更新到 ${generation.resolution.catalogVersion}。`
1923
2910
  : intent.action === 'update' ? 'Catalog 已验证,Active 未变化。'
1924
2911
  : 'Catalog 已验证并启用。',
1925
2912
  messages: result.status === 'partial'
1926
2913
  ? [result.partialReason === 'primary'
1927
- ? `新的 Active 可用;主来源保持不变。错误:${result.errorCode ?? 'catalogSourcePrimaryFailed'}`
1928
- : `旧 Active 保持不变。错误:${result.errorCode ?? 'catalogGenerationRefreshFailed'}`]
2914
+ ? `新的已启用版本可用;主来源保持不变。错误:${result.errorCode ?? 'catalogSourcePrimaryFailed'}`
2915
+ : `旧的已启用版本保持不变。错误:${result.errorCode ?? 'catalogGenerationRefreshFailed'}`]
1929
2916
  : updateActivated
1930
2917
  ? ['已安装插件不会自动升级。', '已重新计算可用的插件升级。']
1931
2918
  : intent.action === 'update'
@@ -1963,11 +2950,15 @@ function operationResultSummary(intent, execution) {
1963
2950
  },
1964
2951
  };
1965
2952
  }
1966
- if (intent.kind === 'pluginWrite') {
2953
+ if (intent.kind === 'pluginWrite' || intent.kind === 'pluginWriteBatch') {
1967
2954
  const command = intent.action === 'refreshAndSync' ? 'sync' : intent.action;
1968
2955
  const rawExecution = typeof execution === 'object' && execution !== null
1969
2956
  ? execution
1970
2957
  : {};
2958
+ const requestedPluginIds = intent.kind === 'pluginWriteBatch'
2959
+ ? canonicalPluginIds(execution.requestedPluginIds ?? [])
2960
+ : [intent.pluginId];
2961
+ const fallbackPluginId = requestedPluginIds[0] ?? 'unknown/plugin';
1971
2962
  const normalizedTargetResults = Array.isArray(rawExecution.targetResults)
1972
2963
  ? rawExecution.targetResults.map((candidate) => {
1973
2964
  if (typeof candidate !== 'object' || candidate === null)
@@ -1984,7 +2975,7 @@ function operationResultSummary(intent, execution) {
1984
2975
  ...rawTarget,
1985
2976
  ...(pluginId ? { pluginId } : {}),
1986
2977
  pluginName: typeof rawTarget.pluginName === 'string'
1987
- ? rawTarget.pluginName : pluginNameFromId(pluginId ?? intent.pluginId),
2978
+ ? rawTarget.pluginName : pluginNameFromId(pluginId ?? fallbackPluginId),
1988
2979
  },
1989
2980
  action: typeof entry.action === 'string'
1990
2981
  ? entry.action : requestedIntentAction(command),
@@ -1995,17 +2986,21 @@ function operationResultSummary(intent, execution) {
1995
2986
  ...rawExecution,
1996
2987
  ...(normalizedTargetResults ? { targetResults: normalizedTargetResults } : {}),
1997
2988
  platforms: Array.isArray(rawExecution.platforms) ? rawExecution.platforms : [],
1998
- plugins: Array.isArray(rawExecution.plugins) ? rawExecution.plugins : [intent.pluginId],
2989
+ plugins: Array.isArray(rawExecution.plugins) ? rawExecution.plugins : requestedPluginIds,
1999
2990
  warnings: Array.isArray(rawExecution.warnings) ? rawExecution.warnings : [],
2000
2991
  });
2001
2992
  const result = buildOperationResultModel(receipt);
2002
- const requestedIds = new Set(receipt.requestedPluginIds ?? [intent.pluginId]);
2993
+ const requestedIds = new Set(receipt.requestedPluginIds ?? requestedPluginIds);
2994
+ const retryTargets = intent.kind === 'pluginWriteBatch'
2995
+ ? pluginWriteBatchFailedRequestedTargets(execution)
2996
+ : [];
2997
+ const failedRequestedPluginIds = canonicalPluginIds(retryTargets.map((target) => target.pluginId));
2003
2998
  return {
2004
2999
  code: result.state === 'succeeded' ? 'operationSucceeded'
2005
3000
  : result.state === 'partial' ? 'operationPartial' : 'operationFailed',
2006
3001
  message: result.state === 'succeeded' ? '操作已完成。'
2007
3002
  : result.state === 'partial' ? '操作只完成了一部分。' : '操作结果无法确认。',
2008
- pluginId: intent.pluginId,
3003
+ ...(intent.kind === 'pluginWrite' ? { pluginId: intent.pluginId } : {}),
2009
3004
  action: intent.action,
2010
3005
  changes: result.changes,
2011
3006
  warnings: result.warnings,
@@ -2014,10 +3009,18 @@ function operationResultSummary(intent, execution) {
2014
3009
  messages: [
2015
3010
  ...result.warnings.map((warning) => `警告:${warning}`),
2016
3011
  ...result.recoveryActions.map((action) => `恢复:${action}`),
2017
- ...(intent.recoveryDescription
3012
+ ...(intent.kind === 'pluginWrite' && intent.recoveryDescription
2018
3013
  ? [intent.recoveryDescription, `后续:${intent.scope === 'user' ? '当前项目' : '所有项目'} sync`]
2019
3014
  : []),
2020
3015
  ],
3016
+ ...(intent.kind === 'pluginWriteBatch' ? {
3017
+ batch: {
3018
+ requestedPluginIds,
3019
+ failedRequestedPluginIds,
3020
+ retryTargets,
3021
+ groupCount: execution.groupResults.length,
3022
+ },
3023
+ } : {}),
2021
3024
  targets: result.targetResults.map((entry) => ({
2022
3025
  pluginId: entry.target.pluginId,
2023
3026
  platform: entry.target.platform,
@@ -2029,19 +3032,18 @@ function operationResultSummary(intent, execution) {
2029
3032
  relation: entry.relation === 'requested' ? 'requested'
2030
3033
  : entry.requiredBy.some((pluginId) => requestedIds.has(pluginId)) ? 'direct' : 'transitive',
2031
3034
  requiredBy: entry.requiredBy,
2032
- ...(entry.errorCode === 'dependencyStillRequired'
2033
- || entry.errorCode === 'codexSystemCliUnsupported'
2034
- || entry.errorCode === 'managedCodexAuthorizationRequired'
2035
- || entry.errorCode === 'managedCodexUnavailable'
2036
- ? { errorCode: entry.errorCode }
2037
- : {}),
3035
+ ...(entry.errorCode ? { errorCode: safeDiagnosticCode(entry.errorCode, 'writeFailed') } : {}),
2038
3036
  })),
2039
3037
  };
2040
3038
  }
2041
3039
  if (intent.kind === 'configSave' || intent.kind === 'configReset') {
2042
- const rawTargets = typeof execution === 'object' && execution !== null
2043
- && Array.isArray(execution.targetResults)
2044
- ? execution.targetResults
3040
+ const rawApply = typeof execution === 'object' && execution !== null
3041
+ && typeof execution.apply === 'object'
3042
+ && execution.apply !== null
3043
+ ? execution.apply
3044
+ : {};
3045
+ const rawTargets = Array.isArray(rawApply.targetResults)
3046
+ ? rawApply.targetResults
2045
3047
  : [];
2046
3048
  const targets = rawTargets.flatMap((entry) => {
2047
3049
  const target = typeof entry.target === 'object' && entry.target !== null
@@ -2049,32 +3051,50 @@ function operationResultSummary(intent, execution) {
2049
3051
  : entry;
2050
3052
  if ((target.platform !== 'codex' && target.platform !== 'claude')
2051
3053
  || (target.scope !== 'user' && target.scope !== 'project')
2052
- || (entry.state !== 'completed' && entry.state !== 'failed'))
3054
+ || (entry.state !== 'completed' && entry.state !== 'failed' && entry.state !== 'skipped'))
2053
3055
  return [];
2054
3056
  return [{
2055
3057
  ...(typeof target.pluginId === 'string' ? { pluginId: target.pluginId } : {}),
2056
3058
  platform: target.platform,
2057
3059
  scope: target.scope,
2058
- state: entry.action === 'skipped' ? 'skipped'
3060
+ state: entry.state === 'skipped' || entry.action === 'skipped' ? 'skipped'
2059
3061
  : entry.state === 'failed' ? 'failed'
2060
3062
  : entry.action === 'keep' ? 'kept'
2061
3063
  : entry.state,
3064
+ ...(typeof entry.errorCode === 'string'
3065
+ ? { errorCode: safeDiagnosticCode(entry.errorCode, 'configApplyFailed') }
3066
+ : {}),
2062
3067
  }];
2063
3068
  });
2064
3069
  const overallState = typeof execution === 'object' && execution !== null
2065
3070
  ? execution.overallState
2066
3071
  : undefined;
2067
- const applySkippedReason = typeof execution === 'object' && execution !== null
2068
- ? execution.applySkippedReason
2069
- : undefined;
2070
- const invalidConfig = applySkippedReason === 'invalidConfig';
3072
+ const applyState = rawApply.state;
3073
+ const applyReason = rawApply.reason;
3074
+ const invalidConfig = applyState === 'unavailable' && applyReason === 'invalidConfig';
2071
3075
  const resetDisposition = typeof execution === 'object' && execution !== null
2072
3076
  ? execution.resetDisposition
2073
3077
  : undefined;
2074
3078
  const quarantineBasename = typeof execution === 'object' && execution !== null
2075
3079
  ? execution.quarantineBasename
2076
3080
  : undefined;
3081
+ const publicWarnings = typeof execution === 'object' && execution !== null
3082
+ && Array.isArray(execution.warnings)
3083
+ ? execution.warnings.filter((warning) => typeof warning === 'string'
3084
+ && warning.startsWith('projectConfigShareabilityUnverified:'))
3085
+ : [];
3086
+ const otherProjectsMayRequireSync = intent.scope === 'user'
3087
+ && typeof execution === 'object' && execution !== null
3088
+ && execution.otherProjectsMayRequireSync === true;
3089
+ const publicMessages = [
3090
+ ...publicWarnings.map((warning) => `警告:${warning}`),
3091
+ ...(otherProjectsMayRequireSync
3092
+ ? ['提示:其它项目没有被扫描,可能需要在对应项目目录中运行 aw sync。']
3093
+ : []),
3094
+ ];
2077
3095
  const partial = overallState === 'partial'
3096
+ || applyState === 'partial'
3097
+ || applyState === 'unavailable'
2078
3098
  || invalidConfig
2079
3099
  || targets.some((target) => target.state === 'failed' || target.state === 'skipped')
2080
3100
  && targets.some((target) => target.state === 'completed' || target.state === 'kept');
@@ -2088,9 +3108,10 @@ function operationResultSummary(intent, execution) {
2088
3108
  pluginId: intent.pluginId,
2089
3109
  configScope: intent.scope,
2090
3110
  configApplyRetryAvailable: intent.kind === 'configSave'
2091
- && intent.apply && !invalidConfig && (failed || partial),
3111
+ && intent.apply && !invalidConfig && (applyState === 'partial' || applyState === 'unavailable'),
3112
+ ...(publicMessages.length > 0 ? { messages: publicMessages } : {}),
2092
3113
  ...(intent.kind === 'configSave'
2093
- ? { configApplied: invalidConfig ? false : intent.apply }
3114
+ ? { configApplied: applyState === 'succeeded' || applyState === 'partial' }
2094
3115
  : invalidConfig ? { configApplied: false } : {}),
2095
3116
  ...(invalidConfig ? { configApplySkippedReason: 'invalidConfig' } : {}),
2096
3117
  ...(intent.kind === 'configReset'
@@ -2117,4 +3138,7 @@ function operationResultSummary(intent, execution) {
2117
3138
  ...(sourceWarnings.length > 0 ? { messages: sourceWarnings } : {}),
2118
3139
  };
2119
3140
  }
3141
+ function operationResultSummary(intent, execution) {
3142
+ return sanitizeOperationSummary(operationResultSummaryUnsafe(intent, execution), collectOperationSensitiveValues(intent));
3143
+ }
2120
3144
  //# sourceMappingURL=controller.js.map