@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.
- package/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
package/dist/tmux/tmuxManager.js
CHANGED
|
@@ -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.
|
|
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
|
|
101
|
+
"-c", safeValue(role.cwd ?? role.workspace, "Role cwd"),
|
|
77
102
|
"--",
|
|
78
103
|
...launchCommand(launch)
|
|
79
104
|
]);
|
|
80
105
|
}
|
|
81
106
|
else {
|
|
82
|
-
this.
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
400
|
+
output = this.run([
|
|
166
401
|
"list-panes", "-s", "-t", this.sessionName(taskId), "-F",
|
|
167
|
-
`#{window_name}${
|
|
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
|
|
211
|
-
//
|
|
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
|
-
`
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
305
|
-
|
|
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.
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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}
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
}
|