@zq-silk/yui 2.22.0 → 2.23.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 (47) hide show
  1. package/dist/cli/commandCatalog.js +2 -2
  2. package/dist/cli/updateCommand.js +20 -3
  3. package/dist/cli/updateOrchestrator.js +13 -0
  4. package/dist/cli/updatePorts.js +27 -0
  5. package/dist/commands/taskRoleRuntimeStatus.js +4 -0
  6. package/dist/context/currentSkills.js +32 -1
  7. package/dist/context/sessionBootstrapManifest.js +17 -4
  8. package/dist/context/sessionSkillNotice.js +33 -12
  9. package/dist/controller/clientRuntime.js +37 -18
  10. package/dist/controller/controller.js +16 -1
  11. package/dist/controller/fileSchedulerStoreAdapter.js +2 -0
  12. package/dist/controller/hostLifecycle.js +153 -0
  13. package/dist/controller/hostLifecycleChecks.js +27 -0
  14. package/dist/controller/hostUpgrade.js +344 -0
  15. package/dist/controller/runtime.js +67 -16
  16. package/dist/controller/sessionNotify.js +5 -46
  17. package/dist/execution/codexThreadNaming.js +54 -43
  18. package/dist/executor/fileRoleLaunchPlanner.js +18 -3
  19. package/dist/runtime/agentEndpoint.js +11 -1
  20. package/dist/runtime/agentEndpointIdentity.js +2 -0
  21. package/dist/runtime/agentHost.js +167 -42
  22. package/dist/runtime/agentHostImplementation.js +3 -1
  23. package/dist/runtime/codexAppServerRuntime.js +3 -0
  24. package/dist/runtime/codexInteractiveHost.js +62 -17
  25. package/dist/runtime/codexNativeAccess.js +1 -1
  26. package/dist/runtime/contextRecovery.js +15 -0
  27. package/dist/runtime/hostSessionLifecycle.js +83 -0
  28. package/dist/runtime/hostUpgradeGate.js +43 -0
  29. package/dist/runtime/lifecycleChecks.js +47 -0
  30. package/dist/runtime/lifecycleReservation.js +9 -1
  31. package/dist/runtime/nativeTitleReceipt.js +31 -0
  32. package/dist/runtime/nativeTitleSync.js +192 -0
  33. package/dist/runtime/providerRetry.js +4 -4
  34. package/dist/runtime/structuredProviderHost.js +42 -10
  35. package/dist/storage/sqliteSchema.js +20 -0
  36. package/dist/storage/storageVersions.js +1 -1
  37. package/docs/managed-turn-and-session-runtime.md +96 -1
  38. package/docs/managed-turn-and-session-runtime.zh-CN.md +70 -1
  39. package/docs/release-workflow.md +28 -0
  40. package/docs/release-workflow.zh-CN.md +20 -0
  41. package/docs/storage-baseline.md +10 -1
  42. package/docs/storage-baseline.zh-CN.md +5 -1
  43. package/docs/testing/verification-levels.md +2 -2
  44. package/docs/testing/verification-levels.zh-CN.md +2 -2
  45. package/package.json +1 -1
  46. package/skills/yui-runtime/SKILL.md +7 -2
  47. package/skills/yui-runtime/VERSION +1 -1
@@ -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);
@@ -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
+ }
@@ -164,16 +164,16 @@ export function completeProviderRetry(binding, turn) {
164
164
  export function providerRetryAttemptId(retry) {
165
165
  return `provider-retry:${retry.chainId}/${retry.transportSequence + 1}`;
166
166
  }
167
- export function providerRetryPrompt(retry, original) {
167
+ /** Recovery guidance only. User input is delivered separately, once. */
168
+ export function providerRetryContext(retry) {
168
169
  if (retry.failedNativeTurnId === undefined)
169
- return original;
170
+ return undefined;
170
171
  return [
171
172
  "Yui infrastructure recovery, not new user authorization.",
172
173
  `Continue the existing input after failed attempt ${retry.failureAttemptId}`,
173
174
  `(native Turn ${retry.failedNativeTurnId}; failure ${retry.failureRef}).`,
174
175
  "Read current authorized context and the original input. Inspect existing work and receipts first;",
175
- "continue only unfinished work. Preserve local changes and do not repeat confirmed side effects.",
176
- original
176
+ "continue only unfinished work. Preserve local changes and do not repeat confirmed side effects."
177
177
  ].join("\n");
178
178
  }
179
179
  /** The same presentation is consumed by explicit detail reads and Web. */