@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,10 +1,14 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { resolve } from "node:path";
3
3
  import { runtimeError } from "../errors/cliError.js";
4
+ import { usableInteractiveTerminal } from "../output/terminal.js";
4
5
  import { handoffTerminal } from "./terminalHandoff.js";
5
6
  import { CommandExecutionError } from "./commandExecutor.js";
6
7
  const DEFAULT_READINESS_TIMEOUT_MS = 15_000;
7
8
  const DEFAULT_READINESS_POLL_MS = 50;
9
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
10
+ const DEFAULT_HISTORY_LIMIT = 100_000;
11
+ const PANE_STATE_MARKER = "__YUI_PANE_STATE__";
8
12
  export class TmuxReadinessTimeoutError extends Error {
9
13
  taskId;
10
14
  roleName;
@@ -32,12 +36,17 @@ export class TmuxManager {
32
36
  tmuxBin;
33
37
  executor;
34
38
  #yuiHome;
39
+ #serverName;
40
+ #onRoleTargetRecorded;
35
41
  #terminalInput;
42
+ #terminalType;
36
43
  #closeInteractiveInput;
37
44
  #readinessTimeoutMs;
38
45
  #readinessPollMs;
39
46
  #initialColumns;
40
47
  #initialRows;
48
+ #historyLimit;
49
+ #onWarning;
41
50
  #now;
42
51
  #sleep;
43
52
  constructor(tmuxBin, executor, yuiHomeOrOptions = {}) {
@@ -47,12 +56,17 @@ export class TmuxManager {
47
56
  ? { yuiHome: yuiHomeOrOptions }
48
57
  : yuiHomeOrOptions;
49
58
  this.#yuiHome = options.yuiHome ?? process.env.YUI_HOME ?? process.cwd();
59
+ this.#serverName = yuiTmuxServerName(this.#yuiHome);
60
+ this.#onRoleTargetRecorded = options.onRoleTargetRecorded;
50
61
  this.#terminalInput = options.terminalInput ?? process.stdin;
62
+ this.#terminalType = usableInteractiveTerminal(options.terminalType ?? process.env.TERM);
51
63
  this.#closeInteractiveInput = options.closeInteractiveInput ?? (() => { });
52
64
  this.#readinessTimeoutMs = positiveInteger(options.readinessTimeoutMs, DEFAULT_READINESS_TIMEOUT_MS, "readiness timeout");
53
65
  this.#readinessPollMs = positiveInteger(options.readinessPollMs, DEFAULT_READINESS_POLL_MS, "readiness poll interval");
54
66
  this.#initialColumns = positiveInteger(options.initialColumns, 120, "initial tmux columns");
55
67
  this.#initialRows = positiveInteger(options.initialRows, 40, "initial tmux rows");
68
+ this.#historyLimit = positiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT, "tmux history limit");
69
+ this.#onWarning = options.onWarning;
56
70
  this.#now = options.now ?? Date.now;
57
71
  this.#sleep = options.sleep ?? blockingSleep;
58
72
  }
@@ -61,29 +75,83 @@ export class TmuxManager {
61
75
  this.attachRole(taskId, role.name);
62
76
  }
63
77
  ensureRoleWindow(taskId, role, launch) {
64
- if (this.windowNames(taskId).includes(role.name))
78
+ if (this.windowNames(taskId).includes(role.name)) {
79
+ this.recordRoleTarget(taskId, role.name);
80
+ this.configureServerHistory();
65
81
  return false;
82
+ }
66
83
  if (launch === undefined) {
67
84
  throw runtimeError(`Agent launch plan is required to create Role window: ${role.name}.`);
68
85
  }
86
+ // Persist the exact target before any tmux mutation. If the recorder
87
+ // cannot prove the current ephemeral identity/token, do not create a
88
+ // pane that a later reaper could not fence.
89
+ this.recordRoleTarget(taskId, role.name);
69
90
  if (!this.hasSession(taskId)) {
70
- this.executor.run(this.tmuxBin, [
91
+ this.run([
92
+ "start-server",
93
+ ";",
94
+ "set-option", "-g", "history-limit", String(this.#historyLimit),
95
+ ";",
71
96
  "new-session", "-d",
72
97
  "-x", String(this.#initialColumns),
73
98
  "-y", String(this.#initialRows),
74
99
  "-s", this.sessionName(taskId),
75
100
  "-n", role.name,
76
- "-c", safeValue(role.workspace, "Role workspace"),
101
+ "-c", safeValue(role.cwd ?? role.workspace, "Role cwd"),
77
102
  "--",
78
103
  ...launchCommand(launch)
79
104
  ]);
80
105
  }
81
106
  else {
82
- this.executor.run(this.tmuxBin, [
107
+ this.configureServerHistory();
108
+ this.run([
83
109
  "new-window",
84
110
  "-t", this.sessionName(taskId),
85
111
  "-n", role.name,
86
- "-c", safeValue(role.workspace, "Role workspace"),
112
+ "-c", safeValue(role.cwd ?? role.workspace, "Role cwd"),
113
+ "--",
114
+ ...launchCommand(launch)
115
+ ]);
116
+ }
117
+ return true;
118
+ }
119
+ async ensureRoleWindowAsync(taskId, role, launch) {
120
+ const snapshot = await this.sessionWindowNamesAsync(taskId);
121
+ if (snapshot.names.includes(role.name)) {
122
+ this.recordRoleTarget(taskId, role.name);
123
+ await this.configureServerHistoryAsync();
124
+ return false;
125
+ }
126
+ if (launch === undefined) {
127
+ throw runtimeError(`Agent launch plan is required to create Role window: ${role.name}.`);
128
+ }
129
+ // Keep the async launch ordering identical to the sync path: a recorder
130
+ // failure is a precondition failure, never a post-launch cleanup hint.
131
+ this.recordRoleTarget(taskId, role.name);
132
+ if (!snapshot.exists) {
133
+ await this.runAsync([
134
+ "start-server",
135
+ ";",
136
+ "set-option", "-g", "history-limit", String(this.#historyLimit),
137
+ ";",
138
+ "new-session", "-d",
139
+ "-x", String(this.#initialColumns),
140
+ "-y", String(this.#initialRows),
141
+ "-s", this.sessionName(taskId),
142
+ "-n", role.name,
143
+ "-c", safeValue(role.cwd ?? role.workspace, "Role cwd"),
144
+ "--",
145
+ ...launchCommand(launch)
146
+ ]);
147
+ }
148
+ else {
149
+ await this.configureServerHistoryAsync();
150
+ await this.runAsync([
151
+ "new-window",
152
+ "-t", this.sessionName(taskId),
153
+ "-n", role.name,
154
+ "-c", safeValue(role.cwd ?? role.workspace, "Role cwd"),
87
155
  "--",
88
156
  ...launchCommand(launch)
89
157
  ]);
@@ -91,13 +159,123 @@ export class TmuxManager {
91
159
  return true;
92
160
  }
93
161
  attachRole(taskId, roleName) {
94
- handoffTerminal(this.#terminalInput, this.#closeInteractiveInput);
95
- this.executor.run(this.tmuxBin, [
96
- "attach-session", "-t", this.target(taskId, roleName)
97
- ], { inheritStdio: true });
162
+ let historyNotice;
163
+ if (this.#onWarning !== undefined) {
164
+ const history = this.inspectRoleHistory(taskId, roleName);
165
+ if (history.limited) {
166
+ historyNotice = roleHistoryWarning(history);
167
+ this.#onWarning(historyNotice);
168
+ }
169
+ }
170
+ const readOnly = this.hasWritableClient(taskId);
171
+ const clientSession = this.createInteractiveClientSession(taskId, historyNotice);
172
+ try {
173
+ handoffTerminal(this.#terminalInput, this.#closeInteractiveInput);
174
+ this.run([
175
+ "attach-session",
176
+ ...(readOnly ? ["-r"] : []),
177
+ "-t", `${clientSession}:${safeValue(roleName, "Role name")}`
178
+ ], {
179
+ inheritStdio: true,
180
+ environment: { TERM: this.#terminalType }
181
+ });
182
+ }
183
+ finally {
184
+ this.destroyInteractiveClientSession(clientSession);
185
+ }
186
+ }
187
+ /**
188
+ * A grouped session shares the Agent windows while keeping the selected
189
+ * window and terminal options local to this one human client.
190
+ */
191
+ createInteractiveClientSession(taskId, attachedNotice) {
192
+ const clientSession = `yui-client-${randomBytes(12).toString("hex")}`;
193
+ this.run([
194
+ "new-session", "-d",
195
+ "-t", this.sessionName(taskId),
196
+ "-s", clientSession
197
+ ]);
198
+ try {
199
+ // Let tmux use the outer terminal's native alternate screen. Mouse mode
200
+ // keeps scrollback inside the long-lived Role pane instead of mixing it
201
+ // with terminal output that predates this attach.
202
+ this.run(["set-option", "-t", clientSession, "status", "off"]);
203
+ this.run(["set-option", "-t", clientSession, "mouse", "on"]);
204
+ this.run([
205
+ "set-hook", "-t", clientSession, "client-detached",
206
+ `kill-session -t ${clientSession}`
207
+ ]);
208
+ if (attachedNotice !== undefined) {
209
+ this.run([
210
+ "set-hook", "-t", clientSession, "client-attached",
211
+ `display-message -d 10000 ${tmuxWord(attachedNotice)}`
212
+ ]);
213
+ }
214
+ return clientSession;
215
+ }
216
+ catch (error) {
217
+ this.destroyInteractiveClientSession(clientSession);
218
+ throw error;
219
+ }
220
+ }
221
+ destroyInteractiveClientSession(clientSession) {
222
+ try {
223
+ this.run(["kill-session", "-t", safeValue(clientSession, "tmux client session")]);
224
+ }
225
+ catch (error) {
226
+ if (!isExplicitlyAbsentTmuxSession(error))
227
+ throw error;
228
+ }
229
+ }
230
+ inspectRoleHistory(taskId, roleName) {
231
+ const output = this.run([
232
+ "display-message", "-p",
233
+ "-t", this.target(taskId, roleName),
234
+ "#{history_limit}"
235
+ ]).trim();
236
+ const actual = Number(output);
237
+ if (!Number.isSafeInteger(actual) || actual <= 0) {
238
+ throw runtimeError(`Tmux returned an invalid history limit for ${taskId}/${roleName}.`);
239
+ }
240
+ return {
241
+ actual,
242
+ configured: this.#historyLimit,
243
+ limited: actual < this.#historyLimit
244
+ };
245
+ }
246
+ /** Multiple viewers are safe, but only the first attached human may type. */
247
+ hasWritableClient(taskId) {
248
+ const formatSeparator = "\u001f";
249
+ const encodedSeparator = "\\037";
250
+ const taskSession = this.sessionName(taskId);
251
+ try {
252
+ const clients = this.run([
253
+ "list-clients",
254
+ "-F",
255
+ `#{session_name}${formatSeparator}#{session_group}${formatSeparator}#{client_readonly}`
256
+ ]);
257
+ return clients.split("\n").some((line) => {
258
+ if (line.length === 0)
259
+ return false;
260
+ const separator = line.includes(encodedSeparator) ? encodedSeparator : formatSeparator;
261
+ const [sessionName, sessionGroup, readOnly, ...extra] = line.split(separator);
262
+ if (sessionName === undefined
263
+ || sessionGroup === undefined
264
+ || readOnly === undefined
265
+ || extra.length > 0) {
266
+ throw runtimeError("Tmux returned an invalid client state row.");
267
+ }
268
+ return (sessionName === taskSession || sessionGroup === taskSession) && readOnly === "0";
269
+ });
270
+ }
271
+ catch (error) {
272
+ if (isExplicitlyAbsentTmuxSession(error))
273
+ return false;
274
+ throw error;
275
+ }
98
276
  }
99
277
  captureRole(taskId, roleName, lines = 80) {
100
- return this.executor.run(this.tmuxBin, [
278
+ return this.run([
101
279
  "capture-pane", "-p", "-t", this.target(taskId, roleName), "-S", `-${lines}`
102
280
  ]);
103
281
  }
@@ -131,7 +309,7 @@ export class TmuxManager {
131
309
  }
132
310
  inspectPane(taskId, roleName) {
133
311
  const target = this.target(taskId, roleName);
134
- const output = this.executor.run(this.tmuxBin, [
312
+ const output = this.run([
135
313
  "display-message", "-p", "-t", target,
136
314
  "#{pane_dead}|#{pane_pid}|#{pane_current_command}"
137
315
  ]).trim();
@@ -144,27 +322,84 @@ export class TmuxManager {
144
322
  const pidText = output.slice(separator + 1, secondSeparator);
145
323
  const currentCommand = output.slice(secondSeparator + 1);
146
324
  const pid = Number(pidText);
147
- const content = this.executor.run(this.tmuxBin, [
148
- "capture-pane", "-p", "-t", target, "-S", "-40"
149
- ]);
150
325
  return {
151
326
  taskId,
152
327
  roleName,
153
328
  target,
154
329
  dead: dead === "1",
155
330
  ...(Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}),
156
- currentCommand,
157
- content
331
+ currentCommand
332
+ };
333
+ }
334
+ async inspectPaneAsync(taskId, roleName) {
335
+ return (await this.inspectDeliveryPaneAsync(taskId, roleName)).pane;
336
+ }
337
+ /** Reads receipt and pane identity through one tmux client without terminal output. */
338
+ async inspectDeliveryPaneAsync(taskId, roleName, receiptId) {
339
+ const target = this.target(taskId, roleName);
340
+ const receiptOption = receiptId === undefined
341
+ ? undefined
342
+ : deliveryReceiptOption(receiptId);
343
+ const receiptFormat = receiptOption === undefined ? "" : `#{${receiptOption}}`;
344
+ const output = await this.runAsync([
345
+ "display-message", "-p", "-t", target,
346
+ [
347
+ PANE_STATE_MARKER,
348
+ "#{pane_dead}",
349
+ "#{pane_pid}",
350
+ "#{cursor_x}",
351
+ "#{cursor_y}",
352
+ "#{history_size}",
353
+ "#{pane_current_command}",
354
+ receiptFormat
355
+ ].join("|")
356
+ ]);
357
+ const state = output.trimEnd().split("|");
358
+ if (state.length !== 8 || state[0] !== PANE_STATE_MARKER) {
359
+ throw runtimeError(`Tmux returned an invalid pane state for ${roleName}.`);
360
+ }
361
+ const [, deadText, pidText, cursorXText, cursorYText, historySizeText, currentCommand, receiptText] = state;
362
+ if ((deadText !== "0" && deadText !== "1")
363
+ || currentCommand === undefined
364
+ || receiptText === undefined
365
+ || (receiptText !== "" && receiptText !== "1")) {
366
+ throw runtimeError(`Tmux returned an invalid pane state for ${roleName}.`);
367
+ }
368
+ const pid = positiveNumber(pidText);
369
+ const cursorX = nonNegativeNumber(cursorXText);
370
+ const cursorY = nonNegativeNumber(cursorYText);
371
+ const historySize = nonNegativeNumber(historySizeText);
372
+ if (cursorX === undefined || cursorY === undefined || historySize === undefined) {
373
+ throw runtimeError(`Tmux returned an invalid pane state for ${roleName}.`);
374
+ }
375
+ return {
376
+ pane: {
377
+ taskId,
378
+ roleName,
379
+ target,
380
+ dead: deadText === "1",
381
+ ...(pid === undefined ? {} : { pid }),
382
+ currentCommand,
383
+ cursorX,
384
+ cursorY,
385
+ historySize
386
+ },
387
+ receiptPresent: receiptText === "1"
158
388
  };
159
389
  }
160
390
  /** Reads every Role pane in one tmux call without capturing terminal output. */
161
391
  inspectTaskRolePanes(taskId) {
162
- const separator = "\u001f";
392
+ const formatSeparator = "\u001f";
393
+ // tmux escapes control bytes in formatted output using backslash-octal
394
+ // notation, so an argv separator of 0x1f is returned as the four
395
+ // printable characters "\\037". Accept the raw form as well for test
396
+ // executors and older implementations that do not escape it.
397
+ const encodedSeparator = "\\037";
163
398
  let output;
164
399
  try {
165
- output = this.executor.run(this.tmuxBin, [
400
+ output = this.run([
166
401
  "list-panes", "-s", "-t", this.sessionName(taskId), "-F",
167
- `#{window_name}${separator}#{pane_dead}${separator}#{pane_pid}${separator}#{pane_current_command}`
402
+ `#{window_name}${formatSeparator}#{pane_dead}${formatSeparator}#{pane_pid}${formatSeparator}#{pane_current_command}`
168
403
  ]);
169
404
  }
170
405
  catch (error) {
@@ -175,6 +410,7 @@ export class TmuxManager {
175
410
  return output.split("\n").flatMap((line) => {
176
411
  if (line.length === 0)
177
412
  return [];
413
+ const separator = line.includes(encodedSeparator) ? encodedSeparator : formatSeparator;
178
414
  const [roleName, deadText, pidText, currentCommand, ...extra] = line.split(separator);
179
415
  if (roleName === undefined
180
416
  || deadText === undefined
@@ -195,6 +431,88 @@ export class TmuxManager {
195
431
  }];
196
432
  });
197
433
  }
434
+ /**
435
+ * Reads the Role pane inventory for this YUI_HOME in one tmux server call.
436
+ * Terminal contents are deliberately excluded; recovery can inspect process
437
+ * identity later for the small set of suspicious panes.
438
+ */
439
+ inspectRolePaneInventory() {
440
+ const formatSeparator = "\u001f";
441
+ const encodedSeparator = "\\037";
442
+ const sessionPrefix = yuiTmuxSessionPrefix(this.#yuiHome);
443
+ let output;
444
+ try {
445
+ output = this.run([
446
+ "list-panes", "-a", "-F",
447
+ [
448
+ "#{session_name}",
449
+ "#{window_name}",
450
+ "#{pane_dead}",
451
+ "#{pane_pid}",
452
+ "#{pane_current_command}"
453
+ ].join(formatSeparator)
454
+ ]);
455
+ }
456
+ catch (error) {
457
+ if (isExplicitlyAbsentTmuxSession(error))
458
+ return [];
459
+ throw error;
460
+ }
461
+ return output.split("\n").flatMap((line) => {
462
+ if (line.length === 0)
463
+ return [];
464
+ const separator = line.includes(encodedSeparator) ? encodedSeparator : formatSeparator;
465
+ const [sessionName, roleName, deadText, pidText, currentCommand, ...extra] = line.split(separator);
466
+ if (sessionName === undefined
467
+ || roleName === undefined
468
+ || deadText === undefined
469
+ || pidText === undefined
470
+ || currentCommand === undefined
471
+ || extra.length > 0
472
+ || (deadText !== "0" && deadText !== "1")) {
473
+ throw runtimeError("Tmux returned an invalid Role pane inventory row.");
474
+ }
475
+ if (!sessionName.startsWith(sessionPrefix))
476
+ return [];
477
+ const taskId = sessionName.slice(sessionPrefix.length);
478
+ if (taskId.length === 0 || roleName.length === 0) {
479
+ throw runtimeError("Tmux returned an invalid Yui Role pane identity.");
480
+ }
481
+ const pid = Number(pidText);
482
+ return [{
483
+ taskId,
484
+ roleName,
485
+ target: this.target(taskId, roleName),
486
+ dead: deadText === "1",
487
+ ...(Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}),
488
+ currentCommand
489
+ }];
490
+ });
491
+ }
492
+ async inspectRolePaneInventoryAsync() {
493
+ const formatSeparator = "\u001f";
494
+ const encodedSeparator = "\\037";
495
+ const sessionPrefix = yuiTmuxSessionPrefix(this.#yuiHome);
496
+ let output;
497
+ try {
498
+ output = await this.runAsync([
499
+ "list-panes", "-a", "-F",
500
+ [
501
+ "#{session_name}",
502
+ "#{window_name}",
503
+ "#{pane_dead}",
504
+ "#{pane_pid}",
505
+ "#{pane_current_command}"
506
+ ].join(formatSeparator)
507
+ ]);
508
+ }
509
+ catch (error) {
510
+ if (isExplicitlyAbsentTmuxSession(error))
511
+ return [];
512
+ throw error;
513
+ }
514
+ return parseRolePaneInventory(output, formatSeparator, encodedSeparator, sessionPrefix, (taskId, roleName) => this.target(taskId, roleName));
515
+ }
198
516
  /**
199
517
  * The receipt test, receipt write, literal input and Enter all run through a
200
518
  * single tmux server command queue. Retrying the same receipt cannot type the
@@ -207,8 +525,8 @@ export class TmuxManager {
207
525
  safeValue(receiptId, "tmux delivery receipt id");
208
526
  safeValue(input, "tmux input");
209
527
  // A delivered run is normally still busy on the next Controller scan. The
210
- // pane receipt is authoritative for that retry, so do not wait for the
211
- // composer merely to discover that the input was already sent.
528
+ // pane receipt is authoritative for that retry, so return before another
529
+ // process-readiness check when the input was already sent.
212
530
  if (this.hasDeliveryReceipt(taskId, roleName, receiptId)) {
213
531
  return "already-sent";
214
532
  }
@@ -224,24 +542,111 @@ export class TmuxManager {
224
542
  const target = this.target(taskId, roleName);
225
543
  const digest = createHash("sha256").update(receiptId).digest("hex");
226
544
  const option = `@yui_delivery_${digest}`;
545
+ const buffer = `yui_delivery_${digest}`;
227
546
  const sentMarker = `__YUI_DELIVERY_SENT_${digest}__`;
228
547
  const presentMarker = `__YUI_DELIVERY_PRESENT_${digest}__`;
548
+ const alreadyApplied = [
549
+ `delete-buffer -b ${buffer}`,
550
+ `display-message -p ${presentMarker}`
551
+ ].join(" ; ");
229
552
  const apply = [
553
+ // One bracketed paste preserves literal and multiline input without
554
+ // expanding it into a long sequence of tmux key events.
555
+ `paste-buffer -dpr -b ${buffer} -t ${tmuxWord(target)}`,
556
+ // Full-screen clients can finish handling the bracketed paste after tmux
557
+ // has already queued the next key. Let the client settle before Enter so
558
+ // the receipt proves that a submission key followed the completed paste.
559
+ `run-shell ${tmuxWord("sleep 0.05")}`,
560
+ `send-keys -t ${tmuxWord(target)} Enter`,
561
+ // A receipt is proof that Enter was accepted, never merely that an
562
+ // attempt began.
230
563
  `set-option -w -t ${tmuxWord(target)} ${option} 1`,
231
- `send-keys -l -t ${tmuxWord(target)} -- ${tmuxWord(input)}`,
564
+ `display-message -p ${sentMarker}`
565
+ ].join(" ; ");
566
+ try {
567
+ const output = this.run([
568
+ "set-buffer", "-b", buffer, "--", input,
569
+ ";",
570
+ "if-shell", "-t", target, "-F", `#{==:#{${option}},1}`,
571
+ alreadyApplied,
572
+ apply
573
+ ]).trim();
574
+ if (output === sentMarker)
575
+ return "sent";
576
+ if (output === presentMarker)
577
+ return "already-sent";
578
+ throw runtimeError(`Tmux did not confirm delivery receipt ${receiptId}.`);
579
+ }
580
+ catch (error) {
581
+ try {
582
+ this.run(["delete-buffer", "-b", buffer]);
583
+ }
584
+ catch {
585
+ // The apply branch normally consumed it with paste-buffer -d.
586
+ }
587
+ throw error;
588
+ }
589
+ }
590
+ async sendReadyRoleInputOnceAsync(taskId, roleName, receiptId, input, expectedPane) {
591
+ safeValue(receiptId, "tmux delivery receipt id");
592
+ safeValue(input, "tmux input");
593
+ const target = this.target(taskId, roleName);
594
+ const digest = createHash("sha256").update(receiptId).digest("hex");
595
+ const option = deliveryReceiptOption(receiptId);
596
+ const buffer = `yui_delivery_${digest}`;
597
+ const sentMarker = `__YUI_DELIVERY_SENT_${digest}__`;
598
+ const presentMarker = `__YUI_DELIVERY_PRESENT_${digest}__`;
599
+ const notReadyMarker = `__YUI_DELIVERY_NOT_READY_${digest}__`;
600
+ const alreadyApplied = [
601
+ `delete-buffer -b ${buffer}`,
602
+ `display-message -p ${presentMarker}`
603
+ ].join(" ; ");
604
+ const apply = [
605
+ `paste-buffer -dpr -b ${buffer} -t ${tmuxWord(target)}`,
606
+ `run-shell ${tmuxWord("sleep 0.05")}`,
232
607
  `send-keys -t ${tmuxWord(target)} Enter`,
608
+ `set-option -w -t ${tmuxWord(target)} ${option} 1`,
233
609
  `display-message -p ${sentMarker}`
234
610
  ].join(" ; ");
235
- const output = this.executor.run(this.tmuxBin, [
236
- "if-shell", "-t", target, "-F", `#{==:#{${option}},1}`,
237
- `display-message -p ${presentMarker}`,
238
- apply
239
- ]).trim();
240
- if (output === sentMarker)
241
- return "sent";
242
- if (output === presentMarker)
243
- return "already-sent";
244
- throw runtimeError(`Tmux did not confirm delivery receipt ${receiptId}.`);
611
+ const guard = expectedPane === undefined ? undefined : deliveryPaneGuard(expectedPane);
612
+ const applyIfUnchanged = guard === undefined
613
+ ? apply
614
+ : [
615
+ "if-shell -t",
616
+ tmuxWord(target),
617
+ "-F",
618
+ tmuxWord(guard),
619
+ tmuxWord(apply),
620
+ tmuxWord([
621
+ `delete-buffer -b ${buffer}`,
622
+ `display-message -p ${notReadyMarker}`
623
+ ].join(" ; "))
624
+ ].join(" ");
625
+ try {
626
+ const output = (await this.runAsync([
627
+ "set-buffer", "-b", buffer, "--", input,
628
+ ";",
629
+ "if-shell", "-t", target, "-F", `#{==:#{${option}},1}`,
630
+ alreadyApplied,
631
+ applyIfUnchanged
632
+ ])).trim();
633
+ if (output === sentMarker)
634
+ return "sent";
635
+ if (output === presentMarker)
636
+ return "already-sent";
637
+ if (output === notReadyMarker)
638
+ return "not-ready";
639
+ throw runtimeError(`Tmux did not confirm delivery receipt ${receiptId}.`);
640
+ }
641
+ catch (error) {
642
+ try {
643
+ await this.runAsync(["delete-buffer", "-b", buffer]);
644
+ }
645
+ catch {
646
+ // The apply branch normally consumed it with paste-buffer -d.
647
+ }
648
+ throw error;
649
+ }
245
650
  }
246
651
  sendRoleInputOnceIfReady(taskId, roleName, receiptId, input, readinessProbe) {
247
652
  if (readinessProbe === undefined) {
@@ -254,16 +659,42 @@ export class TmuxManager {
254
659
  return "not-ready";
255
660
  return this.sendReadyRoleInputOnce(taskId, roleName, receiptId, input);
256
661
  }
662
+ async sendRoleInputOnceIfReadyAsync(taskId, roleName, receiptId, input, readinessProbe) {
663
+ if (readinessProbe === undefined) {
664
+ throw new TmuxReadinessProbeRequiredError();
665
+ }
666
+ let inspected;
667
+ try {
668
+ inspected = await this.inspectDeliveryPaneAsync(taskId, roleName, receiptId);
669
+ }
670
+ catch (error) {
671
+ if (isExplicitlyAbsentTmuxSession(error))
672
+ return "unavailable";
673
+ throw error;
674
+ }
675
+ if (inspected.receiptPresent)
676
+ return "already-sent";
677
+ if (!readinessProbe(inspected.pane))
678
+ return "not-ready";
679
+ return this.sendReadyRoleInputOnceAsync(taskId, roleName, receiptId, input, inspected.pane);
680
+ }
257
681
  hasDeliveryReceipt(taskId, roleName, receiptId) {
258
682
  safeValue(receiptId, "tmux delivery receipt id");
259
683
  const digest = createHash("sha256").update(receiptId).digest("hex");
260
684
  const option = `@yui_delivery_${digest}`;
261
- return this.executor.run(this.tmuxBin, [
685
+ return this.run([
262
686
  "show-options", "-wqv", "-t", this.target(taskId, roleName), option
263
687
  ]).trim() === "1";
264
688
  }
689
+ async hasDeliveryReceiptAsync(taskId, roleName, receiptId) {
690
+ safeValue(receiptId, "tmux delivery receipt id");
691
+ const option = deliveryReceiptOption(receiptId);
692
+ return (await this.runAsync([
693
+ "show-options", "-wqv", "-t", this.target(taskId, roleName), option
694
+ ])).trim() === "1";
695
+ }
265
696
  detachRole(taskId) {
266
- this.executor.run(this.tmuxBin, ["detach-client", "-s", this.sessionName(taskId)]);
697
+ this.run(["detach-client", "-s", this.sessionName(taskId)]);
267
698
  }
268
699
  restartRole(taskId, role, launch) {
269
700
  try {
@@ -288,26 +719,70 @@ export class TmuxManager {
288
719
  return "exited";
289
720
  return this.windowNames(taskId).includes(roleName) ? "running" : "exited";
290
721
  }
722
+ async probeRoleStatusAsync(taskId, roleName) {
723
+ const snapshot = await this.sessionWindowNamesAsync(taskId);
724
+ return snapshot.names.includes(roleName) ? "running" : "exited";
725
+ }
291
726
  stopTask(taskId) {
292
- if (!this.hasSession(taskId))
727
+ const taskSession = this.sessionName(taskId);
728
+ const sessions = this.taskSessionGroupNames(taskId);
729
+ if (sessions.length === 0 && !this.hasSession(taskId))
293
730
  return false;
294
- this.executor.run(this.tmuxBin, ["kill-session", "-t", this.sessionName(taskId)]);
731
+ this.recordTaskTargets(taskId);
732
+ for (const session of [...sessions.filter((name) => name !== taskSession), taskSession]) {
733
+ try {
734
+ this.run(["kill-session", "-t", session]);
735
+ }
736
+ catch (error) {
737
+ if (!isExplicitlyAbsentTmuxSession(error))
738
+ throw error;
739
+ }
740
+ }
741
+ return true;
742
+ }
743
+ async stopTaskAsync(taskId) {
744
+ const taskSession = this.sessionName(taskId);
745
+ const sessions = await this.taskSessionGroupNamesAsync(taskId);
746
+ if (sessions.length === 0 && !(await this.hasSessionAsync(taskId)))
747
+ return false;
748
+ await this.recordTaskTargetsAsync(taskId);
749
+ for (const session of [...sessions.filter((name) => name !== taskSession), taskSession]) {
750
+ try {
751
+ await this.runAsync(["kill-session", "-t", session]);
752
+ }
753
+ catch (error) {
754
+ if (!isExplicitlyAbsentTmuxSession(error))
755
+ throw error;
756
+ }
757
+ }
295
758
  return true;
296
759
  }
297
760
  stopRole(taskId, roleName) {
298
- this.executor.run(this.tmuxBin, ["send-keys", "-t", this.target(taskId, roleName), "C-c"]);
761
+ this.recordRoleTarget(taskId, roleName);
762
+ this.run(["send-keys", "-t", this.target(taskId, roleName), "C-c"]);
299
763
  }
300
764
  killRole(taskId, roleName) {
301
- this.executor.run(this.tmuxBin, ["kill-window", "-t", this.target(taskId, roleName)]);
765
+ this.recordRoleTarget(taskId, roleName);
766
+ this.run(["kill-window", "-t", this.target(taskId, roleName)]);
767
+ }
768
+ async killRoleAsync(taskId, roleName) {
769
+ this.recordRoleTarget(taskId, roleName);
770
+ await this.runAsync(["kill-window", "-t", this.target(taskId, roleName)]);
302
771
  }
303
772
  renameRole(taskId, oldRoleName, newRoleName) {
304
- this.executor.run(this.tmuxBin, [
305
- "rename-window", "-t", this.target(taskId, oldRoleName), safeValue(newRoleName, "Role name")
773
+ const target = this.target(taskId, oldRoleName);
774
+ const nextRoleName = safeValue(newRoleName, "Role name");
775
+ // Retain the new exact fence even if tmux later rejects the rename. The
776
+ // stale fence is conservative and lets a later bounded retry revalidate
777
+ // the pane instead of guessing or dropping ownership evidence.
778
+ this.recordRoleTarget(taskId, newRoleName);
779
+ this.run([
780
+ "rename-window", "-t", target, nextRoleName
306
781
  ]);
307
782
  }
308
783
  hasSession(taskId) {
309
784
  try {
310
- this.executor.run(this.tmuxBin, ["has-session", "-t", this.sessionName(taskId)]);
785
+ this.run(["has-session", "-t", this.sessionName(taskId)]);
311
786
  return true;
312
787
  }
313
788
  catch (error) {
@@ -320,19 +795,181 @@ export class TmuxManager {
320
795
  throw error;
321
796
  }
322
797
  }
798
+ async hasSessionAsync(taskId) {
799
+ try {
800
+ await this.runAsync(["has-session", "-t", this.sessionName(taskId)]);
801
+ return true;
802
+ }
803
+ catch (error) {
804
+ if (isExplicitlyAbsentTmuxSession(error))
805
+ return false;
806
+ if (!(error instanceof CommandExecutionError))
807
+ return false;
808
+ throw error;
809
+ }
810
+ }
323
811
  windowNames(taskId) {
324
812
  if (!this.hasSession(taskId))
325
813
  return [];
326
- return this.executor.run(this.tmuxBin, [
327
- "list-windows", "-t", this.sessionName(taskId), "-F", "#{window_name}"
328
- ]).split("\n").map((name) => name.trim()).filter(Boolean);
814
+ try {
815
+ return this.run([
816
+ "list-windows", "-t", this.sessionName(taskId), "-F", "#{window_name}"
817
+ ]).split("\n").map((name) => name.trim()).filter(Boolean);
818
+ }
819
+ catch (error) {
820
+ if (isUnavailableTmuxStatus(error))
821
+ return [];
822
+ throw error;
823
+ }
824
+ }
825
+ async sessionWindowNamesAsync(taskId) {
826
+ try {
827
+ const names = (await this.runAsync([
828
+ "list-windows", "-t", this.sessionName(taskId), "-F", "#{window_name}"
829
+ ])).split("\n").map((name) => name.trim()).filter(Boolean);
830
+ return { exists: true, names };
831
+ }
832
+ catch (error) {
833
+ if (isUnavailableTmuxStatus(error))
834
+ return { exists: false, names: [] };
835
+ throw error;
836
+ }
329
837
  }
330
838
  sessionName(taskId) {
331
839
  return yuiTmuxSessionName(this.#yuiHome, taskId);
332
840
  }
841
+ taskSessionGroupNames(taskId) {
842
+ const formatSeparator = "\u001f";
843
+ const encodedSeparator = "\\037";
844
+ const taskSession = this.sessionName(taskId);
845
+ let output;
846
+ try {
847
+ output = this.run([
848
+ "list-sessions", "-F",
849
+ `#{session_name}${formatSeparator}#{session_group}`
850
+ ]);
851
+ }
852
+ catch (error) {
853
+ if (isExplicitlyAbsentTmuxSession(error))
854
+ return [];
855
+ throw error;
856
+ }
857
+ return parseTaskSessionGroupNames(output, taskSession, formatSeparator, encodedSeparator);
858
+ }
859
+ async taskSessionGroupNamesAsync(taskId) {
860
+ const formatSeparator = "\u001f";
861
+ const encodedSeparator = "\\037";
862
+ const taskSession = this.sessionName(taskId);
863
+ let output;
864
+ try {
865
+ output = await this.runAsync([
866
+ "list-sessions", "-F",
867
+ `#{session_name}${formatSeparator}#{session_group}`
868
+ ]);
869
+ }
870
+ catch (error) {
871
+ if (isExplicitlyAbsentTmuxSession(error))
872
+ return [];
873
+ throw error;
874
+ }
875
+ return parseTaskSessionGroupNames(output, taskSession, formatSeparator, encodedSeparator);
876
+ }
333
877
  target(taskId, roleName) {
334
878
  return yuiTmuxTarget(this.#yuiHome, taskId, roleName);
335
879
  }
880
+ recordRoleTarget(taskId, roleName) {
881
+ this.#onRoleTargetRecorded?.(this.target(taskId, roleName));
882
+ }
883
+ recordTaskTargets(taskId) {
884
+ if (this.#onRoleTargetRecorded === undefined)
885
+ return;
886
+ for (const pane of this.inspectTaskRolePanes(taskId)) {
887
+ this.#onRoleTargetRecorded(pane.target);
888
+ }
889
+ }
890
+ async recordTaskTargetsAsync(taskId) {
891
+ if (this.#onRoleTargetRecorded === undefined)
892
+ return;
893
+ for (const pane of await this.inspectRolePaneInventoryAsync()) {
894
+ if (pane.taskId === taskId)
895
+ this.#onRoleTargetRecorded(pane.target);
896
+ }
897
+ }
898
+ configureServerHistory() {
899
+ this.run([
900
+ "start-server",
901
+ ";",
902
+ "set-option", "-g", "history-limit", String(this.#historyLimit)
903
+ ]);
904
+ }
905
+ async configureServerHistoryAsync() {
906
+ await this.runAsync([
907
+ "start-server",
908
+ ";",
909
+ "set-option", "-g", "history-limit", String(this.#historyLimit)
910
+ ]);
911
+ }
912
+ /** Every operation is pinned to the server derived from this YUI_HOME. */
913
+ run(args, options) {
914
+ const bounded = options?.inheritStdio === true
915
+ ? options
916
+ : { ...options, timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS };
917
+ return this.executor.run(this.tmuxBin, ["-L", this.#serverName, ...args], bounded);
918
+ }
919
+ runAsync(args, options) {
920
+ const bounded = {
921
+ ...options,
922
+ timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
923
+ };
924
+ if (this.executor.runAsync !== undefined) {
925
+ return this.executor.runAsync(this.tmuxBin, ["-L", this.#serverName, ...args], bounded);
926
+ }
927
+ return Promise.resolve().then(() => this.run(args, bounded));
928
+ }
929
+ }
930
+ function parseRolePaneInventory(output, formatSeparator, encodedSeparator, sessionPrefix, target) {
931
+ return output.split("\n").flatMap((line) => {
932
+ if (line.length === 0)
933
+ return [];
934
+ const separator = line.includes(encodedSeparator) ? encodedSeparator : formatSeparator;
935
+ const [sessionName, roleName, deadText, pidText, currentCommand, ...extra] = line.split(separator);
936
+ if (sessionName === undefined
937
+ || roleName === undefined
938
+ || deadText === undefined
939
+ || pidText === undefined
940
+ || currentCommand === undefined
941
+ || extra.length > 0
942
+ || (deadText !== "0" && deadText !== "1")) {
943
+ throw runtimeError("Tmux returned an invalid Role pane inventory row.");
944
+ }
945
+ if (!sessionName.startsWith(sessionPrefix))
946
+ return [];
947
+ const taskId = sessionName.slice(sessionPrefix.length);
948
+ if (taskId.length === 0 || roleName.length === 0) {
949
+ throw runtimeError("Tmux returned an invalid Yui Role pane identity.");
950
+ }
951
+ const pid = Number(pidText);
952
+ return [{
953
+ taskId,
954
+ roleName,
955
+ target: target(taskId, roleName),
956
+ dead: deadText === "1",
957
+ ...(Number.isSafeInteger(pid) && pid > 0 ? { pid } : {}),
958
+ currentCommand
959
+ }];
960
+ });
961
+ }
962
+ function parseTaskSessionGroupNames(output, taskSession, formatSeparator, encodedSeparator) {
963
+ return output.split("\n").flatMap((line) => {
964
+ if (line.length === 0)
965
+ return [];
966
+ const separator = line.includes(encodedSeparator) ? encodedSeparator : formatSeparator;
967
+ const [sessionName, sessionGroup, ...extra] = line.split(separator);
968
+ if (sessionName === undefined || sessionGroup === undefined || extra.length > 0) {
969
+ throw runtimeError("Tmux returned an invalid session group row.");
970
+ }
971
+ return sessionName === taskSession || sessionGroup === taskSession ? [sessionName] : [];
972
+ });
336
973
  }
337
974
  /** A small FIFO boundary for Controller scheduler passes. */
338
975
  export class TmuxDeliveryPump {
@@ -363,8 +1000,22 @@ export class TmuxDeliveryPump {
363
1000
  }
364
1001
  export function yuiTmuxSessionName(yuiHome, taskId) {
365
1002
  const safeTaskId = safeValue(taskId, "Task id");
1003
+ return `${yuiTmuxSessionPrefix(yuiHome)}${safeTaskId}`;
1004
+ }
1005
+ function yuiTmuxSessionPrefix(yuiHome) {
366
1006
  const namespace = createHash("sha256").update(resolve(yuiHome)).digest("hex").slice(0, 12);
367
- return `yui-${namespace}-${safeTaskId}`;
1007
+ return `yui-${namespace}-`;
1008
+ }
1009
+ /**
1010
+ * tmux socket labels are not paths. Keep the label in a deliberately narrow,
1011
+ * fixed-size alphabet so it is safe as argv and leaves ample Unix socket-path
1012
+ * headroom. resolve() makes equivalent absolute/relative YUI_HOME paths share
1013
+ * one server without depending on whether the directory already exists.
1014
+ */
1015
+ export function yuiTmuxServerName(yuiHome) {
1016
+ const canonicalHome = resolve(safeValue(yuiHome, "YUI_HOME"));
1017
+ const digest = createHash("sha256").update(canonicalHome).digest("hex").slice(0, 24);
1018
+ return `yui-${digest}`;
368
1019
  }
369
1020
  export function yuiTmuxTarget(yuiHome, taskId, roleName) {
370
1021
  return `${yuiTmuxSessionName(yuiHome, taskId)}:${safeValue(roleName, "Role name")}`;
@@ -378,9 +1029,10 @@ function launchCommand(launch) {
378
1029
  }
379
1030
  return `${key}=${safeValue(value, `Agent environment ${key}`)}`;
380
1031
  });
381
- return environment.length === 0
382
- ? [command, ...args]
383
- : ["env", ...environment, command, ...args];
1032
+ // The launch plan is a complete environment assembled by the planner for
1033
+ // this exact Agent. Never inherit the Controller or tmux server environment:
1034
+ // it may contain credentials belonging to another configured Agent.
1035
+ return ["env", "-i", "--", ...environment, command, ...args];
384
1036
  }
385
1037
  function isExplicitlyAbsentTmuxSession(error) {
386
1038
  if (!(error instanceof CommandExecutionError))
@@ -388,9 +1040,49 @@ function isExplicitlyAbsentTmuxSession(error) {
388
1040
  return /can't find (?:session|window|pane)|no server running|error connecting to .+ \(No such file or directory\)/i
389
1041
  .test(error.stderr);
390
1042
  }
1043
+ function isUnavailableTmuxStatus(error) {
1044
+ return isExplicitlyAbsentTmuxSession(error)
1045
+ || (error instanceof CommandExecutionError
1046
+ && /server exited unexpectedly/i.test(error.stderr));
1047
+ }
391
1048
  function tmuxWord(value) {
392
1049
  return JSON.stringify(value);
393
1050
  }
1051
+ function deliveryReceiptOption(receiptId) {
1052
+ safeValue(receiptId, "tmux delivery receipt id");
1053
+ return `@yui_delivery_${createHash("sha256").update(receiptId).digest("hex")}`;
1054
+ }
1055
+ function deliveryPaneGuard(pane) {
1056
+ if (pane.dead
1057
+ || pane.pid === undefined
1058
+ || pane.cursorX === undefined
1059
+ || pane.cursorY === undefined
1060
+ || pane.historySize === undefined
1061
+ || !/^[A-Za-z0-9_.+-]+$/.test(pane.currentCommand)) {
1062
+ return "0";
1063
+ }
1064
+ const comparisons = [
1065
+ "#{==:#{pane_dead},0}",
1066
+ `#{==:#{pane_pid},${pane.pid}}`,
1067
+ `#{==:#{cursor_x},${pane.cursorX}}`,
1068
+ `#{==:#{cursor_y},${pane.cursorY}}`,
1069
+ `#{==:#{history_size},${pane.historySize}}`,
1070
+ `#{==:#{pane_current_command},${pane.currentCommand}}`
1071
+ ];
1072
+ return comparisons.slice(1).reduce((combined, comparison) => `#{&&:${combined},${comparison}}`, comparisons[0]);
1073
+ }
1074
+ function positiveNumber(value) {
1075
+ if (value === undefined)
1076
+ return undefined;
1077
+ const parsed = Number(value);
1078
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
1079
+ }
1080
+ function nonNegativeNumber(value) {
1081
+ if (value === undefined)
1082
+ return undefined;
1083
+ const parsed = Number(value);
1084
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
1085
+ }
394
1086
  function safeValue(value, label) {
395
1087
  if (value.length === 0 || value.includes("\0")) {
396
1088
  throw new TypeError(`${label} must be non-empty and contain no NUL bytes`);
@@ -404,6 +1096,12 @@ function positiveInteger(value, fallback, label) {
404
1096
  }
405
1097
  return resolved;
406
1098
  }
1099
+ function roleHistoryWarning(history) {
1100
+ return "This existing Role has a "
1101
+ + `${history.actual.toLocaleString("en-US")}-line tmux history. `
1102
+ + "Please exit and re-enter the Role to use "
1103
+ + `${history.configured.toLocaleString("en-US")} lines.`;
1104
+ }
407
1105
  function blockingSleep(milliseconds) {
408
1106
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
409
1107
  }