@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
@@ -1,4 +1,6 @@
1
1
  import { readFileSync, readdirSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { nativeExecutable } from "../runtime/nativeExecutable.js";
2
4
  import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
3
5
  import { readHomeFilesystemId, validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
4
6
  /** Exact live-process fence used at Controller startup and handover. */
@@ -6,6 +8,22 @@ export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProc
6
8
  if (!Number.isSafeInteger(pid) || pid < 1 || pid === process.pid)
7
9
  return undefined;
8
10
  const expectedFilesystemId = validateHomeFilesystemId(homeFilesystemId);
11
+ if (process.platform === "darwin") {
12
+ const candidate = inspectDarwinController(pid);
13
+ if (candidate === undefined)
14
+ return undefined;
15
+ try {
16
+ if (readHomeFilesystemId(candidate.home) !== expectedFilesystemId)
17
+ return undefined;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ if (expectedProcessStartIdentity !== undefined
23
+ && candidate.processStartIdentity !== expectedProcessStartIdentity)
24
+ return undefined;
25
+ return Object.freeze({ pid, processStartIdentity: candidate.processStartIdentity });
26
+ }
9
27
  try {
10
28
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
11
29
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
@@ -42,6 +60,20 @@ export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProc
42
60
  /** Finds a Controller whose discovery record was lost. */
43
61
  export function findLiveControllerProcessForHome(homeFilesystemId) {
44
62
  validateHomeFilesystemId(homeFilesystemId);
63
+ if (process.platform === "darwin") {
64
+ const output = runDarwinIdentityHelper(["--controllers"]);
65
+ if (output === undefined)
66
+ return undefined;
67
+ for (const line of output.trim().split("\n")) {
68
+ const candidate = parseDarwinController(line);
69
+ if (candidate === undefined)
70
+ continue;
71
+ const match = inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity);
72
+ if (match !== undefined)
73
+ return match;
74
+ }
75
+ return undefined;
76
+ }
45
77
  if (process.platform !== "linux")
46
78
  return undefined;
47
79
  const entries = readdirSync("/proc", { encoding: "utf8" });
@@ -54,3 +86,25 @@ export function findLiveControllerProcessForHome(homeFilesystemId) {
54
86
  }
55
87
  return undefined;
56
88
  }
89
+ function inspectDarwinController(pid) {
90
+ const output = runDarwinIdentityHelper(["--controller", String(pid)]);
91
+ return output === undefined ? undefined : parseDarwinController(output.trim());
92
+ }
93
+ function parseDarwinController(line) {
94
+ const match = /^([1-9][0-9]*)\t([1-9][0-9]{0,31})\t([^\t\n]+)$/u.exec(line);
95
+ if (match === null)
96
+ return undefined;
97
+ const pid = Number(match[1]);
98
+ if (!Number.isSafeInteger(pid))
99
+ return undefined;
100
+ return { pid, processStartIdentity: match[2], home: match[3] };
101
+ }
102
+ function runDarwinIdentityHelper(args) {
103
+ try {
104
+ return execFileSync(nativeExecutable("process-identity"), [...args], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024,
105
+ stdio: ["ignore", "pipe", "ignore"] });
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
@@ -8,6 +8,7 @@ import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_C
8
8
  import { controllerSocketPath } from "./controllerEndpoint.js";
9
9
  import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
10
10
  import { findLiveControllerProcessForHome } from "./controllerProcessIdentity.js";
11
+ import { currentProcessStartIdentity, processGenerationIsLive } from "./fileLockOwner.js";
11
12
  import { validateHomeId } from "../repository/homeIdentity.js";
12
13
  import { readCurrentHomeIdentity } from "../storage/currentTaskStore.js";
13
14
  import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
@@ -98,7 +99,9 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
98
99
  eventLoopDelay.start();
99
100
  try {
100
101
  await chmod(socketPath, 0o600);
101
- const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
102
+ const processStartIdentity = process.platform === "linux"
103
+ ? await readLinuxProcessStartIdentity(process.pid)
104
+ : currentProcessStartIdentity();
102
105
  const discovery = Object.freeze({
103
106
  schemaVersion: 1,
104
107
  protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
@@ -202,6 +205,14 @@ async function assertExistingDiscoveryReplaceable(discoveryPath) {
202
205
  || !/^[0-9]{1,32}$/u.test(value.processStartIdentity)) {
203
206
  throw unsafeExistingDiscovery(discoveryPath);
204
207
  }
208
+ if (process.platform !== "linux") {
209
+ // The native helper proves the exact macOS generation; a live PID with
210
+ // unreadable identity cannot authorize discovery replacement.
211
+ if (processGenerationIsLive(pid, value.processStartIdentity)) {
212
+ throw controllerAlreadyRunning();
213
+ }
214
+ return;
215
+ }
205
216
  let currentIdentity;
206
217
  try {
207
218
  currentIdentity = await readLinuxProcessStartIdentity(pid);
@@ -340,15 +351,7 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
340
351
  releaseDrifted = true;
341
352
  }
342
353
  }
343
- let processStartIdentity;
344
- try {
345
- processStartIdentity = readLinuxProcessStartIdentitySync(process.pid);
346
- }
347
- catch {
348
- // Non-Linux or an unreadable /proc: the owner cannot be fenced, so the
349
- // handover path treats it as absent. The rest of the identity stays
350
- // available for read-only consumers.
351
- }
354
+ const processStartIdentity = currentProcessStartIdentity();
352
355
  send({
353
356
  id: request.id,
354
357
  ok: true,
@@ -621,7 +624,9 @@ function currentHandoverOwner() {
621
624
  const release = detectRunningRelease(fileURLToPath(import.meta.url));
622
625
  return Object.freeze({
623
626
  pid: process.pid,
624
- processStartIdentity: readLinuxProcessStartIdentitySync(process.pid),
627
+ processStartIdentity: process.platform === "linux"
628
+ ? readLinuxProcessStartIdentitySync(process.pid)
629
+ : currentProcessStartIdentity(),
625
630
  buildId: release?.manifest.buildId ?? "dev",
626
631
  version: YUI_VERSION
627
632
  });
@@ -1,8 +1,41 @@
1
1
  import { lstatSync, readFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
2
3
  import { join } from "node:path";
4
+ import { nativeExecutable } from "../runtime/nativeExecutable.js";
3
5
  /** One process generation, shared by file-lock producers and reclaim checks. */
4
6
  export function currentFileLockOwner() {
5
- return `${process.pid}:${readProcessStart(process.pid)}`;
7
+ return `${process.pid}:${currentProcessStartIdentity()}`;
8
+ }
9
+ /**
10
+ * Identity of the current process. Linux reads the /proc start-time field,
11
+ * which survives PID reuse. macOS uses libproc's process start timestamp.
12
+ */
13
+ export function currentProcessStartIdentity() {
14
+ const identity = readProcessStartIdentity(process.pid);
15
+ if (identity === undefined)
16
+ throw new Error(`Process start identity is unavailable for PID ${process.pid}.`);
17
+ return identity;
18
+ }
19
+ export function readProcessStartIdentity(pid) {
20
+ if (!Number.isSafeInteger(pid) || pid < 1)
21
+ return undefined;
22
+ if (process.platform === "linux") {
23
+ try {
24
+ return readProcessStart(pid);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ if (process.platform !== "darwin")
31
+ return undefined;
32
+ try {
33
+ const identity = execFileSync(nativeExecutable("process-identity"), [String(pid)], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
34
+ return /^(0|[1-9][0-9]{0,31})$/u.test(identity) ? identity : undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
6
39
  }
7
40
  /**
8
41
  * False requires proof of absence or a different process generation.
@@ -42,20 +75,28 @@ export function processGenerationIsLive(pid, startIdentity) {
42
75
  if (!Number.isSafeInteger(pid) || pid < 1 || !/^(0|[1-9][0-9]{0,31})$/u.test(startIdentity)) {
43
76
  throw new Error(`Unverified process generation for PID ${pid}.`);
44
77
  }
78
+ const actual = readProcessStartIdentity(pid);
79
+ if (actual !== undefined)
80
+ return actual === startIdentity;
81
+ // PID liveness can prove a process is gone, never prove its generation.
45
82
  try {
46
- return readProcessStart(pid) === startIdentity;
83
+ process.kill(pid, 0);
47
84
  }
48
- catch {
49
- // PID liveness can prove a process is gone, never prove its generation.
50
- try {
51
- process.kill(pid, 0);
52
- }
53
- catch (error) {
54
- if (hasCode(error, "ESRCH"))
55
- return false;
56
- }
57
- throw new Error(`Unverified process generation for PID ${pid}; OS identity could not be read.`);
85
+ catch (error) {
86
+ if (hasCode(error, "ESRCH"))
87
+ return false;
88
+ }
89
+ throw new Error(`Unverified process generation for PID ${pid}; OS identity could not be read.`);
90
+ }
91
+ /**
92
+ * Owner liveness used by reconciliation. An unreadable identity for a live
93
+ * PID is unknown and must not authorize cleanup or release of durable custody.
94
+ */
95
+ export function processOwnerIsLive(pid, startIdentity) {
96
+ if (!Number.isSafeInteger(pid) || pid < 1 || !/^(0|[1-9][0-9]{0,31})$/u.test(startIdentity)) {
97
+ throw new Error(`Unverified process generation for PID ${pid}.`);
58
98
  }
99
+ return processGenerationIsLive(pid, startIdentity);
59
100
  }
60
101
  function readProcessStart(pid) {
61
102
  const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
@@ -1,10 +1,12 @@
1
- import { accessSync, constants, existsSync, lstatSync } from "node:fs";
1
+ import { accessSync, constants, existsSync, lstatSync, realpathSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import Database from "better-sqlite3";
4
+ import { inspectPty } from "./ptyProbe.js";
4
5
  import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
5
6
  import { operationalAgentEnvironment } from "../agent/launchEnvironment.js";
6
7
  import { resolveTmuxBin } from "../config/yuiConfig.js";
7
8
  import { compileRoleSessionContext } from "../context/roleSessionContext.js";
9
+ import { callController, ControllerClientError } from "../core/controllerClient.js";
8
10
  import { EPHEMERAL_DOMAIN_GRACE_MS, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
9
11
  import { usageError } from "../errors/cliError.js";
10
12
  import { inspectAgentCapabilities, resolveAgentAdapter } from "../executor/agentAdapter.js";
@@ -12,6 +14,8 @@ import { assertCodexLaunchOverridesAvailable, inspectCodexLaunchConfig } from ".
12
14
  import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
13
15
  import { nativeAdditionalDirectories, nativeAgentWorkspace, withNativeProjectDirectories } from "../executor/fileRoleLaunchPlanner.js";
14
16
  import { defaultTableWidth, renderTable } from "../output/table.js";
17
+ import { codexAppNeedsRestart, codexAppProcessIds, codexAppWebSocketUrl, codexSharedSocketPath, findMacCodexAppExecutable, isCodexInspectionPermissionError } from "../runtime/codexSharedDaemon.js";
18
+ import { codexAgentsInUse } from "../runtime/localStartup.js";
15
19
  import { CURRENT_DATABASE_FILENAME, openCurrentTaskStore } from "../storage/currentTaskStore.js";
16
20
  import { inspectStorageSchema } from "../storage/storageSchema.js";
17
21
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
@@ -42,8 +46,8 @@ export function summarizeStorageHealth(checks) {
42
46
  return { healthy: blocking.length === 0, blocking };
43
47
  }
44
48
  /** Build the full machine-readable doctor report (checks + storage health). */
45
- export function buildDoctorReport(env, executor) {
46
- const inspection = inspectDoctor(env, executor);
49
+ export async function buildDoctorReport(env, executor) {
50
+ const inspection = await inspectDoctor(env, executor);
47
51
  const home = resolveYuiHome(env);
48
52
  return {
49
53
  checks: inspection.checks,
@@ -100,29 +104,30 @@ function inspectStorageDetails(home, env) {
100
104
  };
101
105
  }
102
106
  /** Runs the read-only current-storage diagnostics used by `yui doctor`. */
103
- export function runDoctorCommand(args, env, executor) {
107
+ export async function runDoctorCommand(args, env, executor) {
104
108
  if (args.length !== 0)
105
109
  throw usageError("Doctor usage: yui doctor");
106
- const inspection = inspectDoctor(env, executor);
110
+ const inspection = await inspectDoctor(env, executor);
107
111
  return renderDoctor(inspection.checks, inspection.review);
108
112
  }
109
- export function getDoctorChecks(env, executor) {
110
- return inspectDoctor(env, executor).checks;
113
+ export async function getDoctorChecks(env, executor) {
114
+ return (await inspectDoctor(env, executor)).checks;
111
115
  }
112
- function inspectDoctor(env, executor) {
116
+ async function inspectDoctor(env, executor) {
113
117
  const home = resolveYuiHome(env);
114
118
  const homeCheck = checkHome(home);
115
119
  const schema = readSchema(home);
116
120
  const compatibility = inspectCompatibility(home, homeCheck, schema);
117
121
  const schemaCheck = checkSchema(schema);
118
122
  const storage = inspectState(home, homeCheck, schema, compatibility);
123
+ const controller = await checkController(home);
119
124
  const domain = checkEphemeralDomain(home);
120
125
  const durableConfig = readDurableConfigSafely(home);
121
126
  const toolChecks = [
122
127
  checkExecutable("git", durableConfig.gitBin, ["--version"], executor),
123
128
  checkExecutable("tmux", durableConfig.tmuxBin, ["-V"], executor)
124
129
  ];
125
- const agentChecks = storage.agents.flatMap((agent) => checkAgent(agent, executor, env));
130
+ const agentChecks = storage.agents.flatMap((agent) => checkAgent(agent, executor, env, home, storage.activeCodexAgentIds.has(agent.id)));
126
131
  const review = inspectReview({ ...storage.review, home }, agentChecks, env);
127
132
  return {
128
133
  checks: [
@@ -130,14 +135,49 @@ function inspectDoctor(env, executor) {
130
135
  schemaCheck,
131
136
  compatibility.check,
132
137
  storage.check,
138
+ controller,
133
139
  ...(domain === undefined ? [] : [domain]),
134
140
  ...toolChecks,
141
+ ...inspectPty(),
135
142
  ...agentChecks,
136
143
  ...review.checks
137
144
  ],
138
145
  review: review.report
139
146
  };
140
147
  }
148
+ /** Query the authenticated Controller endpoint without starting it. */
149
+ export async function checkController(home, probe = callController) {
150
+ try {
151
+ const result = await probe(home, "controller.status", {}, { timeoutMs: 1_000 });
152
+ if (result !== null && typeof result === "object" && !Array.isArray(result)) {
153
+ const status = result;
154
+ if (status.running === true && Number.isSafeInteger(status.pid) && Number(status.pid) > 0) {
155
+ return { name: "controller", status: "ok", detail: `Controller is running pid=${status.pid}.` };
156
+ }
157
+ }
158
+ return {
159
+ name: "controller", status: "invalid",
160
+ detail: "Controller returned an invalid status; inspect yui controller status --verbose."
161
+ };
162
+ }
163
+ catch (error) {
164
+ if (error instanceof ControllerClientError && error.code === "CONTROLLER_NOT_RUNNING") {
165
+ return { name: "controller", status: "missing", detail: `${error.message} Run yui start.` };
166
+ }
167
+ if (error instanceof ControllerClientError && error.code === "CONTROLLER_UNAVAILABLE") {
168
+ return {
169
+ name: "controller",
170
+ status: "invalid",
171
+ detail: `Controller is not reachable: ${error.message} Run yui start. Its process state is unverified.`
172
+ };
173
+ }
174
+ return {
175
+ name: "controller",
176
+ status: "invalid",
177
+ detail: `Controller status could not be verified: ${error instanceof Error ? error.message : String(error)}. Inspect yui controller status --verbose.`
178
+ };
179
+ }
180
+ }
141
181
  function checkEphemeralDomain(home) {
142
182
  const identity = readEphemeralDomainIdentity(home);
143
183
  if (identity.status === "absent")
@@ -327,6 +367,7 @@ function inspectState(home, homeCheck, schema, compatibility) {
327
367
  detail: `yui.db readable agents=${agents.length} tasks=${tasks.length} roles=${roleCount} globalRoles=${globalRoles.length} defaultAgent=${config.defaultAgent ?? "none"}`
328
368
  },
329
369
  agents,
370
+ activeCodexAgentIds: new Set(codexAgentsInUse(store).map((agent) => agent.id)),
330
371
  review: {
331
372
  storageReady: true,
332
373
  storageDetail: "yui.db is readable",
@@ -362,6 +403,7 @@ function blockedStorage(status, detail) {
362
403
  return {
363
404
  check: { name: "storage state", status, detail },
364
405
  agents: [],
406
+ activeCodexAgentIds: new Set(),
365
407
  review: {
366
408
  storageReady: false,
367
409
  storageDetail: detail
@@ -655,11 +697,14 @@ function checkExecutable(name, command, args, executor) {
655
697
  };
656
698
  }
657
699
  }
658
- function checkAgent(agent, executor, environment) {
700
+ function checkAgent(agent, executor, environment, home, activeCodex) {
659
701
  let snapshot;
702
+ let baseArgs;
703
+ let launchEnvironment;
660
704
  try {
661
705
  const definition = configuredAgentToDefinition(agent);
662
- const launchEnvironment = resolveDoctorAgentEnvironment(definition, environment);
706
+ baseArgs = definition.baseArgs;
707
+ launchEnvironment = resolveDoctorAgentEnvironment(definition, environment);
663
708
  snapshot = inspectAgentCapabilities(definition, {
664
709
  run: (command, args) => runAgentProbe(executor, command, args, launchEnvironment)
665
710
  });
@@ -686,6 +731,9 @@ function checkAgent(agent, executor, environment) {
686
731
  const available = snapshot.fields.filter((field) => field.status === "available").length;
687
732
  const degraded = snapshot.fields.filter((field) => field.status === "degraded").length;
688
733
  const unavailable = snapshot.fields.filter((field) => field.status === "unavailable").length;
734
+ const daemon = activeCodex && status === "ok"
735
+ ? checkCodexSharedDaemon(agent.command, baseArgs, launchEnvironment, executor, agent.id)
736
+ : undefined;
689
737
  return [
690
738
  { name: `agent:${agent.id}:command`, status, detail: commandDetail },
691
739
  {
@@ -698,9 +746,181 @@ function checkAgent(agent, executor, environment) {
698
746
  "installation/help inspection only; no model or account capability query",
699
747
  ...snapshot.warnings.map((warning) => `warning=${warning}`)
700
748
  ].join(" ")
701
- }
749
+ },
750
+ ...(daemon === undefined ? [] : [daemon]),
751
+ ...(daemon !== undefined && process.platform === "darwin"
752
+ ? checkMacCodexApp(agent.id, launchEnvironment, home, daemon, executor)
753
+ : [])
702
754
  ];
703
755
  }
756
+ export function checkMacCodexApp(agentId, environment, home, daemon, executor, appExecutable = findMacCodexAppExecutable(environment)) {
757
+ if (appExecutable === undefined)
758
+ return [{
759
+ name: `agent:${agentId}:mac-app`, status: "ok",
760
+ detail: "Codex App was not detected; App sharing is not configured on this Mac."
761
+ }];
762
+ const expectedUrl = codexAppWebSocketUrl(environment, home);
763
+ let appPids;
764
+ try {
765
+ appPids = codexAppProcessIds(executor.run("/bin/ps", ["-axo", "pid=,args="], {
766
+ timeoutMs: 5_000
767
+ }), appExecutable);
768
+ }
769
+ catch (error) {
770
+ return [{
771
+ name: `agent:${agentId}:mac-app`, status: "invalid",
772
+ detail: `Cannot inspect Codex App processes: ${commandFailure(error)}`
773
+ }];
774
+ }
775
+ const processCheck = {
776
+ name: `agent:${agentId}:mac-app`,
777
+ status: appPids.length > 0 ? "ok" : "missing",
778
+ detail: appPids.length > 0
779
+ ? `Codex App is running pid=${appPids.join(",")}.`
780
+ : "Codex App is installed but not running."
781
+ };
782
+ let launchCheck;
783
+ try {
784
+ const launchUrl = executor.run("/bin/launchctl", ["getenv", "CODEX_APP_SERVER_WS_URL"], {
785
+ timeoutMs: 5_000
786
+ }).trim();
787
+ launchCheck = launchUrl === expectedUrl
788
+ ? {
789
+ name: `agent:${agentId}:mac-app-launch`, status: "ok",
790
+ detail: "Future Codex App launches are configured for this shared app-server."
791
+ }
792
+ : {
793
+ name: `agent:${agentId}:mac-app-launch`, status: "missing",
794
+ detail: "This login is not prepared for App sharing. Run yui start before opening or restarting App."
795
+ };
796
+ }
797
+ catch (error) {
798
+ launchCheck = {
799
+ name: `agent:${agentId}:mac-app-launch`, status: "invalid",
800
+ detail: `Cannot inspect the App launch environment: ${commandFailure(error)}`
801
+ };
802
+ }
803
+ const connectionName = `agent:${agentId}:mac-app-connection`;
804
+ if (appPids.length === 0) {
805
+ return [processCheck, launchCheck, {
806
+ name: connectionName, status: "missing", detail: "No detected App process; connection is not verified."
807
+ }];
808
+ }
809
+ if (daemon.status !== "ok") {
810
+ return [processCheck, launchCheck, {
811
+ name: connectionName, status: daemon.status,
812
+ detail: "Shared daemon is unavailable or unverified; App connection to it cannot be confirmed."
813
+ }];
814
+ }
815
+ let configuredCount = 0;
816
+ for (const pid of appPids) {
817
+ try {
818
+ const command = executor.run("/bin/ps", ["eww", "-p", pid, "-o", "command="], {
819
+ timeoutMs: 5_000
820
+ });
821
+ if (!codexAppNeedsRestart(command, expectedUrl, false))
822
+ configuredCount += 1;
823
+ }
824
+ catch (error) {
825
+ return [processCheck, launchCheck, {
826
+ name: connectionName, status: "invalid",
827
+ detail: `Cannot inspect running Codex App: ${commandFailure(error)}`
828
+ }];
829
+ }
830
+ }
831
+ try {
832
+ const socketTarget = realpathSync(codexSharedSocketPath(environment, home));
833
+ const sockets = parseMacUnixSockets(executor.run("/usr/sbin/lsof", ["-nP", "-U", "-F", "pfdn"], {
834
+ timeoutMs: 5_000
835
+ }));
836
+ const daemonSockets = sockets.filter((socket) => socket.name === socketTarget);
837
+ if (daemonSockets.length === 0) {
838
+ return [processCheck, launchCheck, {
839
+ name: connectionName, status: "invalid",
840
+ detail: "Daemon reports running, but its Unix socket endpoint was not observable."
841
+ }];
842
+ }
843
+ const peer = sockets.find((socket) => appPids.includes(socket.pid)
844
+ && daemonSockets.some((daemonSocket) => socket.name === `->${daemonSocket.device}`));
845
+ return [processCheck, launchCheck, peer === undefined
846
+ ? {
847
+ name: connectionName, status: "missing",
848
+ detail: configuredCount > 0
849
+ ? "App has the shared URL, but no live socket peer to this daemon was observed."
850
+ : "No live App socket peer to this daemon was observed; reopen App after Yui prepares sharing."
851
+ }
852
+ : {
853
+ name: connectionName, status: "ok",
854
+ detail: `App pid=${peer.pid} has a live Unix socket peer on shared daemon pid=${daemonSockets[0].pid}.`
855
+ }];
856
+ }
857
+ catch (error) {
858
+ return [processCheck, launchCheck, {
859
+ name: connectionName, status: "invalid",
860
+ detail: `Cannot verify the live App-to-daemon socket: ${commandFailure(error)}`
861
+ }];
862
+ }
863
+ }
864
+ function parseMacUnixSockets(output) {
865
+ const sockets = [];
866
+ let pid = "";
867
+ let device = "";
868
+ let name = "";
869
+ const finish = () => {
870
+ if (pid !== "" && /^0x[0-9a-f]+$/iu.test(device) && name !== "") {
871
+ sockets.push({ pid, device, name });
872
+ }
873
+ device = "";
874
+ name = "";
875
+ };
876
+ for (const line of output.split("\n")) {
877
+ if (line.startsWith("p")) {
878
+ finish();
879
+ pid = line.slice(1);
880
+ }
881
+ else if (line.startsWith("f"))
882
+ finish();
883
+ else if (line.startsWith("d"))
884
+ device = line.slice(1);
885
+ else if (line.startsWith("n"))
886
+ name = line.slice(1);
887
+ }
888
+ finish();
889
+ return sockets;
890
+ }
891
+ export function checkCodexSharedDaemon(command, baseArgs, environment, executor, agentId) {
892
+ const name = `agent:${agentId}:daemon`;
893
+ const probe = runAgentProbe(executor, command, [...baseArgs, "app-server", "daemon", "version"], environment);
894
+ if (probe.status !== 0) {
895
+ const detail = probe.stderr.trim() || probe.error?.message || "The Codex daemon could not be inspected.";
896
+ if (isCodexInspectionPermissionError(detail))
897
+ return {
898
+ name,
899
+ status: "invalid",
900
+ detail: `Shared Codex app-server state is unverified: this process was denied access during the daemon probe (${detail}). Run Doctor from a local terminal with access to the Codex socket.`
901
+ };
902
+ return {
903
+ name,
904
+ status: /No such file or directory|Connection refused/iu.test(detail) ? "missing" : "invalid",
905
+ detail: `Shared Codex app-server probe failed: ${detail}`
906
+ };
907
+ }
908
+ try {
909
+ const version = JSON.parse(probe.stdout);
910
+ if (version === null || typeof version !== "object" || Array.isArray(version))
911
+ throw new Error();
912
+ const state = version;
913
+ if (state.status !== "running")
914
+ throw new Error();
915
+ return {
916
+ name, status: "ok",
917
+ detail: `Shared Codex app-server running version=${String(state.appServerVersion ?? "unknown")}`
918
+ };
919
+ }
920
+ catch {
921
+ return { name, status: "invalid", detail: "Codex daemon version did not confirm a running app-server." };
922
+ }
923
+ }
704
924
  function runAgentProbe(executor, command, args, environment) {
705
925
  try {
706
926
  return {
@@ -0,0 +1,74 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { fileURLToPath } from "node:url";
5
+ /** Native loading and spawn happen outside Doctor: a native abort cannot kill the CLI. */
6
+ export function inspectPty(options = {}) {
7
+ const started = Date.now();
8
+ const checks = [];
9
+ const major = Number(process.versions.node.split(".")[0]);
10
+ if (![20, 22, 24, 26].includes(major)) {
11
+ checks.push({
12
+ name: "pty Node runtime", status: "unsupported",
13
+ detail: `Node ${process.versions.node} is outside Yui's supported Node 20/22/24/26 range; this is not evidence of a helper permission failure.`
14
+ });
15
+ }
16
+ let entry;
17
+ let identity;
18
+ try {
19
+ const require = createRequire(options.requireFrom ?? import.meta.url);
20
+ entry = require.resolve("node-pty");
21
+ const manifest = require.resolve("node-pty/package.json");
22
+ const version = JSON.parse(readFileSync(manifest, "utf8")).version;
23
+ identity = `node-pty ${version}; entry=${entry}; package=${manifest}`;
24
+ }
25
+ catch (error) {
26
+ return [...checks, {
27
+ name: "node-pty",
28
+ status: error.code === "MODULE_NOT_FOUND" ? "missing" : "invalid",
29
+ detail: `Dependency resolution failed: ${String(error)}`
30
+ }];
31
+ }
32
+ const timeoutMs = options.timeoutMs ?? 1_500;
33
+ const result = spawnSync(process.execPath, [
34
+ fileURLToPath(new URL("./ptyProbeChild.js", import.meta.url)),
35
+ entry, String(options.probeTimeoutMs ?? 750)
36
+ ], {
37
+ // No NODE_OPTIONS, shell startup files, preload hooks or user configuration.
38
+ env: { PATH: "/usr/bin:/bin", LANG: "C" },
39
+ cwd: "/",
40
+ encoding: "utf8",
41
+ timeout: timeoutMs,
42
+ killSignal: "SIGKILL",
43
+ maxBuffer: 64 * 1024
44
+ });
45
+ for (const line of (result.stdout ?? "").split("\n").filter(Boolean)) {
46
+ try {
47
+ const check = JSON.parse(line);
48
+ if (["node-pty", "pty helper", "pty spawn"].includes(check.name)
49
+ && ["ok", "missing", "invalid"].includes(check.status)
50
+ && typeof check.detail === "string") {
51
+ checks.push({ ...check, detail: `${identity}; ${check.detail}` });
52
+ }
53
+ }
54
+ catch { /* Native diagnostics on stdout are retained below on failure. */ }
55
+ }
56
+ const completed = checks.some(check => check.name === "pty spawn")
57
+ || checks.some(check => check.status !== "ok" && check.name !== "pty Node runtime");
58
+ if (result.error || result.signal || result.status !== 0 || !completed) {
59
+ const timedOut = result.error?.code === "ETIMEDOUT";
60
+ // A partial success line is not success if the isolated process failed to finish.
61
+ for (let i = 0; i < checks.length; i += 1) {
62
+ if (checks[i].name === "pty spawn" && checks[i].status === "ok") {
63
+ checks[i] = { ...checks[i], status: "invalid", detail: `${checks[i].detail}; isolated probe did not finish successfully` };
64
+ }
65
+ }
66
+ checks.push({
67
+ name: "pty probe", status: "invalid",
68
+ detail: `${identity}; ${timedOut ? `timeout after ${timeoutMs}ms` : "isolated probe failed"}; exit=${result.status} signal=${result.signal}; ${result.error?.message ?? ""} ${(result.stderr || result.stdout || "").trim()}`
69
+ });
70
+ }
71
+ return checks.map(check => ({
72
+ ...check, detail: `${check.detail}; elapsed=${Date.now() - started}ms`
73
+ }));
74
+ }