@zq-silk/yui 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +15 -1
  2. package/dist/artifacts/artifactCapability.js +39 -10
  3. package/dist/artifacts/imageMetadata.js +120 -0
  4. package/dist/artifacts/managedGit.js +7 -4
  5. package/dist/artifacts/taskArtifactRepository.js +6 -0
  6. package/dist/cli/commandCatalog.js +17 -8
  7. package/dist/cli/invocationAuthority.js +1 -1
  8. package/dist/commands/executionAuditCommands.js +5 -0
  9. package/dist/commands/globalRoleCommands.js +41 -3
  10. package/dist/commands/projectCommands.js +89 -94
  11. package/dist/commands/roleSkillValidation.js +4 -8
  12. package/dist/commands/taskCommands.js +25 -2
  13. package/dist/commands/taskUsageCommand.js +38 -0
  14. package/dist/context/roleSessionContext.js +18 -2
  15. package/dist/context/runContextPack.js +5 -1
  16. package/dist/context/sessionBootstrapManifest.js +10 -1
  17. package/dist/context/skillCatalog.js +108 -0
  18. package/dist/context/skillPackage.js +161 -0
  19. package/dist/context/taskContext.js +3 -2
  20. package/dist/context/taskSearch.js +127 -0
  21. package/dist/controlPlaneCli.js +25 -20
  22. package/dist/controller/fileSchedulerStoreAdapter.js +32 -6
  23. package/dist/controller/jobControl.js +2 -0
  24. package/dist/controller/nativeHostInput.js +37 -0
  25. package/dist/controller/runtime.js +93 -2
  26. package/dist/controller/runtimeEventInbox.js +2 -2
  27. package/dist/controller/runtimeEventProcessor.js +3 -3
  28. package/dist/doctor/doctor.js +10 -0
  29. package/dist/execution/reviewMainRun.js +1 -0
  30. package/dist/execution/workItemMainRun.js +1 -0
  31. package/dist/executor/codexConfigConflict.js +6 -4
  32. package/dist/executor/effectiveLaunch.js +24 -1
  33. package/dist/executor/fileRoleLaunchPlanner.js +13 -5
  34. package/dist/integration/gitIntegrationService.js +7 -12
  35. package/dist/integration/integrationSourceApplication.js +4 -2
  36. package/dist/job/httpPreview.js +61 -0
  37. package/dist/job/jobRunner.js +12 -2
  38. package/dist/kernel/builtinCapabilities.js +3 -1
  39. package/dist/message/message.js +15 -1
  40. package/dist/output/rolePresentation.js +4 -1
  41. package/dist/repository/executionLaneGitSnapshot.js +2 -1
  42. package/dist/repository/gitCompatibility.js +62 -0
  43. package/dist/repository/gitWorkspace.js +48 -71
  44. package/dist/repository/gitWorktreeInventory.js +121 -0
  45. package/dist/repository/project.js +74 -25
  46. package/dist/resources/resourceDiscovery.js +2 -2
  47. package/dist/resources/resourceGc.js +7 -4
  48. package/dist/resources/resourceRegistrar.js +2 -1
  49. package/dist/runtime/agentEndpoint.js +8 -0
  50. package/dist/runtime/agentHost.js +170 -3
  51. package/dist/runtime/codexAppServerRuntime.js +1 -1
  52. package/dist/runtime/codexNativeAccess.js +175 -0
  53. package/dist/runtime/controlledCodexTui.js +116 -0
  54. package/dist/runtime/publicReply.js +23 -0
  55. package/dist/runtime/runtimeObservation.js +48 -0
  56. package/dist/runtime/structuredProviderHost.js +16 -7
  57. package/dist/runtime/taskCostMetrics.js +133 -0
  58. package/dist/runtime/taskUsageMetrics.js +11 -2
  59. package/dist/runtime/taskUsageQuery.js +38 -0
  60. package/dist/scheduler/taskExecutionProjection.js +2 -0
  61. package/dist/scheduler/taskObservabilityProjection.js +9 -1
  62. package/dist/storage/homeLayout.js +13 -0
  63. package/dist/storage/sqliteSchema.js +44 -0
  64. package/dist/storage/sqliteStore.js +53 -0
  65. package/dist/storage/storageVersions.js +1 -1
  66. package/dist/storage/storeRpc.js +2 -0
  67. package/dist/storage/taskSearch.js +35 -0
  68. package/dist/storage/upgrade/projectKnowledgeMigration.js +52 -0
  69. package/dist/task/ordinaryArchive.js +42 -0
  70. package/dist/tmux/tmuxManager.js +8 -8
  71. package/dist/web/assets/assetManifest.js +11 -51
  72. package/dist/web/assets/client/app/catalog.js +215 -0
  73. package/dist/web/assets/client/app/main.js +148 -0
  74. package/dist/web/assets/client/app/selection.js +139 -0
  75. package/dist/web/assets/client/app/settings.js +326 -0
  76. package/dist/web/assets/client/app/taskView.js +280 -0
  77. package/dist/web/assets/client/domain/context.js +96 -0
  78. package/dist/web/assets/client/domain/records.js +126 -0
  79. package/dist/web/assets/client/domain/runs.js +119 -0
  80. package/dist/web/assets/client/domain/taskForms.js +197 -0
  81. package/dist/web/assets/client/domain/vocab.js +69 -0
  82. package/dist/web/assets/client/domain/work.js +116 -0
  83. package/dist/web/assets/client/i18n/en.js +900 -0
  84. package/dist/web/assets/client/i18n/zh-CN.js +898 -0
  85. package/dist/web/assets/client/index.js +114 -0
  86. package/dist/web/assets/client/layout/resizer.js +37 -0
  87. package/dist/web/assets/client/layout/router.js +35 -0
  88. package/dist/web/assets/client/layout/shortcuts.js +45 -0
  89. package/dist/web/assets/client/layout/sizing.js +95 -0
  90. package/dist/web/assets/client/layout/workspace.js +247 -0
  91. package/dist/web/assets/client/{api.js → lib/api.js} +20 -4
  92. package/dist/web/assets/client/{dom.js → lib/dom.js} +24 -12
  93. package/dist/web/assets/client/{format.js → lib/format.js} +7 -0
  94. package/dist/web/assets/client/lib/i18n.js +54 -0
  95. package/dist/web/assets/client/lib/markdown.js +167 -0
  96. package/dist/web/assets/client/lib/prefs.js +28 -0
  97. package/dist/web/assets/client/lib/theme.js +68 -0
  98. package/dist/web/assets/client/ui/containers.js +101 -0
  99. package/dist/web/assets/client/ui/controls.js +51 -0
  100. package/dist/web/assets/client/ui/forms.js +48 -0
  101. package/dist/web/assets/client/ui/metrics.js +137 -0
  102. package/dist/web/assets/client/ui/primitives.js +92 -0
  103. package/dist/web/assets/client/ui/text.js +33 -0
  104. package/dist/web/assets/client/ui/toast.js +12 -0
  105. package/dist/web/assets/client/views/bodySearch.js +172 -0
  106. package/dist/web/assets/client/views/createTask.js +85 -0
  107. package/dist/web/assets/client/views/dock/conversation.js +612 -0
  108. package/dist/web/assets/client/views/dock/discussion.js +173 -0
  109. package/dist/web/assets/client/views/dock/preview.js +129 -0
  110. package/dist/web/assets/client/views/dock/terminal.js +199 -0
  111. package/dist/web/assets/client/views/globalInput.js +67 -0
  112. package/dist/web/assets/client/{overview.js → views/overview.js} +61 -51
  113. package/dist/web/assets/client/{sidebar.js → views/sidebar.js} +56 -62
  114. package/dist/web/assets/client/views/skills.js +152 -0
  115. package/dist/web/assets/client/views/task/actions.js +36 -0
  116. package/dist/web/assets/client/views/task/delivery.js +185 -0
  117. package/dist/web/assets/client/views/task/deliveryResults.js +159 -0
  118. package/dist/web/assets/client/views/task/files.js +272 -0
  119. package/dist/web/assets/client/views/task/observation.js +153 -0
  120. package/dist/web/assets/client/views/task/overview.js +161 -0
  121. package/dist/web/assets/client/views/task/page.js +131 -0
  122. package/dist/web/assets/client/views/task/records.js +163 -0
  123. package/dist/web/assets/client/views/task/runtime.js +130 -0
  124. package/dist/web/assets/client/views/task/timeline.js +190 -0
  125. package/dist/web/assets/shared/geometry.js +19 -0
  126. package/dist/web/assets/shared/taskTabs.js +8 -0
  127. package/dist/web/assets/shared/themes.js +93 -0
  128. package/dist/web/assets/shell/dialogs.js +63 -0
  129. package/dist/web/assets/shell/dock.js +40 -0
  130. package/dist/web/assets/shell/index.js +40 -0
  131. package/dist/web/assets/shell/settings.js +34 -0
  132. package/dist/web/assets/shell/sidebar.js +45 -0
  133. package/dist/web/assets/styles/index.js +41 -0
  134. package/dist/web/assets/styles/{responsive.js → layout/responsive.js} +10 -10
  135. package/dist/web/assets/styles/{layout.js → layout/workspace.js} +17 -24
  136. package/dist/web/assets/styles/markdown.js +22 -3
  137. package/dist/web/assets/styles/tokens.js +10 -63
  138. package/dist/web/assets/styles/ui/controls.js +76 -0
  139. package/dist/web/assets/styles/ui/forms.js +46 -0
  140. package/dist/web/assets/styles/ui/surfaces.js +122 -0
  141. package/dist/web/assets/styles/views/delivery.js +29 -0
  142. package/dist/web/assets/styles/views/dock.js +71 -0
  143. package/dist/web/assets/styles/views/page.js +32 -0
  144. package/dist/web/assets/styles/views/records.js +70 -0
  145. package/dist/web/assets/styles/views/settings.js +26 -0
  146. package/dist/web/assets/styles/views/sidebar.js +74 -0
  147. package/dist/web/assets/styles/views/task.js +80 -0
  148. package/dist/web/assets/styles/views/terminal.js +38 -0
  149. package/dist/web/taskPreviews.js +205 -0
  150. package/dist/web/tmuxWebTerminal.js +16 -9
  151. package/dist/web/webConversation.js +373 -0
  152. package/dist/web/webMaterials.js +155 -0
  153. package/dist/web/webServer.js +295 -18
  154. package/dist/web/webSettings.js +331 -0
  155. package/dist/web/webSkills.js +86 -0
  156. package/dist/web/webSnapshot.js +40 -8
  157. package/dist/web/webTaskSurface.js +106 -8
  158. package/dist/workspace/gitChangeSetCapture.js +3 -1
  159. package/docs/managed-turn-and-session-runtime.md +9 -0
  160. package/docs/managed-turn-and-session-runtime.zh-CN.md +7 -0
  161. package/docs/release-workflow.md +28 -0
  162. package/docs/release-workflow.zh-CN.md +21 -0
  163. package/docs/storage-baseline.md +38 -1
  164. package/docs/storage-baseline.zh-CN.md +22 -0
  165. package/docs/task-discovery.md +28 -0
  166. package/docs/testing/verification-levels.md +2 -2
  167. package/i18n/README.zh-CN.md +1 -1
  168. package/package.json +1 -1
  169. package/dist/web/assets/client/app.js +0 -808
  170. package/dist/web/assets/client/components.js +0 -477
  171. package/dist/web/assets/client/detail.js +0 -379
  172. package/dist/web/assets/client/dock.js +0 -309
  173. package/dist/web/assets/client/evidence.js +0 -364
  174. package/dist/web/assets/client/forms.js +0 -230
  175. package/dist/web/assets/client/i18n.js +0 -1277
  176. package/dist/web/assets/client/markdown.js +0 -114
  177. package/dist/web/assets/client/records.js +0 -198
  178. package/dist/web/assets/client/sections.js +0 -349
  179. package/dist/web/assets/client/theme.js +0 -71
  180. package/dist/web/assets/shell.js +0 -161
  181. package/dist/web/assets/styles/components.js +0 -216
  182. package/dist/web/assets/styles/views.js +0 -309
  183. /package/dist/web/assets/{icons.js → shared/icons.js} +0 -0
@@ -20,6 +20,7 @@ import { CliError } from "../errors/cliError.js";
20
20
  import { assertJobAssignmentScope, JobAssignmentScopeError } from "../job/jobAssignmentScope.js";
21
21
  import { assertContextRecordReadable } from "../context/taskContext.js";
22
22
  import { assertTaskProjectWriteAuthority } from "../task/taskAuthority.js";
23
+ import { validateHttpPreview } from "../job/httpPreview.js";
23
24
  export function createDurableJobControl(store) {
24
25
  const authority = createJobCallAuthority(store);
25
26
  return {
@@ -32,6 +33,7 @@ export function createDurableJobControl(store) {
32
33
  const context = authority.authenticate(Object.freeze({ ...params.caller }), params.taskId);
33
34
  requireJobAssignment(tx, params, params.caller);
34
35
  assertNonSecretJobInput(params);
36
+ validateHttpPreview(params.env, params.steps);
35
37
  const baseKey = durableJobIdempotencyKey({
36
38
  owner: params.owner,
37
39
  projectId: params.projectId,
@@ -0,0 +1,37 @@
1
+ import { applyTaskInputControl } from "../commands/taskCommands.js";
2
+ import { applyGlobalInputControl } from "../commands/globalRoleCommands.js";
3
+ /** The Host terminal is a human ingress, like the authenticated local Web
4
+ * surface. Fence that ingress before reusing the existing Message/control
5
+ * transaction; the Host performs the one Provider write after this returns. */
6
+ export function prepareNativeHostSteer(store, input) {
7
+ return store.transaction(tx => {
8
+ const set = input.taskId === undefined ? tx.getGlobalRoleSessionSet(input.roleName)
9
+ : tx.getTaskRoleSessionSet(input.taskId, input.roleName);
10
+ const session = set?.sessions[input.agentId], binding = set?.providerBinding;
11
+ if (!session || session.status !== "active" || session.adapterId !== "codex"
12
+ || set?.activeAgentId !== input.agentId || session.nativeSessionId !== input.nativeSessionId
13
+ || binding?.run?.status !== "accepted" || binding.run.nativeTurnId !== input.nativeTurnId
14
+ || binding.authority.owner !== input.authorityOwner || binding.authority.epoch !== input.authorityEpoch
15
+ || binding.authority.holderId !== input.holderId
16
+ || (input.taskId !== undefined && input.roleName !== "leader")) {
17
+ throw new Error("Native steer carries a stale or unsupported Session/Turn/writer fence.");
18
+ }
19
+ if (!input.boundedText.trim() || input.boundedText.includes("\0") || Buffer.byteLength(input.boundedText) > 12000) {
20
+ throw new Error("Native steer exceeds its text bound.");
21
+ }
22
+ const control = { action: "steer", body: input.boundedText, to: input.roleName,
23
+ requestId: input.attemptId, expectedTarget: input.nativeTurnId, expectedSessionId: input.nativeSessionId };
24
+ if (input.taskId === undefined) {
25
+ applyGlobalInputControl(input.roleName, control, tx, { env: {} });
26
+ }
27
+ else {
28
+ applyTaskInputControl(input.taskId, { ...control, to: "leader" }, tx, { environment: {}, now: () => input.now });
29
+ }
30
+ const messages = input.taskId === undefined ? tx.listGlobalRoleMessages(input.roleName) : tx.listMessages(input.taskId);
31
+ const message = messages.find(message => message.inputControl?.requestId === input.attemptId);
32
+ if (message?.control?.outcome !== "pending") {
33
+ throw new Error("Native steer is already settled or unconfirmed; do not resubmit.");
34
+ }
35
+ return { attemptId: message.control.receiptId, boundedText: input.boundedText };
36
+ });
37
+ }
@@ -1,4 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { prepareNativeHostSteer } from "./nativeHostInput.js";
2
3
  import { archiveLeaderTask } from "../task/leaderArchive.js";
3
4
  import { TaskWorkspaceCoordinator } from "../repository/taskWorkspaceCoordinator.js";
4
5
  import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, NATIVE_AGENT_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
@@ -33,8 +34,15 @@ import { openSchedulerTelemetry } from "../telemetry/telemetryWiring.js";
33
34
  import { NodeCommandExecutor } from "../tmux/commandExecutor.js";
34
35
  import { TmuxManager, yuiTmuxServerName } from "../tmux/tmuxManager.js";
35
36
  import { createControllerWeb } from "../web/controllerWeb.js";
37
+ import { createTaskPreviews } from "../web/taskPreviews.js";
36
38
  import { TmuxWebTerminalService } from "../web/tmuxWebTerminal.js";
37
39
  import { createWebTaskSurface } from "../web/webTaskSurface.js";
40
+ import { createWebConversationSurface } from "../web/webConversation.js";
41
+ import { WebRequestRejected } from "../web/webMutation.js";
42
+ import { inspectAgentHost, sendAgentHostNativeControl, AGENT_HOST_CONTROL_PROTOCOL } from "../runtime/agentHost.js";
43
+ import { archiveOrdinaryTask } from "../task/ordinaryArchive.js";
44
+ import { createWebSettings } from "../web/webSettings.js";
45
+ import { createWebSkills } from "../web/webSkills.js";
38
46
  import { isTaskOwnedWorkspace, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
39
47
  import { AgentRuntimeObserver } from "./agentRuntimeObserver.js";
40
48
  import { createCapabilityDispatcher } from "./capabilityBridge.js";
@@ -378,10 +386,64 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
378
386
  runningRuntime?.signal(`task:${target.taskId}`);
379
387
  },
380
388
  reconcileTask: (taskId) => runningRuntime?.signal(`task:${taskId}`)
381
- }, yuiHome: home });
389
+ }, yuiHome: home }, [], undefined, {
390
+ archive: async (taskId) => {
391
+ if (!(workspacePreparer instanceof FileTaskWorkspacePreparer))
392
+ throw new Error("Archive workspace owner unavailable.");
393
+ return archiveOrdinaryTask(new TaskWorkspaceCoordinator(store, workspacePreparer, webWorkflow), taskId, { environment: {}, yuiHome: home });
394
+ }
395
+ });
382
396
  const surfaces = new SurfaceContributions(kernel.capabilities.registry);
397
+ const validateNativeWrite = (request) => {
398
+ const sessions = request.scope === "task" ? store.getTaskRoleSessionSet(request.taskId, request.roleName)
399
+ : store.getGlobalRoleSessionSet(request.roleName);
400
+ const selected = sessions?.sessions[sessions.activeAgentId];
401
+ if (!request.nativeSessionId || selected?.nativeSessionId !== request.nativeSessionId
402
+ || selected.status !== "active"
403
+ || (request.scope === "task" && (request.roleName !== "leader"
404
+ || selected.adapterId !== "codex" || !sessions?.providerBinding
405
+ || !["active", "draft"].includes(store.getTask(request.taskId)?.status ?? "")
406
+ || store.getTask(request.taskId)?.executionGate.state !== "enabled"))) {
407
+ throw new Error("Native input requires the selected current controlled Codex Operator/Leader.");
408
+ }
409
+ };
383
410
  const web = createControllerWeb(store, {
411
+ previews: () => createTaskPreviews(store, home),
412
+ skills: createWebSkills(store),
413
+ settings: createWebSettings(store, {
414
+ environment: options.environment ?? process.env, catalogs,
415
+ refreshConfiguration: () => {
416
+ if (!runningRuntime)
417
+ throw new Error("Controller runtime unavailable.");
418
+ runningRuntime.reloadReconciliationInterval();
419
+ }
420
+ }),
384
421
  surface: webSurface,
422
+ conversation: createWebConversationSurface(store, webSurface, (owner, id) => planner.planNativeControl(owner, id), undefined, (owner) => inspectAgentHost({ home, ...owner }), {
423
+ hasWriter: owner => tmux.hasWritableClient(owner.scope === "task" ? owner.taskId : "operator", owner.scope === "task" ? owner.roleName : undefined),
424
+ respond: async (owner, id, requestId, nativeTurnId, result) => {
425
+ const receipt = await sendAgentHostNativeControl({
426
+ home, ...owner, control: { protocol: AGENT_HOST_CONTROL_PROTOCOL, type: "native-respond",
427
+ nativeSessionId: id, requestId, nativeTurnId, result }
428
+ });
429
+ if (receipt.outcome !== "accepted")
430
+ throw new Error(receipt.snapshot.detail ?? "Native answer was not confirmed.");
431
+ return { outcome: receipt.outcome };
432
+ },
433
+ setModel: async (owner, id, model) => {
434
+ const result = await sendAgentHostNativeControl({
435
+ home, ...owner, control: { protocol: AGENT_HOST_CONTROL_PROTOCOL,
436
+ type: "native-model", nativeSessionId: id, model }
437
+ });
438
+ if (result.outcome !== "accepted") {
439
+ const detail = result.snapshot.detail ?? "Model adoption is unconfirmed.";
440
+ if (result.failure?.inputDisposition === "not-accepted")
441
+ throw new WebRequestRejected(detail);
442
+ throw new Error(detail);
443
+ }
444
+ return { outcome: result.outcome, runConfiguration: result.snapshot.runConfiguration };
445
+ }
446
+ }, catalogs),
385
447
  answerInput: async ({ taskId, inputId, answer }) => webSurface.answer(taskId, inputId, answer),
386
448
  panels: {
387
449
  list: (taskId) => surfaces.listPanels(kernel.capabilities.authenticateWebQuery(taskId)),
@@ -390,6 +452,26 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
390
452
  terminal: new TmuxWebTerminalService({
391
453
  yuiHome: home, tmuxBin: resolveTmuxBin(store.getConfig().tmuxBin), tmux,
392
454
  prepareGlobalRole: (roleName) => webWorkflow.prepareGlobalRoleEnter(roleName),
455
+ prepareNative: async (request) => {
456
+ if (!request.nativeSessionId)
457
+ return false;
458
+ const sessions = request.scope === "task" ? store.getTaskRoleSessionSet(request.taskId, request.roleName)
459
+ : store.getGlobalRoleSessionSet(request.roleName);
460
+ const selected = sessions?.sessions[sessions.activeAgentId];
461
+ if (request.scope === "task" && (request.roleName !== "leader" || selected?.adapterId !== "codex"))
462
+ return false;
463
+ validateNativeWrite(request);
464
+ // Preserve ordinary global native access on other adapters and the
465
+ // existing uncontrolled Codex TUI. No lifecycle operation on a switch.
466
+ if (selected?.adapterId !== "codex" || !sessions?.providerBinding)
467
+ return request.scope === "global";
468
+ const result = await sendAgentHostNativeControl({ home, ...request,
469
+ control: { protocol: AGENT_HOST_CONTROL_PROTOCOL, type: "native-open", nativeSessionId: request.nativeSessionId } });
470
+ if (result.outcome !== "accepted" || !result.snapshot.nativeTui)
471
+ throw new Error("Native TUI could not be opened.");
472
+ return true;
473
+ },
474
+ validateWrite: validateNativeWrite,
393
475
  environment: options.environment ?? process.env, onError: options.onError
394
476
  })
395
477
  });
@@ -612,6 +694,12 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
612
694
  throw applicationError("INVALID_PARAMS", error instanceof Error ? error.message : String(error));
613
695
  }
614
696
  }
697
+ if (method === "runtime.native-steer-prepare") {
698
+ const value = providerTurnControlParams(params);
699
+ return prepareNativeHostSteer(store, { ...value,
700
+ nativeTurnId: requiredParam(params.nativeTurnId),
701
+ boundedText: requiredParam(value.boundedText) });
702
+ }
615
703
  if (method === "runtime.provider-turn-begin") {
616
704
  const value = providerTurnControlParams(params);
617
705
  try {
@@ -927,6 +1015,7 @@ export function createRuntimeLifecycleDispatcher(store, schedulerStore, sessionH
927
1015
  function resolveRuntimeDesiredEffective(store, request, role) {
928
1016
  if (request.scope === "global") {
929
1017
  return resolveEffectiveLaunch({
1018
+ store,
930
1019
  role: role,
931
1020
  purpose: "execution"
932
1021
  });
@@ -940,6 +1029,7 @@ function resolveRuntimeDesiredEffective(store, request, role) {
940
1029
  ?? store.getTaskWorkspace(request.taskId)
941
1030
  ?? undefined;
942
1031
  return resolveEffectiveLaunch({
1032
+ store,
943
1033
  role: taskRole,
944
1034
  purpose: store.getTask(request.taskId)?.status === "draft" && request.roleName === "leader"
945
1035
  ? "planning" : "execution",
@@ -1018,7 +1108,7 @@ function assertRuntimeLaunchRequestCurrent(store, request) {
1018
1108
  }
1019
1109
  function currentDesiredEffective(store, request, role) {
1020
1110
  if (request.owner.scope === "global") {
1021
- return resolveEffectiveLaunch({ role: role, purpose: "execution" });
1111
+ return resolveEffectiveLaunch({ store, role: role, purpose: "execution" });
1022
1112
  }
1023
1113
  const item = store.listWorkItems(request.owner.taskId).find((candidate) => (candidate.assignee === request.owner.roleName
1024
1114
  && !["accepted", "retired"].includes(candidate.status))) ?? null;
@@ -1028,6 +1118,7 @@ function currentDesiredEffective(store, request, role) {
1028
1118
  ?? store.getTaskWorkspace(request.owner.taskId)
1029
1119
  ?? undefined;
1030
1120
  return resolveEffectiveLaunch({
1121
+ store,
1031
1122
  role: role,
1032
1123
  purpose: store.getTask(request.owner.taskId)?.status === "draft" && request.owner.roleName === "leader"
1033
1124
  ? "planning" : "execution",
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { closeSync, constants, existsSync, fchmodSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { createRuntimeObservation, isRuntimeTokenEvidence } from "../runtime/runtimeObservation.js";
5
+ import { createRuntimeObservation, isRuntimeUsageEvidence } from "../runtime/runtimeObservation.js";
6
6
  import { boundedRunFailureDiagnostic, transportAgentResult } from "../domain/agentResultTransport.js";
7
7
  import { readAgentHostObservationSource } from "../runtime/agentHostProtocol.js";
8
8
  export const MAX_RUNTIME_EVENT_FILE_BYTES = 16 * 1024 * 1024;
@@ -174,7 +174,7 @@ export class FileRuntimeEventInbox {
174
174
  publish(event) {
175
175
  this.hooks.afterAdmission?.();
176
176
  if (event.type === "runtime-observation"
177
- && isRuntimeTokenEvidence(event.observation)) {
177
+ && isRuntimeUsageEvidence(event.observation)) {
178
178
  const existing = this.list().find((candidate) => (candidate.type === "runtime-observation"
179
179
  && candidate.taskId === event.taskId
180
180
  && candidate.observation.eventId === event.observation.eventId));
@@ -1,4 +1,4 @@
1
- import { isRuntimeTokenEvidence } from "../runtime/runtimeObservation.js";
1
+ import { isRuntimeUsageEvidence } from "../runtime/runtimeObservation.js";
2
2
  const DEFAULT_MAX_RUNTIME_EVENTS_PER_DRAIN = 64;
3
3
  /** Folds immutable Hook facts in one bounded transaction before acknowledging them. */
4
4
  export class FileRuntimeEventProcessor {
@@ -299,7 +299,7 @@ export function coalesceRuntimeProgress(events, instrumentation = {}) {
299
299
  }
300
300
  const representedByIndex = new Map();
301
301
  for (const indices of indicesByStream.values()) {
302
- const hasUsage = isRuntimeTokenEvidence(segment[indices[0]].observation);
302
+ const hasUsage = isRuntimeUsageEvidence(segment[indices[0]].observation);
303
303
  // Usage facts and incomplete boundaries are authoritative history for
304
304
  // read-only Session token projection, so ingress never coalesces them.
305
305
  const retainedPositions = hasUsage ? indices.map((_, position) => position) : [];
@@ -403,7 +403,7 @@ function progressStreamKey(event) {
403
403
  fence.nativeTurnId ?? null,
404
404
  event.host ?? null,
405
405
  payload.activity,
406
- isRuntimeTokenEvidence(event.observation) ? "usage" : "signal"
406
+ isRuntimeUsageEvidence(event.observation) ? "usage" : "signal"
407
407
  ]);
408
408
  }
409
409
  function isRuntimeActivityEvent(event) {
@@ -2,6 +2,8 @@ import { accessSync, constants, existsSync, lstatSync, realpathSync } from "node
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import Database from "better-sqlite3";
4
4
  import { inspectPty } from "./ptyProbe.js";
5
+ import { gitVersionSupport } from "../repository/gitCompatibility.js";
6
+ import { resolveExecutable } from "../external/pinnedCommandRunner.js";
5
7
  import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
6
8
  import { operationalAgentEnvironment } from "../agent/launchEnvironment.js";
7
9
  import { resolveTmuxBin } from "../config/yuiConfig.js";
@@ -684,6 +686,14 @@ function checkReviewerDispatch(_policy, role, binding, agent, launch, command, c
684
686
  function checkExecutable(name, command, args, executor) {
685
687
  try {
686
688
  const output = firstLine(executor.run(command, args));
689
+ if (name === "git") {
690
+ const support = gitVersionSupport(output);
691
+ return {
692
+ name, status: support.supported ? "ok" : "invalid",
693
+ detail: `${resolveExecutable(command, process.env.PATH) ?? command}: ${support.detail}`
694
+ + (support.supported ? "" : " Select a supported Git in PATH and rerun doctor; no upgrade was performed.")
695
+ };
696
+ }
687
697
  return {
688
698
  name,
689
699
  status: "ok",
@@ -65,6 +65,7 @@ export function dispatchReviewSynthesis(store, taskId, reviewRoundId, sourceRunI
65
65
  throw new Error(`Review main workspace is missing: ${taskId}/${round.id}.`);
66
66
  }
67
67
  const effective = resolveEffectiveLaunch({
68
+ store,
68
69
  role,
69
70
  purpose: "review",
70
71
  workspace,
@@ -78,6 +78,7 @@ export function dispatchWorkItemSynthesis(store, taskId, workItemId, sourceRunId
78
78
  throw new Error(`WorkItem main workspace does not match its approved scope: ${item.id}.`);
79
79
  }
80
80
  const effective = resolveEffectiveLaunch({
81
+ store,
81
82
  role,
82
83
  purpose: "execution",
83
84
  workspace,
@@ -196,13 +196,15 @@ function exactTrust(directory, configured) {
196
196
  return undefined;
197
197
  }
198
198
  function resolveRepositoryRootForTrust(workspace) {
199
- const result = spawnSync("git", ["-C", workspace, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", timeout: 1_000, windowsHide: true });
199
+ const result = spawnSync("git", ["-C", workspace, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 1_000, windowsHide: true });
200
200
  if (result.status !== 0 || result.error !== undefined)
201
201
  return undefined;
202
- const commonDirectory = result.stdout.trim();
203
- if (!isAbsolute(commonDirectory) || commonDirectory.includes("\0"))
202
+ if (!result.stdout.endsWith("\n"))
204
203
  return undefined;
205
- const normalized = resolve(commonDirectory);
204
+ const commonDirectory = result.stdout.slice(0, -1);
205
+ if (commonDirectory.length === 0 || commonDirectory.includes("\0"))
206
+ return undefined;
207
+ const normalized = resolve(workspace, commonDirectory);
206
208
  return basename(normalized) === ".git" ? dirname(normalized) : undefined;
207
209
  }
208
210
  function inspectProjectDiscovery(contents) {
@@ -2,7 +2,8 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { validateExecutionEnvironmentSnapshot } from "../resources/projectResource.js";
3
3
  import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
4
4
  import { resolveAgentAdapter } from "./agentAdapter.js";
5
- import { roleSessionKind } from "../context/roleSessionContext.js";
5
+ import { compileRoleSessionContext, roleSessionKind } from "../context/roleSessionContext.js";
6
+ import { validateSkillPackageRef } from "../context/skillPackage.js";
6
7
  import { isAgentAdapterId } from "../agent/adapterCatalog.js";
7
8
  import { adapterIdForExecutionComponent, isAgentExecutionComponentId, resolveAgentExecutionComponent } from "../agent/executionComponents.js";
8
9
  import { SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION, sessionManifestCompatibilityDigest } from "../context/sessionProtocolIdentity.js";
@@ -15,6 +16,15 @@ export function resolveEffectiveLaunch(input) {
15
16
  const workspace = snapshotWorkspace(input.role.workspace, input.workspace);
16
17
  const writeProjectIds = effectiveWriteProjects(input, workspace);
17
18
  const config = resolveAgentAdapter(binding.adapterId).canonicalizeConfig(clone(binding.config));
19
+ const home = input.store?.rootDirectory() ?? input.yuiHome;
20
+ const sessions = "taskId" in input.role
21
+ ? input.store?.getTaskRoleSessionSet(input.role.taskId, input.role.name)
22
+ : input.store?.getGlobalRoleSessionSet(input.role.name);
23
+ const existing = sessions?.sessions[binding.agentId];
24
+ const continuing = existing?.status === "active";
25
+ const skillPackages = continuing ? existing.effective.skillPackages
26
+ : home === undefined ? undefined
27
+ : compileRoleSessionContext(home, input.role, "taskId" in input.role ? { scope: "task", taskId: input.role.taskId } : { scope: "global" }, { purpose: input.purpose }).skills.map(skill => skill.package);
18
28
  return snapshotFromConfig({
19
29
  executionAuthority: input.executionAuthority ?? (input.purpose === "planning" ? "planning" : "delivery"),
20
30
  sourceDesiredRevision: input.role.launchRevision,
@@ -28,6 +38,7 @@ export function resolveEffectiveLaunch(input) {
28
38
  ? { executionEnvironment: input.role.executionEnvironment }
29
39
  : {}),
30
40
  context: snapshotContext(input.role),
41
+ ...(skillPackages === undefined ? {} : { skillPackages }),
31
42
  contextProtocolVersion: SESSION_BOOTSTRAP_MANIFEST_SCHEMA_VERSION,
32
43
  sessionManifestCompatibilityDigest: sessionManifestCompatibilityDigest(input.role.name, roleSessionKind(input.role, "taskId" in input.role
33
44
  ? { scope: "task", taskId: input.role.taskId }
@@ -216,6 +227,17 @@ export function validateEffectiveLaunchSnapshot(snapshot) {
216
227
  throw new Error("Effective launch Session Manifest compatibility digest is invalid.");
217
228
  }
218
229
  validateWorkspace(snapshot.workspace);
230
+ if (snapshot.skillPackages !== undefined) {
231
+ if (!Array.isArray(snapshot.skillPackages))
232
+ throw new Error("Effective Skill packages must be an array.");
233
+ const ids = new Set();
234
+ for (const ref of snapshot.skillPackages) {
235
+ validateSkillPackageRef(ref);
236
+ if (ids.has(ref.id))
237
+ throw new Error("Effective Skill packages must be unique.");
238
+ ids.add(ref.id);
239
+ }
240
+ }
219
241
  if (snapshot.executionEnvironment !== undefined) {
220
242
  validateExecutionEnvironmentSnapshot(snapshot.executionEnvironment);
221
243
  if (snapshot.executionEnvironment.access === "read") {
@@ -299,6 +321,7 @@ function snapshotFromConfig(input) {
299
321
  ? {}
300
322
  : { executionEnvironment: clone(input.executionEnvironment) }),
301
323
  context: cloneContext(input.context),
324
+ ...(input.skillPackages === undefined ? {} : { skillPackages: clone(input.skillPackages) }),
302
325
  contextProtocolVersion: input.contextProtocolVersion,
303
326
  sessionManifestCompatibilityDigest: input.sessionManifestCompatibilityDigest,
304
327
  ...review
@@ -70,11 +70,13 @@ export class FileRoleLaunchPlanner {
70
70
  }
71
71
  /** Recovery addresses the recorded Agent/Session, independent of delivery
72
72
  * admission, a missing worktree, or the old Endpoint code generation. */
73
- planNativeControl(owner) {
73
+ planNativeControl(owner, nativeSessionId) {
74
74
  const set = owner.scope === "task"
75
75
  ? this.store.getTaskRoleSessionSet(owner.taskId, owner.roleName)
76
76
  : this.store.getGlobalRoleSessionSet(owner.roleName);
77
- const session = roleSessionControlTarget(set);
77
+ const session = nativeSessionId === undefined ? roleSessionControlTarget(set)
78
+ : [...Object.values(set?.sessions ?? {}), ...Object.values(set?.history ?? {})]
79
+ .find(entry => entry.nativeSessionId === nativeSessionId);
78
80
  if (session?.adapterId !== "codex")
79
81
  throw new Error("Native metadata control requires a recorded Codex Session.");
80
82
  const configured = this.store.getConfiguredAgent(session.agentId);
@@ -229,7 +231,7 @@ export class FileRoleLaunchPlanner {
229
231
  throw new Error("Global Role has an unmanaged live Session. Stop that exact Session before opening controlled input; it will not be silently replaced.");
230
232
  }
231
233
  const resolvedEffective = activeLiveRoleAgentSession(sessionSet)?.effective
232
- ?? resolveEffectiveLaunch({ role, purpose: "execution" });
234
+ ?? resolveEffectiveLaunch({ store: this.store, role, purpose: "execution" });
233
235
  if (input.effective !== undefined
234
236
  && !isDeepStrictEqual(resolvedEffective, input.effective)) {
235
237
  throw new Error(`Global Role launch effective snapshot changed: ${role.name}.`);
@@ -335,7 +337,12 @@ export class FileRoleLaunchPlanner {
335
337
  Object.assign(launchEnvironment, runtimeIsolation === undefined
336
338
  ? {}
337
339
  : taskRuntimeIsolationEnvironment(runtimeIsolation));
338
- const baseSessionContext = compileRoleSessionContext(this.home, launchRole, owner, sessionPolicy);
340
+ const skillPackages = input.mode === "resume"
341
+ ? existingSession?.effective.skillPackages : effective.skillPackages;
342
+ if (skillPackages === undefined) {
343
+ throw new Error("This launch has no frozen Skill package evidence (pre-1.5 or externally recorded Session). Create a new Session from current Role configuration; historical Skill versions cannot be reconstructed.");
344
+ }
345
+ const baseSessionContext = compileRoleSessionContext(this.home, launchRole, owner, { ...sessionPolicy, skillPackages });
339
346
  const bootstrap = materializeSessionBootstrap({
340
347
  yuiHome: this.home,
341
348
  role: launchRole,
@@ -686,7 +693,7 @@ function resolveTaskRoleEffectiveLaunch(store, role, purpose = "execution") {
686
693
  if (purpose === "planning") {
687
694
  // Planning has no WorkItem assignment and no managed workspace, so it
688
695
  // resolves the Role's own configured workspace with no writable Project.
689
- return resolveEffectiveLaunch({ role, purpose: "planning" });
696
+ return resolveEffectiveLaunch({ store, role, purpose: "planning" });
690
697
  }
691
698
  const item = store.listWorkItems(role.taskId).find((candidate) => (candidate.assignee === role.name
692
699
  && !["accepted", "retired"].includes(candidate.status))) ?? null;
@@ -696,6 +703,7 @@ function resolveTaskRoleEffectiveLaunch(store, role, purpose = "execution") {
696
703
  ?? store.getTaskWorkspace(role.taskId)
697
704
  ?? undefined;
698
705
  return resolveEffectiveLaunch({
706
+ store,
699
707
  role,
700
708
  purpose: "execution",
701
709
  ...(workspace === undefined ? {} : { workspace }),
@@ -5,6 +5,8 @@ import { promisify } from "node:util";
5
5
  import { selectEnvironment } from "../agent/launchEnvironment.js";
6
6
  import { durableJobIdempotencyKey } from "../job/durableJob.js";
7
7
  import { readRuntimeIdentity } from "../release/runtimeRelease.js";
8
+ import { compatibleGitArguments, supportedGit } from "../repository/gitCompatibility.js";
9
+ import { readGitWorktrees } from "../repository/gitWorktreeInventory.js";
8
10
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
9
11
  import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
10
12
  import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
@@ -1047,8 +1049,10 @@ function integrationCheckDirectory(home, taskId, integrationId) {
1047
1049
  return join(home, "artifacts", "integration-checks", taskId, integrationId);
1048
1050
  }
1049
1051
  async function git(args) {
1052
+ const program = supportedGit();
1053
+ const invocation = compatibleGitArguments(args);
1050
1054
  try {
1051
- const result = await executeFile("git", [...args], {
1055
+ const result = await executeFile(program.path, invocation, {
1052
1056
  encoding: "utf8",
1053
1057
  maxBuffer: 16 * 1024 * 1024,
1054
1058
  timeout: 120_000
@@ -1182,15 +1186,6 @@ async function advanceTargetRef(repositoryPath, targetRef, candidateCommit, expe
1182
1186
  }
1183
1187
  }
1184
1188
  async function checkedOutWorktreePaths(repositoryPath, targetRef) {
1185
- const porcelain = (await git([
1186
- "-C", repositoryPath, "worktree", "list", "--porcelain"
1187
- ])).trim();
1188
- if (porcelain.length === 0)
1189
- return [];
1190
- return porcelain.split(/\n\n+/u).flatMap((record) => {
1191
- const lines = record.split("\n");
1192
- const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
1193
- const branch = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length);
1194
- return path !== undefined && branch === targetRef ? [path] : [];
1195
- });
1189
+ return (await readGitWorktrees(repositoryPath))
1190
+ .filter(record => record.branch === targetRef).map(record => record.path);
1196
1191
  }
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { isAbsolute, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { gitOutputLine, supportedGit } from "../repository/gitCompatibility.js";
6
7
  import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
7
8
  import { recordIntegrationConflict, requireResolutionDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
8
9
  const execute = promisify(execFile);
@@ -281,7 +282,7 @@ async function hasExactRefReceipt(path, branch, before, action) {
281
282
  }
282
283
  const actionId = () => `yui-integration-${randomBytes(16).toString("hex")}`;
283
284
  async function gitPath(path, name) {
284
- const value = (await command(path, ["rev-parse", "--git-path", name])).trim();
285
+ const value = gitOutputLine(await command(path, ["rev-parse", "--git-path", name]));
285
286
  return isAbsolute(value) ? value : resolve(path, value);
286
287
  }
287
288
  async function conflicts(path) {
@@ -300,8 +301,9 @@ async function succeeds(path, args, action) {
300
301
  }
301
302
  }
302
303
  async function command(path, args, action) {
304
+ const program = supportedGit();
303
305
  try {
304
- return (await execute("git", [
306
+ return (await execute(program.path, [
305
307
  "-C", path, "-c", "user.name=Yui", "-c", "user.email=yui@local",
306
308
  "-c", "core.editor=true", "-c", "rebase.backend=merge", ...args
307
309
  ], {
@@ -0,0 +1,61 @@
1
+ import { join } from "node:path";
2
+ import { lstatSync, mkdirSync, rmdirSync, unlinkSync } from "node:fs";
3
+ import { jobPreviewSocketRoot } from "../storage/homeLayout.js";
4
+ /** An opt-in launch contract, not a second service registry. The Job remains
5
+ * the sole owner of the process, cancellation and exit evidence. */
6
+ export function isHttpPreview(env) {
7
+ return env.YUI_HTTP_PREVIEW === "1";
8
+ }
9
+ export function validateHttpPreview(env, steps) {
10
+ if (env.YUI_HTTP_PREVIEW !== undefined && !isHttpPreview(env)) {
11
+ throw new Error("YUI_HTTP_PREVIEW must be 1 when supplied.");
12
+ }
13
+ if (env.YUI_PREVIEW_SOCKET !== undefined || steps.some(step => step.env?.YUI_PREVIEW_SOCKET !== undefined || step.env?.YUI_HTTP_PREVIEW !== undefined)) {
14
+ throw new Error("Preview environment is Job-scoped; YUI_PREVIEW_SOCKET is assigned by the runner.");
15
+ }
16
+ if (isHttpPreview(env) && steps.length !== 1) {
17
+ throw new Error("An HTTP preview Job must have exactly one foreground service step.");
18
+ }
19
+ }
20
+ export function httpPreviewSocket(artifactDir) {
21
+ return join(jobPreviewSocketRoot(artifactDir), "http.sock");
22
+ }
23
+ export function assertHttpPreviewSocket(artifactDir) {
24
+ const dir = jobPreviewSocketRoot(artifactDir);
25
+ return assertSocketAt(dir);
26
+ }
27
+ function assertSocketAt(dir) {
28
+ const parent = lstatSync(dir);
29
+ const path = join(dir, "http.sock");
30
+ const socket = lstatSync(path);
31
+ if (!parent.isDirectory() || parent.uid !== process.getuid?.() || (parent.mode & 0o077)
32
+ || !socket.isSocket() || socket.uid !== parent.uid) {
33
+ throw new Error("Preview endpoint is not an owned Unix socket.");
34
+ }
35
+ return path;
36
+ }
37
+ /** Exclusive creation; never adopt or unlink another process's old endpoint.
38
+ * Cleanup checks the exact directory inode and removes no unknown contents. */
39
+ export function prepareHttpPreview(artifactDir) {
40
+ const dir = jobPreviewSocketRoot(artifactDir);
41
+ mkdirSync(dir, { mode: 0o700 });
42
+ const owner = lstatSync(dir);
43
+ return {
44
+ socket: join(dir, "http.sock"),
45
+ cleanup() {
46
+ const current = lstatSync(dir);
47
+ if (!current.isDirectory() || current.dev !== owner.dev || current.ino !== owner.ino) {
48
+ throw new Error(`Preview owner changed; retained endpoint: ${dir}`);
49
+ }
50
+ try {
51
+ const path = assertSocketAt(dir);
52
+ unlinkSync(path);
53
+ }
54
+ catch (error) {
55
+ if (error.code !== "ENOENT")
56
+ throw error;
57
+ }
58
+ rmdirSync(dir);
59
+ }
60
+ };
61
+ }
@@ -12,6 +12,7 @@ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, ut
12
12
  import { join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { jobStepDirectory } from "./stepDirectory.js";
15
+ import { prepareHttpPreview, isHttpPreview } from "./httpPreview.js";
15
16
  import { writeTextFileAtomically } from "../storage/durableFile.js";
16
17
  import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
17
18
  const DEFAULT_STEP_TIMEOUT_MS = 30 * 60_000;
@@ -108,6 +109,7 @@ export async function runDurableJobRunner(specPath) {
108
109
  let exitCode = 0;
109
110
  let signal = null;
110
111
  let failedStep;
112
+ let preview;
111
113
  try {
112
114
  for (let index = 0; index < spec.steps.length; index += 1) {
113
115
  if (cancelled || existsSync(cancelPath)) {
@@ -134,13 +136,16 @@ export async function runDurableJobRunner(specPath) {
134
136
  const stepEnv = step.env === undefined
135
137
  ? spec.env
136
138
  : { ...spec.env, ...step.env };
139
+ const launchEnv = isHttpPreview(spec.env)
140
+ ? { ...stepEnv, YUI_PREVIEW_SOCKET: (preview ??= prepareHttpPreview(artifactDir)).socket }
141
+ : stepEnv;
137
142
  // f4: a new process group (detached) lets the runner kill the whole
138
143
  // step tree on timeout or cancel, for both argv and shell forms.
139
144
  if (step.argv !== undefined) {
140
145
  const [file, ...args] = step.argv;
141
146
  child = spawn(file, args, {
142
147
  cwd: stepCwd,
143
- env: stepEnv,
148
+ env: launchEnv,
144
149
  stdio: ["ignore", logFd, logFd],
145
150
  detached: true
146
151
  });
@@ -148,13 +153,14 @@ export async function runDurableJobRunner(specPath) {
148
153
  else {
149
154
  child = spawn("/bin/sh", ["-lc", step.command], {
150
155
  cwd: stepCwd,
151
- env: stepEnv,
156
+ env: launchEnv,
152
157
  stdio: ["ignore", logFd, logFd],
153
158
  detached: true
154
159
  });
155
160
  }
156
161
  }
157
162
  catch (error) {
163
+ writeFileSync(logFd, `Unable to start Job step: ${error instanceof Error ? error.message : String(error)}\n`);
158
164
  closeSync(logFd);
159
165
  completedSteps.push({
160
166
  name: step.name,
@@ -231,6 +237,10 @@ export async function runDurableJobRunner(specPath) {
231
237
  }
232
238
  finally {
233
239
  clearInterval(heartbeatTimer);
240
+ // The foreground step has settled. Withdraw only its exact endpoint;
241
+ // existing Job/resource checks still own surviving descendant evidence.
242
+ // Failed socket cleanup retains evidence instead of publishing success.
243
+ preview?.cleanup();
234
244
  // f2/rr5: Do NOT remove the signal handlers here. A repeated SIGTERM
235
245
  // between the step loop and the exit.json write must still hit the
236
246
  // idempotent handler (which ignores it) rather than the default handler