@zq-silk/yui 0.6.2 → 0.6.3

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 (61) hide show
  1. package/ARCHITECTURE.md +28 -4
  2. package/README.md +62 -24
  3. package/dist/agent/argumentPolicy.js +1 -1
  4. package/dist/agent/managedRuntimeEnvironment.js +1 -0
  5. package/dist/cli/commandCatalog.js +19 -9
  6. package/dist/cli/interactionPolicy.js +4 -2
  7. package/dist/cli.js +77 -32
  8. package/dist/commands/taskCommands.js +46 -11
  9. package/dist/commands/taskContextCommand.js +1 -1
  10. package/dist/commands/taskRoleRuntimeStatus.js +170 -10
  11. package/dist/controller/agentRuntimeObserver.js +210 -0
  12. package/dist/controller/clientRuntime.js +3 -21
  13. package/dist/controller/controller.js +47 -7
  14. package/dist/controller/fileSchedulerStoreAdapter.js +522 -388
  15. package/dist/controller/runtime.js +9 -3
  16. package/dist/controller/runtimeEventInbox.js +49 -295
  17. package/dist/controller/runtimeEventProcessor.js +184 -321
  18. package/dist/controller/runtimeHookRunFence.js +226 -0
  19. package/dist/controller/runtimeLaunchCoordinator.js +91 -26
  20. package/dist/controller/runtimeObservationHook.js +112 -0
  21. package/dist/core/controllerServer.js +5 -0
  22. package/dist/executor/agentAdapter.js +18 -3
  23. package/dist/executor/fileRoleLaunchPlanner.js +64 -15
  24. package/dist/executor/managedClaudeRunner.js +121 -0
  25. package/dist/observability/executionAudit.js +6 -3
  26. package/dist/repository/taskWorkspacePreparer.js +1 -4
  27. package/dist/run/providerRetryConfig.js +8 -3
  28. package/dist/runtime/agentDriver.js +229 -0
  29. package/dist/runtime/agentDriverObservation.js +57 -0
  30. package/dist/runtime/builtinAgentDrivers.js +235 -0
  31. package/dist/runtime/builtinTranscriptObserver.js +290 -0
  32. package/dist/runtime/builtinTranscriptUsage.js +97 -0
  33. package/dist/runtime/exactControlPlane.js +2 -2
  34. package/dist/runtime/index.js +1 -1
  35. package/dist/runtime/ports.js +12 -1
  36. package/dist/runtime/runtimeObservation.js +297 -0
  37. package/dist/runtime/runtimeProjection.js +277 -0
  38. package/dist/runtime/sessionTerminationGuard.js +78 -22
  39. package/dist/runtime/tmuxAdapters.js +35 -0
  40. package/dist/scheduler/activeRoleRunDelivery.js +28 -13
  41. package/dist/scheduler/leaderWakeupProcessor.js +21 -2
  42. package/dist/scheduler/roleRunLiveness.js +2 -2
  43. package/dist/scheduler/roleRunStall.js +62 -114
  44. package/dist/storage/migration/productionRegistry.js +41 -0
  45. package/dist/storage/sqliteStore.js +3 -3
  46. package/dist/storage/storageVersions.js +1 -1
  47. package/dist/telemetry/sqliteTelemetryStore.js +0 -28
  48. package/dist/telemetry/telemetryCompaction.js +1 -0
  49. package/dist/telemetry/telemetryConfig.js +4 -5
  50. package/dist/tmux/tmuxManager.js +136 -22
  51. package/dist/web/assets/client/view.js +1 -1
  52. package/dist/web/tmuxWebTerminal.js +17 -12
  53. package/dist/web/webSnapshot.js +1 -1
  54. package/dist/worktree/managedWorkspace.js +14 -0
  55. package/i18n/README.zh-CN.md +7 -5
  56. package/package.json +1 -1
  57. package/dist/controller/claudeLifecycleHook.js +0 -203
  58. package/dist/controller/codexLifecycleHook.js +0 -108
  59. package/dist/controller/providerHookRunFence.js +0 -156
  60. package/dist/lifecycle/providerLifecycleMapping.js +0 -190
  61. package/dist/telemetry/telemetryRouter.js +0 -32
@@ -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
- const readOnly = this.hasWritableClient(taskId);
171
- const clientSession = this.createInteractiveClientSession(taskId, historyNotice);
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
- ...(readOnly ? ["-r"] : []),
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 clientSession = `yui-client-${randomBytes(12).toString("hex")}`;
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 only the first attached human may type. */
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.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
- });
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 || "execution-stalled") + " · " + (run.classification || "truly-stalled")),
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 === "task") {
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
- const readOnly = this.#writers.has(hostId)
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
- clientSession = this.options.tmux.createInteractiveClientSession(hostId);
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",
@@ -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") ?? "execution-stalled",
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
@@ -431,16 +431,18 @@ Task 生命周期的交互选择只展示有效来源状态:activate 只展示
431
431
 
432
432
  ## Session 与 tmux
433
433
 
434
- 所有长时间运行的交互式 Agent 进程都由 tmux 承载。执行 `operator enter`、`role enter` `task enter` 前,Yui 会关闭 readline、退出 raw mode、暂停自身 stdin,再同步把终端交给 tmux。attach 会继承外层终端的真实能力并进入干净的 alternate screen;鼠标滚动只查看 Agent pane 100,000 tmux 历史,不再混入 attach 之前的 shell IDE Terminal 历史。因此 Agent 原生的 `/model`、斜杠命令提示、全屏渲染和按键处理都可正常工作。
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
- 同一个 Operator Task tmux session 中,第一个 Terminal/Web 客户端可写,后续查看者自动只读,避免多个入口同时向同一个 Agent 输入。
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 在启动前分配。受管理的 Codex 启动使用 Codex 结构化 `notify` 回调,在 turn 完成后记录 thread ID,不再向模型对话注入 session-bind prompt。
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -1,203 +0,0 @@
1
- import { callController } from "../core/controllerClient.js";
2
- import { runtimeLifecycleSignalKey } from "../runtime/lifecycleReservation.js";
3
- import { FileRuntimeEventInbox, MAX_RUNTIME_EVENT_FILE_BYTES } from "./runtimeEventInbox.js";
4
- import { resolveProviderHookRunFence } from "./providerHookRunFence.js";
5
- /**
6
- * Hidden CLI entrypoint used by the managed Claude lifecycle plugin. It parses
7
- * by hook_event_name and writes one immutable runtime-inbox event per fact:
8
- * SessionStart → native-session-lifecycle (carrying the source variant),
9
- * UserPromptSubmit → native-prompt-accepted (the exact provider-accepted fence),
10
- * PostToolUse → native-turn-progress (the exact provider/tool progress fence),
11
- * StopFailure → claude-stop-failure. The durable write is authoritative; the
12
- * socket call is only a wake-up hint.
13
- */
14
- export async function runClaudeLifecycleHookCommand(stdinJson, environment = process.env, call = callController) {
15
- const home = requireIdentity(environment.YUI_HOME, "YUI_HOME");
16
- const inbox = new FileRuntimeEventInbox(home);
17
- const envelope = parseClaudeHookEnvelope(stdinJson, environment);
18
- if (envelope.kind === "session-start") {
19
- inbox.enqueueSessionLifecycle({
20
- scope: "task",
21
- taskId: envelope.taskId,
22
- roleName: envelope.roleName,
23
- agentId: envelope.agentId,
24
- adapterId: "claude",
25
- launchId: envelope.launchId,
26
- nativeSessionId: envelope.nativeSessionId,
27
- runId: envelope.runId,
28
- sessionSource: envelope.sessionSource
29
- });
30
- }
31
- else if (envelope.kind === "prompt-submit") {
32
- inbox.enqueuePromptAccepted({
33
- scope: "task",
34
- taskId: envelope.taskId,
35
- roleName: envelope.roleName,
36
- agentId: envelope.agentId,
37
- adapterId: "claude",
38
- launchId: envelope.launchId,
39
- nativeSessionId: envelope.nativeSessionId,
40
- runId: envelope.runId,
41
- receiptId: envelope.receiptId
42
- });
43
- }
44
- else if (envelope.kind === "turn-progress") {
45
- inbox.enqueueProviderProgress({
46
- scope: "task",
47
- taskId: envelope.taskId,
48
- roleName: envelope.roleName,
49
- agentId: envelope.agentId,
50
- adapterId: "claude",
51
- launchId: envelope.launchId,
52
- nativeSessionId: envelope.nativeSessionId,
53
- runId: envelope.runId,
54
- progressId: envelope.progressId
55
- });
56
- }
57
- else {
58
- inbox.enqueueClaudeStopFailure({
59
- scope: "task",
60
- taskId: envelope.taskId,
61
- roleName: envelope.roleName,
62
- agentId: envelope.agentId,
63
- adapterId: "claude",
64
- launchId: envelope.launchId,
65
- nativeSessionId: envelope.nativeSessionId,
66
- runId: envelope.runId,
67
- error: envelope.error,
68
- ...(envelope.errorDetails === undefined
69
- ? {}
70
- : { errorDetails: envelope.errorDetails }),
71
- ...(envelope.lastAssistantMessage === undefined
72
- ? {}
73
- : { lastAssistantMessage: envelope.lastAssistantMessage })
74
- });
75
- }
76
- // The immutable event is authoritative; the socket call is only a hint.
77
- await call(home, "scheduler.signal", {
78
- key: runtimeLifecycleSignalKey({
79
- scope: "task",
80
- taskId: envelope.taskId,
81
- roleName: envelope.roleName
82
- })
83
- }, { timeoutMs: 100 }).catch(() => { });
84
- }
85
- /**
86
- * Parses a Claude hook payload by hook_event_name into the exact fenced
87
- * envelope. Every event requires the managed launch envelope (task/role/agent/
88
- * launch/run) and the payload session id must match YUI_NATIVE_SESSION_ID, so a
89
- * mismatched generation fails closed before any inbox write.
90
- */
91
- export function parseClaudeHookEnvelope(stdinJson, environment) {
92
- const payload = parseObject(stdinJson);
93
- if (payload.hook_event_name !== "SessionStart"
94
- && payload.hook_event_name !== "UserPromptSubmit"
95
- && payload.hook_event_name !== "PostToolUse"
96
- && payload.hook_event_name !== "StopFailure") {
97
- throw new Error("Managed Claude lifecycle ingestion received an unsupported hook event.");
98
- }
99
- const sessionSource = payload.hook_event_name === "SessionStart"
100
- ? requireIdentity(payload.source, "Claude SessionStart source")
101
- : undefined;
102
- const base = parseClaudeEnvelope(payload, environment, {
103
- allowPreallocatedClaudeStartup: sessionSource === "startup"
104
- });
105
- switch (payload.hook_event_name) {
106
- case "SessionStart":
107
- return {
108
- ...base,
109
- kind: "session-start",
110
- sessionSource: sessionSource
111
- };
112
- case "UserPromptSubmit":
113
- return {
114
- ...base,
115
- kind: "prompt-submit",
116
- receiptId: requireIdentity(base.receiptId, "Claude transport receipt id")
117
- };
118
- case "PostToolUse":
119
- return {
120
- ...base,
121
- kind: "turn-progress",
122
- progressId: requireIdentity(payload.tool_use_id, "Claude PostToolUse id")
123
- };
124
- case "StopFailure":
125
- return {
126
- ...base,
127
- kind: "stop-failure",
128
- error: requireResult(payload.error, "Claude StopFailure error"),
129
- ...(payload.error_details === undefined
130
- ? {}
131
- : { errorDetails: requireResult(payload.error_details, "Claude StopFailure error_details") }),
132
- ...(payload.last_assistant_message === undefined
133
- ? {}
134
- : {
135
- lastAssistantMessage: requireResult(payload.last_assistant_message, "Claude StopFailure last_assistant_message")
136
- })
137
- };
138
- default:
139
- throw new Error("Managed Claude lifecycle ingestion received an unsupported hook event.");
140
- }
141
- }
142
- function parseClaudeEnvelope(payload, environment, options) {
143
- const nativeSessionId = requireIdentity(payload.session_id, "Claude session id");
144
- return {
145
- ...resolveProviderHookRunFence(environment, "claude", nativeSessionId, options),
146
- adapterId: "claude"
147
- };
148
- }
149
- export function parseClaudeStopFailureHookNotification(stdinJson, environment) {
150
- const envelope = parseClaudeHookEnvelope(stdinJson, environment);
151
- if (envelope.kind !== "stop-failure") {
152
- throw new Error("Managed Claude lifecycle ingestion accepts only StopFailure.");
153
- }
154
- return {
155
- taskId: envelope.taskId,
156
- roleName: envelope.roleName,
157
- agentId: envelope.agentId,
158
- adapterId: "claude",
159
- launchId: envelope.launchId,
160
- runId: envelope.runId,
161
- nativeSessionId: envelope.nativeSessionId,
162
- type: "StopFailure",
163
- error: envelope.error,
164
- ...(envelope.errorDetails === undefined ? {} : { errorDetails: envelope.errorDetails }),
165
- ...(envelope.lastAssistantMessage === undefined
166
- ? {}
167
- : { lastAssistantMessage: envelope.lastAssistantMessage })
168
- };
169
- }
170
- function parseObject(value) {
171
- if (value === undefined
172
- || Buffer.byteLength(value, "utf8") > MAX_RUNTIME_EVENT_FILE_BYTES) {
173
- throw new Error("Claude lifecycle hook stdin JSON is invalid.");
174
- }
175
- try {
176
- const parsed = JSON.parse(value);
177
- if (!isObject(parsed))
178
- throw new Error("shape");
179
- return parsed;
180
- }
181
- catch {
182
- throw new Error("Claude lifecycle hook stdin JSON is invalid.");
183
- }
184
- }
185
- function requireIdentity(value, label) {
186
- if (typeof value !== "string" || value.includes("\0")) {
187
- throw new Error(`${label} is required.`);
188
- }
189
- const normalized = value.trim();
190
- if (normalized.length === 0 || normalized.length > 1_024) {
191
- throw new Error(`${label} is invalid.`);
192
- }
193
- return normalized;
194
- }
195
- function requireResult(value, label) {
196
- if (typeof value !== "string" || value.includes("\0") || value.trim().length === 0) {
197
- throw new Error(`${label} is required.`);
198
- }
199
- return value;
200
- }
201
- function isObject(value) {
202
- return typeof value === "object" && value !== null && !Array.isArray(value);
203
- }