@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,13 +1,53 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
- import { callController } from "../core/controllerClient.js";
3
+ import { callController, readControllerDiscovery } from "../core/controllerClient.js";
4
+ import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
5
+ import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
6
+ import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
7
+ import { hasRuntimeLifecycleWork } from "../runtime/lifecycleReservation.js";
8
+ import { assertControllerStatusIdentity } from "../runtime/exactControlPlane.js";
9
+ import { EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES } from "./domainIdentity.js";
10
+ import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
4
11
  const STARTUP_TIMEOUT_MS = 5_000;
12
+ // A lifecycle RPC may legitimately occupy the Controller for 30 seconds.
13
+ // Restart must allow that request to drain before deciding shutdown is stuck.
14
+ const SHUTDOWN_TIMEOUT_MS = 45_000;
5
15
  const POLL_INTERVAL_MS = 50;
16
+ const LIFECYCLE_REQUEST_TIMEOUT_MS = 30_000;
17
+ const ENVIRONMENT_REFRESH_TIMEOUT_MS = 500;
18
+ const CONFIGURATION_REFRESH_TIMEOUT_MS = 500;
19
+ const CONTROLLER_OPERATIONAL_ENVIRONMENT = [
20
+ ...AGENT_OPERATIONAL_ENVIRONMENT_NAMES,
21
+ "YUI_TMUX_BIN",
22
+ ...EPHEMERAL_DOMAIN_ENVIRONMENT_NAMES
23
+ ];
24
+ /**
25
+ * Direct FileTaskStore writers must not extend state while an older Controller
26
+ * is still reading the same YUI_HOME.
27
+ */
28
+ export async function assertFileTaskControllerStorageCompatible(home, options = {}) {
29
+ const call = options.call ?? callController;
30
+ let status;
31
+ try {
32
+ status = await call(home, "controller.status", {});
33
+ }
34
+ catch (error) {
35
+ if (isDefinitelyNotRunning(error))
36
+ return;
37
+ if (isUnavailable(error)) {
38
+ throw new Error("Controller compatibility could not be verified. Retry or run `yui controller restart`.", { cause: error });
39
+ }
40
+ throw error;
41
+ }
42
+ assertCompatibleControllerStatus(status);
43
+ }
6
44
  /** Starts the per-home FileTask Controller on demand and waits until callable. */
7
45
  export async function ensureFileTaskController(home, options = {}) {
8
46
  const call = options.call ?? callController;
9
47
  try {
10
- return await call(home, "controller.status", {});
48
+ const status = await call(home, "controller.status", {});
49
+ assertCompatibleControllerStatus(status, options.expectedVersion);
50
+ return status;
11
51
  }
12
52
  catch (error) {
13
53
  if (!isUnavailable(error))
@@ -16,11 +56,13 @@ export async function ensureFileTaskController(home, options = {}) {
16
56
  const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
17
57
  const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
18
58
  const spawnController = options.spawnController ?? spawnDetachedFileTaskController;
19
- spawnController(home, { ...process.env, ...options.environment, YUI_HOME: home });
59
+ spawnController(home, controllerSpawnEnvironment(home, options.environment ?? process.env));
20
60
  const deadline = Date.now() + timeoutMs;
21
61
  for (;;) {
22
62
  try {
23
- return await call(home, "controller.status", {});
63
+ const status = await call(home, "controller.status", {});
64
+ assertCompatibleControllerStatus(status, options.expectedVersion);
65
+ return status;
24
66
  }
25
67
  catch (error) {
26
68
  if (!isUnavailable(error))
@@ -32,6 +74,79 @@ export async function ensureFileTaskController(home, options = {}) {
32
74
  }
33
75
  }
34
76
  }
77
+ /** Start and await readiness for a captured Controller identity. */
78
+ export async function ensureFileTaskControllerIdentity(home, identity, options = {}) {
79
+ assertExpectedControllerLaunchIdentity(identity);
80
+ const status = await ensureFileTaskController(home, {
81
+ ...options,
82
+ expectedVersion: identity.version
83
+ });
84
+ // A same-version status is not an identity proof: another Controller binary
85
+ // may own the Home after a restart or a stale discovery race. Authenticate
86
+ // the socket owner and compare the complete launch vector before reporting
87
+ // readiness. This post-readiness check deliberately has no retry or spawn
88
+ // fallback; a mismatch/unavailable/malformed identity is fail-closed.
89
+ const call = options.call ?? callController;
90
+ const actual = await call(home, "controller.identity", {}, {
91
+ timeoutMs: options.requestTimeoutMs
92
+ });
93
+ assertControllerLaunchIdentity(actual, identity);
94
+ return status;
95
+ }
96
+ function assertExpectedControllerLaunchIdentity(identity) {
97
+ if (typeof identity.executablePath !== "string"
98
+ || identity.executablePath.length === 0
99
+ || !Array.isArray(identity.args)
100
+ || identity.args.some((arg) => typeof arg !== "string")
101
+ || typeof identity.version !== "string"
102
+ || identity.version.length === 0) {
103
+ throw new Error("Expected Controller launch identity is malformed; refusing to start or accept a Controller.");
104
+ }
105
+ }
106
+ function assertControllerLaunchIdentity(actual, expected) {
107
+ if (!isJsonRecord(actual)) {
108
+ throw new Error("Authenticated Controller launch identity is malformed; refusing to accept readiness.");
109
+ }
110
+ const actualArgs = actual.args;
111
+ if (typeof actual.executablePath !== "string"
112
+ || !Array.isArray(actualArgs)
113
+ || actualArgs.some((arg) => typeof arg !== "string")
114
+ || typeof actual.version !== "string") {
115
+ throw new Error("Authenticated Controller launch identity is malformed; refusing to accept readiness.");
116
+ }
117
+ const argsMatch = actualArgs.length === expected.args.length
118
+ && actualArgs.every((arg, index) => arg === expected.args[index]);
119
+ if (actual.executablePath !== expected.executablePath
120
+ || !argsMatch
121
+ || actual.version !== expected.version) {
122
+ throw new Error("Authenticated Controller launch identity does not match the captured executable, argv, and version; refusing readiness.");
123
+ }
124
+ }
125
+ function assertCompatibleControllerStatus(status, expectedVersion) {
126
+ const statusRecord = isJsonRecord(status) && status.running === true ? status : null;
127
+ const actual = statusRecord?.protocolVersion;
128
+ if (statusRecord === null || actual !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION) {
129
+ throw new Error(`Controller protocol is incompatible (expected ${FILE_TASK_CONTROLLER_PROTOCOL_VERSION}, found ${typeof actual === "number" ? actual : "unknown"}). `
130
+ + "Run `yui controller restart` before writing new task records.");
131
+ }
132
+ const actualVersion = statusRecord.version;
133
+ const expected = expectedVersion ?? YUI_VERSION;
134
+ const versionMismatch = expectedVersion === undefined
135
+ ? typeof actualVersion === "string" && actualVersion !== expected
136
+ : actualVersion !== expected;
137
+ if (versionMismatch) {
138
+ throw new Error(`Controller version is incompatible (expected ${expected}, found ${typeof actualVersion === "string" ? actualVersion : "unknown"}). `
139
+ + "Run `yui controller restart` before writing new task records.");
140
+ }
141
+ // Ordinary callers must authenticate the complete control-plane identity.
142
+ // An update/upgrade restore instead targets a previously captured Controller
143
+ // binary, which may predate the current storage-layout status fields; that
144
+ // path authenticates its executable, argv, and version immediately after
145
+ // readiness in ensureFileTaskControllerIdentity.
146
+ if (expectedVersion === undefined) {
147
+ assertControllerStatusIdentity(status, yuiVersionIdentity());
148
+ }
149
+ }
35
150
  function spawnDetachedFileTaskController(home, environment) {
36
151
  const child = spawn(process.execPath, [fileURLToPath(new URL("./controllerMain.js", import.meta.url))], {
37
152
  env: environment,
@@ -40,22 +155,80 @@ function spawnDetachedFileTaskController(home, environment) {
40
155
  });
41
156
  child.unref();
42
157
  }
158
+ /** Stops the per-home Controller and waits until its owned discovery is gone. */
159
+ export async function stopFileTaskController(home, options = {}) {
160
+ const call = options.call ?? callController;
161
+ const shutdownTimeoutMs = positive(options.shutdownTimeoutMs, SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
162
+ const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
163
+ const expectedPid = options.expectedPid === undefined
164
+ ? undefined
165
+ : positive(options.expectedPid, 0, "expectedPid");
166
+ const current = await readOptionalControllerStatus(home, call);
167
+ const pid = controllerPid(current);
168
+ if (expectedPid !== undefined && pid !== expectedPid) {
169
+ throw new Error(`Controller ownership changed before fenced stop (expected PID ${expectedPid}, `
170
+ + `found ${pid === undefined ? "none" : pid}).`);
171
+ }
172
+ if (!controllerRunning(current)) {
173
+ return { stopped: false, alreadyStopped: true };
174
+ }
175
+ await callFileTaskController(home, "controller.stop", expectedPid === undefined ? {} : { expectedPid }, options);
176
+ const deadline = Date.now() + shutdownTimeoutMs;
177
+ for (;;) {
178
+ if (options.call === undefined) {
179
+ const discoveryOwned = await ownedControllerDiscoveryExists(home, pid);
180
+ if (!discoveryOwned && expectedPid !== undefined) {
181
+ // A replacement owner appearing during the drain is not equivalent to
182
+ // the fenced process disappearing; fail closed instead of reporting a
183
+ // successful stop against a foreign PID.
184
+ const observed = controllerPid(await readOptionalControllerStatus(home, call));
185
+ if (observed !== undefined && observed !== expectedPid) {
186
+ throw new Error(`Controller ownership changed during fenced stop (expected PID ${expectedPid}, `
187
+ + `found ${observed}).`);
188
+ }
189
+ }
190
+ if (!discoveryOwned)
191
+ break;
192
+ }
193
+ else {
194
+ const observed = controllerPid(await readOptionalControllerStatus(home, call));
195
+ if (observed === undefined)
196
+ break;
197
+ if (expectedPid !== undefined && observed !== expectedPid) {
198
+ throw new Error(`Controller ownership changed during fenced stop (expected PID ${expectedPid}, `
199
+ + `found ${observed}).`);
200
+ }
201
+ if (observed !== pid)
202
+ break;
203
+ }
204
+ if (Date.now() >= deadline) {
205
+ throw new Error(`Controller did not stop within ${shutdownTimeoutMs} ms.`);
206
+ }
207
+ await delay(pollMs);
208
+ }
209
+ return {
210
+ stopped: true,
211
+ ...(pid === undefined ? {} : { pid })
212
+ };
213
+ }
43
214
  /** Restarts only the per-home Controller process; managed tmux sessions remain untouched. */
44
215
  export async function restartFileTaskController(home, options = {}) {
45
216
  const call = options.call ?? callController;
46
- const timeoutMs = positive(options.startupTimeoutMs, STARTUP_TIMEOUT_MS, "startupTimeoutMs");
217
+ const shutdownTimeoutMs = positive(options.shutdownTimeoutMs, SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
47
218
  const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
48
219
  const current = await readOptionalControllerStatus(home, call);
49
220
  const previousPid = controllerPid(current);
50
221
  if (controllerRunning(current)) {
51
222
  await callFileTaskController(home, "controller.stop", {}, options);
52
- const deadline = Date.now() + timeoutMs;
223
+ const deadline = Date.now() + shutdownTimeoutMs;
53
224
  for (;;) {
54
- const status = await readOptionalControllerStatus(home, call);
55
- if (!controllerRunning(status))
225
+ const stillOwned = options.call === undefined
226
+ ? await ownedControllerDiscoveryExists(home, previousPid)
227
+ : controllerPid(await readOptionalControllerStatus(home, call)) === previousPid;
228
+ if (!stillOwned)
56
229
  break;
57
230
  if (Date.now() >= deadline) {
58
- throw new Error(`Controller did not stop within ${timeoutMs} ms.`);
231
+ throw new Error(`Controller did not stop within ${shutdownTimeoutMs} ms.`);
59
232
  }
60
233
  await delay(pollMs);
61
234
  }
@@ -68,6 +241,40 @@ export async function restartFileTaskController(home, options = {}) {
68
241
  ...(pid === undefined ? {} : { pid })
69
242
  };
70
243
  }
244
+ function controllerSpawnEnvironment(home, source) {
245
+ const allowed = new Set(CONTROLLER_OPERATIONAL_ENVIRONMENT);
246
+ try {
247
+ for (const agent of openCompatibleFileTaskStore(home).listConfiguredAgents()) {
248
+ for (const name of nativeAgentEnvironmentNames(agent.adapterId))
249
+ allowed.add(name);
250
+ for (const binding of agent.environment)
251
+ allowed.add(binding.sourceName);
252
+ }
253
+ }
254
+ catch {
255
+ // The Controller remains authoritative for reporting an invalid/unavailable
256
+ // home. Environment filtering must never fall back to forwarding all names.
257
+ }
258
+ const environment = {};
259
+ for (const name of allowed) {
260
+ const value = source[name];
261
+ if (value !== undefined)
262
+ environment[name] = value;
263
+ }
264
+ environment.YUI_HOME = home;
265
+ return environment;
266
+ }
267
+ async function ownedControllerDiscoveryExists(home, previousPid) {
268
+ try {
269
+ const discovery = await readControllerDiscovery(home);
270
+ return previousPid === undefined || discovery.pid === previousPid;
271
+ }
272
+ catch (error) {
273
+ if (isUnavailable(error))
274
+ return false;
275
+ throw error;
276
+ }
277
+ }
71
278
  export async function callFileTaskController(home, method, params = {}, options = {}) {
72
279
  const call = options.call ?? callController;
73
280
  if (method === "controller.status" || method === "controller.stop") {
@@ -82,8 +289,78 @@ export async function callFileTaskController(home, method, params = {}, options
82
289
  : { stopped: false, alreadyStopped: true };
83
290
  }
84
291
  }
292
+ try {
293
+ return await call(home, method, params, {
294
+ timeoutMs: options.requestTimeoutMs
295
+ });
296
+ }
297
+ catch (error) {
298
+ if (!isUnavailable(error))
299
+ throw error;
300
+ }
85
301
  await ensureFileTaskController(home, options);
86
- return call(home, method, params);
302
+ return call(home, method, params, {
303
+ timeoutMs: options.requestTimeoutMs
304
+ });
305
+ }
306
+ /**
307
+ * Best-effort refresh for a Controller that is already running. This path
308
+ * deliberately calls the authenticated socket directly: an Agent config
309
+ * command must not start a background Controller merely to copy secrets.
310
+ */
311
+ export async function refreshRunningFileTaskControllerEnvironment(home, store, source = process.env, options = {}) {
312
+ const configuredSourceNames = new Set();
313
+ const configuredNativeNames = new Set();
314
+ for (const agent of store.listConfiguredAgents()) {
315
+ for (const name of nativeAgentEnvironmentNames(agent.adapterId)) {
316
+ configuredNativeNames.add(name);
317
+ }
318
+ for (const binding of agent.environment) {
319
+ if (!MANAGED_RUNTIME_ENVIRONMENT.has(binding.sourceName)) {
320
+ configuredSourceNames.add(binding.sourceName);
321
+ }
322
+ }
323
+ }
324
+ const sourceNames = options.sourceNames === undefined
325
+ ? [...configuredSourceNames]
326
+ : [...new Set(options.sourceNames)];
327
+ const nativeNames = options.nativeNames === undefined
328
+ ? [...configuredNativeNames]
329
+ : [...new Set(options.nativeNames)];
330
+ const sources = selectEnvironment(source, sourceNames);
331
+ const nativeSources = selectEnvironment(source, nativeNames);
332
+ try {
333
+ await (options.call ?? callController)(home, "runtime.replace-agent-environment", { sources, sourceNames, nativeSources, nativeNames }, {
334
+ timeoutMs: options.requestTimeoutMs
335
+ ?? ENVIRONMENT_REFRESH_TIMEOUT_MS
336
+ });
337
+ return { status: "refreshed" };
338
+ }
339
+ catch (error) {
340
+ return classifyRefreshFailure(error, options);
341
+ }
342
+ }
343
+ /** Reloads durable Controller settings without starting an absent Controller. */
344
+ export async function refreshRunningFileTaskControllerConfiguration(home, options = {}) {
345
+ try {
346
+ await (options.call ?? callController)(home, "scheduler.configure", {}, {
347
+ timeoutMs: options.requestTimeoutMs
348
+ ?? CONFIGURATION_REFRESH_TIMEOUT_MS
349
+ });
350
+ return { status: "refreshed" };
351
+ }
352
+ catch (error) {
353
+ return classifyRefreshFailure(error, options);
354
+ }
355
+ }
356
+ function classifyRefreshFailure(error, options) {
357
+ if (isDefinitelyNotRunning(error))
358
+ return { status: "not-running" };
359
+ options.onError?.(error);
360
+ return {
361
+ status: "failed",
362
+ message: error instanceof Error ? error.message : String(error)
363
+ };
87
364
  }
88
365
  /** Foreground command bridge. It never reads or writes Agent terminal bytes. */
89
366
  export class FileTaskWorkflowRuntime {
@@ -104,81 +381,156 @@ export class FileTaskWorkflowRuntime {
104
381
  this.clientOptions = clientOptions;
105
382
  }
106
383
  notifyStateChanged(taskId) {
107
- void this.#prepareAndScan(taskId).catch(this.clientOptions.onError ?? (() => { }));
384
+ this.notifyMailboxChanged({ kind: "task", taskId });
385
+ }
386
+ notifyMailboxChanged(target) {
387
+ void callFileTaskController(this.home, "scheduler.signal", { key: controllerMailboxKey(target) }, this.clientOptions).catch(this.clientOptions.onError ?? (() => { }));
108
388
  }
109
389
  reconcileTask(taskId) {
110
390
  void this.#prepareAndScan(taskId).catch(this.clientOptions.onError ?? (() => { }));
111
391
  }
112
- prepareTaskRoleEnter(input) {
113
- const role = this.store.getRole(input.taskId, input.roleName);
114
- if (role === null)
115
- throw new Error(`Role not found: ${input.taskId}/${input.roleName}.`);
116
- const session = this.store.getRoleSession(input.taskId, input.roleName);
117
- const mode = session?.nativeSessionId === undefined ? "new" : "resume";
118
- const planned = this.planner.plan({
392
+ async prepareTaskRoleEnter(input) {
393
+ const task = this.store.getTask(input.taskId);
394
+ if (task?.status === "active" && this.workspacePreparer !== undefined) {
395
+ await this.workspacePreparer.prepareTaskWorkspace(task.id);
396
+ }
397
+ const environment = foregroundRoleEnvironment(this.store, { scope: "task", taskId: input.taskId, roleName: input.roleName }, this.clientOptions.environment ?? process.env);
398
+ await callFileTaskController(this.home, "runtime.ensure-role-session", {
399
+ scope: "task",
119
400
  taskId: input.taskId,
120
401
  roleName: input.roleName,
121
- agentId: role.activeAgentId,
122
- adapterId: role.agentBindings[role.activeAgentId].adapterId,
123
- mode,
124
- ...(mode === "resume" ? { nativeSessionId: session.nativeSessionId } : {})
402
+ ...(environment === undefined ? {} : { environment })
403
+ }, {
404
+ ...this.clientOptions,
405
+ requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
125
406
  });
126
- this.tmux.ensureRoleWindow(input.taskId, planned.role, planned.launch);
127
- if (planned.session?.nativeSessionId !== undefined) {
128
- this.schedulerStore.recordRuntimeNativeSession({
129
- taskId: input.taskId,
130
- roleName: input.roleName,
131
- agentId: planned.session.agentId,
132
- adapterId: planned.session.adapterId,
133
- nativeSessionId: planned.session.nativeSessionId
407
+ }
408
+ async stopTaskRoleSessions(taskId, roleNames) {
409
+ const targets = [];
410
+ for (const roleName of [...new Set(roleNames)]) {
411
+ if (this.store.getActiveAgentRun(taskId, roleName) !== null) {
412
+ throw new Error(`Role has an active Run: ${taskId}/${roleName}.`);
413
+ }
414
+ const target = this.schedulerStore.enqueueRuntimeCleanup({
415
+ scope: "task",
416
+ taskId,
417
+ roleName
134
418
  });
419
+ if (target === null)
420
+ throw new Error(`Task not found: ${taskId}.`);
421
+ if (target.kind !== "role-runtime") {
422
+ throw new Error(`Role runtime cleanup target is invalid: ${taskId}/${roleName}.`);
423
+ }
424
+ targets.push(target);
425
+ }
426
+ if (targets.length === 0)
427
+ return;
428
+ await callFileTaskController(this.home, "scheduler.scan", {}, {
429
+ ...this.clientOptions,
430
+ requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
431
+ });
432
+ for (const target of targets) {
433
+ if (hasRuntimeLifecycleWork(this.store.getWorkMailbox(target))) {
434
+ throw new Error(`Role runtime did not stop: ${target.taskId}/${target.roleName}.`);
435
+ }
436
+ const session = this.store.getRoleSession(target.taskId, target.roleName);
437
+ if (session !== null && session.status !== "stopped") {
438
+ throw new Error(`Role runtime session is still active: ${target.taskId}/${target.roleName}.`);
439
+ }
440
+ }
441
+ }
442
+ async stopGlobalRoleSession(roleName) {
443
+ if (this.store.getGlobalRole(roleName) === null) {
444
+ throw new Error(`Global Role not found: ${roleName}.`);
445
+ }
446
+ const target = this.schedulerStore.enqueueRuntimeCleanup({
447
+ scope: "global",
448
+ roleName
449
+ });
450
+ if (target?.kind !== "global-role-runtime") {
451
+ throw new Error(`Global Role runtime cleanup target is invalid: ${roleName}.`);
452
+ }
453
+ await callFileTaskController(this.home, "scheduler.scan", {}, {
454
+ ...this.clientOptions,
455
+ requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
456
+ });
457
+ if (hasRuntimeLifecycleWork(this.store.getWorkMailbox(target))) {
458
+ throw new Error(`Global Role runtime did not stop: ${roleName}.`);
459
+ }
460
+ const sessions = this.store.getGlobalRoleSessionSet(roleName);
461
+ const active = sessions?.sessions[sessions.activeAgentId];
462
+ if (active !== undefined && active.status !== "stopped") {
463
+ throw new Error(`Global Role runtime session is still active: ${roleName}.`);
135
464
  }
136
465
  }
137
466
  inspectTaskRolePanes(taskId) {
138
467
  return this.tmux.inspectTaskRolePanes(taskId);
139
468
  }
140
- prepareGlobalRoleEnter(roleName, tmuxTaskId = "operator") {
141
- const role = this.store.getGlobalRole(roleName);
142
- if (role === null)
143
- throw new Error(`Global Role not found: ${roleName}.`);
144
- const sessionSet = this.store.getGlobalRoleSessionSet(roleName);
145
- const session = sessionSet?.sessions[role.activeAgentId];
146
- const binding = role.agentBindings[role.activeAgentId];
147
- const mode = session?.nativeSessionId === undefined ? "new" : "resume";
148
- const planned = this.planner.planGlobalRole({
469
+ async prepareGlobalRoleEnter(roleName) {
470
+ const environment = foregroundRoleEnvironment(this.store, { scope: "global", roleName }, this.clientOptions.environment ?? process.env);
471
+ await callFileTaskController(this.home, "runtime.ensure-role-session", {
472
+ scope: "global",
149
473
  roleName,
150
- agentId: role.activeAgentId,
151
- adapterId: binding.adapterId,
152
- mode,
153
- ...(mode === "resume" ? { nativeSessionId: session.nativeSessionId } : {})
474
+ ...(environment === undefined ? {} : { environment })
475
+ }, {
476
+ ...this.clientOptions,
477
+ requestTimeoutMs: LIFECYCLE_REQUEST_TIMEOUT_MS
154
478
  });
155
- this.tmux.ensureRoleWindow(tmuxTaskId, planned.role, planned.launch);
156
- if (planned.session?.nativeSessionId !== undefined) {
157
- this.schedulerStore.recordGlobalRuntimeNativeSession({
158
- roleName,
159
- agentId: planned.session.agentId,
160
- adapterId: planned.session.adapterId,
161
- nativeSessionId: planned.session.nativeSessionId
162
- });
479
+ if (roleName === "operator") {
480
+ await callFileTaskController(this.home, "scheduler.signal", { key: "operator" }, this.clientOptions);
163
481
  }
164
482
  }
165
483
  async #prepareAndScan(taskId) {
166
484
  const task = this.store.getTask(taskId);
167
- if (task?.repositoryId !== undefined
168
- && task.status === "active"
485
+ if (task !== null
486
+ && (task.status === "draft" || task.status === "active")
169
487
  && this.workspacePreparer !== undefined) {
170
488
  await this.workspacePreparer.prepareTaskWorkspace(taskId);
171
489
  }
172
490
  await callFileTaskController(this.home, "scheduler.scan", {}, this.clientOptions);
173
491
  }
174
492
  }
493
+ const MANAGED_RUNTIME_ENVIRONMENT = new Set(YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES);
494
+ function foregroundRoleEnvironment(store, owner, source) {
495
+ const role = owner.scope === "task"
496
+ ? store.getRole?.(owner.taskId, owner.roleName)
497
+ : store.getGlobalRole?.(owner.roleName);
498
+ if (role === null || role === undefined)
499
+ return undefined;
500
+ const agent = store.getConfiguredAgent?.(role.activeAgentId);
501
+ if (agent === null || agent === undefined)
502
+ return undefined;
503
+ const declaredSources = new Set(agent.environment.map((binding) => binding.sourceName));
504
+ for (const name of MANAGED_RUNTIME_ENVIRONMENT)
505
+ declaredSources.delete(name);
506
+ return {
507
+ ...operationalAgentEnvironment(agent.adapterId, source),
508
+ ...selectEnvironment(source, declaredSources)
509
+ };
510
+ }
511
+ function controllerMailboxKey(target) {
512
+ switch (target.kind) {
513
+ case "operator": return "operator";
514
+ case "task": return `task:${encodeURIComponent(target.taskId)}`;
515
+ case "role": return `role:${encodeURIComponent(target.taskId)}/${encodeURIComponent(target.roleName)}`;
516
+ case "role-runtime":
517
+ return `role:${encodeURIComponent(target.taskId)}/${encodeURIComponent(target.roleName)}`;
518
+ case "global-role-runtime":
519
+ return `global-role:${encodeURIComponent(target.roleName)}`;
520
+ }
521
+ }
175
522
  function isUnavailable(error) {
176
523
  if (typeof error !== "object" || error === null || !("code" in error))
177
524
  return false;
178
525
  const code = error.code;
179
- return code === "CONTROLLER_UNAVAILABLE"
180
- || code === "CONTROLLER_DISCOVERY_INVALID"
181
- || code === "INVALID_RESPONSE";
526
+ return code === "CONTROLLER_NOT_RUNNING"
527
+ || code === "CONTROLLER_UNAVAILABLE";
528
+ }
529
+ function isDefinitelyNotRunning(error) {
530
+ if (typeof error !== "object" || error === null || !("code" in error))
531
+ return false;
532
+ const code = error.code;
533
+ return code === "CONTROLLER_NOT_RUNNING";
182
534
  }
183
535
  async function readOptionalControllerStatus(home, call) {
184
536
  try {
@@ -0,0 +1,108 @@
1
+ import { callController } from "../core/controllerClient.js";
2
+ import { runtimeLifecycleSignalKey } from "../runtime/lifecycleReservation.js";
3
+ import { FileRuntimeEventInbox, MAX_RUNTIME_EVENT_FILE_BYTES } from "./runtimeEventInbox.js";
4
+ import { resolveProviderHookRunFence } from "./providerHookRunFence.js";
5
+ /**
6
+ * Hidden CLI entrypoint used by the managed Codex lifecycle hooks. Codex 0.145
7
+ * fires SessionStart and UserPromptSubmit inside run_turn(input); this parses
8
+ * by hook_event_name and writes one immutable runtime-inbox event per fact.
9
+ * SessionStart maps (via the adapter) to provider-session-started only — never
10
+ * pre-input readiness; UserPromptSubmit is the exact provider-accepted fence.
11
+ * The durable write is authoritative; the socket call is only a wake-up hint.
12
+ */
13
+ export async function runCodexLifecycleHookCommand(stdinJson, environment = process.env, call = callController) {
14
+ const home = requireIdentity(environment.YUI_HOME, "YUI_HOME");
15
+ const inbox = new FileRuntimeEventInbox(home);
16
+ const envelope = parseCodexHookEnvelope(stdinJson, environment);
17
+ if (envelope.kind === "session-start") {
18
+ inbox.enqueueSessionLifecycle({
19
+ scope: "task",
20
+ taskId: envelope.taskId,
21
+ roleName: envelope.roleName,
22
+ agentId: envelope.agentId,
23
+ adapterId: "codex",
24
+ launchId: envelope.launchId,
25
+ nativeSessionId: envelope.nativeSessionId,
26
+ runId: envelope.runId
27
+ // Codex SessionStart arrives within the first turn, not before input.
28
+ });
29
+ }
30
+ else {
31
+ inbox.enqueuePromptAccepted({
32
+ scope: "task",
33
+ taskId: envelope.taskId,
34
+ roleName: envelope.roleName,
35
+ agentId: envelope.agentId,
36
+ adapterId: "codex",
37
+ launchId: envelope.launchId,
38
+ nativeSessionId: envelope.nativeSessionId,
39
+ runId: envelope.runId,
40
+ receiptId: envelope.receiptId
41
+ });
42
+ }
43
+ await call(home, "scheduler.signal", {
44
+ key: runtimeLifecycleSignalKey({
45
+ scope: "task",
46
+ taskId: envelope.taskId,
47
+ roleName: envelope.roleName
48
+ })
49
+ }, { timeoutMs: 100 }).catch(() => { });
50
+ }
51
+ /**
52
+ * Parses a Codex hook payload by hook_event_name into the exact fenced envelope.
53
+ * Every event requires the managed launch envelope, and the payload session id
54
+ * must match YUI_NATIVE_SESSION_ID, so a mismatched generation fails closed
55
+ * before any inbox write.
56
+ */
57
+ export function parseCodexHookEnvelope(stdinJson, environment) {
58
+ const payload = parseObject(stdinJson);
59
+ if (payload.hook_event_name !== "SessionStart"
60
+ && payload.hook_event_name !== "UserPromptSubmit") {
61
+ throw new Error("Managed Codex lifecycle ingestion received an unsupported hook event.");
62
+ }
63
+ const base = parseCodexEnvelope(payload, environment);
64
+ switch (payload.hook_event_name) {
65
+ case "SessionStart":
66
+ return { ...base, kind: "session-start" };
67
+ case "UserPromptSubmit":
68
+ return {
69
+ ...base,
70
+ kind: "prompt-submit",
71
+ receiptId: requireIdentity(base.receiptId, "Codex transport receipt id")
72
+ };
73
+ default:
74
+ throw new Error("Managed Codex lifecycle ingestion received an unsupported hook event.");
75
+ }
76
+ }
77
+ function parseCodexEnvelope(payload, environment) {
78
+ const nativeSessionId = requireIdentity(payload.session_id, "Codex session id");
79
+ return resolveProviderHookRunFence(environment, "codex", nativeSessionId);
80
+ }
81
+ function parseObject(value) {
82
+ if (value === undefined
83
+ || Buffer.byteLength(value, "utf8") > MAX_RUNTIME_EVENT_FILE_BYTES) {
84
+ throw new Error("Codex lifecycle hook stdin JSON is invalid.");
85
+ }
86
+ try {
87
+ const parsed = JSON.parse(value);
88
+ if (!isObject(parsed))
89
+ throw new Error("shape");
90
+ return parsed;
91
+ }
92
+ catch {
93
+ throw new Error("Codex lifecycle hook stdin JSON is invalid.");
94
+ }
95
+ }
96
+ function requireIdentity(value, label) {
97
+ if (typeof value !== "string" || value.includes("\0")) {
98
+ throw new Error(`${label} is required.`);
99
+ }
100
+ const normalized = value.trim();
101
+ if (normalized.length === 0 || normalized.length > 1_024) {
102
+ throw new Error(`${label} is invalid.`);
103
+ }
104
+ return normalized;
105
+ }
106
+ function isObject(value) {
107
+ return typeof value === "object" && value !== null && !Array.isArray(value);
108
+ }