@zq-silk/yui 0.6.2 → 0.6.4
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 +28 -4
- package/README.md +60 -97
- package/dist/agent/argumentPolicy.js +1 -1
- package/dist/agent/managedRuntimeEnvironment.js +1 -0
- package/dist/cli/commandCatalog.js +19 -9
- package/dist/cli/interactionPolicy.js +4 -2
- package/dist/cli.js +77 -32
- package/dist/commands/taskCommands.js +46 -11
- package/dist/commands/taskContextCommand.js +1 -1
- package/dist/commands/taskRoleRuntimeStatus.js +170 -10
- package/dist/controller/agentRuntimeObserver.js +210 -0
- package/dist/controller/clientRuntime.js +3 -21
- package/dist/controller/controller.js +47 -7
- package/dist/controller/fileSchedulerStoreAdapter.js +522 -388
- package/dist/controller/runtime.js +9 -3
- package/dist/controller/runtimeEventInbox.js +49 -295
- package/dist/controller/runtimeEventProcessor.js +184 -321
- package/dist/controller/runtimeHookRunFence.js +226 -0
- package/dist/controller/runtimeLaunchCoordinator.js +91 -26
- package/dist/controller/runtimeObservationHook.js +112 -0
- package/dist/core/controllerServer.js +5 -0
- package/dist/executor/agentAdapter.js +18 -3
- package/dist/executor/fileRoleLaunchPlanner.js +64 -15
- package/dist/executor/managedClaudeRunner.js +121 -0
- package/dist/observability/executionAudit.js +6 -3
- package/dist/repository/taskWorkspacePreparer.js +1 -4
- package/dist/run/providerRetryConfig.js +8 -3
- package/dist/runtime/agentDriver.js +229 -0
- package/dist/runtime/agentDriverObservation.js +57 -0
- package/dist/runtime/builtinAgentDrivers.js +235 -0
- package/dist/runtime/builtinTranscriptObserver.js +290 -0
- package/dist/runtime/builtinTranscriptUsage.js +97 -0
- package/dist/runtime/exactControlPlane.js +2 -2
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/ports.js +12 -1
- package/dist/runtime/runtimeObservation.js +297 -0
- package/dist/runtime/runtimeProjection.js +277 -0
- package/dist/runtime/sessionTerminationGuard.js +78 -22
- package/dist/runtime/tmuxAdapters.js +35 -0
- package/dist/scheduler/activeRoleRunDelivery.js +28 -13
- package/dist/scheduler/leaderWakeupProcessor.js +21 -2
- package/dist/scheduler/roleRunLiveness.js +2 -2
- package/dist/scheduler/roleRunStall.js +62 -114
- package/dist/storage/migration/productionRegistry.js +85 -0
- package/dist/storage/sqliteStore.js +3 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +2 -1
- package/dist/storage/upgrade/sqliteStateMigration.js +123 -0
- package/dist/telemetry/sqliteTelemetryStore.js +0 -28
- package/dist/telemetry/telemetryCompaction.js +1 -0
- package/dist/telemetry/telemetryConfig.js +4 -5
- package/dist/tmux/tmuxManager.js +136 -22
- package/dist/web/assets/client/view.js +1 -1
- package/dist/web/tmuxWebTerminal.js +17 -12
- package/dist/web/webSnapshot.js +1 -1
- package/dist/worktree/managedWorkspace.js +14 -0
- package/i18n/README.zh-CN.md +12 -7
- package/package.json +1 -1
- package/dist/controller/claudeLifecycleHook.js +0 -203
- package/dist/controller/codexLifecycleHook.js +0 -108
- package/dist/controller/providerHookRunFence.js +0 -156
- package/dist/lifecycle/providerLifecycleMapping.js +0 -190
- package/dist/telemetry/telemetryRouter.js +0 -32
|
@@ -163,34 +163,6 @@ export class SqliteTelemetryStore {
|
|
|
163
163
|
revision() {
|
|
164
164
|
return this.#applied;
|
|
165
165
|
}
|
|
166
|
-
latestProgressEvents(taskId) {
|
|
167
|
-
const db = this.#ensureDb();
|
|
168
|
-
if (db === null)
|
|
169
|
-
return [];
|
|
170
|
-
const rows = db.prepare(`SELECT task_id, role_name, run_id, generation, progress_id, sequence, payload, received_at FROM (
|
|
171
|
-
SELECT *, ROW_NUMBER() OVER (
|
|
172
|
-
PARTITION BY run_id
|
|
173
|
-
ORDER BY COALESCE(sequence, -1) DESC, received_at DESC, progress_id ASC
|
|
174
|
-
) AS rn FROM telemetry WHERE task_id = ?
|
|
175
|
-
) WHERE rn = 1`).all(taskId);
|
|
176
|
-
return rows.map((row) => {
|
|
177
|
-
const entry = rowToEntry(row);
|
|
178
|
-
const payload = { ...entry.payload };
|
|
179
|
-
payload.runId = entry.runId;
|
|
180
|
-
payload.progressId = entry.progressId;
|
|
181
|
-
// The semantic progress event carries progressAt = observation time;
|
|
182
|
-
// mirror that so liveness folds see the same timestamp.
|
|
183
|
-
payload.progressAt = entry.receivedAt;
|
|
184
|
-
return {
|
|
185
|
-
schemaVersion: 2,
|
|
186
|
-
id: `telemetry-progress-${entry.runId}-${entry.progressId}`,
|
|
187
|
-
taskId: entry.taskId,
|
|
188
|
-
type: "runtime.provider-turn-progress",
|
|
189
|
-
payload,
|
|
190
|
-
createdAt: entry.receivedAt
|
|
191
|
-
};
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
166
|
// -- retention -----------------------------------------------------------------
|
|
195
167
|
pruneGeneration(taskId, roleName, runId, generation, keep = this.#terminalKeep) {
|
|
196
168
|
const db = this.#ensureDb();
|
|
@@ -15,6 +15,7 @@ import { DEFAULT_TERMINAL_KEEP } from "./telemetryConfig.js";
|
|
|
15
15
|
* copy only after this module validates the result; a validation failure
|
|
16
16
|
* throws and leaves the original history unchanged.
|
|
17
17
|
*/
|
|
18
|
+
/** Historical pre-Driver event type accepted only by the offline migration. */
|
|
18
19
|
export const PROGRESS_EVENT_TYPE = "runtime.provider-turn-progress";
|
|
19
20
|
/**
|
|
20
21
|
* Scan a store and build the compaction plan. Read-only: nothing is modified.
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Issue 09 — telemetry retention & compaction configuration.
|
|
3
3
|
*
|
|
4
4
|
* Telemetry lives in the Home's authoritative `yui.db` (database-only
|
|
5
|
-
* direction). Its activation is an explicit per-Home switch
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (§4.4); the environment only overrides them.
|
|
5
|
+
* direction). Its activation is an explicit per-Home diagnostic switch and
|
|
6
|
+
* never changes canonical runtime-state persistence. Retention defaults are
|
|
7
|
+
* the schema's own constants (§4.4); the environment only overrides them.
|
|
9
8
|
*/
|
|
10
9
|
import { TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "../storage/sqliteSchema.js";
|
|
11
10
|
export const DEFAULT_TELEMETRY_MODE = "legacy";
|
|
@@ -23,7 +22,7 @@ const TELEMETRY_MODES = ["legacy", "dual", "bounded"];
|
|
|
23
22
|
/**
|
|
24
23
|
* Resolve `YUI_TELEMETRY_MODE` (default `legacy`). Only the three exact
|
|
25
24
|
* values (case-insensitive) are accepted; anything else fails closed at
|
|
26
|
-
* startup instead of silently changing
|
|
25
|
+
* startup instead of silently changing diagnostic retention.
|
|
27
26
|
*/
|
|
28
27
|
export function resolveTelemetryMode(env = process.env) {
|
|
29
28
|
const raw = env.YUI_TELEMETRY_MODE?.trim().toLowerCase();
|
package/dist/tmux/tmuxManager.js
CHANGED
|
@@ -9,6 +9,9 @@ const DEFAULT_READINESS_POLL_MS = 50;
|
|
|
9
9
|
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
|
|
10
10
|
const DEFAULT_HISTORY_LIMIT = 100_000;
|
|
11
11
|
const PANE_STATE_MARKER = "__YUI_PANE_STATE__";
|
|
12
|
+
const WRITABLE_CLIENT_SESSION_PREFIX = "yui-writer-";
|
|
13
|
+
const HOST_WRITABLE_CLIENT_SESSION_PREFIX = `${WRITABLE_CLIENT_SESSION_PREFIX}host-`;
|
|
14
|
+
const ROLE_WRITABLE_CLIENT_SESSION_PREFIX = `${WRITABLE_CLIENT_SESSION_PREFIX}role-`;
|
|
12
15
|
export class TmuxReadinessTimeoutError extends Error {
|
|
13
16
|
taskId;
|
|
14
17
|
roleName;
|
|
@@ -72,12 +75,13 @@ export class TmuxManager {
|
|
|
72
75
|
}
|
|
73
76
|
enterRole(taskId, role, launch) {
|
|
74
77
|
this.ensureRoleWindow(taskId, role, launch);
|
|
75
|
-
this.attachRole(taskId, role.name);
|
|
78
|
+
this.attachRole(taskId, role.name, "auto");
|
|
76
79
|
}
|
|
77
80
|
ensureRoleWindow(taskId, role, launch) {
|
|
78
81
|
if (this.windowNames(taskId).includes(role.name)) {
|
|
79
82
|
this.recordRoleTarget(taskId, role.name);
|
|
80
83
|
this.configureServerHistory();
|
|
84
|
+
this.configureRoleWindowSizing(taskId, role.name);
|
|
81
85
|
return false;
|
|
82
86
|
}
|
|
83
87
|
if (launch === undefined) {
|
|
@@ -114,6 +118,7 @@ export class TmuxManager {
|
|
|
114
118
|
...launchCommand(launch)
|
|
115
119
|
]);
|
|
116
120
|
}
|
|
121
|
+
this.configureRoleWindowSizing(taskId, role.name);
|
|
117
122
|
return true;
|
|
118
123
|
}
|
|
119
124
|
async ensureRoleWindowAsync(taskId, role, launch) {
|
|
@@ -121,6 +126,7 @@ export class TmuxManager {
|
|
|
121
126
|
if (snapshot.names.includes(role.name)) {
|
|
122
127
|
this.recordRoleTarget(taskId, role.name);
|
|
123
128
|
await this.configureServerHistoryAsync();
|
|
129
|
+
await this.configureRoleWindowSizingAsync(taskId, role.name);
|
|
124
130
|
return false;
|
|
125
131
|
}
|
|
126
132
|
if (launch === undefined) {
|
|
@@ -156,9 +162,13 @@ export class TmuxManager {
|
|
|
156
162
|
...launchCommand(launch)
|
|
157
163
|
]);
|
|
158
164
|
}
|
|
165
|
+
await this.configureRoleWindowSizingAsync(taskId, role.name);
|
|
159
166
|
return true;
|
|
160
167
|
}
|
|
161
|
-
attachRole(taskId, roleName) {
|
|
168
|
+
attachRole(taskId, roleName, access, options = {}) {
|
|
169
|
+
if (access !== "auto" && access !== "read-only" && access !== "read-write") {
|
|
170
|
+
throw runtimeError("Tmux attach access must be auto, read-only, or read-write.");
|
|
171
|
+
}
|
|
162
172
|
let historyNotice;
|
|
163
173
|
if (this.#onWarning !== undefined) {
|
|
164
174
|
const history = this.inspectRoleHistory(taskId, roleName);
|
|
@@ -167,18 +177,36 @@ export class TmuxManager {
|
|
|
167
177
|
this.#onWarning(historyNotice);
|
|
168
178
|
}
|
|
169
179
|
}
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
let resolvedAccess = access === "read-only"
|
|
181
|
+
? "read-only"
|
|
182
|
+
: "read-write";
|
|
183
|
+
// Automatic entry is used by global Roles and retains one writer for the
|
|
184
|
+
// whole tmux host. Explicit Task write access is scoped to the selected
|
|
185
|
+
// Role so a human in one pane cannot pause unrelated Role launches.
|
|
186
|
+
const writerRoleName = access === "read-write" ? roleName : undefined;
|
|
187
|
+
let clientSession = this.createInteractiveClientSession(taskId, historyNotice, resolvedAccess, writerRoleName);
|
|
172
188
|
try {
|
|
189
|
+
if (resolvedAccess === "read-write"
|
|
190
|
+
&& this.hasWritableClient(taskId, writerRoleName, clientSession)) {
|
|
191
|
+
if (access !== "auto") {
|
|
192
|
+
throw runtimeError(`A writable tmux client is already attached to ${taskId}; use read-only access.`);
|
|
193
|
+
}
|
|
194
|
+
this.destroyInteractiveClientSession(clientSession);
|
|
195
|
+
resolvedAccess = "read-only";
|
|
196
|
+
clientSession = this.createInteractiveClientSession(taskId, historyNotice, resolvedAccess);
|
|
197
|
+
}
|
|
198
|
+
if (resolvedAccess === "read-write")
|
|
199
|
+
options.revalidateWritableAttach?.();
|
|
173
200
|
handoffTerminal(this.#terminalInput, this.#closeInteractiveInput);
|
|
174
201
|
this.run([
|
|
175
202
|
"attach-session",
|
|
176
|
-
...(
|
|
203
|
+
...(resolvedAccess === "read-only" ? ["-r"] : []),
|
|
177
204
|
"-t", `${clientSession}:${safeValue(roleName, "Role name")}`
|
|
178
205
|
], {
|
|
179
206
|
inheritStdio: true,
|
|
180
207
|
environment: { TERM: this.#terminalType }
|
|
181
208
|
});
|
|
209
|
+
return resolvedAccess;
|
|
182
210
|
}
|
|
183
211
|
finally {
|
|
184
212
|
this.destroyInteractiveClientSession(clientSession);
|
|
@@ -188,8 +216,11 @@ export class TmuxManager {
|
|
|
188
216
|
* A grouped session shares the Agent windows while keeping the selected
|
|
189
217
|
* window and terminal options local to this one human client.
|
|
190
218
|
*/
|
|
191
|
-
createInteractiveClientSession(taskId, attachedNotice) {
|
|
192
|
-
const
|
|
219
|
+
createInteractiveClientSession(taskId, attachedNotice, access = "read-only", writerRoleName) {
|
|
220
|
+
const prefix = access === "read-write"
|
|
221
|
+
? writableClientSessionPrefix(writerRoleName)
|
|
222
|
+
: "yui-client-";
|
|
223
|
+
const clientSession = `${prefix}${randomBytes(12).toString("hex")}`;
|
|
193
224
|
this.run([
|
|
194
225
|
"new-session", "-d",
|
|
195
226
|
"-t", this.sessionName(taskId),
|
|
@@ -243,8 +274,12 @@ export class TmuxManager {
|
|
|
243
274
|
limited: actual < this.#historyLimit
|
|
244
275
|
};
|
|
245
276
|
}
|
|
246
|
-
/** Multiple viewers are safe, but
|
|
247
|
-
hasWritableClient(taskId) {
|
|
277
|
+
/** Multiple viewers are safe, but one pane may have only one writer. */
|
|
278
|
+
hasWritableClient(taskId, roleName, excludedLease) {
|
|
279
|
+
if (this.taskSessionGroupNames(taskId).some((sessionName) => (sessionName !== excludedLease
|
|
280
|
+
&& writableLeaseMatchesRole(sessionName, roleName)))) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
248
283
|
const formatSeparator = "\u001f";
|
|
249
284
|
const encodedSeparator = "\\037";
|
|
250
285
|
const taskSession = this.sessionName(taskId);
|
|
@@ -254,19 +289,29 @@ export class TmuxManager {
|
|
|
254
289
|
"-F",
|
|
255
290
|
`#{session_name}${formatSeparator}#{session_group}${formatSeparator}#{client_readonly}`
|
|
256
291
|
]);
|
|
257
|
-
return clients
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
292
|
+
return writableClientRowsContainMatch(clients, taskSession, roleName, excludedLease, formatSeparator, encodedSeparator);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (isExplicitlyAbsentTmuxSession(error))
|
|
296
|
+
return false;
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async hasWritableClientAsync(taskId, roleName, excludedLease) {
|
|
301
|
+
if ((await this.taskSessionGroupNamesAsync(taskId)).some((sessionName) => (sessionName !== excludedLease
|
|
302
|
+
&& writableLeaseMatchesRole(sessionName, roleName)))) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
const formatSeparator = "\u001f";
|
|
306
|
+
const encodedSeparator = "\\037";
|
|
307
|
+
const taskSession = this.sessionName(taskId);
|
|
308
|
+
try {
|
|
309
|
+
const clients = await this.runAsync([
|
|
310
|
+
"list-clients",
|
|
311
|
+
"-F",
|
|
312
|
+
`#{session_name}${formatSeparator}#{session_group}${formatSeparator}#{client_readonly}`
|
|
313
|
+
]);
|
|
314
|
+
return writableClientRowsContainMatch(clients, taskSession, roleName, excludedLease, formatSeparator, encodedSeparator);
|
|
270
315
|
}
|
|
271
316
|
catch (error) {
|
|
272
317
|
if (isExplicitlyAbsentTmuxSession(error))
|
|
@@ -928,6 +973,28 @@ export class TmuxManager {
|
|
|
928
973
|
"set-option", "-g", "history-limit", String(this.#historyLimit)
|
|
929
974
|
]);
|
|
930
975
|
}
|
|
976
|
+
/**
|
|
977
|
+
* Pin the Role window to the largest attached client. The tmux default
|
|
978
|
+
* `window-size latest` lets a later-attaching smaller client (e.g. the Web
|
|
979
|
+
* terminal or a second `task enter` from a smaller pane) shrink the shared
|
|
980
|
+
* Role window, leaving the primary viewer with a TUI pinned to the top of a
|
|
981
|
+
* large terminal and no scrollback. `largest` keeps the window at the
|
|
982
|
+
* biggest attached client so a compact viewer cannot compress it.
|
|
983
|
+
*/
|
|
984
|
+
configureRoleWindowSizing(taskId, roleName) {
|
|
985
|
+
this.run([
|
|
986
|
+
"set-option", "-w",
|
|
987
|
+
"-t", `${this.sessionName(taskId)}:${safeValue(roleName, "Role name")}`,
|
|
988
|
+
"window-size", "largest"
|
|
989
|
+
]);
|
|
990
|
+
}
|
|
991
|
+
async configureRoleWindowSizingAsync(taskId, roleName) {
|
|
992
|
+
await this.runAsync([
|
|
993
|
+
"set-option", "-w",
|
|
994
|
+
"-t", `${this.sessionName(taskId)}:${safeValue(roleName, "Role name")}`,
|
|
995
|
+
"window-size", "largest"
|
|
996
|
+
]);
|
|
997
|
+
}
|
|
931
998
|
/** Every operation is pinned to the server derived from this YUI_HOME. */
|
|
932
999
|
run(args, options) {
|
|
933
1000
|
const bounded = options?.inheritStdio === true
|
|
@@ -990,6 +1057,53 @@ function parseTaskSessionGroupNames(output, taskSession, formatSeparator, encode
|
|
|
990
1057
|
return sessionName === taskSession || sessionGroup === taskSession ? [sessionName] : [];
|
|
991
1058
|
});
|
|
992
1059
|
}
|
|
1060
|
+
function writableClientSessionPrefix(roleName) {
|
|
1061
|
+
if (roleName === undefined)
|
|
1062
|
+
return HOST_WRITABLE_CLIENT_SESSION_PREFIX;
|
|
1063
|
+
const digest = createHash("sha256")
|
|
1064
|
+
.update(safeValue(roleName, "Role name"))
|
|
1065
|
+
.digest("hex")
|
|
1066
|
+
.slice(0, 24);
|
|
1067
|
+
return `${ROLE_WRITABLE_CLIENT_SESSION_PREFIX}${digest}-`;
|
|
1068
|
+
}
|
|
1069
|
+
function writableLeaseMatchesRole(sessionName, roleName) {
|
|
1070
|
+
if (!sessionName.startsWith(WRITABLE_CLIENT_SESSION_PREFIX))
|
|
1071
|
+
return false;
|
|
1072
|
+
if (roleName === undefined)
|
|
1073
|
+
return true;
|
|
1074
|
+
if (sessionName.startsWith(HOST_WRITABLE_CLIENT_SESSION_PREFIX))
|
|
1075
|
+
return true;
|
|
1076
|
+
if (sessionName.startsWith(ROLE_WRITABLE_CLIENT_SESSION_PREFIX)) {
|
|
1077
|
+
return sessionName.startsWith(writableClientSessionPrefix(roleName));
|
|
1078
|
+
}
|
|
1079
|
+
// Conservative compatibility for writer leases created before Role-scoped
|
|
1080
|
+
// lease names existed.
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
function writableClientRowsContainMatch(clients, taskSession, roleName, excludedLease, formatSeparator, encodedSeparator) {
|
|
1084
|
+
return clients.split("\n").some((line) => {
|
|
1085
|
+
if (line.length === 0)
|
|
1086
|
+
return false;
|
|
1087
|
+
const separator = line.includes(encodedSeparator) ? encodedSeparator : formatSeparator;
|
|
1088
|
+
const [sessionName, sessionGroup, readOnly, ...extra] = line.split(separator);
|
|
1089
|
+
if (sessionName === undefined
|
|
1090
|
+
|| sessionGroup === undefined
|
|
1091
|
+
|| readOnly === undefined
|
|
1092
|
+
|| extra.length > 0) {
|
|
1093
|
+
throw runtimeError("Tmux returned an invalid client state row.");
|
|
1094
|
+
}
|
|
1095
|
+
if (sessionName === excludedLease
|
|
1096
|
+
|| (sessionName !== taskSession && sessionGroup !== taskSession)
|
|
1097
|
+
|| readOnly !== "0") {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
// Current Yui clients publish a lease before attach. A direct or legacy
|
|
1101
|
+
// writable tmux client has no Role identity, so conservatively fence every
|
|
1102
|
+
// Role in that host.
|
|
1103
|
+
return !sessionName.startsWith(WRITABLE_CLIENT_SESSION_PREFIX)
|
|
1104
|
+
|| writableLeaseMatchesRole(sessionName, roleName);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
993
1107
|
/** A small FIFO boundary for Controller scheduler passes. */
|
|
994
1108
|
export class TmuxDeliveryPump {
|
|
995
1109
|
tmux;
|
|
@@ -320,7 +320,7 @@ export function renderTaskDetail(detail, data, t, locale, actions) {
|
|
|
320
320
|
const card = node("article", "record-card");
|
|
321
321
|
card.append(
|
|
322
322
|
node("strong", "", run.roleName + " · " + run.runId),
|
|
323
|
-
node("p", "record-copy", (run.kind || "
|
|
323
|
+
node("p", "record-copy", (run.kind || "workflow-not-progressing") + " · " + (run.classification || "truly-stalled")),
|
|
324
324
|
node("small", "", t("detail.lastProgress") + " · " + formatDateTime(run.progressAt, locale))
|
|
325
325
|
);
|
|
326
326
|
runtimeHealthBody.append(card);
|
|
@@ -14,24 +14,29 @@ export class TmuxWebTerminalService {
|
|
|
14
14
|
}
|
|
15
15
|
async open(request) {
|
|
16
16
|
const hostId = request.scope === "task" ? request.taskId : "operator";
|
|
17
|
-
if (request.scope === "
|
|
18
|
-
await this.options.prepareTaskRole({
|
|
19
|
-
taskId: request.taskId,
|
|
20
|
-
roleName: request.roleName
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
17
|
+
if (request.scope === "global") {
|
|
24
18
|
await this.options.prepareGlobalRole(request.roleName);
|
|
25
19
|
}
|
|
26
20
|
const roleHistory = this.options.tmux.inspectRoleHistory?.(hostId, request.roleName);
|
|
27
|
-
|
|
28
|
-
|| this.options.tmux.hasWritableClient(hostId);
|
|
29
|
-
if (!readOnly)
|
|
30
|
-
this.#writers.add(hostId);
|
|
21
|
+
let readOnly = request.scope === "task" || this.#writers.has(hostId);
|
|
31
22
|
let clientSession;
|
|
32
23
|
let process;
|
|
33
24
|
try {
|
|
34
|
-
|
|
25
|
+
if (!readOnly) {
|
|
26
|
+
// Publish the same host-scoped lease used by Terminal auto-attach
|
|
27
|
+
// before deciding that this Web client may write. This closes the
|
|
28
|
+
// cross-surface gap where neither client was visible to list-clients.
|
|
29
|
+
clientSession = this.options.tmux.createInteractiveClientSession(hostId, undefined, "read-write");
|
|
30
|
+
if (this.options.tmux.hasWritableClient(hostId, undefined, clientSession)) {
|
|
31
|
+
this.options.tmux.destroyInteractiveClientSession(clientSession);
|
|
32
|
+
clientSession = undefined;
|
|
33
|
+
readOnly = true;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
this.#writers.add(hostId);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
clientSession ??= this.options.tmux.createInteractiveClientSession(hostId);
|
|
35
40
|
process = this.#spawnPty(this.options.tmuxBin, [
|
|
36
41
|
"-L", yuiTmuxServerName(this.options.yuiHome),
|
|
37
42
|
"attach-session",
|
package/dist/web/webSnapshot.js
CHANGED
|
@@ -68,7 +68,7 @@ export function buildWebTaskDetail(store, taskId) {
|
|
|
68
68
|
runId: run.id,
|
|
69
69
|
roleName: run.roleName,
|
|
70
70
|
progressAt: latestStallProgress(events, run.id),
|
|
71
|
-
kind: latestStallField(events, run.id, "kind") ?? "
|
|
71
|
+
kind: latestStallField(events, run.id, "kind") ?? "workflow-not-progressing",
|
|
72
72
|
classification: latestStallField(events, run.id, "classification") ?? "truly-stalled"
|
|
73
73
|
}));
|
|
74
74
|
const activeRuns = new Map(runs
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
/**
|
|
4
|
+
* Launch-stable workspace identity. Audit timestamps describe persistence
|
|
5
|
+
* activity, not a change to the workspace a runtime is authorized to use.
|
|
6
|
+
*/
|
|
7
|
+
export function managedWorkspaceIdentity(workspace) {
|
|
8
|
+
return {
|
|
9
|
+
owner: workspace.owner,
|
|
10
|
+
root: workspace.root,
|
|
11
|
+
entries: workspace.entries
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function sameManagedWorkspaceIdentity(left, right) {
|
|
15
|
+
return isDeepStrictEqual(managedWorkspaceIdentity(left), managedWorkspaceIdentity(right));
|
|
16
|
+
}
|
|
3
17
|
/** Every active Task launch is fenced by this durable owner. */
|
|
4
18
|
export function isTaskOwnedWorkspace(workspace, taskId, taskRoot, bindings) {
|
|
5
19
|
if (workspace === null || workspace === undefined
|
package/i18n/README.zh-CN.md
CHANGED
|
@@ -431,16 +431,18 @@ Task 生命周期的交互选择只展示有效来源状态:activate 只展示
|
|
|
431
431
|
|
|
432
432
|
## Session 与 tmux
|
|
433
433
|
|
|
434
|
-
|
|
434
|
+
tmux 负责 Agent 进程生命周期及其可观察输出。Global Operator 与 global Role 仍使用原生交互式 CLI;受管理的 Task Claude Run 则为每个 Run 启动一个有限生命周期进程,使用 `--print`、stream-json 输入和 stream-json 输出。Yui 通过 stdin 写入一条以换行结尾的精确 Run JSON user frame,并发排空 stdout/stderr,并通过 Claude session ID 保持原生上下文连续性。因此启动和投递不再依赖 TUI composer、ready 字符、粘贴延迟或模拟 Enter 键;Codex 保留其 adapter 原生的启动 prompt 与结构化 callback 路径。
|
|
435
|
+
|
|
436
|
+
`task enter` 与 `task role enter` 只是附着到已存在的 Task Role pane:不会启动 Controller、准备 workspace、创建或恢复 Agent、唤醒 Role,也不会投递输入。Task attach 默认为 `--read-only`;只有显式指定 `--read-write` 才可交互,并且 Role 存在 active managed Run、受管理的 Claude 进程仍在退出,或同一 pane 已有 writer 时会被拒绝。读写 attach 会先发布 Role 级 tmux writer lease,再复核持久化 Run 状态,从而闭合与 Controller 启动之间的竞态。lease 存续期间只暂停该 Role 的受管理投递且不消耗有界投递重试;detach 会释放 lease,并且只通知已经存在的持久化 Role 工作重新评估,同一 Task 的其他 Role 不受影响。attach 前,Yui 会关闭 readline、退出 raw mode、暂停自身 stdin,再同步把终端交给 tmux。attach 会继承外层终端的真实能力并进入干净的 alternate screen;鼠标滚动只查看 Agent pane 的 100,000 行 tmux 历史,不再混入此前的 shell 或 IDE Terminal 历史。读写 attach 可以使用现有 pane 本身支持的原生交互,但它不参与受管理会话的启动或投递。
|
|
435
437
|
|
|
436
438
|
tmux 会在 pane 创建时固定其历史容量。配置该限制之前创建的 Role 会保留原容量;Yui 会在 Terminal attach 和 Web 中提示用户退出并重新进入一次,从而在保留 Agent 原生对话的同时创建具有 100,000 行历史的新 pane。
|
|
437
439
|
|
|
438
|
-
|
|
440
|
+
Global 交互入口在不存在 writer 时保持可写;已有 writer 时自动降级为只读。global Web 对每个 tmux session 只允许一个 writer;Task Web 始终只读。Task CLI 入口除非显式请求 `--read-write`,否则始终只读,避免观察动作改变 Agent 执行。
|
|
439
441
|
|
|
440
442
|
```sh
|
|
441
443
|
yui role enter <global-role>
|
|
442
|
-
yui task enter <task-id> [role]
|
|
443
|
-
yui task role enter <task-id> <role>
|
|
444
|
+
yui task enter <task-id> [role] [--read-only | --read-write]
|
|
445
|
+
yui task role enter <task-id> <role> [--read-only | --read-write]
|
|
444
446
|
```
|
|
445
447
|
|
|
446
448
|
每个 Role 可绑定多个 Agent,但任一时刻只有一个 active Agent,并为每个
|
|
@@ -454,7 +456,7 @@ binding 是预先保存、可随时切换的配置,而不是并行身份。Ope
|
|
|
454
456
|
|
|
455
457
|
使用 `yui role unbind <global-role> <agent-id>` 或 `yui task role unbind <task-id> <role> <agent-id>` 可移除休眠 binding。active binding 或任何未 stopped 的 native session 都会被拒绝;stopped session 记录会和 binding 在同一事务中删除。
|
|
456
458
|
|
|
457
|
-
Claude 的 session ID
|
|
459
|
+
Claude 的 session ID 在启动前分配。每个受管理的 Task Claude Run 都使用新的有限生命周期进程;resume 会针对固定 native session 启动新进程,而不是复用交互式 pane。受管理的 Codex 启动使用 Codex 结构化 `notify` 回调,在 turn 完成后记录 thread ID,不再向模型对话注入 session-bind prompt。
|
|
458
460
|
|
|
459
461
|
自动生命周期与投递判断只使用结构化 Hook payload、持久身份、tmux process
|
|
460
462
|
state、receipt 与 pane fence。Yui 不会解析 prompt glyph、进度文本、trust dialog
|
|
@@ -532,8 +534,6 @@ Agent 环境变量绑定只保存进程环境变量名,不保存 secret 值;
|
|
|
532
534
|
Yui 面向一台机器上的一个受信任本地用户。它的 Web/API 仅支持 loopback,不包含远程或多用户 Web、分布式协调、backup/import/export、trash/restore、derived index、recovery journal、runtime lease、inactivity TTL、cooldown 或 recurring schedule。
|
|
533
535
|
|
|
534
536
|
持久化和调度细节见 [ARCHITECTURE.md](../ARCHITECTURE.md)。
|
|
535
|
-
可复用的用户视角验收方案见
|
|
536
|
-
[Operator 路由与长期任务端到端测试方案](../docs/testing/operator-routing-e2e-plan.md)。
|
|
537
537
|
|
|
538
538
|
## 本地开发
|
|
539
539
|
|
|
@@ -544,6 +544,11 @@ npm test
|
|
|
544
544
|
npm run lint
|
|
545
545
|
```
|
|
546
546
|
|
|
547
|
+
`npm test` 只保留秒级核心 smoke:CLI 启动、正常 SQLite Task、受支持迁移和内置
|
|
548
|
+
Agent Driver。针对当前修改编写的 TDD、异常数据和故障复现仅作为开发期证据,需求完成后
|
|
549
|
+
删除,不累积为常驻回归测试。具体约束见
|
|
550
|
+
[验证策略](../docs/testing/verification-levels.md)。
|
|
551
|
+
|
|
547
552
|
如需让用户终端使用当前 checkout,可逆地接管用户级 `yui` 命令:
|
|
548
553
|
|
|
549
554
|
```sh
|