@zq-silk/yui 0.2.0 → 0.4.2

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 (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. package/dist/worktree/roleWorkspace.js +0 -62
@@ -1,6 +1,8 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { agentNotFound, usageError } from "../errors/cliError.js";
2
3
  import { defaultTableWidth, renderTable } from "../output/table.js";
3
- import { createConfiguredAgent } from "../agent/agent.js";
4
+ import { createConfiguredAgent, validateConfiguredAgent } from "../agent/agent.js";
5
+ import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
4
6
  const SUPPORTED_ADAPTERS = Object.freeze(["codex", "claude"]);
5
7
  export function runAgentCommand(args, store) {
6
8
  const [command, ...rest] = args;
@@ -95,7 +97,37 @@ function updateAgent(args, store) {
95
97
  ? { environment: parsed.many("--env").map(parseEnvironmentBinding) }
96
98
  : parsed.has("--clear-env") ? { environment: [] } : {})
97
99
  };
98
- const result = store.updateConfiguredAgent(id, patch, new Date());
100
+ const now = new Date();
101
+ const result = store.transaction((tx) => {
102
+ const existing = tx.getConfiguredAgent(id);
103
+ if (existing === null)
104
+ return null;
105
+ const changes = actualAgentChanges(existing, patch);
106
+ if (!changes.operational) {
107
+ return { status: "unchanged", agent: existing };
108
+ }
109
+ const lifecycle = findRuntimeLifecycleReference(tx, id);
110
+ if (lifecycle !== null) {
111
+ throw usageError(`Agent ${id} cannot be updated because ${describeReference(lifecycle)} `
112
+ + "has pending runtime lifecycle launch or cleanup work. "
113
+ + "Wait for lifecycle reconciliation to finish before changing Agent launch settings.");
114
+ }
115
+ const liveSession = findNonStoppedSessionReference(tx, id);
116
+ if (liveSession !== null) {
117
+ throw usageError(`Agent ${id} cannot be updated because ${describeReference(liveSession)} `
118
+ + `retains a non-stopped native session (${liveSession.status}). `
119
+ + "Stop that Role session before changing Agent launch settings.");
120
+ }
121
+ if (changes.adapter) {
122
+ const binding = findRoleBindingReference(tx, id);
123
+ if (binding !== null) {
124
+ throw usageError(`Agent ${id} adapter cannot change because ${describeReference(binding)} references it. `
125
+ + "Create a new Agent ID with the target adapter and bind the Role to it instead.");
126
+ }
127
+ }
128
+ assertValidAgentCandidate(existing, patch, now);
129
+ return tx.updateConfiguredAgent(id, patch, now);
130
+ });
99
131
  if (result === null)
100
132
  throw agentNotFound(id);
101
133
  return result.status === "unchanged"
@@ -106,10 +138,121 @@ function removeAgent(args, store) {
106
138
  const [rawId, ...rest] = args;
107
139
  const id = agentId(rawId);
108
140
  assertNoArguments(rest, "Agent remove usage: yui agent remove <agent-id>");
109
- if (!store.removeConfiguredAgent(id))
141
+ const removed = store.transaction((tx) => {
142
+ if (tx.getConfiguredAgent(id) === null)
143
+ return false;
144
+ if (tx.getConfig().defaultAgent === id) {
145
+ throw usageError(`Agent ${id} cannot be removed because config.defaultAgent references it. `
146
+ + "Set another default Agent first.");
147
+ }
148
+ const lifecycle = findRuntimeLifecycleReference(tx, id);
149
+ if (lifecycle !== null) {
150
+ throw usageError(`Agent ${id} cannot be removed because ${describeReference(lifecycle)} `
151
+ + "has pending runtime lifecycle launch or cleanup work. "
152
+ + "Wait for lifecycle reconciliation to finish first.");
153
+ }
154
+ const binding = findRoleBindingReference(tx, id);
155
+ if (binding !== null) {
156
+ throw usageError(`Agent ${id} cannot be removed because ${describeReference(binding)} references it. `
157
+ + "Migrate or remove that Role binding before removing this Agent.");
158
+ }
159
+ const session = findNonStoppedSessionReference(tx, id);
160
+ if (session !== null) {
161
+ throw usageError(`Agent ${id} cannot be removed because ${describeReference(session)} `
162
+ + `retains a native session (${session.status}). Stop that Role session first.`);
163
+ }
164
+ return tx.removeConfiguredAgent(id);
165
+ });
166
+ if (!removed)
110
167
  throw agentNotFound(id);
111
168
  return `Removed agent ${id}\n`;
112
169
  }
170
+ function actualAgentChanges(existing, patch) {
171
+ const adapter = patch.adapterId !== undefined && patch.adapterId !== existing.adapterId;
172
+ const operational = adapter
173
+ || (patch.command !== undefined && patch.command !== existing.command)
174
+ || (patch.baseArgs !== undefined && !isDeepStrictEqual(patch.baseArgs, existing.baseArgs))
175
+ || (patch.environment !== undefined
176
+ && !isDeepStrictEqual(patch.environment, existing.environment));
177
+ return { adapter, operational };
178
+ }
179
+ function assertValidAgentCandidate(existing, patch, now) {
180
+ try {
181
+ validateConfiguredAgent({
182
+ ...existing,
183
+ ...structuredClone(patch),
184
+ updatedAt: now.toISOString()
185
+ });
186
+ }
187
+ catch (error) {
188
+ throw usageError(error instanceof Error ? error.message : String(error));
189
+ }
190
+ }
191
+ function findRuntimeLifecycleReference(store, agentId) {
192
+ for (const role of store.listGlobalRoles()) {
193
+ if (role.activeAgentId === agentId
194
+ && hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({
195
+ scope: "global",
196
+ roleName: role.name
197
+ })))) {
198
+ return { scope: "global", roleName: role.name };
199
+ }
200
+ }
201
+ for (const task of store.listTasks()) {
202
+ for (const role of store.listRoles(task.id)) {
203
+ if (role.activeAgentId === agentId
204
+ && hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({
205
+ scope: "task",
206
+ taskId: task.id,
207
+ roleName: role.name
208
+ })))) {
209
+ return { scope: "task", taskId: task.id, roleName: role.name };
210
+ }
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+ function findRoleBindingReference(store, agentId) {
216
+ for (const role of store.listGlobalRoles()) {
217
+ if (Object.hasOwn(role.agentBindings, agentId)) {
218
+ return { scope: "global", roleName: role.name };
219
+ }
220
+ }
221
+ for (const task of store.listTasks()) {
222
+ for (const role of store.listRoles(task.id)) {
223
+ if (Object.hasOwn(role.agentBindings, agentId)) {
224
+ return { scope: "task", taskId: task.id, roleName: role.name };
225
+ }
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+ function findNonStoppedSessionReference(store, agentId) {
231
+ for (const set of store.listGlobalRoleSessionSets()) {
232
+ const reference = sessionReference(set, agentId, { scope: "global", roleName: set.owner.roleName });
233
+ if (reference !== null)
234
+ return reference;
235
+ }
236
+ for (const task of store.listTasks()) {
237
+ for (const set of store.listRoleSessionSets(task.id)) {
238
+ const reference = sessionReference(set, agentId, { scope: "task", taskId: task.id, roleName: set.owner.roleName });
239
+ if (reference !== null)
240
+ return reference;
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+ function sessionReference(set, agentId, reference) {
246
+ const session = set.sessions[agentId];
247
+ if (session === undefined || session.status === "stopped")
248
+ return null;
249
+ return { ...reference, status: session.status };
250
+ }
251
+ function describeReference(reference) {
252
+ return reference.scope === "global"
253
+ ? `Global Role ${reference.roleName}`
254
+ : `Task ${reference.taskId ?? "?"} Role ${reference.roleName}`;
255
+ }
113
256
  function parseAgentOptions(args, mode) {
114
257
  const valueOptions = new Map([
115
258
  ["--adapter", { repeatable: false, allowOptionLikeValue: false }],
@@ -0,0 +1,126 @@
1
+ import { usageError } from "../errors/cliError.js";
2
+ import { DEFAULT_RECONCILIATION_INTERVAL_SECONDS, reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
3
+ import { resolveTimeZone } from "../output/timePresentation.js";
4
+ import { REVIEW_TRIGGERS } from "../review/reviewConfig.js";
5
+ const CONFIG_SET_USAGE = "Config set usage: yui config set "
6
+ + "<--time-zone <IANA timezone> | "
7
+ + "--reconciliation-interval-seconds <seconds>>.";
8
+ export function runConfigCommand(args, store) {
9
+ const [command, ...rest] = args;
10
+ if (command === "review")
11
+ return runReviewConfigCommand(rest, store);
12
+ if (command === "show") {
13
+ if (rest.length !== 0)
14
+ throw usageError("Config show usage: yui config show.");
15
+ const config = store.getConfig();
16
+ const reconciliationIntervalSeconds = config.reconciliationIntervalSeconds
17
+ ?? DEFAULT_RECONCILIATION_INTERVAL_SECONDS;
18
+ reconciliationIntervalMilliseconds(reconciliationIntervalSeconds);
19
+ return [
20
+ `Time zone: ${resolveTimeZone(config.timeZone)}`,
21
+ `Reconciliation interval: ${reconciliationIntervalSeconds} seconds`,
22
+ ""
23
+ ].join("\n");
24
+ }
25
+ if (command === "set") {
26
+ if (rest.length !== 2) {
27
+ throw configSetUsageError();
28
+ }
29
+ if (rest[0] === "--time-zone") {
30
+ const timeZone = validatedConfigValue(() => resolveTimeZone(rest[1]));
31
+ store.transaction((tx) => {
32
+ tx.saveConfig({ ...tx.getConfig(), timeZone });
33
+ });
34
+ return `Time zone set to ${timeZone}\n`;
35
+ }
36
+ if (rest[0] === "--reconciliation-interval-seconds") {
37
+ const reconciliationIntervalSeconds = parseReconciliationIntervalSeconds(rest[1]);
38
+ validatedConfigValue(() => reconciliationIntervalMilliseconds(reconciliationIntervalSeconds));
39
+ store.transaction((tx) => {
40
+ tx.saveConfig({ ...tx.getConfig(), reconciliationIntervalSeconds });
41
+ });
42
+ return `Reconciliation interval set to ${reconciliationIntervalSeconds} seconds\n`;
43
+ }
44
+ throw configSetUsageError();
45
+ }
46
+ throw usageError(command === undefined
47
+ ? "Config command is required."
48
+ : `Unknown command: config ${command}`);
49
+ }
50
+ function runReviewConfigCommand(args, store) {
51
+ const [command, ...rest] = args;
52
+ if (command === "show") {
53
+ if (rest.length !== 0)
54
+ throw usageError("Config review show usage: yui config review show.");
55
+ const review = store.getConfig().review;
56
+ return review === undefined
57
+ ? "Review: disabled\n"
58
+ : `Review: ${review.roleName} (${review.trigger})\n`;
59
+ }
60
+ if (command === "clear") {
61
+ if (rest.length !== 0)
62
+ throw usageError("Config review clear usage: yui config review clear.");
63
+ store.transaction((tx) => {
64
+ const { review: _review, ...config } = tx.getConfig();
65
+ tx.saveConfig(config);
66
+ });
67
+ return "Review disabled\n";
68
+ }
69
+ if (command === "set") {
70
+ const usage = "Config review set usage: "
71
+ + "yui config review set --role <global-role> --trigger <always|leader|final>.";
72
+ if (rest.length !== 4)
73
+ throw usageError(usage);
74
+ const options = new Map();
75
+ for (let index = 0; index < rest.length; index += 2) {
76
+ const name = rest[index];
77
+ const value = rest[index + 1];
78
+ if (!["--role", "--trigger"].includes(name)
79
+ || value === undefined
80
+ || options.has(name)) {
81
+ throw usageError(usage);
82
+ }
83
+ options.set(name, value);
84
+ }
85
+ const roleName = options.get("--role")?.trim();
86
+ const rawTrigger = options.get("--trigger")?.trim();
87
+ if (roleName === undefined || roleName.length === 0
88
+ || rawTrigger === undefined
89
+ || !REVIEW_TRIGGERS.includes(rawTrigger)) {
90
+ throw usageError(usage);
91
+ }
92
+ if (store.getGlobalRole?.(roleName) === null) {
93
+ throw usageError(`Global Role not found: ${roleName}.`);
94
+ }
95
+ const trigger = rawTrigger;
96
+ store.transaction((tx) => {
97
+ tx.saveConfig({
98
+ ...tx.getConfig(),
99
+ review: { roleName, trigger }
100
+ });
101
+ });
102
+ return `Review set to ${roleName} (${trigger})\n`;
103
+ }
104
+ throw usageError(command === undefined
105
+ ? "Config review command is required."
106
+ : `Unknown command: config review ${command}`);
107
+ }
108
+ function parseReconciliationIntervalSeconds(value) {
109
+ if (!/^\d+$/.test(value))
110
+ return Number.NaN;
111
+ return Number(value);
112
+ }
113
+ function validatedConfigValue(validate) {
114
+ try {
115
+ return validate();
116
+ }
117
+ catch (error) {
118
+ if (error instanceof TypeError) {
119
+ throw usageError(error.message, CONFIG_SET_USAGE);
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ function configSetUsageError() {
125
+ return usageError(CONFIG_SET_USAGE);
126
+ }
@@ -0,0 +1,365 @@
1
+ import { usageError } from "../errors/cliError.js";
2
+ import { defaultTableWidth, renderTable } from "../output/table.js";
3
+ import { DEFAULT_RECONCILIATION_INTERVAL_SECONDS } from "../config/yuiConfig.js";
4
+ export function parseControllerStatusOptions(args) {
5
+ const allowed = new Set(["--all", "--verbose"]);
6
+ if (args.some((argument) => !allowed.has(argument))
7
+ || new Set(args).size !== args.length) {
8
+ throw usageError("Controller status usage: yui controller status [--all] [--verbose].");
9
+ }
10
+ return {
11
+ scope: args.includes("--all") ? "all" : "current",
12
+ verbose: args.includes("--verbose")
13
+ };
14
+ }
15
+ export function parseControllerCleanupOptions(args) {
16
+ if (args.some((argument) => argument !== "--all")
17
+ || new Set(args).size !== args.length) {
18
+ throw usageError("Controller cleanup usage: yui controller cleanup [--all].");
19
+ }
20
+ return { scope: args.includes("--all") ? "all" : "current" };
21
+ }
22
+ export function renderControllerResourceStatus(snapshot, verbose, width = defaultTableWidth()) {
23
+ const warnings = snapshot.warnings ?? [];
24
+ const current = snapshot.resources.find((resource) => (resource.kind === "controller"
25
+ && resource.state === "current"
26
+ && resource.yuiHome === snapshot.currentHome));
27
+ const currentPid = current?.processes[0]?.pid;
28
+ const lines = [
29
+ currentPid === undefined
30
+ ? "Controller is not running."
31
+ : `Controller is running (PID ${currentPid}).`,
32
+ "",
33
+ `Observed ${snapshot.summary.domainCount} control domain(s), ${snapshot.summary.liveProcessCount} live process(es), ${formatBytes(snapshot.summary.rssBytes)} RSS.`
34
+ ];
35
+ if (snapshot.scope === "all"
36
+ || snapshot.domains.length > 1
37
+ || snapshot.domains.some(({ domainKind }) => domainKind !== undefined)) {
38
+ const visibleDomains = selectVisibleDomains(snapshot, verbose);
39
+ lines.push("", renderTable("Control domains", [
40
+ { header: "YUI_HOME", minWidth: 16, maxWidth: 54 },
41
+ { header: "Domain", minWidth: 10, maxWidth: 18 },
42
+ { header: "Lifetime", minWidth: 10, maxWidth: 18 },
43
+ { header: "Storage", minWidth: 8, maxWidth: 13 },
44
+ { header: "Resources", minWidth: 9, maxWidth: 9 },
45
+ { header: "Processes", minWidth: 9, maxWidth: 9 },
46
+ { header: "RSS", minWidth: 8, maxWidth: 11 },
47
+ { header: "Issues", minWidth: 6, maxWidth: 6 },
48
+ { header: "Reason", minWidth: 12, maxWidth: 30 }
49
+ ], visibleDomains.map((domain) => [
50
+ domain.yuiHome,
51
+ domain.domainKind ?? "legacy",
52
+ domain.liveness === undefined
53
+ ? "—"
54
+ : `${domain.liveness}/${domain.disposition ?? "review"}`,
55
+ domain.storageStatus,
56
+ String(domain.resourceCount),
57
+ String(domain.liveProcessCount),
58
+ formatBytes(domain.rssBytes),
59
+ String(domain.issueCount),
60
+ domain.reasonCode ?? "—"
61
+ ]), width));
62
+ lines.push("", `Automatic ephemeral reap: every ${DEFAULT_RECONCILIATION_INTERVAL_SECONDS}s; `
63
+ + "only expired marked test domains with matching identities are eligible.");
64
+ if (visibleDomains.length < snapshot.domains.length) {
65
+ lines.push("", `${snapshot.domains.length - visibleDomains.length} residual-only control domain(s) `
66
+ + "are summarized below; use --verbose to expand them.");
67
+ }
68
+ }
69
+ const sessions = snapshot.resources.filter((resource) => (resource.kind === "agent-session"
70
+ && (verbose || resource.disposition === "protected")));
71
+ if (sessions.length > 0) {
72
+ lines.push("", renderTable("Agent sessions", [
73
+ { header: "Task", minWidth: 8, maxWidth: 18 },
74
+ { header: "Role", minWidth: 12, maxWidth: 22 },
75
+ { header: "Agent", minWidth: 7, maxWidth: 14 },
76
+ { header: "State", minWidth: 8, maxWidth: 12 },
77
+ { header: "PID", minWidth: 5, maxWidth: 8 },
78
+ { header: "RSS", minWidth: 8, maxWidth: 11 }
79
+ ], sessions.map((resource) => sessionRow(resource)), width));
80
+ }
81
+ const residual = snapshot.resources.filter(({ disposition }) => disposition !== "protected");
82
+ if (residual.length > 0) {
83
+ lines.push("", renderTable("Residual resources", [
84
+ { header: "Reason", minWidth: 16, maxWidth: 34 },
85
+ { header: "Count", minWidth: 5, maxWidth: 5 },
86
+ { header: "Live", minWidth: 4, maxWidth: 4 },
87
+ { header: "RSS", minWidth: 8, maxWidth: 11 },
88
+ { header: "Oldest", minWidth: 7, maxWidth: 10 },
89
+ { header: "Action", minWidth: 10, maxWidth: 12 }
90
+ ], residualSummaryRows(residual), width));
91
+ }
92
+ const liveAnomalies = residual.filter((resource) => resource.processes.length > 0)
93
+ .sort((left, right) => right.rssBytes - left.rssBytes);
94
+ const visible = verbose ? liveAnomalies : liveAnomalies.slice(0, 5);
95
+ if (visible.length > 0) {
96
+ lines.push("", renderTable(verbose ? "Live anomalous resources" : "Largest live anomalies", [
97
+ { header: "Kind", minWidth: 10, maxWidth: 18 },
98
+ { header: "Reason", minWidth: 16, maxWidth: 30 },
99
+ { header: "PID", minWidth: 5, maxWidth: 8 },
100
+ { header: "RSS", minWidth: 8, maxWidth: 11 },
101
+ { header: "Age", minWidth: 7, maxWidth: 10 },
102
+ { header: "Owner", minWidth: 12, maxWidth: 30 }
103
+ ], visible.map((resource) => [
104
+ resource.kind,
105
+ resource.reasonCode,
106
+ processLabel(resource),
107
+ formatBytes(resource.rssBytes),
108
+ formatDuration(resource.ageMs),
109
+ ownerLabel(resource.owner)
110
+ ]), width));
111
+ if (!verbose && visible.length < liveAnomalies.length) {
112
+ lines.push("", `${liveAnomalies.length - visible.length} more live anomalous resource(s); `
113
+ + "use --verbose to expand them.");
114
+ }
115
+ }
116
+ if (warnings.length > 0) {
117
+ lines.push("", "Warnings", ...warnings.slice(0, verbose ? warnings.length : 5)
118
+ .map((warning) => ` ! ${warning}`));
119
+ if (!verbose && warnings.length > 5) {
120
+ lines.push(` ! ${warnings.length - 5} more warning(s); use --verbose.`);
121
+ }
122
+ }
123
+ return lines.join("\n");
124
+ }
125
+ export async function runInteractiveControllerCleanup(input) {
126
+ if (!input.io.interactive) {
127
+ throw usageError("Controller cleanup requires an interactive terminal.");
128
+ }
129
+ const initial = await input.scan();
130
+ const safe = initial.resources.filter(({ disposition }) => disposition === "safe");
131
+ const review = initial.resources.filter(({ disposition }) => disposition === "review");
132
+ const protectedCount = initial.resources.filter(({ disposition }) => disposition === "protected").length;
133
+ const reportOnlyCount = initial.resources.filter(({ disposition }) => disposition === "report-only").length;
134
+ input.io.write(renderCleanupPlan(safe, review, protectedCount, reportOnlyCount));
135
+ const selected = [];
136
+ if (safe.length > 0 && await confirmed(input.io, `Clean ${safe.length} safe resource(s), reclaiming up to ${formatBytes(totalRss(safe))}? [y/N]: `)) {
137
+ selected.push(...safe);
138
+ }
139
+ if (review.length > 0 && await confirmed(input.io, `Review ${review.length} live resource(s) individually? [y/N]: `)) {
140
+ for (const resource of review) {
141
+ input.io.write(`${cleanupResourceLabel(resource)}\n`);
142
+ if (await confirmed(input.io, `Clean ${resource.kind} ${resource.id}? This may interrupt running work. [y/N]: `)) {
143
+ selected.push(resource);
144
+ }
145
+ }
146
+ }
147
+ if (selected.length === 0) {
148
+ return cleanupResult("No resources were selected; nothing was changed.", [], [], [], [], initial);
149
+ }
150
+ if (selected.some(({ processes }) => processes.length > 0)
151
+ && (await input.io.question(`Type cleanup to stop ${selected.filter(({ processes }) => processes.length > 0).length} `
152
+ + `live resource(s) and clean ${selected.length} selected resource(s): `))?.trim().toLowerCase() !== "cleanup") {
153
+ return cleanupResult("Cancelled; nothing was changed.", selected, [], [], [], initial);
154
+ }
155
+ const revalidated = await input.scan();
156
+ const currentById = new Map(revalidated.resources.map((resource) => [resource.id, resource]));
157
+ const ready = [];
158
+ const skipped = [];
159
+ for (const resource of selected) {
160
+ const current = currentById.get(resource.id);
161
+ if (current === undefined
162
+ || current.fingerprint !== resource.fingerprint
163
+ || (current.disposition !== "safe" && current.disposition !== "review")
164
+ || (resource.disposition === "safe" && current.disposition !== "safe")) {
165
+ skipped.push({ id: resource.id, reason: "changed-since-scan" });
166
+ continue;
167
+ }
168
+ ready.push(current);
169
+ }
170
+ const cleaned = [];
171
+ const failed = [];
172
+ for (const resource of ready) {
173
+ try {
174
+ await input.clean(resource);
175
+ cleaned.push({ id: resource.id, rssBytes: resource.rssBytes });
176
+ }
177
+ catch (error) {
178
+ failed.push({
179
+ id: resource.id,
180
+ message: error instanceof Error ? error.message : String(error)
181
+ });
182
+ }
183
+ }
184
+ const after = await input.scan();
185
+ const output = [
186
+ `Cleaned ${cleaned.length} resource(s); skipped ${skipped.length}; failed ${failed.length}.`,
187
+ `Estimated reclaimed RSS: ${formatBytes(cleaned.reduce((total, resource) => total + resource.rssBytes, 0))}.`,
188
+ `Remaining issues: ${after.resources.filter(({ disposition }) => disposition !== "protected").length}.`,
189
+ ...failed.map((failure) => `Failed ${failure.id}: ${failure.message}`)
190
+ ].join("\n");
191
+ return cleanupResult(output, selected, cleaned, skipped, failed, after);
192
+ }
193
+ function sessionRow(resource) {
194
+ const pid = resource.processes[0]?.pid;
195
+ if (resource.owner.kind === "task-role") {
196
+ return [
197
+ resource.owner.taskId,
198
+ resource.owner.roleName,
199
+ resource.owner.agentId,
200
+ resource.state,
201
+ pid === undefined ? "—" : String(pid),
202
+ formatBytes(resource.rssBytes)
203
+ ];
204
+ }
205
+ if (resource.owner.kind === "global-role") {
206
+ return [
207
+ "global",
208
+ resource.owner.roleName,
209
+ resource.owner.agentId,
210
+ resource.state,
211
+ pid === undefined ? "—" : String(pid),
212
+ formatBytes(resource.rssBytes)
213
+ ];
214
+ }
215
+ return [
216
+ "—",
217
+ resource.target ?? resource.kind,
218
+ "—",
219
+ resource.state,
220
+ pid === undefined ? "—" : String(pid),
221
+ formatBytes(resource.rssBytes)
222
+ ];
223
+ }
224
+ function selectVisibleDomains(snapshot, verbose) {
225
+ if (verbose)
226
+ return snapshot.domains;
227
+ const activeHomes = new Set([
228
+ snapshot.currentHome,
229
+ ...snapshot.resources
230
+ .filter(({ disposition }) => disposition === "protected")
231
+ .flatMap(({ yuiHome }) => yuiHome === undefined ? [] : [yuiHome])
232
+ ]);
233
+ const active = snapshot.domains.filter(({ yuiHome }) => activeHomes.has(yuiHome));
234
+ const residual = snapshot.domains
235
+ .filter(({ yuiHome }) => !activeHomes.has(yuiHome))
236
+ .sort((left, right) => right.rssBytes - left.rssBytes)
237
+ .slice(0, 5);
238
+ return [...active, ...residual];
239
+ }
240
+ function residualSummaryRows(resources) {
241
+ const groups = new Map();
242
+ for (const resource of resources) {
243
+ const entries = groups.get(resource.reasonCode) ?? [];
244
+ entries.push(resource);
245
+ groups.set(resource.reasonCode, entries);
246
+ }
247
+ return [...groups.entries()]
248
+ .sort(([left], [right]) => left.localeCompare(right))
249
+ .map(([reason, entries]) => [
250
+ reason,
251
+ String(entries.length),
252
+ String(entries.filter(({ processes }) => processes.length > 0).length),
253
+ formatBytes(entries.reduce((total, resource) => total + resource.rssBytes, 0)),
254
+ formatDuration(Math.max(0, ...entries.map(({ ageMs }) => ageMs))),
255
+ strongestDisposition(entries)
256
+ ]);
257
+ }
258
+ function strongestDisposition(resources) {
259
+ if (resources.some(({ disposition }) => disposition === "review"))
260
+ return "review";
261
+ if (resources.some(({ disposition }) => disposition === "safe"))
262
+ return "safe";
263
+ return "report-only";
264
+ }
265
+ function ownerLabel(owner) {
266
+ switch (owner.kind) {
267
+ case "controller-domain": return owner.yuiHome;
268
+ case "task-role": return `${owner.taskId}/${owner.roleName}`;
269
+ case "global-role": return `global/${owner.roleName}`;
270
+ case "none": return "unattributed";
271
+ }
272
+ }
273
+ function processLabel(resource) {
274
+ const first = resource.processes[0]?.pid;
275
+ if (first === undefined)
276
+ return "—";
277
+ return resource.processes.length === 1
278
+ ? String(first)
279
+ : `${first} +${resource.processes.length - 1}`;
280
+ }
281
+ function renderCleanupPlan(safe, review, protectedCount, reportOnlyCount) {
282
+ const domains = [...safe, ...review].reduce((counts, resource) => {
283
+ const home = resource.yuiHome ?? "unattributed";
284
+ counts.set(home, (counts.get(home) ?? 0) + 1);
285
+ return counts;
286
+ }, new Map());
287
+ return [
288
+ "Controller cleanup plan",
289
+ "",
290
+ ` Safe ${safe.length} resource(s), ${formatBytes(totalRss(safe))} RSS`,
291
+ ` Review ${review.length} resource(s), ${formatBytes(totalRss(review))} RSS`,
292
+ ` Protected ${protectedCount} resource(s)`,
293
+ ` Report only ${reportOnlyCount} resource(s)`,
294
+ ...(domains.size === 0 ? [] : [
295
+ "",
296
+ " Domains",
297
+ ...[...domains.entries()].map(([home, count]) => ` ${home}: ${count} candidate(s)`)
298
+ ]),
299
+ ""
300
+ ].join("\n");
301
+ }
302
+ function cleanupResourceLabel(resource) {
303
+ const pids = resource.processes.map(({ pid }) => pid).join(",") || "—";
304
+ const domain = resource.domain;
305
+ return [
306
+ `${resource.kind}: ${resource.reasonCode}`,
307
+ ` Home ${resource.yuiHome ?? "unattributed"}`,
308
+ ` PID ${pids}`,
309
+ ` RSS ${formatBytes(resource.rssBytes)}`,
310
+ ` Age ${formatDuration(resource.ageMs)}`,
311
+ ` Owner ${ownerLabel(resource.owner)}`,
312
+ ...(domain === undefined ? [] : [
313
+ ` Domain ${domain.kind}/${domain.liveness}/${domain.disposition}`,
314
+ ` Host ${domain.hostPid === undefined ? "—" : `${domain.hostPid}:${domain.hostProcessStartIdentity ?? "?"}`}`,
315
+ ` Cost ${formatBytes(resource.rssBytes)} RSS; tmux=${domain.tmuxTargets.length}`
316
+ ])
317
+ ].join("\n");
318
+ }
319
+ async function confirmed(io, prompt) {
320
+ const answer = (await io.question(prompt))?.trim().toLowerCase();
321
+ return answer === "y" || answer === "yes";
322
+ }
323
+ function cleanupResult(output, selected, cleaned, skipped, failed, finalSnapshot) {
324
+ return {
325
+ output,
326
+ data: {
327
+ selected: selected.map(({ id }) => id),
328
+ cleaned,
329
+ skipped,
330
+ failed,
331
+ reclaimedRssBytes: cleaned.reduce((total, resource) => total + resource.rssBytes, 0),
332
+ remainingIssues: finalSnapshot.resources.filter(({ disposition }) => disposition !== "protected").length
333
+ }
334
+ };
335
+ }
336
+ function totalRss(resources) {
337
+ return resources.reduce((total, resource) => total + resource.rssBytes, 0);
338
+ }
339
+ function formatBytes(bytes) {
340
+ if (bytes <= 0)
341
+ return "—";
342
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
343
+ let value = bytes;
344
+ let unit = 0;
345
+ while (value >= 1024 && unit < units.length - 1) {
346
+ value /= 1024;
347
+ unit += 1;
348
+ }
349
+ const digits = value >= 10 || unit === 0 ? 0 : 1;
350
+ return `${value.toFixed(digits)} ${units[unit]}`;
351
+ }
352
+ function formatDuration(milliseconds) {
353
+ if (milliseconds <= 0)
354
+ return "—";
355
+ const seconds = Math.floor(milliseconds / 1000);
356
+ if (seconds < 60)
357
+ return `${seconds}s`;
358
+ const minutes = Math.floor(seconds / 60);
359
+ if (minutes < 60)
360
+ return `${minutes}m`;
361
+ const hours = Math.floor(minutes / 60);
362
+ if (hours < 48)
363
+ return `${hours}h`;
364
+ return `${Math.floor(hours / 24)}d`;
365
+ }