@zq-silk/yui 2.22.0 → 2.25.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 (89) hide show
  1. package/README.md +9 -4
  2. package/dist/artifacts/artifactCapability.js +6 -6
  3. package/dist/artifacts/gitArtifactRef.js +10 -10
  4. package/dist/artifacts/jobArtifact.js +5 -5
  5. package/dist/artifacts/managedGit.js +15 -12
  6. package/dist/artifacts/taskArtifactRepository.js +35 -33
  7. package/dist/cli/commandCatalog.js +2 -2
  8. package/dist/cli/updateCommand.js +20 -3
  9. package/dist/cli/updateOrchestrator.js +13 -0
  10. package/dist/cli/updatePorts.js +27 -0
  11. package/dist/cli.js +9 -2
  12. package/dist/commands/configCommands.js +64 -1
  13. package/dist/commands/projectCommands.js +9 -9
  14. package/dist/commands/taskCompletionGate.js +1 -1
  15. package/dist/commands/taskIntegrationCommands.js +1 -1
  16. package/dist/commands/taskPublicationAdoptCommand.js +1 -1
  17. package/dist/commands/taskRoleRuntimeStatus.js +4 -0
  18. package/dist/commands/taskUpstreamCommands.js +1 -1
  19. package/dist/config/configCatalog.js +3 -0
  20. package/dist/config/gitConfig.js +20 -0
  21. package/dist/config/yuiConfig.js +1 -0
  22. package/dist/context/currentSkills.js +32 -1
  23. package/dist/context/sessionBootstrapManifest.js +17 -4
  24. package/dist/context/sessionSkillNotice.js +33 -12
  25. package/dist/controlPlaneCli.js +4 -4
  26. package/dist/controller/clientRuntime.js +37 -18
  27. package/dist/controller/controller.js +16 -1
  28. package/dist/controller/fileSchedulerStoreAdapter.js +2 -0
  29. package/dist/controller/hostLifecycle.js +153 -0
  30. package/dist/controller/hostLifecycleChecks.js +27 -0
  31. package/dist/controller/hostUpgrade.js +344 -0
  32. package/dist/controller/jobArtifactControl.js +3 -3
  33. package/dist/controller/jobDefinitionControl.js +1 -1
  34. package/dist/controller/runtime.js +67 -16
  35. package/dist/controller/sessionNotify.js +5 -46
  36. package/dist/execution/codexThreadNaming.js +54 -43
  37. package/dist/executor/fileRoleLaunchPlanner.js +18 -3
  38. package/dist/integration/gitIntegrationService.js +1 -1
  39. package/dist/kernel/builtinCapabilities.js +3 -3
  40. package/dist/repository/gitCommand.js +153 -0
  41. package/dist/repository/gitCompatibility.js +3 -2
  42. package/dist/repository/gitWorkspace.js +579 -563
  43. package/dist/repository/gitWorktreeInventory.js +15 -19
  44. package/dist/repository/projectRefresh.js +1 -1
  45. package/dist/repository/taskBaseFreshness.js +1 -4
  46. package/dist/repository/taskWorkspacePreparer.js +1 -1
  47. package/dist/runtime/agentEndpoint.js +11 -1
  48. package/dist/runtime/agentEndpointIdentity.js +2 -0
  49. package/dist/runtime/agentHost.js +167 -42
  50. package/dist/runtime/agentHostImplementation.js +3 -1
  51. package/dist/runtime/codexAppServerRuntime.js +3 -0
  52. package/dist/runtime/codexInteractiveHost.js +62 -17
  53. package/dist/runtime/codexNativeAccess.js +1 -1
  54. package/dist/runtime/contextRecovery.js +15 -0
  55. package/dist/runtime/hostSessionLifecycle.js +83 -0
  56. package/dist/runtime/hostUpgradeGate.js +43 -0
  57. package/dist/runtime/lifecycleChecks.js +47 -0
  58. package/dist/runtime/lifecycleReservation.js +9 -1
  59. package/dist/runtime/nativeExecutable.js +5 -2
  60. package/dist/runtime/nativeTitleReceipt.js +31 -0
  61. package/dist/runtime/nativeTitleSync.js +192 -0
  62. package/dist/runtime/platform.js +11 -0
  63. package/dist/runtime/providerRetry.js +4 -4
  64. package/dist/runtime/structuredProviderHost.js +42 -10
  65. package/dist/storage/sqliteSchema.js +30 -0
  66. package/dist/storage/storageVersions.js +1 -1
  67. package/dist/storage/taskStore.js +7 -1
  68. package/dist/web/webConversation.js +2 -2
  69. package/dist/web/webMaterials.js +19 -19
  70. package/dist/web/webTaskSurface.js +3 -3
  71. package/dist/workspace/workItemChangeSetManager.js +3 -3
  72. package/docs/managed-turn-and-session-runtime.md +96 -1
  73. package/docs/managed-turn-and-session-runtime.zh-CN.md +70 -1
  74. package/docs/project-refresh.md +3 -2
  75. package/docs/project-refresh.zh-CN.md +2 -1
  76. package/docs/release-workflow.md +86 -2
  77. package/docs/release-workflow.zh-CN.md +65 -1
  78. package/docs/roles-and-configuration.md +34 -0
  79. package/docs/roles-and-configuration.zh-CN.md +27 -0
  80. package/docs/storage-baseline.md +14 -1
  81. package/docs/storage-baseline.zh-CN.md +7 -1
  82. package/docs/testing/verification-levels.md +7 -7
  83. package/docs/testing/verification-levels.zh-CN.md +6 -6
  84. package/i18n/README.zh-CN.md +8 -4
  85. package/package.json +1 -1
  86. package/skills/yui-runtime/SKILL.md +7 -2
  87. package/skills/yui-runtime/VERSION +1 -1
  88. package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
  89. package/dist/runtime/native/darwin-x64/process-identity +0 -0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Core CI](https://github.com/zhangqian-silk/yui/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/zhangqian-silk/yui/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  ![Node](https://img.shields.io/badge/node-20%20%7C%2022%20%7C%2024-brightgreen.svg)
8
- ![Platform](https://img.shields.io/badge/platform-Linux%20x64%20%28glibc%29%20%7C%20macOS%20x64%2Farm64-blue.svg)
8
+ ![Platform](https://img.shields.io/badge/platform-Linux%20x64%20%28glibc%29%20%7C%20macOS%20arm64-blue.svg)
9
9
  [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing)
10
10
 
11
11
  Give your Agents work to carry forward, not just another chat to answer.
@@ -46,15 +46,20 @@ For the self-built coding Agent (without a Controller or Yui Home), see
46
46
  [`yui agent`](docs/native-agent.md). Its explicit configuration and product state
47
47
  are separate from the orchestration setup below.
48
48
 
49
- You need Linux x64 (glibc) or macOS (x64 or Apple Silicon), Git 2.29.0 or newer, tmux, and
49
+ You need Linux x64 (glibc) or macOS Apple Silicon with native arm64 Node.js, Git 2.29.0 or newer, tmux, and
50
50
  Node.js `^20.17.0`, `^22.9.0`, `^24.0.0` or `^26.0.0`. On macOS install tmux with
51
51
  `brew install tmux`. For the simplest setup, have Codex CLI or Claude Code CLI
52
52
  installed and ready to use with your own account. Yui coordinates those
53
53
  Agents; it does not supply model access.
54
54
 
55
- The npm package includes prebuilt Yui helpers for Linux x64, Mac Intel and
56
- Apple Silicon. The same package is tested on all three platforms before
55
+ The npm package includes prebuilt Yui helpers for Linux x64 and macOS
56
+ Apple Silicon. The same package is tested on both platforms before
57
57
  publication; Yui selects only the helper for the running platform.
58
+ Starting with 2.25.0, macOS Intel and x64 Node.js under Rosetta are unsupported.
59
+ Intel Mac users must stay on 2.23.0; Apple Silicon users must switch to native
60
+ arm64 Node.js. npm's OS/CPU fields cannot express these platform pairs, so npm
61
+ installation may succeed on an unsupported combination, but Yui rejects it
62
+ before loading either CLI product or opening the Home. No install scripts are added.
58
63
 
59
64
  For Claude, Yui passes through your authentication environment and native
60
65
  configuration directory; Claude selects the API key or login method using its
@@ -32,13 +32,13 @@ function requireTextContent(content) {
32
32
  * `artifact.save`: write `content` to `relativePath` in the Task's local repo,
33
33
  * commit exactly that path, and return the resulting commit-pinned reference.
34
34
  */
35
- export async function saveArtifactCapability(home, taskId, input) {
35
+ export async function saveArtifactCapability(home, taskId, input, getConfig) {
36
36
  const bytes = artifactInputBytes(input);
37
37
  return saveArtifactFile(home, taskId, {
38
38
  relativePath: input.relativePath, bytes,
39
39
  ...(input.message === undefined ? {} : { message: input.message }),
40
40
  ...(input.expectedHead === undefined ? {} : { expectedHead: input.expectedHead })
41
- });
41
+ }, getConfig);
42
42
  }
43
43
  /** Shared bounded text/static-image input; never executes HTML or scripts. */
44
44
  export function artifactInputBytes(input) {
@@ -66,8 +66,8 @@ export function artifactInputBytes(input) {
66
66
  * frozen evidence. Images return explicit base64 and bounded raster metadata;
67
67
  * text returns UTF-8. Both carry the resolved commit and exact digest.
68
68
  */
69
- export async function readArtifactCapability(home, taskId, input) {
70
- const repo = openTaskArtifactRepository(home, taskId);
69
+ export async function readArtifactCapability(home, taskId, input, getConfig) {
70
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
71
71
  if (!repo.exists())
72
72
  throw new Error(`Artifact repository is unavailable for ${taskId}.`);
73
73
  const safeRelative = safeRelativeArtifactPath(input.relativePath);
@@ -101,8 +101,8 @@ export function presentArtifactContent(content) {
101
101
  * `artifact.list`: tracked artifacts at HEAD (empty when the Task has no repo
102
102
  * yet). This is an ordinary current read; it does not freeze anything.
103
103
  */
104
- export async function listArtifactsCapability(home, taskId) {
105
- const repo = openTaskArtifactRepository(home, taskId);
104
+ export async function listArtifactsCapability(home, taskId, getConfig) {
105
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
106
106
  if (!repo.exists())
107
107
  return [];
108
108
  return repo.list();
@@ -67,9 +67,9 @@ export function parseGitArtifactRef(value, taskId) {
67
67
  * though for a correctly pinned commit this can only happen if the recorded
68
68
  * digest was wrong, since the commit freezes the bytes.
69
69
  */
70
- export async function resolveGitArtifact(home, ref) {
70
+ export async function resolveGitArtifact(home, ref, getConfig) {
71
71
  const valid = validateGitArtifactRef(ref);
72
- const repo = openTaskArtifactRepository(home, valid.taskId);
72
+ const repo = openTaskArtifactRepository(home, valid.taskId, getConfig);
73
73
  if (!repo.exists()) {
74
74
  throw new Error(`Artifact repository is unavailable for ${valid.taskId}.`);
75
75
  }
@@ -88,8 +88,8 @@ export async function resolveGitArtifact(home, ref) {
88
88
  * for the resulting state. The returned reference is self-certifying, so a
89
89
  * caller can record it directly as frozen evidence.
90
90
  */
91
- export async function saveArtifactFile(home, taskId, input) {
92
- const repo = openTaskArtifactRepository(home, taskId);
91
+ export async function saveArtifactFile(home, taskId, input, getConfig) {
92
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
93
93
  const relativePath = safeRelativeArtifactPath(input.relativePath);
94
94
  const result = await repo.save({
95
95
  files: [{ relativePath, bytes: input.bytes }],
@@ -105,15 +105,15 @@ export async function saveArtifactFile(home, taskId, input) {
105
105
  digest: content.digest
106
106
  });
107
107
  }
108
- export async function readCurrentArtifact(home, taskId, relativePath) {
109
- const repo = openTaskArtifactRepository(home, taskId);
108
+ export async function readCurrentArtifact(home, taskId, relativePath, getConfig) {
109
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
110
110
  if (!repo.exists())
111
111
  throw new Error(`Artifact repository is unavailable for ${taskId}.`);
112
112
  return repo.read(safeRelativeArtifactPath(relativePath));
113
113
  }
114
114
  /** Ordinary listing: tracked artifacts at HEAD (ASYNC, not frozen). Empty when no repo yet. */
115
- export async function listCurrentArtifacts(home, taskId) {
116
- const repo = openTaskArtifactRepository(home, taskId);
115
+ export async function listCurrentArtifacts(home, taskId, getConfig) {
116
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
117
117
  if (!repo.exists())
118
118
  return [];
119
119
  return repo.list();
@@ -123,8 +123,8 @@ export async function listCurrentArtifacts(home, taskId) {
123
123
  * references (ASYNC). Used at Candidate submission / completion time to capture
124
124
  * exactly what exists now. Fails if any path is not tracked at HEAD.
125
125
  */
126
- export async function pinCurrentArtifacts(home, taskId, relativePaths) {
127
- const repo = openTaskArtifactRepository(home, taskId);
126
+ export async function pinCurrentArtifacts(home, taskId, relativePaths, getConfig) {
127
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
128
128
  if (!repo.exists())
129
129
  throw new Error(`Artifact repository is unavailable for ${taskId}.`);
130
130
  const head = await repo.head();
@@ -57,7 +57,7 @@ export function jobArtifactOwner(run) {
57
57
  }
58
58
  /** Each Run has a distinct working tree and commit fence even for identical paths.
59
59
  * The retained storage is unrelated to execution workspaces/logs/checkpoints. */
60
- export function openJobArtifactRepository(home, owner, sourceRun) {
60
+ export function openJobArtifactRepository(home, owner, sourceRun, getConfig) {
61
61
  validateScope(owner.scope);
62
62
  validateScope(sourceRun.scope);
63
63
  if (!isDeepStrictEqual(owner.scope, sourceRun.scope))
@@ -65,13 +65,13 @@ export function openJobArtifactRepository(home, owner, sourceRun) {
65
65
  const scopePath = owner.scope.kind === "global" ? ["global"] : ["task", owner.scope.taskId];
66
66
  const path = join(home, "job-artifacts", ...scopePath, identity(owner.jobId), identity(sourceRun.runId));
67
67
  const lockKey = `job-${createHash("sha256").update(JSON.stringify([jobScopeKey(owner.scope), owner.jobId, sourceRun.runId])).digest("hex")}`;
68
- return openVersionedArtifactRepository(home, lockKey, path);
68
+ return openVersionedArtifactRepository(home, lockKey, path, getConfig);
69
69
  }
70
- export async function readJobArtifactReference(home, ref) {
70
+ export async function readJobArtifactReference(home, ref, getConfig) {
71
71
  validateJobArtifactReference(ref);
72
72
  const repo = ref.owner.kind === "task"
73
- ? openTaskArtifactRepository(home, ref.owner.taskId)
74
- : openJobArtifactRepository(home, ref.owner, ref.sourceRun);
73
+ ? openTaskArtifactRepository(home, ref.owner.taskId, getConfig)
74
+ : openJobArtifactRepository(home, ref.owner, ref.sourceRun, getConfig);
75
75
  const content = await repo.read(ref.relativePath, ref.version);
76
76
  if (content.digest !== ref.digest)
77
77
  throw new Error("Artifact digest mismatch; preserved evidence was not replaced.");
@@ -1,7 +1,6 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import { supportedGit } from "../repository/gitCompatibility.js";
4
- const executeFile = promisify(execFile);
2
+ import { executeGitCommand, GitCommandError, LOCAL_GIT_TIMEOUT_MS } from "../repository/gitCommand.js";
3
+ import { resolveGitLocalTimeoutSeconds } from "../config/gitConfig.js";
5
4
  /**
6
5
  * Hardened, isolated Git runner for per-Task artifact repositories.
7
6
  *
@@ -65,7 +64,9 @@ export class ManagedGitError extends Error {
65
64
  stderr;
66
65
  args;
67
66
  constructor(args, stderr, cause) {
68
- super(stderr.length === 0 ? "Managed git command failed." : `Managed git command failed: ${stderr}`, { cause });
67
+ super(cause instanceof Error
68
+ ? cause.message.replace(/^Git command/u, "Managed git command")
69
+ : stderr.length === 0 ? "Managed git command failed." : `Managed git command failed: ${stderr}`, { cause });
69
70
  this.name = "ManagedGitError";
70
71
  this.stderr = stderr;
71
72
  this.args = args;
@@ -124,14 +125,14 @@ function assertSafeArguments(args) {
124
125
  */
125
126
  async function spawnManagedGit(repoPath, args, options) {
126
127
  assertSafeArguments(args);
127
- const program = supportedGit();
128
+ const timeoutMs = options?.timeoutMs ?? (options?.getConfig === undefined
129
+ ? LOCAL_GIT_TIMEOUT_MS : resolveGitLocalTimeoutSeconds(options.getConfig().gitLocalTimeoutSeconds) * 1_000);
130
+ const program = supportedGit(timeoutMs);
128
131
  try {
129
- const result = await executeFile(program.path, [...HARDENING_FLAGS, "-C", repoPath, ...args], {
130
- encoding: "buffer",
131
- env: managedGitEnvironment(),
132
+ const result = await executeGitCommand(program.path, [...HARDENING_FLAGS, "-C", repoPath, ...args], {
133
+ environment: managedGitEnvironment(),
132
134
  maxBuffer: options?.maxBuffer ?? 16 * 1024 * 1024,
133
- timeout: options?.timeoutMs ?? 30_000,
134
- windowsHide: true
135
+ budget: { totalMs: timeoutMs }
135
136
  });
136
137
  return { stdout: result.stdout, stderr: result.stderr };
137
138
  }
@@ -160,14 +161,16 @@ export async function managedGitBuffer(repoPath, args, options) {
160
161
  const { stdout } = await spawnManagedGit(repoPath, args, options);
161
162
  return stdout;
162
163
  }
163
- /** Run a managed Git command purely for its exit status; never throws on non-zero. */
164
+ /** Return false for a completed non-zero exit, but preserve timeouts/process failures. */
164
165
  export async function managedGitSucceeds(repoPath, args, options) {
165
166
  try {
166
167
  await spawnManagedGit(repoPath, args, options);
167
168
  return true;
168
169
  }
169
170
  catch (error) {
170
- if (error instanceof ManagedGitError)
171
+ if (error instanceof ManagedGitError && error.cause instanceof GitCommandError
172
+ && error.cause.timeoutKind === undefined && error.cause.signal === null
173
+ && typeof error.cause.code === "number")
171
174
  return false;
172
175
  throw error;
173
176
  }
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { lstat, mkdir, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { supportedGit } from "../repository/gitCompatibility.js";
6
+ import { resolveGitLocalTimeoutSeconds } from "../config/gitConfig.js";
6
7
  import { acquireArtifactCommitLock } from "./artifactCommitLock.js";
7
8
  import { resolveContainedArtifactPath, safeRelativeArtifactPath, taskArtifactRepoPath } from "./artifactPaths.js";
8
9
  import { ManagedGitError, externalProgramConfigViolations, managedGit, managedGitBuffer, managedGitSucceeds, requireCommitId } from "./managedGit.js";
@@ -88,14 +89,14 @@ export class ArtifactHeadConflictError extends Error {
88
89
  * (e.g. `store.rootDirectory()`), so ownership is derived from the Task, never
89
90
  * from ambient state.
90
91
  */
91
- export function openTaskArtifactRepository(home, taskId) {
92
+ export function openTaskArtifactRepository(home, taskId, getConfig) {
92
93
  const repoPath = taskArtifactRepoPath(home, taskId);
93
- const repo = openVersionedArtifactRepository(home, taskId, repoPath);
94
+ const repo = openVersionedArtifactRepository(home, taskId, repoPath, getConfig);
94
95
  return { ...repo, taskId, save: async (input) => ({ ...await repo.save(input), taskId }) };
95
96
  }
96
97
  /** Shared local-only storage. Callers derive paths from validated owning identities,
97
98
  * never from a user-supplied filesystem location. Task layout/commits stay intact. */
98
- export function openVersionedArtifactRepository(home, key, repoPath) {
99
+ export function openVersionedArtifactRepository(home, key, repoPath, getConfig) {
99
100
  const taskId = key; // Existing error fields retain their public spelling.
100
101
  const checkBoundary = async () => {
101
102
  await resolveContainedArtifactPath(home, `${relative(home, repoPath).split("\\").join("/")}/boundary-check`);
@@ -115,21 +116,21 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
115
116
  await checkBoundary();
116
117
  if (!exists())
117
118
  return null;
118
- return currentHead(repoPath);
119
+ return currentHead(repoPath, getConfig);
119
120
  };
120
121
  const ensureUnlocked = async () => {
121
122
  await checkBoundary();
122
- supportedGit();
123
+ supportedGit(resolveGitLocalTimeoutSeconds(getConfig?.().gitLocalTimeoutSeconds) * 1_000);
123
124
  if (exists()) {
124
- await assertNoRemote(taskId, repoPath);
125
+ await assertNoRemote(taskId, repoPath, getConfig);
125
126
  return;
126
127
  }
127
128
  await mkdir(repoPath, { recursive: true, mode: 0o700 });
128
- await managedGit(repoPath, ["init", "-b", ARTIFACT_BRANCH]);
129
+ await managedGit(repoPath, ["init", "-b", ARTIFACT_BRANCH], { getConfig });
129
130
  // An empty root commit guarantees HEAD exists, so scoped per-path commits
130
131
  // and expected-HEAD checks work uniformly from the very first save.
131
- await managedGit(repoPath, ["commit", "--allow-empty", "-m", "init artifact repo"]);
132
- await assertNoRemote(taskId, repoPath);
132
+ await managedGit(repoPath, ["commit", "--allow-empty", "-m", "init artifact repo"], { getConfig });
133
+ await assertNoRemote(taskId, repoPath, getConfig);
133
134
  };
134
135
  const ensure = async () => {
135
136
  const release = await acquireArtifactCommitLock(home, key);
@@ -169,10 +170,10 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
169
170
  const release = await acquireArtifactCommitLock(home, key);
170
171
  try {
171
172
  await ensureUnlocked();
172
- await assertNoRemote(taskId, repoPath);
173
+ await assertNoRemote(taskId, repoPath, getConfig);
173
174
  // Expected-HEAD check under the lock: a conflict stops without writing.
174
175
  if (input.expectedHead !== undefined) {
175
- const actual = await currentHead(repoPath);
176
+ const actual = await currentHead(repoPath, getConfig);
176
177
  if (actual !== input.expectedHead) {
177
178
  throw new ArtifactHeadConflictError(taskId, input.expectedHead, actual);
178
179
  }
@@ -186,17 +187,17 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
186
187
  }
187
188
  const pathspecs = prepared.map((file) => file.relativePath);
188
189
  // Stage exactly the saved paths so new files become known to Git...
189
- await managedGit(repoPath, ["add", "--", ...pathspecs]);
190
+ await managedGit(repoPath, ["add", "--", ...pathspecs], { getConfig });
190
191
  // ...but if nothing actually changed, do not create an empty commit.
191
192
  const noChanges = await managedGitSucceeds(repoPath, [
192
193
  "diff", "--cached", "--quiet", "--", ...pathspecs
193
- ]);
194
+ ], { getConfig });
194
195
  if (noChanges) {
195
- return { commit: await currentHead(repoPath), savedPaths: pathspecs };
196
+ return { commit: await currentHead(repoPath, getConfig), savedPaths: pathspecs };
196
197
  }
197
198
  // `--only <paths>` records exactly these paths; any other WIP stays WIP.
198
- await managedGit(repoPath, ["commit", "--only", "-m", message, "--", ...pathspecs]);
199
- return { commit: await currentHead(repoPath), savedPaths: pathspecs };
199
+ await managedGit(repoPath, ["commit", "--only", "-m", message, "--", ...pathspecs], { getConfig });
200
+ return { commit: await currentHead(repoPath, getConfig), savedPaths: pathspecs };
200
201
  }
201
202
  finally {
202
203
  release();
@@ -208,30 +209,31 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
208
209
  // A pinned commit reads frozen evidence; otherwise read the current HEAD.
209
210
  // Both resolve `<commit>:<path>`, an OBJECT spec (never the working tree, so
210
211
  // a read never follows a symlink or sees unrelated WIP).
211
- const pinned = await artifactCommit(repoPath, commit);
212
+ const pinned = await artifactCommit(repoPath, commit, getConfig);
212
213
  const objectSpec = `${pinned}:${safeRelative}`;
213
214
  // `<commit>:<path>` for a DIRECTORY resolves to a tree, and `git show` would
214
215
  // print a tree listing rather than fail — silently returning directory
215
216
  // metadata as if it were file bytes. Verify the object is a blob first, so a
216
217
  // read only ever yields real file content.
217
- const objectType = (await managedGit(repoPath, ["cat-file", "-t", objectSpec]).catch((error) => {
218
+ const objectType = (await managedGit(repoPath, ["cat-file", "-t", objectSpec], { getConfig }).catch((error) => {
218
219
  if (error instanceof ManagedGitError) {
219
- throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
220
+ throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}: ${error.message}`, { cause: error });
220
221
  }
221
222
  throw error;
222
223
  })).trim();
223
224
  if (objectType !== "blob") {
224
225
  throw new Error(`Artifact ${safeRelative} at ${pinned} is not a file (${objectType}).`);
225
226
  }
226
- const size = Number((await managedGit(repoPath, ["cat-file", "-s", objectSpec])).trim());
227
+ const size = Number((await managedGit(repoPath, ["cat-file", "-s", objectSpec], { getConfig })).trim());
227
228
  if (!Number.isSafeInteger(size) || size > MAX_ARTIFACT_BYTES) {
228
229
  throw new Error(`Artifact ${safeRelative} exceeds the ${MAX_ARTIFACT_BYTES}-byte limit.`);
229
230
  }
230
231
  const bytes = await managedGitBuffer(repoPath, ["cat-file", "blob", objectSpec], {
232
+ getConfig,
231
233
  maxBuffer: MAX_ARTIFACT_BYTES + 4096
232
234
  }).catch((error) => {
233
235
  if (error instanceof ManagedGitError) {
234
- throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
236
+ throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}: ${error.message}`, { cause: error });
235
237
  }
236
238
  throw error;
237
239
  });
@@ -246,10 +248,10 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
246
248
  await checkBoundary();
247
249
  if (!exists())
248
250
  return [];
249
- const pinned = await artifactCommit(repoPath, commit);
251
+ const pinned = await artifactCommit(repoPath, commit, getConfig);
250
252
  const output = await managedGit(repoPath, [
251
253
  "ls-tree", "-r", "-z", "--long", "--full-tree", pinned
252
- ]);
254
+ ], { getConfig });
253
255
  const entries = [];
254
256
  for (const record of output.split("\0").filter(Boolean)) {
255
257
  // Format: "<mode> <type> <object> <size>\t<path>"
@@ -271,7 +273,7 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
271
273
  return [];
272
274
  const output = await managedGit(repoPath, [
273
275
  "status", "--porcelain=v1", "--untracked-files=all", "-z"
274
- ]);
276
+ ], { getConfig });
275
277
  // Porcelain v1 -z records: "XY <path>\0" (rename adds a second \0 field).
276
278
  const changes = [];
277
279
  for (const record of output.split("\0").filter(Boolean)) {
@@ -283,16 +285,16 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
283
285
  };
284
286
  return { repoPath, ensure, exists, head, save, read, list, workingChanges };
285
287
  }
286
- async function currentHead(repoPath) {
287
- return requireCommitId(await managedGit(repoPath, ["rev-parse", "HEAD^{commit}"]));
288
+ async function currentHead(repoPath, getConfig) {
289
+ return requireCommitId(await managedGit(repoPath, ["rev-parse", "HEAD^{commit}"], { getConfig }));
288
290
  }
289
- async function artifactCommit(repoPath, value) {
291
+ async function artifactCommit(repoPath, value, getConfig) {
290
292
  if (value === undefined)
291
- return currentHead(repoPath);
293
+ return currentHead(repoPath, getConfig);
292
294
  const commit = requireCommitId(value);
293
- const type = await managedGit(repoPath, ["cat-file", "-t", commit]).catch(error => {
295
+ const type = await managedGit(repoPath, ["cat-file", "-t", commit], { getConfig }).catch(error => {
294
296
  if (error instanceof ManagedGitError)
295
- throw new Error(`Artifact commit ${commit} is unavailable.`, { cause: error });
297
+ throw new Error(`Artifact commit ${commit} is unavailable: ${error.message}`, { cause: error });
296
298
  throw error;
297
299
  });
298
300
  if (type.trim() !== "commit") {
@@ -306,8 +308,8 @@ async function artifactCommit(repoPath, value) {
306
308
  * program on add/diff/commit. Both are external violations — we report and stop,
307
309
  * never silently remove them.
308
310
  */
309
- async function assertNoRemote(taskId, repoPath) {
310
- const output = await managedGit(repoPath, ["remote"]);
311
+ async function assertNoRemote(taskId, repoPath, getConfig) {
312
+ const output = await managedGit(repoPath, ["remote"], { getConfig });
311
313
  const remotes = output.split("\n").map((line) => line.trim()).filter(Boolean);
312
314
  if (remotes.length > 0) {
313
315
  throw new ArtifactRemoteViolationError(taskId, remotes);
@@ -315,7 +317,7 @@ async function assertNoRemote(taskId, repoPath) {
315
317
  // A repo's own `.git/config` is read even with global/system config disabled,
316
318
  // so a committed `.gitattributes` filter paired with a repo-local
317
319
  // `filter.<n>.clean = <cmd>` would run `<cmd>` on `git add`. Detect and stop.
318
- const configListZ = await managedGit(repoPath, ["config", "--local", "--list", "-z"]);
320
+ const configListZ = await managedGit(repoPath, ["config", "--local", "--list", "-z"], { getConfig });
319
321
  const violations = externalProgramConfigViolations(configListZ);
320
322
  if (violations.length > 0) {
321
323
  throw new ArtifactManagedConfigViolationError(taskId, violations);
@@ -1289,8 +1289,8 @@ export const ROOT_COMMAND = buildNode({
1289
1289
  {
1290
1290
  name: "update", discovery: { surface: "public", audiences: ["public", "operator"] },
1291
1291
  summary: "Install the latest or an exact published Yui version after storage preflight.",
1292
- usage: "yui update [--version <exact-version>]",
1293
- options: ["--version"]
1292
+ usage: "yui update [--version <exact-version>] | --hosts-status",
1293
+ options: ["--version", "--hosts-status"]
1294
1294
  },
1295
1295
  {
1296
1296
  name: "upgrade", discovery: { surface: "public", audiences: ["public", "operator"] },
@@ -14,9 +14,16 @@ import { usageError } from "../errors/cliError.js";
14
14
  * Returns a process exit code: 0 on success or already-current, 5 on abort.
15
15
  */
16
16
  export function runUpdateCommand(args = [], environment = process.env, spawn, write = (text) => process.stdout.write(text), ports) {
17
- const version = parseUpdateVersion(args);
17
+ const statusOnly = args.length === 1 && args[0] === "--hosts-status";
18
+ const version = statusOnly ? undefined : parseUpdateVersion(args);
18
19
  const home = resolveYuiHome(environment);
19
20
  const resolvedPorts = ports ?? createUpdatePorts(environment, spawn);
21
+ if (statusOnly) {
22
+ if (!resolvedPorts.hostStatus)
23
+ throw usageError("Host status inspection is unavailable.");
24
+ write(`${renderHosts(resolvedPorts.hostStatus(home)).join("\n")}\n`);
25
+ return 0;
26
+ }
20
27
  const result = runUpdate(resolvedPorts, { home, version });
21
28
  write(`${renderUpdateResult(result)}\n`);
22
29
  if (result.outcome === "aborted")
@@ -29,7 +36,12 @@ function parseUpdateVersion(args) {
29
36
  if (args.length === 2 && args[0] === "--version" && isConcreteVersion(args[1])) {
30
37
  return args[1].trim();
31
38
  }
32
- throw usageError("Update usage: yui update [--version <exact-version>]");
39
+ throw usageError("Update usage: yui update [--version <exact-version>] | --hosts-status");
40
+ }
41
+ function renderHosts(hosts) {
42
+ return hosts.length === 0 ? ["Hosts: no recorded active Sessions."] : hosts.map(host => `Host ${host.owner.scope === "task" ? `${host.owner.taskId}/` : "global/"}${host.owner.roleName} `
43
+ + `(${host.nativeSessionId}): ${host.status}; loaded Host=${host.hostImplementation?.generation ?? "unreported"}; `
44
+ + `loaded Endpoint=${host.endpointImplementation?.generation ?? "unreported"}${host.detail ? ` — ${host.detail}` : ""}`);
33
45
  }
34
46
  /** Render an {@link UpdateResult} as concise, CLI-style text. */
35
47
  export function renderUpdateResult(result) {
@@ -66,7 +78,12 @@ export function renderUpdateResult(result) {
66
78
  return [
67
79
  rendered,
68
80
  ...(result.outcome === "updated" ? [
69
- "Runtime: existing Hosts are retained, not upgraded in place. Host control capabilities and event delivery are checked separately; accepted input is never replayed."
81
+ result.controllerStarted ? "Controller: target startup verified." : "Controller: not started by this update.",
82
+ "Hosts: graceful switching is separate from installation; accepted input is never replayed.",
83
+ ...(result.hosts === undefined ? ["Hosts: not confirmed; existing Hosts may still run older code."]
84
+ : renderHosts(result.hosts)),
85
+ "Inspect later without reinstalling: yui update --hosts-status",
86
+ ...(result.hostUpgradeError === undefined ? [] : [result.hostUpgradeError])
70
87
  ] : []),
71
88
  ...(result.controllerReconciliation === undefined ? [] : [
72
89
  `Controller reconciliation: ${JSON.stringify(result.controllerReconciliation)}`
@@ -250,9 +250,22 @@ function activateAndVerify(ports, staged, home, lifecycle, preflight) {
250
250
  : restoreControllerOrReport(ports, home, lifecycle, failure);
251
251
  }
252
252
  }
253
+ let hosts;
254
+ let hostUpgradeError;
255
+ if (lifecycle.ensureRunning && ports.upgradeHosts) {
256
+ try {
257
+ hosts = ports.upgradeHosts(home);
258
+ }
259
+ catch (error) {
260
+ hostUpgradeError = `Host upgrade request outcome is unknown: ${messageOf(error)}. Inspect existing requests; do not replay inputs.`;
261
+ }
262
+ }
253
263
  return {
254
264
  outcome: "updated",
255
265
  version: staged.version,
266
+ ...(lifecycle.ensureRunning ? { controllerStarted: true } : {}),
267
+ ...(hosts === undefined ? {} : { hosts }),
268
+ ...(hostUpgradeError === undefined ? {} : { hostUpgradeError }),
256
269
  ...(migration?.backupPath === undefined ? {} : { backupPath: migration.backupPath })
257
270
  };
258
271
  }
@@ -236,9 +236,36 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
236
236
  },
237
237
  restoreController(home, identity) {
238
238
  restoreControllerIdentity(home, identity, environment, spawn);
239
+ },
240
+ upgradeHosts(home) {
241
+ if (!verifiedActivatedBinary)
242
+ throw new Error("Host upgrade requires the verified activated binary.");
243
+ const root = dirname(dirname(activatedControllerEntrypoint(verifiedActivatedBinary)));
244
+ return requestHosts(root, home, "runtime.upgrade-hosts", environment, spawn);
245
+ },
246
+ hostStatus(home) {
247
+ return requestHosts(fileURLToPath(new URL("../", import.meta.url)), home, "runtime.host-upgrade-status", environment, spawn);
239
248
  }
240
249
  };
241
250
  }
251
+ function requestHosts(root, home, method, environment, spawn) {
252
+ const helper = [
253
+ "const [root, home] = process.argv.slice(1);",
254
+ "(async () => {",
255
+ " const { pathToFileURL } = await import('node:url');",
256
+ " const { callController } = await import(pathToFileURL(root + '/core/controllerClient.js').href);",
257
+ " const { AGENT_HOST_IMPLEMENTATION } = await import(pathToFileURL(root + '/runtime/agentHostImplementation.js').href);",
258
+ ` const data = await callController(home, ${JSON.stringify(method)}, { generation: AGENT_HOST_IMPLEMENTATION.generation });`,
259
+ " process.stdout.write(JSON.stringify({ok: true, data}));",
260
+ "})().catch(error => { process.stderr.write(String(error)); process.exitCode = 5; });"
261
+ ].join(" ");
262
+ const result = spawn(process.execPath, ["-e", helper, root, home], { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
263
+ const data = parseSuccessEnvelopeData(result);
264
+ if (result.error || result.status !== 0 || !Array.isArray(data?.hosts)) {
265
+ throw new Error(`Host request was not confirmed: ${result.stderr.toString("utf8")}`);
266
+ }
267
+ return data.hosts;
268
+ }
242
269
  const UPDATE_CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
243
270
  const UPDATE_CLIENT_RUNTIME_PATH = fileURLToPath(new URL("../controller/clientRuntime.js", import.meta.url));
244
271
  const UPDATE_INVENTORY_PATH = fileURLToPath(new URL("../controller/resourceInventoryLinux.js", import.meta.url));
package/dist/cli.js CHANGED
@@ -1,11 +1,19 @@
1
1
  #!/usr/bin/env node
2
+ import { unsupportedPlatformMessage } from "./runtime/platform.js";
2
3
  // No static control-plane imports: the product never resolves/initializes Yui Home.
3
4
  const arguments_ = process.argv.slice(2);
4
5
  let commandOffset = 0;
5
6
  while (arguments_[commandOffset] === '--json')
6
7
  commandOffset++;
7
8
  const commandArguments = arguments_.slice(commandOffset);
8
- if (commandArguments[0] === 'agent' || (commandArguments[0] === 'help' && commandArguments[1] === 'agent')) {
9
+ const platformFailure = unsupportedPlatformMessage();
10
+ if (platformFailure !== undefined) {
11
+ process.stderr.write(`${arguments_.includes("--json")
12
+ ? JSON.stringify({ ok: false, code: "unsupported_platform", message: platformFailure })
13
+ : platformFailure}\n`);
14
+ process.exitCode = 1;
15
+ }
16
+ else if (commandArguments[0] === 'agent' || (commandArguments[0] === 'help' && commandArguments[1] === 'agent')) {
9
17
  let outputFailed = false;
10
18
  // Cover early help/config writes as well as execution UI disconnection.
11
19
  process.stdout.on('error', () => { outputFailed = true; process.exitCode = 1; });
@@ -31,4 +39,3 @@ if (commandArguments[0] === 'agent' || (commandArguments[0] === 'help' && comman
31
39
  else {
32
40
  await import('./controlPlaneCli.js');
33
41
  }
34
- export {};
@@ -2,7 +2,7 @@ import { mkdirSync, realpathSync } from "node:fs";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { CONFIG_DEFINITIONS, CONFIG_KEYS, configDefinition, configDefinitionsForDomain } from "../config/configCatalog.js";
4
4
  import { resolveTimeZone } from "../config/timeZone.js";
5
- import { DEFAULT_AGENT_LAUNCH_INACTIVITY_TIMEOUT_SECONDS, DEFAULT_CONTROLLER_TASK_CONCURRENCY, DEFAULT_DELIVERY_TIMEOUT_SECONDS, DEFAULT_RECONCILIATION_INTERVAL_SECONDS, DEFAULT_RESOURCES_GC_MODE, DEFAULT_RESOURCES_QUARANTINE_TTL_HOURS, DEFAULT_TMUX_HISTORY_LIMIT, reconciliationIntervalMilliseconds, resolveAgentLaunchInactivityTimeoutSeconds, resolveControllerTaskConcurrency, resolveDeliveryTimeoutSeconds, resolveResourcesGcAutoQuarantine, resolveResourcesGcMode, resolveResourcesQuarantineTtlHours, resolveRuntimeHealth, resolveTelemetryEnabled, resolveTelemetryRunCap, resolveTelemetryTerminalKeep, resolveTmuxBin, resolveTmuxHistoryLimit } from "../config/yuiConfig.js";
5
+ import { DEFAULT_AGENT_LAUNCH_INACTIVITY_TIMEOUT_SECONDS, DEFAULT_CONTROLLER_TASK_CONCURRENCY, DEFAULT_DELIVERY_TIMEOUT_SECONDS, DEFAULT_RECONCILIATION_INTERVAL_SECONDS, DEFAULT_RESOURCES_GC_MODE, DEFAULT_RESOURCES_QUARANTINE_TTL_HOURS, DEFAULT_TMUX_HISTORY_LIMIT, reconciliationIntervalMilliseconds, resolveAgentLaunchInactivityTimeoutSeconds, resolveControllerTaskConcurrency, resolveDeliveryTimeoutSeconds, resolveGitLocalTimeoutSeconds, resolveGitRemoteInactivityTimeoutSeconds, resolveGitRemoteTimeoutSeconds, resolveResourcesGcAutoQuarantine, resolveResourcesGcMode, resolveResourcesQuarantineTtlHours, resolveRuntimeHealth, resolveTelemetryEnabled, resolveTelemetryRunCap, resolveTelemetryTerminalKeep, resolveTmuxBin, resolveTmuxHistoryLimit } from "../config/yuiConfig.js";
6
6
  import { usageError } from "../errors/cliError.js";
7
7
  import { defaultTableWidth, renderTable } from "../output/table.js";
8
8
  import { REVIEW_TRIGGERS } from "../review/reviewConfig.js";
@@ -75,6 +75,9 @@ export function effectiveConfigData(config, domain) {
75
75
  controllerTaskConcurrency: resolveControllerTaskConcurrency(config.controllerTaskConcurrency),
76
76
  agentLaunchInactivityTimeoutSeconds: resolveAgentLaunchInactivityTimeoutSeconds(config.agentLaunchInactivityTimeoutSeconds),
77
77
  deliveryTimeoutSeconds: resolveDeliveryTimeoutSeconds(config.deliveryTimeoutSeconds),
78
+ gitLocalTimeoutSeconds: resolveGitLocalTimeoutSeconds(config.gitLocalTimeoutSeconds),
79
+ gitRemoteInactivityTimeoutSeconds: resolveGitRemoteInactivityTimeoutSeconds(config.gitRemoteInactivityTimeoutSeconds),
80
+ gitRemoteTimeoutSeconds: resolveGitRemoteTimeoutSeconds(config.gitRemoteTimeoutSeconds),
78
81
  tmuxBin: resolveTmuxBin(config.tmuxBin),
79
82
  tmuxHistoryLimit: resolveTmuxHistoryLimit(config.tmuxHistoryLimit),
80
83
  telemetryEnabled: resolveTelemetryEnabled(config.telemetryEnabled),
@@ -393,6 +396,66 @@ const CONFIG_KEY_HANDLERS = [
393
396
  return `Delivery timeout reset to ${DEFAULT_DELIVERY_TIMEOUT_SECONDS} seconds\n`;
394
397
  }
395
398
  },
399
+ {
400
+ key: "git-local-timeout-seconds",
401
+ showLabel: "Git local total timeout",
402
+ showValue: (config) => `${resolveGitLocalTimeoutSeconds(config.gitLocalTimeoutSeconds)} seconds`,
403
+ set(args, store) {
404
+ const usage = "Tools config set usage: yui config tools set git-local-timeout-seconds <1-86400>.";
405
+ if (args.length !== 1)
406
+ throw usageError(usage);
407
+ const gitLocalTimeoutSeconds = validatedConfigValue(() => resolveGitLocalTimeoutSeconds(Number(args[0])), usage);
408
+ saveConfigKey(store, (config) => ({ ...config, gitLocalTimeoutSeconds }));
409
+ return `Git local total timeout set to ${gitLocalTimeoutSeconds} seconds\n`;
410
+ },
411
+ clear(store) {
412
+ saveConfigKey(store, (config) => {
413
+ const { gitLocalTimeoutSeconds: _removed, ...rest } = config;
414
+ return rest;
415
+ });
416
+ return "Git local total timeout reset to default\n";
417
+ }
418
+ },
419
+ {
420
+ key: "git-remote-inactivity-timeout-seconds",
421
+ showLabel: "Git remote inactivity timeout",
422
+ showValue: (config) => `${resolveGitRemoteInactivityTimeoutSeconds(config.gitRemoteInactivityTimeoutSeconds)} seconds`,
423
+ set(args, store) {
424
+ const usage = "Tools config set usage: yui config tools set git-remote-inactivity-timeout-seconds <1-86400>.";
425
+ if (args.length !== 1)
426
+ throw usageError(usage);
427
+ const gitRemoteInactivityTimeoutSeconds = validatedConfigValue(() => resolveGitRemoteInactivityTimeoutSeconds(Number(args[0])), usage);
428
+ saveConfigKey(store, (config) => ({ ...config, gitRemoteInactivityTimeoutSeconds }));
429
+ return `Git remote inactivity timeout set to ${gitRemoteInactivityTimeoutSeconds} seconds\n`;
430
+ },
431
+ clear(store) {
432
+ saveConfigKey(store, (config) => {
433
+ const { gitRemoteInactivityTimeoutSeconds: _removed, ...rest } = config;
434
+ return rest;
435
+ });
436
+ return "Git remote inactivity timeout reset to default\n";
437
+ }
438
+ },
439
+ {
440
+ key: "git-remote-timeout-seconds",
441
+ showLabel: "Git remote total timeout",
442
+ showValue: (config) => `${resolveGitRemoteTimeoutSeconds(config.gitRemoteTimeoutSeconds)} seconds`,
443
+ set(args, store) {
444
+ const usage = "Tools config set usage: yui config tools set git-remote-timeout-seconds <1-86400>.";
445
+ if (args.length !== 1)
446
+ throw usageError(usage);
447
+ const gitRemoteTimeoutSeconds = validatedConfigValue(() => resolveGitRemoteTimeoutSeconds(Number(args[0])), usage);
448
+ saveConfigKey(store, (config) => ({ ...config, gitRemoteTimeoutSeconds }));
449
+ return `Git remote total timeout set to ${gitRemoteTimeoutSeconds} seconds\n`;
450
+ },
451
+ clear(store) {
452
+ saveConfigKey(store, (config) => {
453
+ const { gitRemoteTimeoutSeconds: _removed, ...rest } = config;
454
+ return rest;
455
+ });
456
+ return "Git remote total timeout reset to default\n";
457
+ }
458
+ },
396
459
  {
397
460
  key: "tmux-bin",
398
461
  showLabel: "Tmux bin",