@zq-silk/yui 1.0.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +27 -9
  2. package/dist/cli/commandCatalog.js +7 -5
  3. package/dist/cli/invocationAuthority.js +4 -0
  4. package/dist/cli.js +130 -5
  5. package/dist/commands/releaseCommands.js +33 -24
  6. package/dist/commands/taskCommands.js +16 -25
  7. package/dist/commands/taskFactCommands.js +6 -2
  8. package/dist/controller/clientRuntime.js +7 -2
  9. package/dist/controller/domainIdentity.js +7 -7
  10. package/dist/controller/handoverCandidate.js +2 -5
  11. package/dist/controller/jobSupervisor.js +4 -4
  12. package/dist/controller/resourceCleanupLinux.js +35 -19
  13. package/dist/controller/resourceInventoryLinux.js +104 -7
  14. package/dist/controller/runtime.js +4 -13
  15. package/dist/controller/runtimeLaunchCoordinator.js +2 -4
  16. package/dist/controller/sessionOwnerReconciliation.js +40 -16
  17. package/dist/core/controllerClient.js +44 -16
  18. package/dist/core/controllerEndpoint.js +4 -3
  19. package/dist/core/controllerProcessIdentity.js +54 -0
  20. package/dist/core/controllerServer.js +16 -11
  21. package/dist/core/fileLockOwner.js +53 -12
  22. package/dist/doctor/doctor.js +232 -12
  23. package/dist/doctor/ptyProbe.js +74 -0
  24. package/dist/doctor/ptyProbeChild.js +90 -0
  25. package/dist/execution/workItemMainRun.js +1 -1
  26. package/dist/executor/agentAdapter.js +0 -8
  27. package/dist/executor/agentExecutor.js +4 -1
  28. package/dist/executor/fileRoleLaunchPlanner.js +3 -22
  29. package/dist/message/messageContinuation.js +1 -1
  30. package/dist/release/releaseHandover.js +77 -25
  31. package/dist/release/runtimeRelease.js +7 -7
  32. package/dist/repository/gitWorkspace.js +9 -8
  33. package/dist/repository/taskWorkspacePreparer.js +15 -57
  34. package/dist/resources/liveReferences.js +64 -7
  35. package/dist/runtime/agentEndpointIdentity.js +4 -1
  36. package/dist/runtime/agentHost.js +8 -9
  37. package/dist/runtime/codexInteractiveHost.js +1 -1
  38. package/dist/runtime/codexSharedDaemon.js +211 -0
  39. package/dist/runtime/index.js +1 -1
  40. package/dist/runtime/jsonLineChannel.js +6 -1
  41. package/dist/runtime/localStartup.js +65 -0
  42. package/dist/runtime/native/darwin-arm64/claude-process-owner +0 -0
  43. package/dist/runtime/native/darwin-arm64/process-identity +0 -0
  44. package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
  45. package/dist/runtime/native/darwin-x64/process-identity +0 -0
  46. package/dist/runtime/{claude-process-owner → native/linux-x64/claude-process-owner} +0 -0
  47. package/dist/runtime/nativeExecutable.js +10 -0
  48. package/dist/runtime/runtimeCoherence.js +10 -3
  49. package/dist/runtime/sessionOwnerIdentity.js +19 -0
  50. package/dist/runtime/sessionTerminationGuard.js +10 -3
  51. package/dist/runtime/structuredProviderHost.js +51 -26
  52. package/dist/scheduler/activeRoleRunDelivery.js +2 -11
  53. package/dist/scheduler/leaderWakeupProcessor.js +1 -2
  54. package/dist/scheduler/ports.js +1 -8
  55. package/dist/scheduler/taskExecutionProjection.js +1 -6
  56. package/dist/storage/sqliteSchema.js +15 -3
  57. package/dist/storage/storageVersions.js +1 -1
  58. package/dist/storage/taskCatalog.js +10 -2
  59. package/dist/storage/upgrade/taskMainWorkspaceMigration.js +83 -0
  60. package/dist/storage/upgrade/upgradeOrchestrator.js +25 -6
  61. package/dist/task/task.js +0 -12
  62. package/dist/tmux/tmuxManager.js +8 -2
  63. package/dist/tmux/tmuxSocketEndpoint.js +23 -1
  64. package/dist/web/assets/client/app.js +131 -11
  65. package/dist/web/assets/client/i18n.js +18 -0
  66. package/dist/web/assets/client/taskSummary.js +7 -2
  67. package/dist/web/assets/client/taskSurface.js +54 -5
  68. package/dist/web/assets/client/view.js +7 -2
  69. package/dist/web/assets/shell.js +21 -11
  70. package/dist/web/assets/styles/layout.js +33 -6
  71. package/dist/web/assets/styles/responsive.js +18 -5
  72. package/dist/web/assets/styles/tokens.js +1 -1
  73. package/dist/web/assets/styles/widgets.js +5 -0
  74. package/docs/provider-runtime.md +7 -0
  75. package/docs/provider-runtime.zh-CN.md +5 -0
  76. package/docs/release-workflow.md +7 -1
  77. package/docs/task-delivery.md +5 -4
  78. package/docs/testing/verification-levels.md +19 -7
  79. package/docs/testing/verification-levels.zh-CN.md +8 -0
  80. package/i18n/README.zh-CN.md +19 -6
  81. package/package.json +7 -8
  82. package/skills/yui-leader/SKILL.md +8 -0
  83. package/skills/yui-leader/references/execution.md +4 -1
@@ -5,11 +5,12 @@
5
5
  * consumes another Issue's completion flag. Any source that cannot be read
6
6
  * fails closed: the resource is treated as referenced and retained.
7
7
  */
8
- import { execFile } from "node:child_process";
8
+ import { execFile, execFileSync } from "node:child_process";
9
9
  import { createHash } from "node:crypto";
10
10
  import { existsSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
11
11
  import { join, resolve } from "node:path";
12
12
  import { promisify } from "node:util";
13
+ import { processOwnerIsLive } from "../core/fileLockOwner.js";
13
14
  import { readRuntimeIdentity, releasesDirectory, resolveActiveRelease } from "../release/runtimeRelease.js";
14
15
  import { isControllerSocketPathForHome } from "../core/controllerEndpoint.js";
15
16
  import { parseControllerDiscovery } from "../core/protocol.js";
@@ -172,8 +173,10 @@ function readRuntimeIdentityClaims(home) {
172
173
  if (!isProcessAlive(identity.pid)) {
173
174
  return Object.freeze({ claims: Object.freeze([]), diagnostics: Object.freeze([]) });
174
175
  }
175
- const currentIdentity = readLinuxProcessStartIdentity(identity.pid);
176
- if (currentIdentity === undefined || currentIdentity !== identity.processStartIdentity) {
176
+ const sameGeneration = process.platform === "linux"
177
+ ? readLinuxProcessStartIdentity(identity.pid) === identity.processStartIdentity
178
+ : processOwnerIsLive(identity.pid, identity.processStartIdentity);
179
+ if (!sameGeneration) {
177
180
  return Object.freeze({
178
181
  claims: Object.freeze([]),
179
182
  diagnostics: Object.freeze([{
@@ -242,8 +245,10 @@ export function readSessionOwnerClaims(records) {
242
245
  // The normal reconciliation owner releases dead custody records.
243
246
  continue;
244
247
  }
245
- const currentIdentity = readLinuxProcessStartIdentity(pid);
246
- if (currentIdentity === undefined || currentIdentity !== startIdentity) {
248
+ const sameGeneration = process.platform === "linux"
249
+ ? readLinuxProcessStartIdentity(pid) === startIdentity
250
+ : processOwnerIsLive(pid, startIdentity);
251
+ if (!sameGeneration) {
247
252
  diagnostics.push({
248
253
  source: "controller",
249
254
  severity: "error",
@@ -270,6 +275,8 @@ export function readSessionOwnerClaims(records) {
270
275
  * scan fails closed: every candidate path receives a protective ref.
271
276
  */
272
277
  export function scanProcessPathRefs(paths) {
278
+ if (process.platform === "darwin")
279
+ return scanDarwinProcessPathRefs(paths);
273
280
  const refs = new Map();
274
281
  const diagnostics = [];
275
282
  const add = (path, token) => {
@@ -335,6 +342,54 @@ export function scanProcessPathRefs(paths) {
335
342
  }
336
343
  return freezeResult(refs, diagnostics);
337
344
  }
345
+ /** lsof supplies the same-user cwd and open-file observations on macOS. */
346
+ function scanDarwinProcessPathRefs(paths) {
347
+ const refs = new Map();
348
+ try {
349
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
350
+ const output = execFileSync("/usr/sbin/lsof", [
351
+ "-nP", "-w", "-F0pfn", "-u", String(uid)
352
+ ], { encoding: "utf8", timeout: 10_000, maxBuffer: 16 * 1024 * 1024 });
353
+ if (output.includes("\ufffd"))
354
+ throw new Error("lsof returned an undecodable path.");
355
+ let pid = "";
356
+ let descriptor = "";
357
+ let sawProcess = false;
358
+ for (const raw of output.split("\0")) {
359
+ const field = raw.replace(/^\n+/u, "");
360
+ if (field.startsWith("p") && /^[1-9][0-9]*$/u.test(field.slice(1))) {
361
+ pid = field.slice(1);
362
+ descriptor = "";
363
+ sawProcess = true;
364
+ }
365
+ else if (field.startsWith("f")) {
366
+ descriptor = field.slice(1);
367
+ }
368
+ else if (field.startsWith("n") && pid !== "" && field[1] === "/") {
369
+ const observed = field.slice(1);
370
+ for (const path of paths) {
371
+ if (!isPathWithin(observed, path))
372
+ continue;
373
+ const tokens = refs.get(path) ?? new Set();
374
+ tokens.add(`lsof:${descriptor}:${pid}`);
375
+ refs.set(path, tokens);
376
+ }
377
+ }
378
+ }
379
+ if (!sawProcess)
380
+ throw new Error("lsof did not return a process inventory.");
381
+ return freezeResult(refs, []);
382
+ }
383
+ catch (error) {
384
+ const detail = error instanceof Error ? error.message : String(error);
385
+ const diagnostics = [{
386
+ source: "proc", severity: "error", message: `macOS process path scan failed: ${detail}`
387
+ }];
388
+ for (const path of paths)
389
+ refs.set(path, new Set(["lsof:unverified"]));
390
+ return freezeResult(refs, diagnostics);
391
+ }
392
+ }
338
393
  function freezeResult(refs, diagnostics) {
339
394
  const frozen = new Map();
340
395
  for (const [path, tokens] of refs)
@@ -435,8 +490,10 @@ export function readControllerDiscovery(home) {
435
490
  }
436
491
  // PID is alive. When a start identity is recorded, verify it to guard
437
492
  // against PID reuse.
438
- const currentIdentity = readLinuxProcessStartIdentity(record.pid);
439
- if (currentIdentity === undefined || currentIdentity !== record.processStartIdentity) {
493
+ const sameGeneration = process.platform === "linux"
494
+ ? readLinuxProcessStartIdentity(record.pid) === record.processStartIdentity
495
+ : processOwnerIsLive(record.pid, record.processStartIdentity);
496
+ if (!sameGeneration) {
440
497
  return Object.freeze({
441
498
  protects: false,
442
499
  token: "",
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { detectRunningRelease } from "../release/runtimeRelease.js";
5
5
  import { isAgentAdapterId } from "../agent/adapterCatalog.js";
6
+ import { nativeExecutable } from "./nativeExecutable.js";
6
7
  /**
7
8
  * The generation of the Endpoint code this process is actually executing.
8
9
  *
@@ -28,7 +29,7 @@ const ENDPOINT_CHECKOUT_MODULES = Object.freeze([
28
29
  "agentEndpoint.js",
29
30
  "agentEndpointIdentity.js",
30
31
  "structuredProviderHost.js",
31
- "claude-process-owner",
32
+ "nativeExecutable.js",
32
33
  "codexAppServerRuntime.js",
33
34
  "acpSession.js",
34
35
  "acpProtocol.js",
@@ -48,6 +49,8 @@ function resolveEndpointGeneration() {
48
49
  // to an identity that would silently match a different build.
49
50
  hash.update(module).update("\0").update(readFileSync(new URL(module, import.meta.url))).update("\0");
50
51
  }
52
+ hash.update("claude-process-owner").update("\0")
53
+ .update(readFileSync(nativeExecutable("claude-process-owner"))).update("\0");
51
54
  return `checkout-${hash.digest("hex").slice(0, 23)}`;
52
55
  }
53
56
  const ENDPOINT_GENERATION = resolveEndpointGeneration();
@@ -24,7 +24,7 @@ import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox }
24
24
  import { sameProviderAuthorityFence, validateProviderAuthorityFence } from "./providerAuthorityFence.js";
25
25
  import { ProviderConversationMissingError, ProviderDeliveryUnknownError, ProviderTurnBusyError, ProviderTurnRejectedError } from "./providerErrors.js";
26
26
  import { AGENT_HOST_CLIENT_EXIT_GRACE_MS, AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS, ENDPOINT_DRAIN_TIMEOUT_MS, PROVIDER_ACCEPT_TIMEOUT_MS } from "./runtimeDeadlines.js";
27
- import { createSessionOwnerIdentity, readLinuxProcessIdentity } from "./sessionOwnerIdentity.js";
27
+ import { createSessionOwnerIdentity, readProcessIdentity } from "./sessionOwnerIdentity.js";
28
28
  export { AGENT_HOST_CONTROL_PROTOCOL } from "./agentHostProtocol.js";
29
29
  const HOST_CONTROL_MAX_BYTES = 32 * 1024;
30
30
  const CODEX_CLIENT_STABLE_MS = 5_000;
@@ -1243,10 +1243,8 @@ export async function runAgentHost(input) {
1243
1243
  /** Host-restart-independent OS custody. No secret or launch payload is stored. */
1244
1244
  async function recordProviderConnection(home, payload, endpoint) {
1245
1245
  const environment = payload.environment;
1246
- const identity = endpoint.ownedProcessId === undefined ? undefined : readLinuxProcessIdentity(endpoint.ownedProcessId);
1247
- if (endpoint.ownedProcessId !== undefined && identity === undefined) {
1248
- throw new Error("Dedicated Provider process identity was lost before registration.");
1249
- }
1246
+ const identity = endpoint.ownedProcessId === undefined ? undefined
1247
+ : readProcessIdentity(endpoint.ownedProcessId);
1250
1248
  const taskId = environment.YUI_TASK_ID;
1251
1249
  const roleName = environment.YUI_ROLE;
1252
1250
  const userHome = resolve(payload.cwd, environment.HOME ?? homedir());
@@ -1289,10 +1287,11 @@ export function agentHostControlSocketPath(input) {
1289
1287
  .digest("hex")
1290
1288
  .slice(0, 16);
1291
1289
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
1292
- // Linux sockaddr_un paths have a small fixed budget. Keep the endpoint
1293
- // independent of a potentially deep YUI_HOME while fencing aliases and
1294
- // copied Homes by their physical filesystem identity.
1295
- const root = process.platform === "linux" ? "/tmp" : tmpdir();
1290
+ // sockaddr_un paths have a small fixed budget (104 bytes on macOS, 108 on
1291
+ // Linux). Keep the endpoint under the short system root, independent of a
1292
+ // potentially deep YUI_HOME or the long per-user TMPDIR macOS assigns,
1293
+ // while fencing aliases and copied Homes by physical filesystem identity.
1294
+ const root = process.platform === "win32" ? tmpdir() : "/tmp";
1296
1295
  return join(root, `yui-${uid}`, "agent-host", `${homeDigest}-${ownerDigest}.sock`);
1297
1296
  }
1298
1297
  export async function sendAgentHostLaunchControl(input) {
@@ -63,7 +63,7 @@ export async function runCodexInteractiveHost(home, payload, openAgentHostContro
63
63
  connection = await openCodexInteractiveConnection({
64
64
  command: payload.command, args: [...baseArgs, "app-server", "proxy"],
65
65
  environment, cwd: payload.cwd
66
- });
66
+ }, true);
67
67
  connection.onClose(fail);
68
68
  const token = randomBytes(32).toString("hex");
69
69
  relay = new WebSocketServer({
@@ -0,0 +1,211 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { createConnection } from "node:net";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+ const execFileAsync = promisify(execFile);
8
+ const DAEMON_START_TIMEOUT_MS = 15_000;
9
+ const CODEX_APP_BUNDLE_ID = "com.openai.codex";
10
+ /** A denied probe says nothing about whether the shared daemon is running. */
11
+ export function isCodexInspectionPermissionError(error) {
12
+ if (typeof error === "string") {
13
+ return /\b(?:EPERM|EACCES)\b|operation not permitted|permission denied/iu.test(error);
14
+ }
15
+ let current = error;
16
+ for (let depth = 0; depth < 4 && current !== null && typeof current === "object"; depth += 1) {
17
+ const candidate = current;
18
+ if (candidate.code === "EPERM" || candidate.code === "EACCES"
19
+ || [candidate.message, candidate.stderr].some((value) => typeof value === "string"
20
+ && /\b(?:EPERM|EACCES)\b|operation not permitted|permission denied/iu.test(value)))
21
+ return true;
22
+ current = candidate.cause;
23
+ }
24
+ return false;
25
+ }
26
+ /** Locate the installed App by bundle identity, including non-default installs. */
27
+ export function findMacCodexAppExecutable(environment = process.env) {
28
+ if (process.platform !== "darwin")
29
+ return undefined;
30
+ const home = environment.HOME || homedir();
31
+ const candidates = [
32
+ "/Applications/ChatGPT.app", "/Applications/Codex.app",
33
+ join(home, "Applications/ChatGPT.app"), join(home, "Applications/Codex.app")
34
+ ];
35
+ for (const candidate of candidates) {
36
+ const executable = executableForCodexApp(candidate);
37
+ if (executable !== undefined)
38
+ return executable;
39
+ }
40
+ try {
41
+ const registered = execFileSync("/usr/bin/osascript", [
42
+ "-e", `POSIX path of (path to application id "${CODEX_APP_BUNDLE_ID}")`
43
+ ], { encoding: "utf8", timeout: 10_000 }).trim();
44
+ return executableForCodexApp(registered);
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ function executableForCodexApp(bundlePath) {
51
+ const plist = join(bundlePath, "Contents/Info.plist");
52
+ if (!existsSync(plist))
53
+ return undefined;
54
+ try {
55
+ const identifier = execFileSync("/usr/libexec/PlistBuddy", [
56
+ "-c", "Print :CFBundleIdentifier", plist
57
+ ], { encoding: "utf8", timeout: 2_000 }).trim();
58
+ if (identifier !== CODEX_APP_BUNDLE_ID)
59
+ return undefined;
60
+ const name = execFileSync("/usr/libexec/PlistBuddy", [
61
+ "-c", "Print :CFBundleExecutable", plist
62
+ ], { encoding: "utf8", timeout: 2_000 }).trim();
63
+ const executable = join(bundlePath, "Contents/MacOS", name);
64
+ return existsSync(executable) ? executable : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ /** Prepare future App launches when an interactive Yui Codex Session starts. */
71
+ export async function prepareMacCodexAppConnection(launch, appExecutable = findMacCodexAppExecutable(launch.environment), daemonStartedEarlier = false) {
72
+ if (process.platform !== "darwin")
73
+ return { state: "not-macos" };
74
+ if (launch.args.at(-2) !== "app-server" || launch.args.at(-1) !== "proxy") {
75
+ throw new Error("Codex App sharing requires the configured app-server proxy connection.");
76
+ }
77
+ const socketPath = codexSharedSocketPath(launch.environment, launch.cwd);
78
+ const started = await ensureCodexSharedDaemon(launch) || daemonStartedEarlier;
79
+ if (appExecutable === undefined)
80
+ return { state: "ready", socketPath };
81
+ const wsUrl = codexAppWebSocketUrl(launch.environment, launch.cwd);
82
+ try {
83
+ await execFileAsync("/bin/launchctl", ["setenv", "CODEX_APP_SERVER_WS_URL", wsUrl], {
84
+ cwd: launch.cwd, env: launch.environment, timeout: 5_000
85
+ });
86
+ }
87
+ catch (error) {
88
+ throw new Error(`Could not configure future Codex App launches: ${String(error)}`, { cause: error });
89
+ }
90
+ let stdout;
91
+ try {
92
+ ({ stdout } = await execFileAsync("/bin/ps", ["-axo", "pid=,args="], {
93
+ cwd: launch.cwd, env: launch.environment, timeout: 5_000, maxBuffer: 4 * 1024 * 1024
94
+ }));
95
+ }
96
+ catch (error) {
97
+ throw new Error(`Could not inspect running Codex App processes: ${String(error)}`, { cause: error });
98
+ }
99
+ const pids = codexAppProcessIds(stdout, appExecutable);
100
+ for (const pid of pids) {
101
+ let appCommand;
102
+ try {
103
+ const app = await execFileAsync("/bin/ps", ["eww", "-p", pid, "-o", "command="], {
104
+ cwd: launch.cwd, env: launch.environment, timeout: 5_000, maxBuffer: 4 * 1024 * 1024
105
+ });
106
+ appCommand = String(app.stdout);
107
+ }
108
+ catch (error) {
109
+ // The App can exit between listing processes and reading its environment.
110
+ try {
111
+ process.kill(Number(pid), 0);
112
+ }
113
+ catch (probeError) {
114
+ if (probeError.code === "ESRCH")
115
+ continue;
116
+ }
117
+ throw new Error(`Could not inspect Codex App pid=${pid}: ${String(error)}`, { cause: error });
118
+ }
119
+ if (codexAppNeedsRestart(appCommand, wsUrl, started)) {
120
+ return { state: "restart-required", socketPath };
121
+ }
122
+ }
123
+ return { state: "ready", socketPath };
124
+ }
125
+ export function codexAppProcessIds(processTable, appExecutable = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT") {
126
+ return processTable.split("\n").flatMap((line) => {
127
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
128
+ return match !== null
129
+ && (match[2] === appExecutable || match[2].startsWith(`${appExecutable} `))
130
+ ? [match[1]] : [];
131
+ });
132
+ }
133
+ export function codexAppWebSocketUrl(environment, cwd) {
134
+ return `ws+unix://localhost${codexSharedSocketPath(environment, cwd)}:/rpc`;
135
+ }
136
+ export function codexAppNeedsRestart(runningAppCommand, wsUrl, daemonStarted) {
137
+ return daemonStarted || !runningAppCommand.split(/\s+/).includes(`CODEX_APP_SERVER_WS_URL=${wsUrl}`);
138
+ }
139
+ /** Only a failed connection to the default shared socket can start the local daemon. */
140
+ export async function startMissingCodexSharedDaemon(launch) {
141
+ if (launch.args.at(-2) !== "app-server" || launch.args.at(-1) !== "proxy")
142
+ return false;
143
+ const socketPath = codexSharedSocketPath(launch.environment, launch.cwd);
144
+ if (!await socketIsMissing(socketPath))
145
+ return false;
146
+ try {
147
+ await execFileAsync(launch.command, [
148
+ ...daemonBaseArgs(launch), "app-server", "daemon", "start"
149
+ ], {
150
+ cwd: launch.cwd,
151
+ env: launch.environment,
152
+ timeout: DAEMON_START_TIMEOUT_MS,
153
+ maxBuffer: 32 * 1024
154
+ });
155
+ }
156
+ catch (error) {
157
+ const detail = error instanceof Error ? error.message : String(error);
158
+ throw new Error(`Codex shared app-server is unavailable at ${socketPath}; daemon start failed: ${detail}`, {
159
+ cause: error
160
+ });
161
+ }
162
+ return true;
163
+ }
164
+ export async function ensureCodexSharedDaemon(launch) {
165
+ const started = await startMissingCodexSharedDaemon(launch);
166
+ const socketPath = codexSharedSocketPath(launch.environment, launch.cwd);
167
+ for (let attempt = 0; attempt < 40; attempt += 1) {
168
+ if (!await socketIsMissing(socketPath))
169
+ return started;
170
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
171
+ }
172
+ throw new Error(`Codex shared app-server did not open ${socketPath}.`);
173
+ }
174
+ export function codexSharedSocketPath(environment, cwd) {
175
+ const accountHome = environment.CODEX_HOME?.trim()
176
+ || join(environment.HOME || homedir(), ".codex");
177
+ const codexHome = isAbsolute(accountHome) ? accountHome : resolve(cwd, accountHome);
178
+ return join(codexHome, "app-server-control", "app-server-control.sock");
179
+ }
180
+ function daemonBaseArgs(launch) {
181
+ let parsed;
182
+ try {
183
+ parsed = JSON.parse(launch.environment.YUI_AGENT_BASE_ARGS ?? "");
184
+ }
185
+ catch {
186
+ throw new Error("Codex daemon launch is missing the configured Agent base arguments.");
187
+ }
188
+ if (!Array.isArray(parsed) || !parsed.every((arg) => typeof arg === "string")) {
189
+ throw new Error("Codex daemon launch has invalid configured Agent base arguments.");
190
+ }
191
+ return parsed;
192
+ }
193
+ function socketIsMissing(path) {
194
+ return new Promise((resolvePromise, reject) => {
195
+ const socket = createConnection(path);
196
+ socket.setTimeout(1_000, () => socket.destroy(new Error("Codex shared app-server socket probe timed out.")));
197
+ socket.once("connect", () => {
198
+ socket.destroy();
199
+ resolvePromise(false);
200
+ });
201
+ socket.once("error", (error) => {
202
+ if (error.code === "ENOENT" || error.code === "ECONNREFUSED")
203
+ resolvePromise(true);
204
+ else if (isCodexInspectionPermissionError(error)) {
205
+ reject(new Error(`Cannot verify Codex shared app-server socket at ${path}: access denied; daemon state is unverified. ${error.message}`, { cause: error }));
206
+ }
207
+ else
208
+ reject(error);
209
+ });
210
+ });
211
+ }
@@ -8,7 +8,7 @@ export { DEFAULT_RECENT_TURN_ID_LIMIT, hasRecentTurnId, rememberRecentTurnId, va
8
8
  export { promptPushOutcome, RuntimeHostContentionError, RuntimeHostUnavailableError, RuntimeLaunchError } from "./ports.js";
9
9
  export { AgentHostPromptPushAdapter, TmuxSessionHost } from "./tmuxAdapters.js";
10
10
  export { FileTaskRuntimeIsolation, YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR, YUI_TASK_RUNTIME_SERVICE_NAMESPACE, assertTaskRuntimeIsolationPreflight, createTaskRuntimeIsolationDescriptor, parseTaskRuntimeIsolationDescriptor, planTaskRuntimeCleanup, taskRuntimeIsolationEnvironment, taskRuntimeIsolationFingerprint } from "./taskRuntimeIsolation.js";
11
- export { createSessionOwnerIdentity, isLinuxProcessLive, listOwnedProcessTree, readLinuxProcessIdentity } from "./sessionOwnerIdentity.js";
11
+ export { createSessionOwnerIdentity, isLinuxProcessLive, listOwnedProcessTree, readLinuxProcessIdentity, readProcessIdentity } from "./sessionOwnerIdentity.js";
12
12
  export { formatRuntimeLaunchDiagnostic, redactLaunchArgument, redactLaunchText, RuntimeLaunchFailure, toRuntimeLaunchFailure } from "./launchDiagnostics.js";
13
13
  export { DEFAULT_FORCED_GRACE_MS, DEFAULT_GRACEFUL_GRACE_MS, terminateSessionOwners } from "./sessionTerminationGuard.js";
14
14
  export { ProviderContinuationReconciliationService } from "./providerContinuationReconciliationService.js";
@@ -130,8 +130,13 @@ export function terminateProcessGroup(child, signal) {
130
130
  process.kill(-child.pid, signal);
131
131
  }
132
132
  catch (error) {
133
- if (error.code !== "ESRCH")
133
+ const code = error.code;
134
+ if (code !== "ESRCH" && code !== "EPERM")
134
135
  throw error;
136
+ // ESRCH: the group is already gone. EPERM: the group ID was reused by or
137
+ // inherited a process owned by another user (observed on macOS after the
138
+ // fixture child exits); the direct child is still ours to terminate.
139
+ // Node's ChildProcess.kill treats an already-exited child as a no-op.
135
140
  child.kill(signal);
136
141
  }
137
142
  }
@@ -0,0 +1,65 @@
1
+ import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
2
+ import { codexSharedSocketPath, ensureCodexSharedDaemon, findMacCodexAppExecutable, prepareMacCodexAppConnection } from "./codexSharedDaemon.js";
3
+ /** Current Role demand, not every CLI installed on the machine or stale Agent record. */
4
+ export function codexAgentsInUse(store, targetAgentId) {
5
+ const ids = new Set();
6
+ if (targetAgentId !== undefined) {
7
+ ids.add(targetAgentId);
8
+ }
9
+ else {
10
+ for (const role of store.listGlobalRoles())
11
+ ids.add(role.activeAgentId);
12
+ for (const taskId of store.listActiveTaskIds()) {
13
+ for (const role of store.listRoles(taskId))
14
+ ids.add(role.activeAgentId);
15
+ }
16
+ }
17
+ return [...ids].flatMap((id) => {
18
+ const agent = store.getConfiguredAgent(id);
19
+ return agent?.adapterId === "codex" ? [agent] : [];
20
+ });
21
+ }
22
+ export async function prepareLocalCodexStartup(store, home, environment, targetAgentId, platform = process.platform) {
23
+ const agents = codexAgentsInUse(store, targetAgentId);
24
+ if (agents.length === 0)
25
+ return { daemons: [], app: "not-applicable" };
26
+ const groups = new Map();
27
+ for (const configured of agents) {
28
+ const agent = configuredAgentToDefinition(configured);
29
+ const launch = {
30
+ command: agent.command,
31
+ args: [...agent.baseArgs, "app-server", "proxy"],
32
+ cwd: home,
33
+ environment: {
34
+ ...environment,
35
+ ...resolveAgentEnvironment(agent, environment),
36
+ YUI_AGENT_BASE_ARGS: JSON.stringify(agent.baseArgs)
37
+ }
38
+ };
39
+ const socketPath = codexSharedSocketPath(launch.environment, home);
40
+ const existing = groups.get(socketPath);
41
+ if (existing === undefined)
42
+ groups.set(socketPath, { launch, agentIds: [agent.id] });
43
+ else
44
+ existing.agentIds.push(agent.id);
45
+ }
46
+ const daemons = [];
47
+ for (const [socketPath, group] of groups) {
48
+ const started = await ensureCodexSharedDaemon(group.launch);
49
+ daemons.push({ agentIds: group.agentIds, socketPath, started });
50
+ }
51
+ if (platform !== "darwin")
52
+ return { daemons, app: "not-applicable" };
53
+ const appExecutable = findMacCodexAppExecutable(environment);
54
+ if (appExecutable === undefined)
55
+ return { daemons, app: "not-detected" };
56
+ const operatorAgentId = store.getGlobalRole("operator")?.activeAgentId;
57
+ const selected = [...groups.values()].find((group) => group.agentIds.includes(operatorAgentId ?? ""))
58
+ ?? (groups.size === 1 ? [...groups.values()][0] : undefined);
59
+ if (selected === undefined)
60
+ return { daemons, app: "multiple-homes" };
61
+ const selectedSocketPath = codexSharedSocketPath(selected.launch.environment, home);
62
+ const daemonStartedEarlier = daemons.find((daemon) => daemon.socketPath === selectedSocketPath)?.started ?? false;
63
+ const prepared = await prepareMacCodexAppConnection(selected.launch, appExecutable, daemonStartedEarlier);
64
+ return { daemons, app: prepared.state };
65
+ }
@@ -0,0 +1,10 @@
1
+ import { fileURLToPath } from "node:url";
2
+ /** Native binaries are shipped together; never execute another platform's helper. */
3
+ export function nativeExecutable(name, platform = process.platform, architecture = process.arch) {
4
+ const target = `${platform}-${architecture}`;
5
+ if (!["linux-x64", "darwin-x64", "darwin-arm64"].includes(target)
6
+ || (name === "process-identity" && platform !== "darwin")) {
7
+ throw new Error(`Unsupported native runtime: ${target}/${name}.`);
8
+ }
9
+ return fileURLToPath(new URL(`./native/${target}/${name}`, import.meta.url));
10
+ }
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { isStorageVersion, compareStorageVersions } from "../storage/storageVersions.js";
3
- import { callController as defaultCallController } from "../core/controllerClient.js";
3
+ import { callController as defaultCallController, ControllerClientError } from "../core/controllerClient.js";
4
4
  import { inspectStorageSchema } from "../storage/storageSchema.js";
5
5
  import { yuiVersionIdentity } from "../version.js";
6
6
  export async function assertRuntimeCoherence(input, options = {}) {
@@ -22,8 +22,15 @@ export async function assertRuntimeCoherence(input, options = {}) {
22
22
  assertControllerContinuityIdentity(status, identity);
23
23
  }
24
24
  catch (error) {
25
- if (!isDefinitelyNotRunning(error))
26
- throw error;
25
+ if (isDefinitelyNotRunning(error))
26
+ return identity;
27
+ if (error instanceof ControllerClientError
28
+ && ["CONTROLLER_UNAVAILABLE", "CONTROLLER_TIMEOUT", "CONTROLLER_DISCOVERY_INVALID", "INVALID_RESPONSE"].includes(error.code)) {
29
+ throw new Error(`Managed Controller could not be verified before this command: ${error.message} `
30
+ + "Run `yui doctor` with this Session's YUI_HOME and CLI; ask the Operator or user "
31
+ + "to restore it with `yui start` or `yui controller restart` as diagnosed.", { cause: error });
32
+ }
33
+ throw error;
27
34
  }
28
35
  }
29
36
  return identity;
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, readdirSync } from "node:fs";
2
+ import { readProcessStartIdentity } from "../core/fileLockOwner.js";
2
3
  import { requireSafeIdentity, requireText, requireTimestamp } from "./validation.js";
3
4
  /** A concrete OS process identity, not an Agent launch identity. */
4
5
  export function sessionOwnerProcessKey(record) {
@@ -109,6 +110,24 @@ export function readLinuxProcessIdentity(pid) {
109
110
  return undefined;
110
111
  }
111
112
  }
113
+ /**
114
+ * Identity of a process for durable ownership records. Linux returns the
115
+ * exact /proc start-time identity; macOS uses libproc's process start time.
116
+ */
117
+ export function readProcessIdentity(pid = process.pid) {
118
+ if (process.platform === "linux") {
119
+ const identity = readLinuxProcessIdentity(pid);
120
+ if (identity === undefined) {
121
+ throw new Error(`Cannot read Linux process identity for PID ${pid}.`);
122
+ }
123
+ return identity;
124
+ }
125
+ const startIdentity = readProcessStartIdentity(pid);
126
+ if (startIdentity === undefined) {
127
+ throw new Error(`Dedicated Provider process identity could not be verified for PID ${pid}.`);
128
+ }
129
+ return { pid, startIdentity, rssBytes: 0 };
130
+ }
112
131
  function readLinuxProcessRssBytes(pid) {
113
132
  try {
114
133
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
@@ -30,11 +30,16 @@ export async function terminateSessionOwners(owner, records, ports, options = {}
30
30
  if (records.length === 0) {
31
31
  // Without a recorded root only the exact Role pane can be stopped.
32
32
  const stopped = await ports.gracefulStop(owner);
33
- const outcome = stopped ? "stop-confirmed" : "stop-blocked";
33
+ const outcome = stopped && options.requireProcessRecord !== true
34
+ ? "stop-confirmed" : "stop-blocked";
34
35
  emit(outcome);
35
36
  return {
36
37
  outcome, owner, confirmed: [], remaining: [],
37
- ...(stopped ? {} : { verificationGap: "Role pane stop is unconfirmed." })
38
+ ...(outcome === "stop-confirmed" ? {} : {
39
+ verificationGap: stopped
40
+ ? "No recorded Host process identity; pane exit cannot prove physical exit."
41
+ : "Role pane stop is unconfirmed."
42
+ })
38
43
  };
39
44
  }
40
45
  const pollMs = duration(options.pollMs, DEFAULT_TERMINATION_POLL_MS);
@@ -60,7 +65,9 @@ export async function terminateSessionOwners(owner, records, ports, options = {}
60
65
  }
61
66
  emit(signal === "SIGTERM" ? "graceful-stop" : "forced-stop");
62
67
  const deadline = Date.now() + grace;
63
- while (records.some((record) => state(record) === "live") && Date.now() < deadline) {
68
+ // A process can be briefly unreadable while exiting. Wait for absence,
69
+ // not merely for the first unverified observation.
70
+ while (records.some((record) => state(record) !== "absent") && Date.now() < deadline) {
64
71
  await ports.sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
65
72
  }
66
73
  if (records.every((record) => state(record) === "absent"))