@zq-silk/yui 2.22.0 → 2.25.0

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 (89) hide show
  1. package/README.md +9 -4
  2. package/dist/artifacts/artifactCapability.js +6 -6
  3. package/dist/artifacts/gitArtifactRef.js +10 -10
  4. package/dist/artifacts/jobArtifact.js +5 -5
  5. package/dist/artifacts/managedGit.js +15 -12
  6. package/dist/artifacts/taskArtifactRepository.js +35 -33
  7. package/dist/cli/commandCatalog.js +2 -2
  8. package/dist/cli/updateCommand.js +20 -3
  9. package/dist/cli/updateOrchestrator.js +13 -0
  10. package/dist/cli/updatePorts.js +27 -0
  11. package/dist/cli.js +9 -2
  12. package/dist/commands/configCommands.js +64 -1
  13. package/dist/commands/projectCommands.js +9 -9
  14. package/dist/commands/taskCompletionGate.js +1 -1
  15. package/dist/commands/taskIntegrationCommands.js +1 -1
  16. package/dist/commands/taskPublicationAdoptCommand.js +1 -1
  17. package/dist/commands/taskRoleRuntimeStatus.js +4 -0
  18. package/dist/commands/taskUpstreamCommands.js +1 -1
  19. package/dist/config/configCatalog.js +3 -0
  20. package/dist/config/gitConfig.js +20 -0
  21. package/dist/config/yuiConfig.js +1 -0
  22. package/dist/context/currentSkills.js +32 -1
  23. package/dist/context/sessionBootstrapManifest.js +17 -4
  24. package/dist/context/sessionSkillNotice.js +33 -12
  25. package/dist/controlPlaneCli.js +4 -4
  26. package/dist/controller/clientRuntime.js +37 -18
  27. package/dist/controller/controller.js +16 -1
  28. package/dist/controller/fileSchedulerStoreAdapter.js +2 -0
  29. package/dist/controller/hostLifecycle.js +153 -0
  30. package/dist/controller/hostLifecycleChecks.js +27 -0
  31. package/dist/controller/hostUpgrade.js +344 -0
  32. package/dist/controller/jobArtifactControl.js +3 -3
  33. package/dist/controller/jobDefinitionControl.js +1 -1
  34. package/dist/controller/runtime.js +67 -16
  35. package/dist/controller/sessionNotify.js +5 -46
  36. package/dist/execution/codexThreadNaming.js +54 -43
  37. package/dist/executor/fileRoleLaunchPlanner.js +18 -3
  38. package/dist/integration/gitIntegrationService.js +1 -1
  39. package/dist/kernel/builtinCapabilities.js +3 -3
  40. package/dist/repository/gitCommand.js +153 -0
  41. package/dist/repository/gitCompatibility.js +3 -2
  42. package/dist/repository/gitWorkspace.js +579 -563
  43. package/dist/repository/gitWorktreeInventory.js +15 -19
  44. package/dist/repository/projectRefresh.js +1 -1
  45. package/dist/repository/taskBaseFreshness.js +1 -4
  46. package/dist/repository/taskWorkspacePreparer.js +1 -1
  47. package/dist/runtime/agentEndpoint.js +11 -1
  48. package/dist/runtime/agentEndpointIdentity.js +2 -0
  49. package/dist/runtime/agentHost.js +167 -42
  50. package/dist/runtime/agentHostImplementation.js +3 -1
  51. package/dist/runtime/codexAppServerRuntime.js +3 -0
  52. package/dist/runtime/codexInteractiveHost.js +62 -17
  53. package/dist/runtime/codexNativeAccess.js +1 -1
  54. package/dist/runtime/contextRecovery.js +15 -0
  55. package/dist/runtime/hostSessionLifecycle.js +83 -0
  56. package/dist/runtime/hostUpgradeGate.js +43 -0
  57. package/dist/runtime/lifecycleChecks.js +47 -0
  58. package/dist/runtime/lifecycleReservation.js +9 -1
  59. package/dist/runtime/nativeExecutable.js +5 -2
  60. package/dist/runtime/nativeTitleReceipt.js +31 -0
  61. package/dist/runtime/nativeTitleSync.js +192 -0
  62. package/dist/runtime/platform.js +11 -0
  63. package/dist/runtime/providerRetry.js +4 -4
  64. package/dist/runtime/structuredProviderHost.js +42 -10
  65. package/dist/storage/sqliteSchema.js +30 -0
  66. package/dist/storage/storageVersions.js +1 -1
  67. package/dist/storage/taskStore.js +7 -1
  68. package/dist/web/webConversation.js +2 -2
  69. package/dist/web/webMaterials.js +19 -19
  70. package/dist/web/webTaskSurface.js +3 -3
  71. package/dist/workspace/workItemChangeSetManager.js +3 -3
  72. package/docs/managed-turn-and-session-runtime.md +96 -1
  73. package/docs/managed-turn-and-session-runtime.zh-CN.md +70 -1
  74. package/docs/project-refresh.md +3 -2
  75. package/docs/project-refresh.zh-CN.md +2 -1
  76. package/docs/release-workflow.md +86 -2
  77. package/docs/release-workflow.zh-CN.md +65 -1
  78. package/docs/roles-and-configuration.md +34 -0
  79. package/docs/roles-and-configuration.zh-CN.md +27 -0
  80. package/docs/storage-baseline.md +14 -1
  81. package/docs/storage-baseline.zh-CN.md +7 -1
  82. package/docs/testing/verification-levels.md +7 -7
  83. package/docs/testing/verification-levels.zh-CN.md +6 -6
  84. package/i18n/README.zh-CN.md +8 -4
  85. package/package.json +1 -1
  86. package/skills/yui-runtime/SKILL.md +7 -2
  87. package/skills/yui-runtime/VERSION +1 -1
  88. package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
  89. package/dist/runtime/native/darwin-x64/process-identity +0 -0
@@ -4,7 +4,11 @@ import { once } from "node:events";
4
4
  import WebSocket, { WebSocketServer } from "ws";
5
5
  import { AGENT_HOST_CONTROL_PROTOCOL } from "./agentHostProtocol.js";
6
6
  import { openCodexInteractiveConnection } from "./structuredProviderHost.js";
7
- import { createSessionSkillNotice } from "../context/sessionSkillNotice.js";
7
+ import { createSessionSkillNotice, createSessionSkillLifecycleCheck } from "../context/sessionSkillNotice.js";
8
+ import { isNativeContextRecovery } from "./contextRecovery.js";
9
+ import { createHostSessionLifecycle } from "./hostSessionLifecycle.js";
10
+ import { createNonBlockingChecks } from "./lifecycleChecks.js";
11
+ import { createNativeTitleSync } from "./nativeTitleSync.js";
8
12
  /**
9
13
  * Keep the native TUI and its transparent App Server attachment in one pane.
10
14
  * The existing Host acknowledgement carries the ID from that TUI's exact
@@ -31,8 +35,19 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
31
35
  throw new Error("Codex proxy base arguments are invalid.");
32
36
  }
33
37
  const expectedId = environment.YUI_NATIVE_SESSION_ID;
38
+ const lifecycleChecks = createNonBlockingChecks([createSessionSkillLifecycleCheck(home, () => environment)], error => process.stderr.write(`Session lifecycle check failed: ${String(error)}\n`));
39
+ const lifecycle = createHostSessionLifecycle({ after: lifecycleChecks });
40
+ lifecycle.send({ type: "launch" });
34
41
  let snapshot = {
35
- schemaVersion: 1, state: "starting", adapterId: "codex", updatedAt: new Date().toISOString()
42
+ schemaVersion: 1, state: lifecycle.state, adapterId: "codex", updatedAt: new Date().toISOString()
43
+ };
44
+ const transition = (event, fields = {}) => {
45
+ const result = lifecycle.send(event);
46
+ if (result.status === "ignored")
47
+ return;
48
+ if (result.status === "invalid")
49
+ throw new Error(result.reason);
50
+ snapshot = { ...snapshot, ...fields, state: result.state, updatedAt: new Date().toISOString() };
36
51
  };
37
52
  const control = await openAgentHostControl(home, payload, () => snapshot, async (request) => {
38
53
  if (request.type !== "status") {
@@ -44,6 +59,9 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
44
59
  let client;
45
60
  let closing = false;
46
61
  let startupRequestId;
62
+ let initializeRequestId;
63
+ let nativeAccountHome;
64
+ let titleSync;
47
65
  let connection;
48
66
  let relay;
49
67
  const signals = ["SIGTERM", "SIGHUP", "SIGINT"];
@@ -51,11 +69,9 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
51
69
  const fail = (error) => {
52
70
  if (closing || snapshot.state === "failed")
53
71
  return;
54
- snapshot = {
55
- ...snapshot, state: "failed",
56
- detail: error instanceof Error ? error.message : String(error),
57
- updatedAt: new Date().toISOString()
58
- };
72
+ transition({ type: "failure", outcome: "failed" }, {
73
+ detail: error instanceof Error ? error.message : String(error)
74
+ });
59
75
  process.stderr.write(`Yui Codex attachment failed: ${snapshot.detail}\n`);
60
76
  client?.terminate();
61
77
  stop();
@@ -94,6 +110,11 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
94
110
  if (binary)
95
111
  throw new Error("Codex TUI sent a binary App Server request.");
96
112
  const message = jsonObject(JSON.parse(data.toString()));
113
+ if (message.method === "thread/name/set")
114
+ titleSync?.close();
115
+ if (message.method === "initialize"
116
+ && (typeof message.id === "string" || typeof message.id === "number"))
117
+ initializeRequestId = message.id;
97
118
  if (snapshot.nativeSessionId === undefined
98
119
  && (message.method === "thread/start" || message.method === "thread/resume")) {
99
120
  if (startupRequestId !== undefined)
@@ -135,6 +156,17 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
135
156
  });
136
157
  connection.onMessage((message) => {
137
158
  try {
159
+ if (snapshot.nativeSessionId !== undefined
160
+ && isNativeContextRecovery("codex", snapshot.nativeSessionId, message))
161
+ skillNotice.invalidate();
162
+ if (message.method === "thread/name/updated"
163
+ && message.params?.threadId === snapshot.nativeSessionId)
164
+ titleSync?.close();
165
+ if (message.method === undefined && message.id === initializeRequestId) {
166
+ const initialized = message.result;
167
+ nativeAccountHome = typeof initialized?.codexHome === "string" ? initialized.codexHome : undefined;
168
+ initializeRequestId = undefined;
169
+ }
138
170
  if (message.method === undefined && (typeof message.id === "string" || typeof message.id === "number")) {
139
171
  const pending = pendingNotices.get(message.id);
140
172
  if (pending !== undefined) {
@@ -142,8 +174,10 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
142
174
  const result = message.result;
143
175
  const turnId = result?.turn?.id ?? result?.turnId;
144
176
  if (message.error === undefined && typeof turnId === "string" && turnId.length > 0
145
- && (pending.expectedTurnId === undefined || pending.expectedTurnId === turnId))
177
+ && (pending.expectedTurnId === undefined || pending.expectedTurnId === turnId)) {
146
178
  pending.commit();
179
+ titleSync?.notify();
180
+ }
147
181
  }
148
182
  }
149
183
  let nativeSessionId;
@@ -166,13 +200,17 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
166
200
  fail(error);
167
201
  return;
168
202
  }
169
- if (nativeSessionId === undefined || snapshot.state === "failed")
203
+ if (nativeSessionId === undefined || closing || snapshot.state === "failed")
170
204
  return;
171
205
  startupRequestId = undefined;
172
- snapshot = {
173
- ...snapshot, state: "ready", nativeSessionId, conversationId: nativeSessionId,
174
- updatedAt: new Date().toISOString()
175
- };
206
+ // Ready here means the native TUI attachment is ready, not a managed
207
+ // AgentRun acceptance or proof that the native Thread is idle.
208
+ transition({ type: "interactive-connected", nativeSessionId }, {
209
+ nativeSessionId, conversationId: nativeSessionId
210
+ });
211
+ titleSync = createNativeTitleSync(payload, nativeSessionId, nativeAccountHome, result => process.stderr.write(`Native title: ${JSON.stringify(result)}\n`));
212
+ if (expectedId !== undefined)
213
+ titleSync.notify();
176
214
  });
177
215
  }
178
216
  catch (error) {
@@ -196,6 +234,7 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
196
234
  for (const signal of signals)
197
235
  process.on(signal, stop);
198
236
  const [code] = await once(child, "exit");
237
+ transition({ type: "exit" });
199
238
  return typeof code === "number" ? code : 1;
200
239
  }
201
240
  catch (error) {
@@ -203,7 +242,9 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
203
242
  throw error;
204
243
  }
205
244
  finally {
245
+ lifecycleChecks.close();
206
246
  closing = true;
247
+ titleSync?.close();
207
248
  for (const signal of signals)
208
249
  process.removeListener(signal, stop);
209
250
  stop();
@@ -214,7 +255,8 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
214
255
  await control.close();
215
256
  }
216
257
  }
217
- /** Preserve all native content parts (including images), adding only Host metadata. */
258
+ /** Keep application metadata out of native user input, including on steer.
259
+ * Formal titles are synchronized separately; no automatic-title heuristic is assumed. */
218
260
  export function codexInteractiveSkillInput(notice, environment, nativeSessionId, params) {
219
261
  if (!Array.isArray(params.input))
220
262
  throw new Error("Turn input must be an array.");
@@ -222,9 +264,12 @@ export function codexInteractiveSkillInput(notice, environment, nativeSessionId,
222
264
  return {
223
265
  params: {
224
266
  ...params,
225
- input: prepared.text.length === 0 ? params.input : [
226
- { type: "text", text: prepared.text.trimEnd(), text_elements: [] }, ...params.input
227
- ]
267
+ ...(prepared.context === undefined ? {} : {
268
+ additionalContext: {
269
+ ...(params.additionalContext == null ? {} : jsonObject(params.additionalContext)),
270
+ "yui.session-skills": { kind: "application", value: prepared.context }
271
+ }
272
+ })
228
273
  },
229
274
  commit: prepared.commit
230
275
  };
@@ -5,7 +5,7 @@ const READS = new Set([
5
5
  "config/read", "configRequirements/read", "model/list", "collaborationMode/list",
6
6
  "experimentalFeature/list", "skills/list", "hooks/list", "mcpServerStatus/list", "account/read",
7
7
  "account/rateLimits/read", "modelProvider/capabilities/read", "permissionProfile/list",
8
- "thread/read", "thread/turns/list", "thread/items/list", "thread/goal/get"
8
+ "thread/read", "thread/turns/list", "thread/items/list", "thread/goal/get", "thread/backgroundTerminals/list"
9
9
  ]);
10
10
  const REQUESTS = new Set([
11
11
  "item/commandExecution/requestApproval", "item/fileChange/requestApproval",
@@ -0,0 +1,15 @@
1
+ /** Only explicit, identity-bound native lifecycle events invalidate context.
2
+ * Absence of an event never proves that instructions are still in memory. */
3
+ export function isNativeContextRecovery(adapter, nativeSessionId, message) {
4
+ if (adapter === "claude") {
5
+ return message.type === "system" && message.subtype === "compact_boundary"
6
+ && message.session_id === nativeSessionId;
7
+ }
8
+ const params = message.params;
9
+ if (!params || params.threadId !== nativeSessionId)
10
+ return false;
11
+ if (message.method === "thread/compacted")
12
+ return true;
13
+ const item = params.item;
14
+ return message.method === "item/completed" && item?.type === "contextCompaction";
15
+ }
@@ -0,0 +1,83 @@
1
+ const live = [
2
+ "idle", "starting", "ready", "busy", "settling", "delivery-unknown", "rejected", "failed"
3
+ ];
4
+ const available = ["idle", "busy", "rejected", "failed"];
5
+ const occupancy = (value) => (event) => "occupancy" in event && event.occupancy === value;
6
+ /** Lifecycle only: no version checks, upgrade phases, I/O or business policy.
7
+ * Each event selects one guarded edge; callers cannot assign a target state.
8
+ * Existing authority/admission checks must succeed before sending commands. */
9
+ export const HOST_SESSION_LIFECYCLE_TRANSITIONS = {
10
+ launch: [{ from: [...available, "exited"], to: "starting" }],
11
+ submit: [{ from: available, to: "starting" }],
12
+ connected: [
13
+ { from: live, guard: occupancy("owned"), to: "ready" },
14
+ { from: live, guard: occupancy("unknown"), to: "delivery-unknown" },
15
+ { from: live, guard: occupancy("busy"), to: "busy" },
16
+ { from: live, guard: occupancy("idle"), to: "idle" }
17
+ ],
18
+ accepted: [{ from: ["starting", "ready", "delivery-unknown"], to: "ready" }],
19
+ "interactive-connected": [{ from: ["starting", "ready"], to: "ready" }],
20
+ "native-started": [{ from: live, to: "busy" }],
21
+ terminal: [{ from: ["starting", "ready", "busy", "delivery-unknown", "settling", "failed"], to: "settling" }],
22
+ settled: [
23
+ { from: live, guard: occupancy("idle"), to: "idle" },
24
+ { from: live, guard: occupancy("busy"), to: "busy" }
25
+ ],
26
+ disconnected: [{ from: live, to: "starting" }],
27
+ "authority-changed": [{ from: available, to: "idle" }],
28
+ failure: ["busy", "delivery-unknown", "rejected", "failed"].map(outcome => ({
29
+ from: live, guard: event => event.type === "failure" && event.outcome === outcome, to: outcome
30
+ })),
31
+ exit: [{ from: live, to: "exited" }]
32
+ };
33
+ export function createHostSessionLifecycle(hooks = {}) {
34
+ let state = "idle";
35
+ let revision = 0;
36
+ let identity = {};
37
+ const edgeFor = (event) => HOST_SESSION_LIFECYCLE_TRANSITIONS[event.type].find(rule => rule.from.includes(state) && (!rule.guard || rule.guard(event)));
38
+ return {
39
+ get state() { return state; },
40
+ get revision() { return revision; },
41
+ /** State eligibility only; caller still owns exact writer/resource guards. */
42
+ can(event) { return edgeFor(event) !== undefined; },
43
+ send(input) {
44
+ const event = Object.freeze({ ...input });
45
+ if (state === "exited" && event.type !== "launch")
46
+ return { status: "ignored", state, reason: "exited" };
47
+ if (!["launch", "connected"].includes(event.type)) {
48
+ for (const key of ["processInstanceId", "nativeSessionId"]) {
49
+ if (identity[key] && event[key] && identity[key] !== event[key]) {
50
+ return { status: "ignored", state, reason: `stale-${key}` };
51
+ }
52
+ }
53
+ if (["accepted", "terminal"].includes(event.type)
54
+ && identity.attemptId && event.attemptId && identity.attemptId !== event.attemptId) {
55
+ return { status: "ignored", state, reason: "stale-attempt" };
56
+ }
57
+ if (event.type === "terminal" && identity.nativeTurnId && event.nativeTurnId
58
+ && identity.nativeTurnId !== event.nativeTurnId) {
59
+ return { status: "ignored", state, reason: "stale-turn" };
60
+ }
61
+ }
62
+ const edge = edgeFor(event);
63
+ if (!edge)
64
+ return { status: "invalid", state, reason: `No ${event.type} edge from ${state}` };
65
+ const transition = Object.freeze({ from: state, to: edge.to, revision: revision + 1, event });
66
+ hooks.before?.notify(transition);
67
+ state = edge.to;
68
+ revision += 1;
69
+ // A new authorized attachment must not inherit the departed proxy's
70
+ // identity fence. A launch reusing a live connection supplies its IDs.
71
+ if (event.type === "launch")
72
+ identity = {};
73
+ identity = {
74
+ processInstanceId: event.processInstanceId ?? identity.processInstanceId,
75
+ nativeSessionId: event.nativeSessionId ?? identity.nativeSessionId,
76
+ attemptId: event.attemptId,
77
+ nativeTurnId: event.nativeTurnId
78
+ };
79
+ hooks.after?.notify(transition);
80
+ return { status: "applied", state };
81
+ }
82
+ };
83
+ }
@@ -0,0 +1,43 @@
1
+ /** Host-local input admission fence. The existing lifecycle mailbox owns the
2
+ * durable request; this gate owns only the actual loaded process's drain. */
3
+ export function createHostUpgradeGate() {
4
+ let current;
5
+ let draining;
6
+ return {
7
+ get observation() { return current; },
8
+ get fenced() { return current !== undefined; },
9
+ request(fence, identity) {
10
+ if (fence.nativeSessionId !== identity.nativeSessionId
11
+ || fence.processInstanceId !== identity.processInstanceId
12
+ || fence.hostGeneration !== identity.hostGeneration)
13
+ throw new Error("Host upgrade identity changed.");
14
+ if (current && (fence.requestId !== current.requestId || fence.nativeSessionId !== current.nativeSessionId
15
+ || fence.processInstanceId !== current.processInstanceId || fence.hostGeneration !== current.hostGeneration)) {
16
+ throw new Error("Host already holds a different upgrade request.");
17
+ }
18
+ current ??= { requestId: fence.requestId, nativeSessionId: fence.nativeSessionId,
19
+ processInstanceId: fence.processInstanceId, hostGeneration: fence.hostGeneration, state: "waiting" };
20
+ },
21
+ async drain(input) {
22
+ if (!current || current.state !== "waiting")
23
+ return;
24
+ if (input.busy || input.events.pending !== 0 || input.events.pendingTerminals !== 0
25
+ || input.events.unpersisted !== 0 || input.events.failure !== undefined)
26
+ return;
27
+ draining ??= (async () => {
28
+ try {
29
+ if (!await input.checkQuiescent())
30
+ return;
31
+ await input.release();
32
+ current = { ...current, state: "drained" };
33
+ }
34
+ catch (error) {
35
+ current = { ...current, state: "failed",
36
+ detail: error instanceof Error ? error.message : String(error) };
37
+ }
38
+ })();
39
+ await draining;
40
+ draining = undefined;
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,47 @@
1
+ export function createNonBlockingChecks(checks, onError = () => { }) {
2
+ if (new Set(checks.map(check => check.id)).size !== checks.length)
3
+ throw new Error("Duplicate lifecycle check id.");
4
+ const abort = new AbortController();
5
+ const lanes = checks.map(check => ({ check, running: false, pending: undefined }));
6
+ const launch = (lane) => {
7
+ if (lane.running || abort.signal.aborted)
8
+ return;
9
+ lane.running = true;
10
+ // Defer even synchronous callbacks until the caller has committed its state.
11
+ void Promise.resolve().then(async () => {
12
+ while (lane.pending && !abort.signal.aborted) {
13
+ const { value } = lane.pending;
14
+ lane.pending = undefined;
15
+ try {
16
+ await lane.check.run(value, abort.signal);
17
+ }
18
+ catch (error) {
19
+ try {
20
+ onError(error);
21
+ }
22
+ catch { /* Diagnostics cannot gate lifecycle. */ }
23
+ }
24
+ }
25
+ }).finally(() => {
26
+ lane.running = false;
27
+ if (lane.pending)
28
+ launch(lane);
29
+ });
30
+ };
31
+ return {
32
+ notify(observation) {
33
+ if (abort.signal.aborted)
34
+ return;
35
+ for (const lane of lanes) {
36
+ lane.pending = { value: observation };
37
+ launch(lane);
38
+ }
39
+ },
40
+ get pendingCount() { return lanes.filter(lane => lane.pending !== undefined).length; },
41
+ close() {
42
+ abort.abort();
43
+ for (const lane of lanes)
44
+ lane.pending = undefined;
45
+ }
46
+ };
47
+ }
@@ -3,6 +3,9 @@ export const RUNTIME_LIFECYCLE_OWNER = "runtime-lifecycle";
3
3
  export const RUNTIME_CLEANUP_REQUIRED_REASON = "runtime-cleanup-required";
4
4
  export const RUNTIME_HOST_DETACH_REQUIRED_REASON = "runtime-host-detach-required";
5
5
  export const RUNTIME_SESSION_REPLACE_REQUIRED_REASON = "runtime-session-replace-required";
6
+ export const RUNTIME_HOST_UPGRADE_REASON = "runtime-host-upgrade-required";
7
+ export const RUNTIME_HOST_UPGRADE_DRAINED = "runtime-host-upgrade-drained";
8
+ export const RUNTIME_HOST_UPGRADE_FAILED = "runtime-host-upgrade-failed";
6
9
  /**
7
10
  * A Role runtime lifecycle lane already holds an explicit cleanup obligation.
8
11
  * This is scheduler
@@ -48,12 +51,17 @@ export function runtimeCleanupDisposition(mailbox) {
48
51
  return "end-session";
49
52
  if (reasons.includes(RUNTIME_HOST_DETACH_REQUIRED_REASON))
50
53
  return "detach-host";
54
+ if (reasons.includes(RUNTIME_HOST_UPGRADE_REASON))
55
+ return "upgrade-host";
51
56
  return null;
52
57
  }
53
58
  export function isRuntimeCleanupReason(reason) {
54
59
  return reason === RUNTIME_CLEANUP_REQUIRED_REASON
55
60
  || reason === RUNTIME_SESSION_REPLACE_REQUIRED_REASON
56
- || reason === RUNTIME_HOST_DETACH_REQUIRED_REASON;
61
+ || reason === RUNTIME_HOST_DETACH_REQUIRED_REASON
62
+ || reason === RUNTIME_HOST_UPGRADE_REASON
63
+ || reason === RUNTIME_HOST_UPGRADE_DRAINED
64
+ || reason === RUNTIME_HOST_UPGRADE_FAILED;
57
65
  }
58
66
  export function hasRuntimeLifecycleWork(mailbox) {
59
67
  return mailbox !== null && mailboxHasWork(mailbox);
@@ -1,9 +1,12 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { unsupportedPlatformMessage } from "./platform.js";
2
3
  /** Native binaries are shipped together; never execute another platform's helper. */
3
4
  export function nativeExecutable(name, platform = process.platform, architecture = process.arch) {
4
5
  const target = `${platform}-${architecture}`;
5
- if (!["linux-x64", "darwin-x64", "darwin-arm64"].includes(target)
6
- || (name === "process-identity" && platform !== "darwin")) {
6
+ const failure = unsupportedPlatformMessage(platform, architecture);
7
+ if (failure !== undefined)
8
+ throw new Error(failure);
9
+ if (name === "process-identity" && platform !== "darwin") {
7
10
  throw new Error(`Unsupported native runtime: ${target}/${name}.`);
8
11
  }
9
12
  return fileURLToPath(new URL(`./native/${target}/${name}`, import.meta.url));
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export function nativeTitleDirectory(home, identity) {
5
+ const key = createHash("sha256").update(JSON.stringify([
6
+ identity.scope, identity.taskId ?? null, identity.roleName, identity.agentId, identity.threadId
7
+ ])).digest("hex");
8
+ return join(home, "runtime", "native-titles", key);
9
+ }
10
+ export function parseNativeTitleReceipt(text, identity) {
11
+ const value = JSON.parse(text);
12
+ if (value.schemaVersion !== 1 || JSON.stringify(value.identity) !== JSON.stringify(identity)
13
+ || !value.accountHome || !value.attemptId || !value.requestedTitle
14
+ || !(value.expectedTitle === null || typeof value.expectedTitle === "string")
15
+ || !["submitted", "confirmed", "unknown", "preserved"].includes(value.status)
16
+ || !Array.isArray(value.generatedTitles) || value.generatedTitles.some(title => typeof title !== "string")) {
17
+ throw new Error("Invalid native title receipt; preserve it for diagnosis.");
18
+ }
19
+ return value;
20
+ }
21
+ /** Read-only diagnostics can reuse confirmation, never a submitted naming intent.
22
+ * Unavailable/uncertain evidence supplies no ownership of an existing title. */
23
+ export function confirmedNativeTitles(home, identity) {
24
+ try {
25
+ const receipt = parseNativeTitleReceipt(readFileSync(join(nativeTitleDirectory(home, identity), "receipt.json"), "utf8"), identity);
26
+ return receipt.status === "confirmed" ? receipt.generatedTitles : [];
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rmdir } from "node:fs/promises";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { createNonBlockingChecks } from "./lifecycleChecks.js";
5
+ import { CodexNamingClient } from "../execution/codexThreadNaming.js";
6
+ import { nativeTitleDirectory, parseNativeTitleReceipt } from "./nativeTitleReceipt.js";
7
+ /** A standalone metadata process, NOT a second connection to the shared
8
+ * business daemon. It never starts/resumes a Thread or runs a Turn. Its hard
9
+ * process deadline releases even native locks after an unanswered request.
10
+ * Caller launches this in an independent lane; Goal/Turn never await it. */
11
+ export async function synchronizeNativeTitle(request) {
12
+ const result = (status, detail, extra = {}) => ({ threadId: request.identity.threadId, status, detail, ...extra });
13
+ let client;
14
+ let locked = false;
15
+ let submitted = false;
16
+ let receipt;
17
+ const cancel = () => client?.close("SIGKILL");
18
+ const directory = nativeTitleDirectory(request.home, request.identity);
19
+ try {
20
+ if (!isAbsolute(request.home) || !isAbsolute(request.accountHome)
21
+ || [request.title, request.generatedTitle].some(title => title !== undefined
22
+ && (!title || title.trim() !== title || title.length > 1024 || /[\r\n\0]/u.test(title)))) {
23
+ return result("not-submitted", "Native title request has an invalid local identity or title.");
24
+ }
25
+ if (request.signal?.aborted)
26
+ return result("not-submitted", "Title attachment closed.");
27
+ await mkdir(directory, { recursive: true, mode: 0o700 });
28
+ // Never remove another attempt's lock, including after a Host crash.
29
+ await mkdir(join(directory, "writing"), { mode: 0o700 });
30
+ locked = true;
31
+ try {
32
+ receipt = parseNativeTitleReceipt(await readFile(join(directory, "receipt.json"), "utf8"), request.identity);
33
+ }
34
+ catch (error) {
35
+ if (error.code !== "ENOENT")
36
+ throw error;
37
+ }
38
+ if (receipt !== undefined && (receipt.accountHome !== request.accountHome || receipt.status !== "confirmed")) {
39
+ return result("not-submitted", "Prior title identity/effect is unresolved; no rename replayed.", { attemptId: receipt.attemptId, receiptPath: join(directory, "receipt.json") });
40
+ }
41
+ if (request.localTitle !== undefined && request.localTitle !== request.previousTitle
42
+ && !receipt?.generatedTitles.includes(request.localTitle)) {
43
+ return result("preserved", "Yui Session has a custom or unowned local title; native name left unchanged.");
44
+ }
45
+ client = await CodexNamingClient.start(request.command, { ...request.environment, CODEX_HOME: request.accountHome }, request.baseArgs, request.timeoutMs ?? 2_000);
46
+ request.signal?.addEventListener("abort", cancel, { once: true });
47
+ if (request.signal?.aborted)
48
+ return result("not-submitted", "Title attachment closed.");
49
+ if (client.accountHome !== request.accountHome) {
50
+ return result("not-submitted", "Metadata process account identity did not match the business connection.");
51
+ }
52
+ const read = await client.request("thread/read", { threadId: request.identity.threadId, includeTurns: false });
53
+ const thread = read.thread;
54
+ if (thread?.id !== request.identity.threadId
55
+ || !(thread.name == null || typeof thread.name === "string")) {
56
+ return result("not-submitted", "Native title observation has no exact Thread/name identity.");
57
+ }
58
+ const current = typeof thread.name === "string" && thread.name.length > 0 ? thread.name : null;
59
+ const previous = receipt?.requestedTitle ?? request.previousTitle;
60
+ const title = request.generatedTitle !== undefined
61
+ && (request.title === previous || receipt?.generatedTitles.includes(request.title))
62
+ ? request.generatedTitle : request.title;
63
+ // A prefix (including the historic Skill notice) is not ownership proof.
64
+ if (current !== previous && !(request.newThread && current === null && receipt === undefined)) {
65
+ const pollution = [current, thread.preview].some(value => typeof value === "string"
66
+ && (value.startsWith("Yui Host checked current Skills;")
67
+ || value.startsWith("Yui Session Manifest:")
68
+ || value.startsWith("<!-- yui:skills-checked -->")));
69
+ return result("preserved", pollution
70
+ ? "Historical Skill-prefix title/preview candidate; no native ownership proof, so no repair submitted."
71
+ : "Native name has no matching generated-title provenance; left unchanged.");
72
+ }
73
+ if (current === title)
74
+ return result("confirmed", "Existing generated title observed.", { title, ...(receipt === undefined ? {} : { attemptId: receipt.attemptId }) });
75
+ receipt = {
76
+ schemaVersion: 1, identity: request.identity, accountHome: request.accountHome,
77
+ attemptId: randomUUID(), requestedTitle: title, expectedTitle: current,
78
+ status: "submitted", generatedTitles: receipt?.generatedTitles ?? (previous === undefined ? [] : [previous])
79
+ };
80
+ // Durable intent precedes the only mutation. A crash anywhere after this
81
+ // write is an unknown effect, never permission to resubmit.
82
+ await saveReceipt(directory, receipt);
83
+ if (request.signal?.aborted)
84
+ return result("not-submitted", "Title attachment closed before mutation.");
85
+ // Persistence may yield to a user's edit. Recheck immediately before
86
+ // sending; the native API has no expected-name compare-and-swap.
87
+ const prewrite = await client.request("thread/read", { threadId: request.identity.threadId, includeTurns: false });
88
+ const fresh = prewrite.thread;
89
+ if (fresh?.id !== request.identity.threadId || (fresh.name ?? null) !== current) {
90
+ await saveReceipt(directory, { ...receipt, status: "preserved" });
91
+ return result("preserved", "Native name changed before submission; no rename sent.", { attemptId: receipt.attemptId });
92
+ }
93
+ if (request.signal?.aborted)
94
+ return result("not-submitted", "Title attachment closed before mutation.");
95
+ submitted = true;
96
+ await client.request("thread/name/set", { threadId: request.identity.threadId, name: title });
97
+ const confirmation = await client.request("thread/read", { threadId: request.identity.threadId, includeTurns: false });
98
+ const observed = confirmation.thread;
99
+ if (observed?.id !== request.identity.threadId || observed.name !== title) {
100
+ throw new Error("Title mutation has no matching readback; preserve unknown effect.");
101
+ }
102
+ receipt = { ...receipt, status: "confirmed",
103
+ generatedTitles: [...new Set([...receipt.generatedTitles, title])] };
104
+ await saveReceipt(directory, receipt);
105
+ return result("confirmed", "Native title acknowledged and read back.", {
106
+ attemptId: receipt.attemptId, title, receiptPath: join(directory, "receipt.json")
107
+ });
108
+ }
109
+ catch (error) {
110
+ if (submitted && receipt !== undefined) {
111
+ await saveReceipt(directory, { ...receipt, status: "unknown" }).catch(() => { });
112
+ }
113
+ return result(submitted ? "unknown" : "not-submitted", error instanceof Error ? error.message : String(error), receipt === undefined ? {} : { attemptId: receipt.attemptId, receiptPath: join(directory, "receipt.json") });
114
+ }
115
+ finally {
116
+ request.signal?.removeEventListener("abort", cancel);
117
+ client?.close();
118
+ await client?.exited;
119
+ if (locked)
120
+ await rmdir(join(directory, "writing")).catch(() => { });
121
+ }
122
+ }
123
+ /** Use task-161's independent lifecycle-check lane without changing its FSM.
124
+ * One trigger per attachment; receipt fencing handles reconnects/other Hosts. */
125
+ export function createNativeTitleSync(payload, threadId, accountHome, report) {
126
+ let triggered = false;
127
+ const checks = createNonBlockingChecks([{
128
+ id: "native-title",
129
+ async run(_observation, signal) {
130
+ const env = payload.environment;
131
+ const title = payload.providerControl?.sessionTitle ?? env.YUI_SESSION_TITLE ?? env.YUI_NATIVE_TITLE_PROPOSAL;
132
+ if (title === undefined)
133
+ return;
134
+ const scope = env.YUI_SESSION_SCOPE;
135
+ if (!accountHome || !env.YUI_HOME || !env.YUI_ROLE || !env.YUI_AGENT_ID
136
+ || !["task", "global"].includes(scope ?? "") || scope === "task" && !env.YUI_TASK_ID
137
+ || !env.YUI_AGENT_BASE_ARGS) {
138
+ report({ threadId, status: "not-submitted", detail: "Native title synchronization lacks an exact local account/launch identity." });
139
+ return;
140
+ }
141
+ const baseArgs = JSON.parse(env.YUI_AGENT_BASE_ARGS);
142
+ if (!Array.isArray(baseArgs) || baseArgs.some(value => typeof value !== "string")) {
143
+ throw new Error("Invalid native naming executable arguments.");
144
+ }
145
+ const observed = await synchronizeNativeTitle({
146
+ home: env.YUI_HOME, identity: {
147
+ scope: scope,
148
+ ...(scope === "task" ? { taskId: env.YUI_TASK_ID } : {}),
149
+ roleName: env.YUI_ROLE, agentId: env.YUI_AGENT_ID, threadId
150
+ },
151
+ accountHome, command: payload.command, baseArgs,
152
+ environment: env, title,
153
+ ...(env.YUI_NATIVE_TITLE_PROPOSAL === undefined ? {} : { generatedTitle: env.YUI_NATIVE_TITLE_PROPOSAL }),
154
+ ...(env.YUI_NATIVE_TITLE_LOCAL === undefined ? {} : { localTitle: env.YUI_NATIVE_TITLE_LOCAL }),
155
+ newThread: payload.providerControl?.mode === "new"
156
+ || payload.providerControl === undefined && env.YUI_NATIVE_SESSION_ID === undefined,
157
+ ...(payload.providerControl?.previousSessionTitle === undefined ? {} : {
158
+ previousTitle: payload.providerControl.previousSessionTitle
159
+ }),
160
+ signal
161
+ });
162
+ if (!signal.aborted)
163
+ report(observed);
164
+ }
165
+ }], error => report({ threadId, status: "not-submitted", detail: String(error) }));
166
+ return {
167
+ notify() { if (!triggered) {
168
+ triggered = true;
169
+ checks.notify(true);
170
+ } },
171
+ close() { checks.close(); }
172
+ };
173
+ }
174
+ async function saveReceipt(directory, receipt) {
175
+ const temporary = join(directory, `${receipt.attemptId}-${receipt.status}.tmp`);
176
+ const file = await open(temporary, "wx", 0o600);
177
+ try {
178
+ await file.writeFile(JSON.stringify(receipt) + "\n");
179
+ await file.sync();
180
+ }
181
+ finally {
182
+ await file.close();
183
+ }
184
+ await rename(temporary, join(directory, "receipt.json"));
185
+ const parent = await open(directory, "r");
186
+ try {
187
+ await parent.sync();
188
+ }
189
+ finally {
190
+ await parent.close();
191
+ }
192
+ }
@@ -0,0 +1,11 @@
1
+ /** Check the Node runtime's exact OS/architecture before loading the control plane. */
2
+ export function unsupportedPlatformMessage(platform = process.platform, architecture = process.arch) {
3
+ const target = `${platform}-${architecture}`;
4
+ if (target === "linux-x64" || target === "darwin-arm64")
5
+ return undefined;
6
+ return `Unsupported Yui runtime: ${target}. `
7
+ + "Yui supports Linux x64 (glibc) and macOS Apple Silicon with native arm64 Node.js."
8
+ + (target === "darwin-x64"
9
+ ? " macOS Intel is no longer supported; Intel Macs must remain on Yui 2.23.0."
10
+ : "");
11
+ }