@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
@@ -14,11 +14,11 @@
14
14
  */
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { buildRuntimeIdentityReceipt } from "../core/controllerServer.js";
17
+ import { currentProcessStartIdentity } from "../core/fileLockOwner.js";
17
18
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
18
19
  import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
19
20
  import { detectRunningRelease, isOwnerLive, readActiveReleasePointer, readHandoverFence, removeCandidateDiscovery, removeHandoverFence, writeCandidateDiscovery, writeHandoverFence, writeHandoverReceipt, writeRuntimeIdentity } from "../release/runtimeRelease.js";
20
21
  import { startFileTaskControllerRuntime } from "./runtime.js";
21
- import { readLinuxProcessStartIdentity } from "./domainIdentity.js";
22
22
  import { RELEASE_HANDOVER_OLD_OWNER_GRACE_MS } from "../runtime/runtimeDeadlines.js";
23
23
  export const CONTROLLER_CANDIDATE_ENV = "YUI_CONTROLLER_CANDIDATE";
24
24
  export const CONTROLLER_HANDOVER_ID_ENV = "YUI_CONTROLLER_HANDOVER_ID";
@@ -53,10 +53,7 @@ export async function runHandoverCandidate(home, handoverId, options = {}) {
53
53
  const dualOwnerGraceMs = options.dualOwnerGraceMs ?? DEFAULT_DUAL_OWNER_GRACE_MS;
54
54
  const now = options.now ?? (() => new Date());
55
55
  const release = detectRunningRelease(fileURLToPath(import.meta.url));
56
- const startIdentity = readLinuxProcessStartIdentity(process.pid);
57
- if (startIdentity === undefined) {
58
- throw new Error(`Cannot read process start identity for handover candidate PID ${process.pid}.`);
59
- }
56
+ const startIdentity = currentProcessStartIdentity();
60
57
  const fence = readHandoverFence(home);
61
58
  if (fence === null || fence.handoverId !== handoverId) {
62
59
  throw new Error(`Handover fence for ${handoverId} is missing or does not match; aborting candidate.`);
@@ -19,7 +19,7 @@ import { cancelQueuedDurableJob, completeDurableJob, isDurableJobTerminal, markD
19
19
  import { recordOperationEvidence } from "../kernel/operationFacts.js";
20
20
  import { wakeReason } from "../scheduler/wakeReason.js";
21
21
  import { writeTextFileAtomically } from "../storage/durableFile.js";
22
- import { readLinuxProcessStartIdentity } from "./domainIdentity.js";
22
+ import { readProcessStartIdentity } from "../core/fileLockOwner.js";
23
23
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
24
24
  const HEARTBEAT_STALE_MS = 2 * 60_000;
25
25
  const SIGKILL_GRACE_MS = 30_000;
@@ -401,7 +401,7 @@ export function createLinuxProcessPort() {
401
401
  spawnJobRunner(specPath) {
402
402
  const child = spawn(process.execPath, [fileURLToPath(new URL("../job/jobRunner.js", import.meta.url)), specPath], { detached: true, stdio: "ignore" });
403
403
  child.unref();
404
- const startIdentity = readLinuxProcessStartIdentity(child.pid);
404
+ const startIdentity = readProcessStartIdentity(child.pid);
405
405
  return {
406
406
  pid: child.pid,
407
407
  startIdentity,
@@ -416,7 +416,7 @@ export function createLinuxProcessPort() {
416
416
  };
417
417
  },
418
418
  processStartIdentity(pid) {
419
- return readLinuxProcessStartIdentity(pid);
419
+ return readProcessStartIdentity(pid);
420
420
  },
421
421
  isProcessAlive(pid) {
422
422
  try {
@@ -428,7 +428,7 @@ export function createLinuxProcessPort() {
428
428
  }
429
429
  },
430
430
  signalIfOwned(pid, startIdentity, signal) {
431
- if (readLinuxProcessStartIdentity(pid) !== startIdentity)
431
+ if (readProcessStartIdentity(pid) !== startIdentity)
432
432
  return;
433
433
  try {
434
434
  process.kill(pid, signal);
@@ -1,5 +1,7 @@
1
1
  import { lstatSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { createConnection } from "node:net";
2
3
  import { basename, dirname, join, resolve } from "node:path";
4
+ import { readProcessStartIdentity } from "../core/fileLockOwner.js";
3
5
  import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
4
6
  import { TmuxManager } from "../tmux/tmuxManager.js";
5
7
  import { tmuxSocketDirectory, tmuxSocketEnvironment } from "../tmux/tmuxSocketEndpoint.js";
@@ -17,7 +19,7 @@ export async function cleanControllerResource(resource, options = {}) {
17
19
  const ports = options.ports ?? linuxCleanupPorts(environment, options.tmuxBin ?? "tmux");
18
20
  const cleanup = async () => {
19
21
  if (resource.artifact !== undefined) {
20
- cleanArtifact(resource, ports, environment);
22
+ await cleanArtifact(resource, ports, environment);
21
23
  return;
22
24
  }
23
25
  if (resource.kind === "agent-session") {
@@ -88,7 +90,7 @@ function assertEphemeralDomainFence(resource) {
88
90
  throw new Error(`Resource changed since scan: ${resource.id}.`);
89
91
  }
90
92
  }
91
- function cleanArtifact(resource, ports, environment) {
93
+ async function cleanArtifact(resource, ports, environment) {
92
94
  const artifact = resource.artifact;
93
95
  if (artifact === undefined)
94
96
  throw new Error(`Artifact is unavailable: ${resource.id}.`);
@@ -116,9 +118,15 @@ function cleanArtifact(resource, ports, environment) {
116
118
  return;
117
119
  }
118
120
  if (artifact.artifactKind.endsWith("-socket")
119
- && ports.socketActive(artifact.path)) {
121
+ && await ports.socketActive(artifact.path)) {
120
122
  throw new Error(`Resource socket is active: ${artifact.path}.`);
121
123
  }
124
+ // The listener probe is asynchronous on macOS. A concurrent owner may
125
+ // replace the path while it runs; the pre-probe fingerprint no longer
126
+ // authorizes unlinking that new socket.
127
+ if (ports.artifactFingerprint(artifact.path) !== artifact.fingerprint) {
128
+ throw new Error(`Resource changed since scan: ${resource.id}.`);
129
+ }
122
130
  ports.removeArtifact(artifact.path);
123
131
  }
124
132
  function assertProcessIdentities(resource, ports) {
@@ -153,7 +161,7 @@ async function waitForProcesses(resource, ports, graceMs, pollMs) {
153
161
  }
154
162
  function linuxCleanupPorts(environment, tmuxBin) {
155
163
  return {
156
- processStartIdentity: readProcessStartIdentity,
164
+ processStartIdentity: readOwnedProcessStartIdentity,
157
165
  signal: (pid, signal) => process.kill(pid, signal),
158
166
  sleep: (milliseconds) => new Promise((resolveSleep) => {
159
167
  setTimeout(resolveSleep, milliseconds);
@@ -166,7 +174,8 @@ function linuxCleanupPorts(environment, tmuxBin) {
166
174
  return undefined;
167
175
  }
168
176
  },
169
- socketActive: unixSocketIsActive,
177
+ socketActive: process.platform === "darwin"
178
+ ? darwinUnixSocketIsActive : unixSocketIsActive,
170
179
  removeArtifact: (path) => unlinkSync(path),
171
180
  inspectTmuxServerPanes: async (resource) => {
172
181
  const manager = tmuxManagerForResource(resource, environment, tmuxBin);
@@ -218,20 +227,8 @@ function tmuxManagerForResource(resource, environment, tmuxBin) {
218
227
  })
219
228
  }, { yuiHome: home });
220
229
  }
221
- function readProcessStartIdentity(pid) {
222
- try {
223
- const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
224
- const closing = stat.lastIndexOf(")");
225
- if (closing < 0)
226
- return undefined;
227
- const identity = stat.slice(closing + 1).trim().split(/\s+/u)[19];
228
- return identity !== undefined && /^[0-9]{1,32}$/u.test(identity)
229
- ? identity
230
- : undefined;
231
- }
232
- catch {
233
- return undefined;
234
- }
230
+ function readOwnedProcessStartIdentity(pid) {
231
+ return readProcessStartIdentity(pid);
235
232
  }
236
233
  function unixSocketIsActive(expectedPath) {
237
234
  try {
@@ -243,6 +240,25 @@ function unixSocketIsActive(expectedPath) {
243
240
  return true;
244
241
  }
245
242
  }
243
+ function darwinUnixSocketIsActive(path) {
244
+ return new Promise((resolveActive) => {
245
+ const socket = createConnection(path);
246
+ let settled = false;
247
+ const finish = (active) => {
248
+ if (settled)
249
+ return;
250
+ settled = true;
251
+ socket.destroy();
252
+ resolveActive(active);
253
+ };
254
+ socket.once("connect", () => finish(true));
255
+ socket.once("error", (error) => {
256
+ finish(error.code !== "ENOENT" && error.code !== "ECONNREFUSED");
257
+ });
258
+ socket.once("close", () => finish(true));
259
+ socket.setTimeout(1_000, () => finish(true));
260
+ });
261
+ }
246
262
  function assertOwnedArtifactPath(resource, environment) {
247
263
  const artifact = resource.artifact;
248
264
  if (artifact === undefined)
@@ -1,6 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
3
+ import { createConnection } from "node:net";
3
4
  import { basename, join, resolve } from "node:path";
5
+ import { processOwnerIsLive, readProcessStartIdentity } from "../core/fileLockOwner.js";
4
6
  import { activeLiveRoleAgentSession } from "../executor/agentExecutor.js";
5
7
  import { inspectStorageSchema } from "../storage/storageSchema.js";
6
8
  import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
@@ -12,16 +14,31 @@ import { CONTROLLER_DISCOVERY_PATH } from "../core/protocol.js";
12
14
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
13
15
  import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
14
16
  import { buildControllerResourceInventory, runtimeRoleProtectsDomain } from "./resourceInventory.js";
15
- import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "./domainIdentity.js";
17
+ import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity } from "./domainIdentity.js";
16
18
  const LINUX_PROCESS_SCAN_BATCH_SIZE = 64;
17
19
  export const INVENTORY_EVENT_LOOP_RUN_BUDGET_MS = 25;
18
20
  export async function scanControllerResourceInventory(options) {
21
+ if (options.scope === "all" && process.platform !== "linux") {
22
+ throw new Error("Controller inventory --all requires cross-Home process discovery, which is unavailable on this platform. Use the current-Home scope.");
23
+ }
19
24
  const currentHome = resolve(options.currentHome);
20
25
  const environment = options.environment ?? process.env;
21
26
  const observedAt = (options.now ?? (() => new Date()))();
22
27
  const warnings = [];
23
- const activeSockets = readActiveUnixSocketPaths(warnings);
24
- const processes = normalizePhysicalHomeAliases((await listLinuxProcesses(warnings)).filter(({ pid }) => pid !== process.pid), currentHome);
28
+ const tmuxDirectory = tmuxSocketDirectory(environment);
29
+ // /proc-backed process and socket enumeration is Linux-only. On macOS the
30
+ // current Controller is synthesized from its exact discovery identity.
31
+ let activeSockets;
32
+ let processes;
33
+ if (process.platform === "linux") {
34
+ activeSockets = readActiveUnixSocketPaths(warnings);
35
+ processes = normalizePhysicalHomeAliases((await listLinuxProcesses(warnings)).filter(({ pid }) => pid !== process.pid), currentHome);
36
+ }
37
+ else {
38
+ const observed = await observeCurrentController(currentHome, tmuxDirectory);
39
+ activeSockets = observed.activeSockets;
40
+ processes = observed.process;
41
+ }
25
42
  const homes = new Set([currentHome]);
26
43
  if (options.scope === "all") {
27
44
  for (const process of processes) {
@@ -29,7 +46,6 @@ export async function scanControllerResourceInventory(options) {
29
46
  homes.add(process.yuiHome);
30
47
  }
31
48
  }
32
- const tmuxDirectory = tmuxSocketDirectory(environment);
33
49
  const listTmuxSocketArtifacts = options.listTmuxSocketArtifacts
34
50
  ?? listSharedTmuxSocketArtifacts;
35
51
  // scope=current owns exactly one YUI_HOME, so only its exact tmux server
@@ -86,7 +102,7 @@ export async function scanControllerResourceInventory(options) {
86
102
  // otherwise its own expired-domain pass would delete controller.json
87
103
  // and strand the still-running server without a callable endpoint.
88
104
  || (discovery.pid === process.pid
89
- && readLinuxProcessStartIdentity(process.pid) === discovery.processStartIdentity));
105
+ && readProcessStartIdentity(process.pid) === discovery.processStartIdentity));
90
106
  const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
91
107
  const expectedControllerSocketPath = state.homeId === undefined
92
108
  ? undefined
@@ -179,6 +195,78 @@ export function classifyRuntimeProcess(args, command) {
179
195
  return "agent";
180
196
  return "other";
181
197
  }
198
+ /**
199
+ * Non-Linux inventory observations. There is no /proc to enumerate, so the
200
+ * current Home's Controller is reconstructed from its discovery record when
201
+ * its process-generation identity is still live, and listening Unix sockets are proven with
202
+ * a direct connection (the equivalent of reading /proc/net/unix). Only the
203
+ * current Home is observable without /proc; scope=all is rejected at entry.
204
+ */
205
+ async function observeCurrentController(currentHome, tmuxDirectory) {
206
+ const activeSockets = new Set();
207
+ for (const path of listProbeableSocketPaths(tmuxDirectory)) {
208
+ if (await unixSocketListens(path))
209
+ activeSockets.add(path);
210
+ }
211
+ let discovery;
212
+ try {
213
+ discovery = await readControllerDiscovery(currentHome);
214
+ }
215
+ catch {
216
+ return { activeSockets, process: [] };
217
+ }
218
+ if (await unixSocketListens(discovery.socketPath)) {
219
+ activeSockets.add(discovery.socketPath);
220
+ }
221
+ if (!processOwnerIsLive(discovery.pid, discovery.processStartIdentity)) {
222
+ return { activeSockets, process: [] };
223
+ }
224
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
225
+ const processFact = {
226
+ pid: discovery.pid,
227
+ ppid: 1,
228
+ uid,
229
+ startIdentity: discovery.processStartIdentity,
230
+ yuiHome: currentHome,
231
+ ...(discovery.homeFilesystemId === undefined
232
+ ? {}
233
+ : { homeFilesystemId: discovery.homeFilesystemId }),
234
+ kind: "controller",
235
+ command: "yui-controller",
236
+ args: [],
237
+ rssBytes: 0,
238
+ cpuTimeMs: 0,
239
+ ageMs: 0
240
+ };
241
+ return { activeSockets, process: [processFact] };
242
+ }
243
+ function listProbeableSocketPaths(tmuxDirectory) {
244
+ try {
245
+ return readdirSync(tmuxDirectory, { encoding: "utf8" })
246
+ .filter((entry) => /^yui-[a-f0-9]{24}$/u.test(entry))
247
+ .map((entry) => join(tmuxDirectory, entry));
248
+ }
249
+ catch {
250
+ return [];
251
+ }
252
+ }
253
+ function unixSocketListens(path) {
254
+ return new Promise((resolveProbe) => {
255
+ const socket = createConnection(path);
256
+ let settled = false;
257
+ const finish = (listens) => {
258
+ if (settled)
259
+ return;
260
+ settled = true;
261
+ socket.destroy();
262
+ resolveProbe(listens);
263
+ };
264
+ socket.once("connect", () => finish(true));
265
+ socket.once("error", () => finish(false));
266
+ const timer = setTimeout(() => finish(false), 200);
267
+ timer.unref();
268
+ });
269
+ }
182
270
  async function listLinuxProcesses(warnings) {
183
271
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
184
272
  const clockTicks = readClockTicks();
@@ -497,8 +585,8 @@ function inspectRuntimeDomain(home, processes, roles, storageStatus, now) {
497
585
  graceMs: EPHEMERAL_DOMAIN_GRACE_MS
498
586
  };
499
587
  }
500
- const hostStartIdentity = readLinuxProcessStartIdentity(identity.hostPid);
501
- const hostPathExists = existsSync(`/proc/${identity.hostPid}`);
588
+ const hostStartIdentity = readProcessStartIdentity(identity.hostPid);
589
+ const hostPathExists = processExists(identity.hostPid);
502
590
  const hostActive = hostStartIdentity === identity.hostProcessStartIdentity;
503
591
  const storageSafe = storageStatus === "current";
504
592
  if (hostActive) {
@@ -607,6 +695,15 @@ function inspectRuntimeDomain(home, processes, roles, storageStatus, now) {
607
695
  graceMs: EPHEMERAL_DOMAIN_GRACE_MS
608
696
  };
609
697
  }
698
+ function processExists(pid) {
699
+ try {
700
+ process.kill(pid, 0);
701
+ return true;
702
+ }
703
+ catch (error) {
704
+ return error.code !== "ESRCH";
705
+ }
706
+ }
610
707
  function loadHomeState(home, warnings, options) {
611
708
  const schema = (options.inspectStorage ?? inspectStorageSchema)(home);
612
709
  if (schema.status !== "current") {
@@ -27,7 +27,6 @@ import { managedRuntimeRoot } from "../storage/homeLayout.js";
27
27
  import { SqliteTaskStore } from "../storage/sqliteStore.js";
28
28
  import { AsyncTaskStoreClient, resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
29
29
  import { SurfaceContributions } from "../surface/surfaceContributions.js";
30
- import { taskOwnsManagedWorkspace } from "../task/task.js";
31
30
  import { openSchedulerTelemetry } from "../telemetry/telemetryWiring.js";
32
31
  import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
33
32
  import { TmuxManager, yuiTmuxServerName } from "../tmux/tmuxManager.js";
@@ -817,13 +816,10 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
817
816
  const planningDraft = request.scope === "task"
818
817
  && activeRun?.purpose === "planning"
819
818
  && task?.status === "draft";
820
- // A Task activated with an empty environment plan owns no managed
821
- // workspace, so there is nothing to prove ready. Every Task that does own
822
- // one keeps the full ownership fence.
819
+ // Draft planning precedes workspace adoption; active Tasks need ownership.
823
820
  if (request.scope === "task"
824
821
  && task !== null
825
- && !planningDraft
826
- && taskOwnsManagedWorkspace(task)) {
822
+ && !planningDraft) {
827
823
  const taskWorkspace = store.getTaskWorkspace(task.id);
828
824
  if (!isTaskOwnedWorkspace(taskWorkspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
829
825
  throw new Error(`Task workspace is not ready: ${task.id}.`);
@@ -849,16 +845,11 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
849
845
  ? activeRun?.workspace
850
846
  ?? currentDesiredManagedWorkspace(store, request.taskId, request.roleName)
851
847
  : undefined;
852
- // Distinguish "this Task owns no workspace by design" from "the
853
- // authoritative workspace is missing". Only the former may launch without
854
- // one. Two cases qualify: an empty plan binding no Project, and a Draft
855
- // planning conversation, which may bind a Project but has not activated, so
856
- // no worktree exists or is owed. Without the planning term a Project-bound
857
- // Draft fails closed on a workspace activation was never asked to create.
848
+ // Only Draft planning may launch without an adopted delivery workspace.
858
849
  const workspaceFree = request.scope === "task"
859
850
  && managedWorkspace === undefined
860
851
  && task !== null
861
- && (planningDraft || !taskOwnsManagedWorkspace(task));
852
+ && planningDraft;
862
853
  const sessions = request.scope === "task"
863
854
  ? store.getTaskRoleSessionSet(request.taskId, request.roleName)
864
855
  : store.getGlobalRoleSessionSet(request.roleName);
@@ -48,10 +48,8 @@ export class RuntimeLaunchCoordinator {
48
48
  let isolation;
49
49
  if (request.owner.scope === "task" && this.options.runtimeIsolation !== undefined) {
50
50
  const workspace = request.managedWorkspace;
51
- // A Task that legitimately owns no workspace has nothing to isolate: an
52
- // empty environment plan over no bound Project (S27). Skipping isolation
53
- // here is what lets such a Leader launch at all; a *missing*
54
- // authoritative workspace still fails closed below.
51
+ // Draft planning has no delivery workspace to isolate. Active Task
52
+ // launches require the exact authoritative ManagedWorkspace below.
55
53
  if (request.workspaceFree === true) {
56
54
  if (workspace !== undefined) {
57
55
  throw new Error("A workspace-free Task launch cannot carry a ManagedWorkspace.");
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { processOwnerIsLive } from "../core/fileLockOwner.js";
3
4
  import { createTaskEvent } from "../event/taskEvent.js";
4
5
  import { createGlobalRoleMessage } from "../message/message.js";
5
- import { createSessionOwnerIdentity, isLinuxProcessLive, listOwnedProcessTree, readLinuxProcessIdentity, reconcileSessionOwners, terminateSessionOwners } from "../runtime/index.js";
6
+ import { createSessionOwnerIdentity, isLinuxProcessLive, listOwnedProcessTree, readLinuxProcessIdentity, readProcessIdentity, reconcileSessionOwners, terminateSessionOwners } from "../runtime/index.js";
6
7
  import { sessionOwnerProcessKey } from "../runtime/sessionOwnerIdentity.js";
7
8
  import { AGENT_HOST_CONTROL_PROTOCOL, sendAgentHostCancelControl } from "../runtime/agentHost.js";
8
9
  import { settleProviderTurn, cancelQuiescentProviderInput, clearProviderGoal } from "../runtime/providerRuntimeIdentity.js";
@@ -45,7 +46,7 @@ export class SessionOwnerReconciliation {
45
46
  */
46
47
  recordHostOwner(input) {
47
48
  const observed = input.panePid !== undefined
48
- ? readLinuxProcessIdentity(input.panePid)
49
+ ? readOwnerProcessIdentity(input.panePid)
49
50
  : undefined;
50
51
  const root = observed === undefined
51
52
  ? undefined
@@ -131,7 +132,10 @@ export class SessionOwnerReconciliation {
131
132
  at: new Date() });
132
133
  throw error;
133
134
  }
134
- const result = await terminateSessionOwners(owner, records, this.#terminationPorts(), options);
135
+ const result = await terminateSessionOwners(owner, records, this.#terminationPorts(), {
136
+ ...options,
137
+ requireProcessRecord: process.platform === "darwin"
138
+ });
135
139
  if (result.outcome === "stop-confirmed") {
136
140
  // A signalled Host exits into a retained tmux pane. Explicit release
137
141
  // removes that window too, after physical exit proof, not on observation.
@@ -158,15 +162,8 @@ export class SessionOwnerReconciliation {
158
162
  }
159
163
  return this.#tmux.probeRoleStatus(hostId, target.roleName) === "exited";
160
164
  },
161
- processIdentity: readLinuxProcessIdentity,
162
- procEntryExists: (pid) => {
163
- try {
164
- return existsSync(`/proc/${pid}`);
165
- }
166
- catch {
167
- return false;
168
- }
169
- },
165
+ processIdentity: readOwnerProcessIdentity,
166
+ procEntryExists: processEntryExists,
170
167
  signalProcess: (pid, signal) => process.kill(pid, signal),
171
168
  sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
172
169
  emit: (event) => this.#recordTerminationEvent(event),
@@ -395,8 +392,10 @@ export function durableSessionFacts(store) {
395
392
  /** /proc observation for one owner record; undefined is a verification gap. */
396
393
  export function observeSessionOwnerPhysical(record) {
397
394
  const { pid, startIdentity } = record.providerRoot;
398
- const current = readLinuxProcessIdentity(pid);
395
+ const current = readOwnerProcessIdentity(pid);
399
396
  if (current === undefined) {
397
+ if (processEntryExists(pid))
398
+ return undefined;
400
399
  return {
401
400
  alive: false,
402
401
  identityConflict: false,
@@ -432,7 +431,8 @@ export function observeSessionOwnerPhysical(record) {
432
431
  childCount: 0
433
432
  };
434
433
  }
435
- const tree = listOwnedProcessTree(pid, current.processGroupId);
434
+ const tree = process.platform === "linux"
435
+ ? listOwnedProcessTree(pid, current.processGroupId) : [];
436
436
  return {
437
437
  alive: true,
438
438
  identityConflict: false,
@@ -440,9 +440,30 @@ export function observeSessionOwnerPhysical(record) {
440
440
  startIdentity,
441
441
  rssBytes: current.rssBytes,
442
442
  ageMs: 0,
443
- childCount: Math.max(0, tree.length - 1)
443
+ childCount: process.platform === "linux" ? Math.max(0, tree.length - 1) : 0
444
444
  };
445
445
  }
446
+ function readOwnerProcessIdentity(pid) {
447
+ if (process.platform === "linux")
448
+ return readLinuxProcessIdentity(pid);
449
+ try {
450
+ return readProcessIdentity(pid);
451
+ }
452
+ catch {
453
+ return undefined;
454
+ }
455
+ }
456
+ function processEntryExists(pid) {
457
+ if (process.platform === "linux")
458
+ return existsSync(`/proc/${pid}`);
459
+ try {
460
+ process.kill(pid, 0);
461
+ return true;
462
+ }
463
+ catch (error) {
464
+ return error.code !== "ESRCH";
465
+ }
466
+ }
446
467
  /** Reads the latest termination outcome recorded for one Role. */
447
468
  export function lastSessionTerminationOutcome(store, taskId, roleName) {
448
469
  if (taskId === undefined)
@@ -459,7 +480,10 @@ export function lastSessionTerminationOutcome(store, taskId, roleName) {
459
480
  }
460
481
  /** True when one owner record's Provider root is still the exact live process. */
461
482
  export function ownerRootIsLive(record) {
462
- return isLinuxProcessLive(record.providerRoot.pid, record.providerRoot.startIdentity);
483
+ const { pid, startIdentity } = record.providerRoot;
484
+ return process.platform === "linux"
485
+ ? isLinuxProcessLive(pid, startIdentity)
486
+ : processOwnerIsLive(pid, startIdentity);
463
487
  }
464
488
  /** Exact tmux target for one owner, for reports and diagnostics. */
465
489
  export function ownerTmuxTarget(home, owner) {
@@ -8,8 +8,8 @@ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
8
8
  import { CONTROLLER_DISCOVERY_PATH, ControllerProtocolError, encodeControllerRequest, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
9
9
  export class ControllerClientError extends Error {
10
10
  code;
11
- constructor(code, message) {
12
- super(message);
11
+ constructor(code, message, cause) {
12
+ super(message, { cause });
13
13
  this.code = code;
14
14
  this.name = "ControllerClientError";
15
15
  }
@@ -50,9 +50,12 @@ export async function readControllerDiscovery(home) {
50
50
  if (error instanceof ControllerClientError)
51
51
  throw error;
52
52
  if (isNodeError(error) && error.code === "ENOENT") {
53
- throw new ControllerClientError("CONTROLLER_NOT_RUNNING", "Controller is not running.");
53
+ throw new ControllerClientError("CONTROLLER_NOT_RUNNING", withOriginalError("Controller is not running.", error), error);
54
54
  }
55
- throw invalidDiscovery();
55
+ if (isNodeError(error) && (error.code === "EPERM" || error.code === "EACCES")) {
56
+ throw controllerAccessDenied(error, "discovery record");
57
+ }
58
+ throw invalidDiscovery(error);
56
59
  }
57
60
  }
58
61
  function discoverySocketPath(value) {
@@ -90,9 +93,9 @@ export async function callController(home, method, params = {}, options = {}) {
90
93
  }
91
94
  catch (error) {
92
95
  if (error instanceof ControllerProtocolError) {
93
- throw new ControllerClientError(error.code, error.message);
96
+ throw new ControllerClientError(error.code, error.message, error);
94
97
  }
95
- throw new ControllerClientError("INVALID_REQUEST", "Invalid controller request.");
98
+ throw new ControllerClientError("INVALID_REQUEST", withOriginalError("Invalid controller request.", error), error);
96
99
  }
97
100
  return exchange(discovery.socketPath, requestLine, id, timeoutMs);
98
101
  }
@@ -201,26 +204,51 @@ function exchange(socketPath, requestLine, expectedId, timeoutMs) {
201
204
  else
202
205
  fail(new ControllerClientError(response.error.code, response.error.message));
203
206
  }
204
- catch {
205
- fail(invalidResponse());
207
+ catch (error) {
208
+ fail(invalidResponse(error));
206
209
  }
207
210
  });
208
211
  socket.on("end", () => {
209
212
  if (!settled)
210
213
  fail(invalidResponse());
211
214
  });
212
- socket.on("error", () => {
213
- fail(deliveryStarted
214
- ? new ControllerClientError("CONTROLLER_DELIVERY_UNKNOWN", "Controller request delivery is unknown.")
215
- : new ControllerClientError("CONTROLLER_UNAVAILABLE", "Controller is unavailable."));
215
+ socket.on("error", (error) => {
216
+ fail(classifyControllerSocketError(error, deliveryStarted));
216
217
  });
217
218
  });
218
219
  }
219
- function invalidDiscovery() {
220
- return new ControllerClientError("CONTROLLER_DISCOVERY_INVALID", "Controller discovery is invalid.");
220
+ export function classifyControllerSocketError(error, deliveryStarted) {
221
+ if (deliveryStarted) {
222
+ return new ControllerClientError("CONTROLLER_DELIVERY_UNKNOWN", withOriginalError("Controller request delivery is unknown.", error), error);
223
+ }
224
+ if (isNodeError(error) && (error.code === "EPERM" || error.code === "EACCES")) {
225
+ return controllerAccessDenied(error, "socket");
226
+ }
227
+ return new ControllerClientError("CONTROLLER_UNAVAILABLE", withOriginalError("Controller is unavailable.", error), error);
228
+ }
229
+ function controllerAccessDenied(error, target) {
230
+ return new ControllerClientError("CONTROLLER_ACCESS_DENIED", `Controller ${target} access was denied (${error.code}) before any request was sent. `
231
+ + "In Codex, request sandbox escalation to rerun the same Yui CLI command outside the sandbox "
232
+ + `if authorized. If escalation is denied, report that to the user.${originalErrorDetail(error)}`, error);
233
+ }
234
+ function invalidDiscovery(error) {
235
+ return new ControllerClientError("CONTROLLER_DISCOVERY_INVALID", error === undefined
236
+ ? "Controller discovery is invalid."
237
+ : withOriginalError("Controller discovery is invalid.", error), error);
238
+ }
239
+ function invalidResponse(error) {
240
+ return new ControllerClientError("INVALID_RESPONSE", error === undefined
241
+ ? "Controller response is invalid."
242
+ : withOriginalError("Controller response is invalid.", error), error);
243
+ }
244
+ function withOriginalError(message, error) {
245
+ return `${message}${originalErrorDetail(error)}`;
221
246
  }
222
- function invalidResponse() {
223
- return new ControllerClientError("INVALID_RESPONSE", "Controller response is invalid.");
247
+ function originalErrorDetail(error) {
248
+ const code = isNodeError(error) && typeof error.code === "string"
249
+ ? ` (${error.code})`
250
+ : "";
251
+ return ` Original error${code}: ${error instanceof Error ? error.message : String(error)}`;
224
252
  }
225
253
  function isNodeError(error) {
226
254
  return error instanceof Error && "code" in error;
@@ -5,9 +5,10 @@ export function controllerSocketPath(homeId) {
5
5
  const identity = validateHomeId(homeId);
6
6
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
7
7
  const socketName = `${identity}.sock`;
8
- // Linux Unix-socket paths have a small fixed budget. A stable per-uid system
9
- // root is both short and independent of each caller's isolated TMPDIR.
10
- const root = process.platform === "linux" ? "/tmp" : tmpdir();
8
+ // Unix-socket paths have a small fixed budget (104 bytes on macOS, 108 on
9
+ // Linux). A stable per-uid system root is short and independent of each
10
+ // caller's isolated TMPDIR, which is a long /var/folders path on macOS.
11
+ const root = process.platform === "win32" ? tmpdir() : "/tmp";
11
12
  return join(root, `yui-${uid}`, socketName);
12
13
  }
13
14
  /**