orkestrator 2.26.1-alpha → 2.26.2-beta

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.
package/dist/client.js CHANGED
@@ -7444,7 +7444,7 @@ import path4 from "path";
7444
7444
  // ../../apps/backend/package.json
7445
7445
  var package_default = {
7446
7446
  name: "@orkestrator/backend",
7447
- version: "2.26.1-alpha",
7447
+ version: "2.26.2-beta",
7448
7448
  private: true,
7449
7449
  type: "module",
7450
7450
  exports: {
@@ -7458,7 +7458,7 @@ var package_default = {
7458
7458
  "start:web": "bun dist/main.js --fallback-host 127.0.0.1",
7459
7459
  "start:web-public": "bun dist/main.js --tailscale-serve --allowed-origins https://orkestrator.dev,https://www.orkestrator.dev",
7460
7460
  test: "bun test --preload ../../tests/setup-node.ts src tests --parallel",
7461
- "test:workspace": "bun test --preload ../../tests/setup-node.ts src tests --only-failures ${ORKESTRATOR_TEST_TIMINGS_DIR:+--timings=$ORKESTRATOR_TEST_TIMINGS_DIR/backend.json --update-timings} --parallel=${ORKESTRATOR_TEST_WORKERS:-2}",
7461
+ "test:workspace": "bun test --preload ../../tests/setup-node.ts src tests --only-failures ${ORKESTRATOR_TEST_TIMINGS_DIR:+--timings=$ORKESTRATOR_TEST_TIMINGS_DIR/backend.json --update-timings} $ORKESTRATOR_BACKEND_TEST_IGNORE --parallel=${ORKESTRATOR_BACKEND_TEST_WORKERS:-${ORKESTRATOR_TEST_WORKERS:-2}}",
7462
7462
  typecheck: "bunx tsc --noEmit -p tsconfig.json && bunx tsc --noEmit -p tsconfig.test.json"
7463
7463
  },
7464
7464
  dependencies: {
package/dist/main.js CHANGED
@@ -818,7 +818,7 @@ var package_default;
818
818
  var init_package = __esm(() => {
819
819
  package_default = {
820
820
  name: "@orkestrator/backend",
821
- version: "2.26.1-alpha",
821
+ version: "2.26.2-beta",
822
822
  private: true,
823
823
  type: "module",
824
824
  exports: {
@@ -832,7 +832,7 @@ var init_package = __esm(() => {
832
832
  "start:web": "bun dist/main.js --fallback-host 127.0.0.1",
833
833
  "start:web-public": "bun dist/main.js --tailscale-serve --allowed-origins https://orkestrator.dev,https://www.orkestrator.dev",
834
834
  test: "bun test --preload ../../tests/setup-node.ts src tests --parallel",
835
- "test:workspace": "bun test --preload ../../tests/setup-node.ts src tests --only-failures ${ORKESTRATOR_TEST_TIMINGS_DIR:+--timings=$ORKESTRATOR_TEST_TIMINGS_DIR/backend.json --update-timings} --parallel=${ORKESTRATOR_TEST_WORKERS:-2}",
835
+ "test:workspace": "bun test --preload ../../tests/setup-node.ts src tests --only-failures ${ORKESTRATOR_TEST_TIMINGS_DIR:+--timings=$ORKESTRATOR_TEST_TIMINGS_DIR/backend.json --update-timings} $ORKESTRATOR_BACKEND_TEST_IGNORE --parallel=${ORKESTRATOR_BACKEND_TEST_WORKERS:-${ORKESTRATOR_TEST_WORKERS:-2}}",
836
836
  typecheck: "bunx tsc --noEmit -p tsconfig.json && bunx tsc --noEmit -p tsconfig.test.json"
837
837
  },
838
838
  dependencies: {
@@ -3220,6 +3220,79 @@ var init_resource_events = __esm(() => {
3220
3220
  RESOURCE_MANIFEST_KIND_SET = new Set(RESOURCE_MANIFEST_KINDS);
3221
3221
  });
3222
3222
 
3223
+ // ../protocol/src/controller-settings.ts
3224
+ function pickInheritedGlobalSettings(global2) {
3225
+ const source = global2;
3226
+ const picked = {};
3227
+ for (const key of INHERITED_GLOBAL_SETTING_KEYS) {
3228
+ if (source[key] !== undefined)
3229
+ picked[key] = source[key];
3230
+ }
3231
+ return picked;
3232
+ }
3233
+ function parseInheritedGlobalSettings(value) {
3234
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
3235
+ throw new Error("Expected settings to be an object.");
3236
+ }
3237
+ let serialized;
3238
+ try {
3239
+ serialized = JSON.stringify(value);
3240
+ } catch {
3241
+ throw new Error("Settings must be plain JSON.");
3242
+ }
3243
+ if (new TextEncoder().encode(serialized).byteLength > MAX_INHERITED_SETTINGS_BYTES) {
3244
+ throw new Error("Settings are too large.");
3245
+ }
3246
+ const parsed = JSON.parse(serialized);
3247
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3248
+ throw new Error("Expected settings to be an object.");
3249
+ }
3250
+ return pickInheritedGlobalSettings(parsed);
3251
+ }
3252
+ function emptyControllerSettingsStore() {
3253
+ return { version: 1, entries: [] };
3254
+ }
3255
+ function parseControllerSettingsStore(value) {
3256
+ if (!value || typeof value !== "object")
3257
+ return emptyControllerSettingsStore();
3258
+ const entries = value.entries;
3259
+ if (!Array.isArray(entries))
3260
+ return emptyControllerSettingsStore();
3261
+ const parsed = [];
3262
+ for (const entry of entries) {
3263
+ if (!entry || typeof entry !== "object")
3264
+ continue;
3265
+ const record = entry;
3266
+ if (!isBackendId(record.controllerId) || typeof record.changedAt !== "string" || !Number.isFinite(Date.parse(record.changedAt)))
3267
+ continue;
3268
+ try {
3269
+ parsed.push({
3270
+ controllerId: record.controllerId,
3271
+ settings: parseInheritedGlobalSettings(record.settings),
3272
+ changedAt: record.changedAt
3273
+ });
3274
+ } catch {}
3275
+ }
3276
+ return { version: 1, entries: parsed };
3277
+ }
3278
+ var SYNC_CONTROLLER_SETTINGS_COMMAND = "sync_controller_settings", INHERITED_GLOBAL_SETTING_KEYS, MAX_INHERITED_SETTINGS_BYTES, INHERITED_KEYS;
3279
+ var init_controller_settings = __esm(() => {
3280
+ init_federation();
3281
+ INHERITED_GLOBAL_SETTING_KEYS = [
3282
+ "agentSettings",
3283
+ "favoriteModels",
3284
+ "reviewInstruction",
3285
+ "openCodeModelProviders",
3286
+ "codexMaxConcurrentThreads",
3287
+ "claudeBackgroundCommandLimitHours",
3288
+ "terminalAppearance",
3289
+ "terminalScrollback",
3290
+ "agentMessaging"
3291
+ ];
3292
+ MAX_INHERITED_SETTINGS_BYTES = 256 * 1024;
3293
+ INHERITED_KEYS = new Set(INHERITED_GLOBAL_SETTING_KEYS);
3294
+ });
3295
+
3223
3296
  // ../../apps/backend/src/core/github-cli-binary.ts
3224
3297
  function setManagedGitHubCli(executable) {
3225
3298
  managedGitHubCli = executable;
@@ -3967,7 +4040,7 @@ var SYSTEM_INSTRUCTIONS_FRAME_OPEN = "<orkestrator-system-instructions>", SYSTEM
3967
4040
  1. Inspect the current Git status and changes. Commit only relevant safe changes using the repository's commit conventions and hooks. Never skip hooks, force a clean tree, delete unrelated files, push, merge, rebase, reset, switch branches, or create a worktree. Do not implement features or fix validation failures. If unrelated or sensitive changes prevent a clean worktree, report the limitation.
3968
4041
  2. Discover validation requirements afresh from the CURRENT repository. First read the repository's agent instructions \u2014 AGENTS.md (and CLAUDE.md or an equivalent) at the root and in the directories that contain changed paths, plus any testing guide they point to \u2014 and follow what they say about which tests, checks, and builds a given kind of change needs. Then use directory structure, changed paths, CI workflows, manifests, task definitions, toolchain configuration, and relevant scripts. Do not assume a language, package manager, fixed list of files, or that the codebase resembles an earlier review. Follow repository-specific test entrypoints. Do not infer that a command covers another merely from its name.
3969
4042
  3. Select validation proportionate to the change. Produce at most 32 commands covering the tests, static checks, and build that the changed paths can affect, plus any repository-specific requirements. Some suites are only needed in some situations: when the repository instructions scope a suite to particular paths, platforms, or kinds of change, include it only when this change meets that condition, and omit suites for areas the change cannot affect (for example a documentation-only change does not need an unrelated app's test suite). Include the full relevant coverage when the instructions require it for every change, or when a change's reach is uncertain (shared configuration, dependencies, lockfiles, toolchain, or code shared across areas). Summarize deliberately omitted suites and why in one concise limitation. Do not RUN validation, install dependencies, inspect validation output, or perform the code review. Command execution, timing, artifact paths, and exit codes belong to the backend. A skipped requirement needs an explicit limitation; an empty plan requires a limitation.
3970
- 4. Each command has a unique short id, a non-interactive shell command, a workspace-relative cwd (usually "."), and dependsOn listing prerequisite ids EARLIER in the array. Split independent work so it can run concurrently. Inspect what each selected stage actually runs before adding another command: if the full test stage already runs the production build, omit a separate build command instead of repeating it. Shared build prerequisites run once; avoid overlapping aggregate commands that repeat the same validation. Preserve necessary build/test dependencies and setup/cleanup semantics. Prefer a repository-owned one-shot lifecycle task for service-backed checks. Never place a foreground server/supervisor before its dependent tests in a sequential shell command: readiness output does not mean that the starter exits. Only when no repository-owned lifecycle task exists may tightly coupled setup, test, and cleanup use a shell trap, with explicit readiness, bounded waits, supervision, and cleanup.
4043
+ 4. Each command has a unique short id, a non-interactive shell command, a workspace-relative cwd (usually "."), and dependsOn listing prerequisite ids EARLIER in the array. Split independent work so it can run concurrently. Inspect what each selected stage actually runs before adding another command: if the full test stage already runs the production build, omit a separate build command instead of repeating it. Shared build prerequisites run once; avoid overlapping aggregate commands that repeat the same validation. List each command once: never plan the same command twice, such as a cold and a warm run or a confirmation rerun, because every command already runs exactly once against the committed snapshot and the backend records any repeat as covered by its first copy. Preserve necessary build/test dependencies and setup/cleanup semantics. Prefer a repository-owned one-shot lifecycle task for service-backed checks. Never place a foreground server/supervisor before its dependent tests in a sequential shell command: readiness output does not mean that the starter exits. Only when no repository-owned lifecycle task exists may tightly coupled setup, test, and cleanup use a shell trap, with explicit readiness, bounded waits, supervision, and cleanup.
3971
4044
  5. resources names directories or shared services the command writes or consumes exclusively (for example a generated output directory, test database, or simulator). Commands with the same resource serialize. Use workspace: prefixes for workspace directories and services (for example workspace:dist); use ["workspace:*"] when interference within this workspace is uncertain. Use host: prefixes for genuinely host-shared resources (for example host:tcp:1422 or host:ios-simulator), and ["host:*"] only when host-wide interference is uncertain. Legacy unprefixed resource names and ["*"] retain host-wide scope for compatibility: do not use them for workspace-only uncertainty. Empty resources means you have verified parallel safety. weight=2 marks internally parallel or memory-heavy work and weight=1 lighter work; weight only sizes a command until its real CPU use has been measured, and the host starts commands with partial capacity when busy. Do not guess a command is lightweight. timeoutMs must be between 1000 and 7200000 and appropriate to this project. Never request watch mode, interactive input, background servers without cleanup, or a detached process. If .orkestrator-test-scheduler.json is present, its cooperativeCommands are eligible alternatives, NOT a checklist. Select only the required coverage, using each selected command verbatim from cwd="." without shell wrappers or compound commands; those runners reserve their own host capacity. Select repository-owned commandProfiles entries verbatim from cwd="." as separate commands too; do not synthesize setup/test/cleanup wrappers around an available one-shot task. Only cooperativeCommands reserve their own capacity; ordinary profiled commands are admitted by the review worker. Follow exact commandProfiles resource and coverage declarations when present. A profile covers list declares commands whose validation is already included: choose the covering command once instead of also scheduling its covered alternatives. Waiting for capacity does not count against timeoutMs.
3972
4045
  6. Read the final full HEAD commit SHA into headRef. Commands run against that HEAD. Generated or untracked files created during validation are recorded as environment state changes and do not block the review. Include actual missing prerequisites and coverage uncertainty in limitations. Do not include secrets or environment-variable values in the plan.
3973
4046
 
@@ -19837,9 +19910,11 @@ var init_storage_projects = __esm(() => {
19837
19910
 
19838
19911
  // ../../apps/backend/src/core/storage-config.ts
19839
19912
  import path20 from "path";
19913
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
19840
19914
  var StorageConfig;
19841
19915
  var init_storage_config = __esm(() => {
19842
19916
  init_coordinator_web_research();
19917
+ init_controller_settings();
19843
19918
  init_agent_settings();
19844
19919
  init_debug_logging();
19845
19920
  init_notification_sounds();
@@ -19849,7 +19924,55 @@ var init_storage_config = __esm(() => {
19849
19924
  init_storage_shared();
19850
19925
  init_storage_projects();
19851
19926
  StorageConfig = class StorageConfig extends StorageProjects {
19927
+ inheritedGlobalSettings = null;
19852
19928
  async loadConfig() {
19929
+ return this.withInheritedSettings(await this.loadOwnConfig());
19930
+ }
19931
+ announceInheritedSettingsChanged() {
19932
+ this.announce("config", "app");
19933
+ }
19934
+ withInheritedSettings(config) {
19935
+ const inherited = this.inheritedGlobalSettings?.() ?? null;
19936
+ if (!inherited)
19937
+ return config;
19938
+ const values = { ...config.global };
19939
+ const defaults = defaultConfig().global;
19940
+ for (const key of INHERITED_GLOBAL_SETTING_KEYS) {
19941
+ delete values[key];
19942
+ if (defaults[key] !== undefined)
19943
+ values[key] = defaults[key];
19944
+ }
19945
+ const global2 = { ...values, ...inherited };
19946
+ const agentSettings = normalizeAgentSettings(global2.agentSettings);
19947
+ const enabled = normalizeAgentPlatforms(global2.enabledAgentPlatforms, []);
19948
+ global2.agentSettings = enabled.length > 0 ? {
19949
+ ...agentSettings,
19950
+ defaultAgent: firstEnabledAgentPlatform(enabled, agentSettings.defaultAgent)
19951
+ } : agentSettings;
19952
+ return { ...config, global: global2 };
19953
+ }
19954
+ keepOwnInheritedValues(incoming, own) {
19955
+ const inherited = this.inheritedGlobalSettings?.() ?? null;
19956
+ if (!inherited)
19957
+ return incoming;
19958
+ const effective = this.withInheritedSettings({
19959
+ version: "",
19960
+ global: own,
19961
+ repositories: {}
19962
+ }).global;
19963
+ const next = { ...incoming };
19964
+ const ownValues = own;
19965
+ for (const key of INHERITED_GLOBAL_SETTING_KEYS) {
19966
+ if (!isDeepStrictEqual2(next[key], effective[key]))
19967
+ continue;
19968
+ if (ownValues[key] === undefined)
19969
+ delete next[key];
19970
+ else
19971
+ next[key] = ownValues[key];
19972
+ }
19973
+ return next;
19974
+ }
19975
+ async loadOwnConfig() {
19853
19976
  const configExists = await fs6.access(this.configFile()).then(() => true, () => false);
19854
19977
  const config = await this.loadJsonCached(this.configFile(), defaultConfig);
19855
19978
  const normalized = normalizePersistedConfig(config);
@@ -19887,17 +20010,19 @@ var init_storage_config = __esm(() => {
19887
20010
  async saveConfig(config, options2 = {}) {
19888
20011
  const validated = validateConfigReviewInstruction(config);
19889
20012
  await this.enqueueConfigMutation(async () => {
19890
- const current = options2.preserveCredentials ? await this.loadConfig() : null;
20013
+ const own = await this.loadOwnConfig();
20014
+ const current = options2.preserveCredentials ? own : null;
20015
+ const ownGlobal = this.keepOwnInheritedValues(validated.global, own.global);
19891
20016
  const next = current ? {
19892
20017
  ...validated,
19893
20018
  global: {
19894
- ...validated.global,
20019
+ ...ownGlobal,
19895
20020
  ...current.global.githubToken ? { githubToken: current.global.githubToken } : {},
19896
20021
  ...current.global.anthropicApiKey ? { anthropicApiKey: current.global.anthropicApiKey } : {},
19897
20022
  ...current.global.cursorApiKey ? { cursorApiKey: current.global.cursorApiKey } : {},
19898
20023
  ...current.global.openCodeZenApiKey ? { openCodeZenApiKey: current.global.openCodeZenApiKey } : {}
19899
20024
  }
19900
- } : validated;
20025
+ } : { ...validated, global: ownGlobal };
19901
20026
  await this.saveSensitiveJson(this.configFile(), next);
19902
20027
  });
19903
20028
  this.announce("config", "app");
@@ -19985,7 +20110,7 @@ var init_storage_config = __esm(() => {
19985
20110
  async saveDesktopConnections(desktopConnections) {
19986
20111
  const validated = parseStoredDesktopConnections(desktopConnections);
19987
20112
  await this.enqueueConfigMutation(async () => {
19988
- const config = await this.loadConfig();
20113
+ const config = await this.loadOwnConfig();
19989
20114
  config.desktopConnections = validated;
19990
20115
  await this.saveSensitiveJson(this.configFile(), config);
19991
20116
  });
@@ -19997,16 +20122,16 @@ var init_storage_config = __esm(() => {
19997
20122
  }
19998
20123
  async updateRepositoryConfig(projectId, repoConfig) {
19999
20124
  return this.enqueueConfigMutation(async () => {
20000
- const config = await this.loadConfig();
20125
+ const config = await this.loadOwnConfig();
20001
20126
  config.repositories[projectId] = { ...defaultRepositoryConfig(), ...repoConfig };
20002
20127
  await this.saveSensitiveJson(this.configFile(), config);
20003
20128
  this.announce("config", "app");
20004
- return config;
20129
+ return this.withInheritedSettings(config);
20005
20130
  });
20006
20131
  }
20007
20132
  async updateRepositorySettings(projectId, repoConfig) {
20008
20133
  return this.enqueueConfigMutation(async () => {
20009
- const config = await this.loadConfig();
20134
+ const config = await this.loadOwnConfig();
20010
20135
  const current = config.repositories[projectId] ?? defaultRepositoryConfig();
20011
20136
  const userSettings = { ...repoConfig };
20012
20137
  delete userSettings.lastEnvironmentType;
@@ -20021,12 +20146,12 @@ var init_storage_config = __esm(() => {
20021
20146
  };
20022
20147
  await this.saveSensitiveJson(this.configFile(), config);
20023
20148
  this.announce("config", "app");
20024
- return config;
20149
+ return this.withInheritedSettings(config);
20025
20150
  });
20026
20151
  }
20027
20152
  async patchRepositoryConfig(projectId, updates) {
20028
20153
  return this.enqueueConfigMutation(async () => {
20029
- const config = await this.loadConfig();
20154
+ const config = await this.loadOwnConfig();
20030
20155
  const current = config.repositories[projectId] ?? defaultRepositoryConfig();
20031
20156
  config.repositories[projectId] = {
20032
20157
  ...defaultRepositoryConfig(),
@@ -20035,7 +20160,7 @@ var init_storage_config = __esm(() => {
20035
20160
  };
20036
20161
  await this.saveSensitiveJson(this.configFile(), config);
20037
20162
  this.announce("config", "app");
20038
- return config;
20163
+ return this.withInheritedSettings(config);
20039
20164
  });
20040
20165
  }
20041
20166
  async updateGlobalConfig(globalConfig, options2 = {}) {
@@ -20091,7 +20216,7 @@ var init_storage_config = __esm(() => {
20091
20216
  }
20092
20217
  };
20093
20218
  return this.enqueueConfigMutation(async () => {
20094
- const config = await this.loadConfig();
20219
+ const config = await this.loadOwnConfig();
20095
20220
  validated.coordinatorProviderTierDefaultVersion = Math.max(validated.coordinatorProviderTierDefaultVersion ?? COORDINATOR_PROVIDER_TIER_DEFAULT_VERSION, normalizeCoordinatorProviderTierDefaultVersion(config.global.coordinatorProviderTierDefaultVersion));
20096
20221
  if (validated.mcpManagement === undefined && config.global.mcpManagement !== undefined)
20097
20222
  validated.mcpManagement = config.global.mcpManagement;
@@ -20105,21 +20230,29 @@ var init_storage_config = __esm(() => {
20105
20230
  validated.githubCliReminderDismissed = true;
20106
20231
  else
20107
20232
  delete validated.githubCliReminderDismissed;
20233
+ const ownGlobal = this.keepOwnInheritedValues(validated, config.global);
20234
+ if (options2.preserveInheritedSettings) {
20235
+ for (const key of INHERITED_GLOBAL_SETTING_KEYS) {
20236
+ delete ownGlobal[key];
20237
+ if (config.global[key] !== undefined)
20238
+ Object.assign(ownGlobal, { [key]: config.global[key] });
20239
+ }
20240
+ }
20108
20241
  config.global = options2.preserveCredentials ? {
20109
- ...validated,
20242
+ ...ownGlobal,
20110
20243
  ...config.global.githubToken ? { githubToken: config.global.githubToken } : {},
20111
20244
  ...config.global.anthropicApiKey ? { anthropicApiKey: config.global.anthropicApiKey } : {},
20112
20245
  ...config.global.cursorApiKey ? { cursorApiKey: config.global.cursorApiKey } : {},
20113
20246
  ...config.global.openCodeZenApiKey ? { openCodeZenApiKey: config.global.openCodeZenApiKey } : {}
20114
- } : validated;
20247
+ } : ownGlobal;
20115
20248
  await this.saveSensitiveJson(this.configFile(), config);
20116
20249
  this.announce("config", "app");
20117
- return config;
20250
+ return this.withInheritedSettings(config);
20118
20251
  });
20119
20252
  }
20120
20253
  async updateWorktreeLocations(locations) {
20121
20254
  return this.enqueueConfigMutation(async () => {
20122
- const config = await this.loadConfig();
20255
+ const config = await this.loadOwnConfig();
20123
20256
  const next = {
20124
20257
  ...locations.environments ? { environments: locations.environments } : {},
20125
20258
  ...locations.dev ? { dev: locations.dev } : {}
@@ -20130,89 +20263,89 @@ var init_storage_config = __esm(() => {
20130
20263
  delete config.global.worktreeLocations;
20131
20264
  await this.saveSensitiveJson(this.configFile(), config);
20132
20265
  this.announce("config", "app");
20133
- return config;
20266
+ return this.withInheritedSettings(config);
20134
20267
  });
20135
20268
  }
20136
20269
  async updateMcpManagementRollout(settings) {
20137
20270
  return this.enqueueConfigMutation(async () => {
20138
- const config = await this.loadConfig();
20271
+ const config = await this.loadOwnConfig();
20139
20272
  config.global = { ...config.global, mcpManagement: settings };
20140
20273
  await this.saveSensitiveJson(this.configFile(), config);
20141
20274
  this.announce("config", "app");
20142
- return config;
20275
+ return this.withInheritedSettings(config);
20143
20276
  });
20144
20277
  }
20145
20278
  async updateNotificationSoundSettings(settings) {
20146
20279
  const notificationSounds = normalizeNotificationSoundSettings(settings);
20147
20280
  return this.enqueueConfigMutation(async () => {
20148
- const config = await this.loadConfig();
20281
+ const config = await this.loadOwnConfig();
20149
20282
  config.global = {
20150
20283
  ...config.global,
20151
20284
  notificationSounds
20152
20285
  };
20153
20286
  await this.saveSensitiveJson(this.configFile(), config);
20154
20287
  this.announce("config", "app");
20155
- return config;
20288
+ return this.withInheritedSettings(config);
20156
20289
  });
20157
20290
  }
20158
20291
  async setGitHubCliReminderDismissed(dismissed) {
20159
20292
  return this.enqueueConfigMutation(async () => {
20160
- const config = await this.loadConfig();
20293
+ const config = await this.loadOwnConfig();
20161
20294
  if (dismissed)
20162
20295
  config.global.githubCliReminderDismissed = true;
20163
20296
  else
20164
20297
  delete config.global.githubCliReminderDismissed;
20165
20298
  await this.saveSensitiveJson(this.configFile(), config);
20166
20299
  this.announce("config", "app");
20167
- return config;
20300
+ return this.withInheritedSettings(config);
20168
20301
  });
20169
20302
  }
20170
20303
  async setGitHubToken(token) {
20171
20304
  return this.enqueueConfigMutation(async () => {
20172
- const config = await this.loadConfig();
20305
+ const config = await this.loadOwnConfig();
20173
20306
  if (token === null)
20174
20307
  delete config.global.githubToken;
20175
20308
  else
20176
20309
  config.global.githubToken = token;
20177
20310
  await this.saveSensitiveJson(this.configFile(), config);
20178
20311
  this.announce("config", "app");
20179
- return config;
20312
+ return this.withInheritedSettings(config);
20180
20313
  });
20181
20314
  }
20182
20315
  async setCursorApiKey(apiKey) {
20183
20316
  return this.enqueueConfigMutation(async () => {
20184
- const config = await this.loadConfig();
20317
+ const config = await this.loadOwnConfig();
20185
20318
  if (apiKey === null)
20186
20319
  delete config.global.cursorApiKey;
20187
20320
  else
20188
20321
  config.global.cursorApiKey = apiKey;
20189
20322
  await this.saveSensitiveJson(this.configFile(), config);
20190
20323
  this.announce("config", "app");
20191
- return config;
20324
+ return this.withInheritedSettings(config);
20192
20325
  });
20193
20326
  }
20194
20327
  async setAnthropicApiKey(apiKey) {
20195
20328
  return this.enqueueConfigMutation(async () => {
20196
- const config = await this.loadConfig();
20329
+ const config = await this.loadOwnConfig();
20197
20330
  if (apiKey === null)
20198
20331
  delete config.global.anthropicApiKey;
20199
20332
  else
20200
20333
  config.global.anthropicApiKey = apiKey;
20201
20334
  await this.saveSensitiveJson(this.configFile(), config);
20202
20335
  this.announce("config", "app");
20203
- return config;
20336
+ return this.withInheritedSettings(config);
20204
20337
  });
20205
20338
  }
20206
20339
  async setOpenCodeZenApiKey(apiKey) {
20207
20340
  return this.enqueueConfigMutation(async () => {
20208
- const config = await this.loadConfig();
20341
+ const config = await this.loadOwnConfig();
20209
20342
  if (apiKey === null)
20210
20343
  delete config.global.openCodeZenApiKey;
20211
20344
  else
20212
20345
  config.global.openCodeZenApiKey = apiKey;
20213
20346
  await this.saveSensitiveJson(this.configFile(), config);
20214
20347
  this.announce("config", "app");
20215
- return config;
20348
+ return this.withInheritedSettings(config);
20216
20349
  });
20217
20350
  }
20218
20351
  };
@@ -26771,6 +26904,7 @@ var init_storage_preview_services = __esm(() => {
26771
26904
  // ../../apps/backend/src/core/storage-nodes.ts
26772
26905
  var StorageNodes;
26773
26906
  var init_storage_nodes = __esm(() => {
26907
+ init_controller_settings();
26774
26908
  init_node_store_format();
26775
26909
  init_storage_preview_services();
26776
26910
  StorageNodes = class StorageNodes extends StoragePreviewServices {
@@ -26791,6 +26925,21 @@ var init_storage_nodes = __esm(() => {
26791
26925
  portalLayoutFile() {
26792
26926
  return this.file("portal-layout.json");
26793
26927
  }
26928
+ controllerSettingsFile() {
26929
+ return this.file("controller-settings.json");
26930
+ }
26931
+ async getControllerSettings() {
26932
+ return parseControllerSettingsStore(await this.loadJson(this.controllerSettingsFile(), () => null));
26933
+ }
26934
+ async mutateControllerSettings(mutate) {
26935
+ return this.serializeMutation(this.controllerSettingsFile(), "controller settings", async () => {
26936
+ const current = parseControllerSettingsStore(await this.loadJson(this.controllerSettingsFile(), () => null));
26937
+ const { next, result } = mutate(structuredClone(current));
26938
+ if (next)
26939
+ await this.saveJson(this.controllerSettingsFile(), { ...next, version: 1 });
26940
+ return result;
26941
+ });
26942
+ }
26794
26943
  async getPortalLayout() {
26795
26944
  return parsePortalLayout(await this.loadJson(this.portalLayoutFile(), () => null));
26796
26945
  }
@@ -27195,7 +27344,7 @@ var init_storage_public_operations = __esm(() => {
27195
27344
  }
27196
27345
  async patchRepositorySettingsAtRevision(projectId, expectedRevision, apply) {
27197
27346
  const next = await this.enqueueConfigMutation(async () => {
27198
- const config = await this.loadConfig();
27347
+ const config = await this.loadOwnConfig();
27199
27348
  const stored = config.repositories[projectId];
27200
27349
  if (expectedRevision !== undefined && repositorySettingsRevision(stored) !== expectedRevision) {
27201
27350
  throw new PublicActionError("revision-conflict", "The project settings changed since they were read; re-read and apply the change again", { details: { currentRevision: repositorySettingsRevision(stored) } });
@@ -108134,10 +108283,16 @@ class TmuxSession {
108134
108283
  forceNextObservation = false;
108135
108284
  observationInFlight;
108136
108285
  inputMutex = new AsyncMutex;
108137
- constructor(environmentId, tabId, backend, runtimeRootPrefix, resumeSessionId, claudeCommand) {
108286
+ pollTick;
108287
+ now;
108288
+ sleep;
108289
+ constructor(environmentId, tabId, backend, runtimeRootPrefix, resumeSessionId, claudeCommand, timing = {}) {
108138
108290
  this.environmentId = environmentId;
108139
108291
  this.tabId = tabId;
108140
108292
  this.backend = backend;
108293
+ this.pollTick = timing.pollTick ?? ((intervalMs) => delay(intervalMs));
108294
+ this.now = timing.now ?? (() => Date.now());
108295
+ this.sleep = timing.sleep ?? ((delayMs) => delay(delayMs));
108141
108296
  this.resumed = resumeSessionId !== undefined;
108142
108297
  this.firstPromptClaimed = this.resumed;
108143
108298
  this.sessionId = resumeSessionId ?? randomUUID38();
@@ -108397,7 +108552,7 @@ ${help.stderr}`;
108397
108552
  }
108398
108553
  async sendInitialPromptWhenReady(prompt, launchedNew) {
108399
108554
  if (launchedNew)
108400
- await delay(800);
108555
+ await this.sleep(800);
108401
108556
  await this.waitForTuiInputReady();
108402
108557
  await this.submit(prompt);
108403
108558
  this.setBusyState(true);
@@ -108409,17 +108564,27 @@ ${help.stderr}`;
108409
108564
  this.busyStartedAt = busy ? Date.now() : null;
108410
108565
  }
108411
108566
  async waitForTuiInputReady() {
108412
- const deadline = Date.now() + 10 * 60 * 1000;
108413
- while (Date.now() < deadline) {
108567
+ const deadline = this.now() + 10 * 60 * 1000;
108568
+ while (this.now() < deadline) {
108414
108569
  if (!await this.tmuxAlive().catch(() => false)) {
108415
108570
  throw new Error("tmux session stopped before Claude was ready");
108416
108571
  }
108417
- const snapshot = await this.capturePane().catch(() => "");
108572
+ let snapshot;
108573
+ try {
108574
+ snapshot = await this.capturePane();
108575
+ } catch {
108576
+ await this.sleep(500);
108577
+ continue;
108578
+ }
108418
108579
  if (paneHasClaudeExited(snapshot))
108419
108580
  throw new Error("Claude exited before the initial prompt was sent");
108420
- if (!paneHasSelectionPrompt(snapshot))
108581
+ if (!paneHasSelectionPrompt(snapshot)) {
108582
+ if (!await this.tmuxAlive().catch(() => false)) {
108583
+ throw new Error("tmux session stopped before Claude was ready");
108584
+ }
108421
108585
  return;
108422
- await delay(500);
108586
+ }
108587
+ await this.sleep(500);
108423
108588
  }
108424
108589
  throw new Error("timed out waiting for Claude to leave its startup prompt");
108425
108590
  }
@@ -108434,7 +108599,7 @@ ${help.stderr}`;
108434
108599
  (async () => {
108435
108600
  try {
108436
108601
  while (!this.stopRequested) {
108437
- await delay(POLL_INTERVAL_MS);
108602
+ await this.pollTick(POLL_INTERVAL_MS);
108438
108603
  if (this.stopRequested)
108439
108604
  break;
108440
108605
  tick += 1;
@@ -108887,11 +109052,11 @@ ${help.stderr}`;
108887
109052
  }
108888
109053
  }
108889
109054
  async waitForPaneFastMode(target, initialSnapshot, command) {
108890
- const deadline = Date.now() + FAST_MODE_SWITCH_TIMEOUT_MS;
108891
- while (Date.now() < deadline) {
109055
+ const deadline = this.now() + FAST_MODE_SWITCH_TIMEOUT_MS;
109056
+ while (this.now() < deadline) {
108892
109057
  const snapshot = await this.capturePane();
108893
109058
  if (snapshot === initialSnapshot) {
108894
- await delay(FAST_MODE_POLL_MS);
109059
+ await this.sleep(FAST_MODE_POLL_MS);
108895
109060
  continue;
108896
109061
  }
108897
109062
  if (paneHasClaudeExited(snapshot)) {
@@ -108907,7 +109072,7 @@ ${help.stderr}`;
108907
109072
  const observed = fastModeFromPane(response);
108908
109073
  if (observed === target)
108909
109074
  return;
108910
- await delay(FAST_MODE_POLL_MS);
109075
+ await this.sleep(FAST_MODE_POLL_MS);
108911
109076
  }
108912
109077
  throw new Error(`Claude did not confirm fast mode ${target ? "on" : "off"}`);
108913
109078
  }
@@ -108944,16 +109109,16 @@ ${help.stderr}`;
108944
109109
  return permissionModeFromPane(snapshot);
108945
109110
  }
108946
109111
  async waitForPanePermissionMode(targetMode) {
108947
- const deadline = Date.now() + PERMISSION_MODE_SWITCH_TIMEOUT_MS;
109112
+ const deadline = this.now() + PERMISSION_MODE_SWITCH_TIMEOUT_MS;
108948
109113
  let lastObservedMode;
108949
- while (Date.now() < deadline) {
109114
+ while (this.now() < deadline) {
108950
109115
  const observedMode = await this.capturePanePermissionMode();
108951
109116
  if (observedMode) {
108952
109117
  lastObservedMode = observedMode;
108953
109118
  if (observedMode === targetMode)
108954
109119
  return observedMode;
108955
109120
  }
108956
- await delay(PERMISSION_MODE_POLL_MS);
109121
+ await this.sleep(PERMISSION_MODE_POLL_MS);
108957
109122
  }
108958
109123
  const observed = lastObservedMode ? `; observed ${lastObservedMode}` : "";
108959
109124
  throw new Error(`Claude did not enter ${targetMode}${observed}`);
@@ -108971,20 +109136,20 @@ ${help.stderr}`;
108971
109136
  });
108972
109137
  }
108973
109138
  async waitForCommandIdle() {
108974
- const started = Date.now();
109139
+ const started = this.now();
108975
109140
  const deadline = started + COMMAND_IDLE_TIMEOUT_MS;
108976
109141
  const noHookDeadline = started + COMMAND_NO_HOOK_SETTLE_MS;
108977
109142
  let sawBusy = this.busy;
108978
- while (Date.now() < deadline) {
109143
+ while (this.now() < deadline) {
108979
109144
  if (this.busy) {
108980
109145
  sawBusy = true;
108981
109146
  } else if (sawBusy) {
108982
- await delay(COMMAND_AFTER_IDLE_SETTLE_MS);
109147
+ await this.sleep(COMMAND_AFTER_IDLE_SETTLE_MS);
108983
109148
  return;
108984
- } else if (Date.now() >= noHookDeadline) {
109149
+ } else if (this.now() >= noHookDeadline) {
108985
109150
  return;
108986
109151
  }
108987
- await delay(50);
109152
+ await this.sleep(50);
108988
109153
  }
108989
109154
  console.warn("[tmux] timed out waiting for Claude slash command to settle", this.tmuxSession);
108990
109155
  }
@@ -109255,12 +109420,12 @@ function resolveBundledClaudePath(context) {
109255
109420
  function resolvePinnedClaudeCommand(context, backend) {
109256
109421
  return backend.kind === "container" ? undefined : resolveBundledClaudePath(context);
109257
109422
  }
109258
- async function getOrCreateSession(context, environmentId, tabId, resumeSessionId) {
109423
+ async function getOrCreateSession(context, environmentId, tabId, resumeSessionId, timing) {
109259
109424
  const existing = tmuxManager.get(environmentId, tabId);
109260
109425
  if (existing)
109261
109426
  return existing;
109262
109427
  const backend = await resolveBackend(environmentId, context);
109263
- const session = new TmuxSession(environmentId, tabId, backend, runtimeRootPrefixForContext(context), resumeSessionId, resolvePinnedClaudeCommand(context, backend));
109428
+ const session = new TmuxSession(environmentId, tabId, backend, runtimeRootPrefixForContext(context), resumeSessionId, resolvePinnedClaudeCommand(context, backend), timing);
109264
109429
  tmuxManager.insert(environmentId, tabId, session);
109265
109430
  return session;
109266
109431
  }
@@ -109982,7 +110147,7 @@ function registerTmuxBackendCommands(registerCommand, options2 = {}) {
109982
110147
  else
109983
110148
  await killOrphanSession(context, envId, tab);
109984
110149
  }
109985
- const session = await getOrCreateSession(context, envId, tab, resumeId);
110150
+ const session = await getOrCreateSession(context, envId, tab, resumeId, options2.sessionTiming);
109986
110151
  if (firstPrompt)
109987
110152
  session.claimFirstPromptForNaming();
109988
110153
  await installWorkspaceHooks(session.backend, session.workspaceHookPaths);
@@ -112637,6 +112802,19 @@ async function main() {
112637
112802
  if (index !== candidate.index && cmd.cwd === "." && !coveredBy.has(index) && !Array.from(coveredBy.values()).includes(index) && candidate.covers.includes(cmd.command) && !dependsOn(candidate.index, index) && cmd.dependsOn.every(id => dependsOn(candidate.index, commands.findIndex(dependency => dependency.id === id)))) coveredBy.set(index, candidate.index);
112638
112803
  }
112639
112804
  }
112805
+ // A repeated identical command (same command line and directory) adds no
112806
+ // evidence, only its cost again. The first copy covers it, unless the copy
112807
+ // waits on setup the first does not, which may make the rerun meaningful.
112808
+ const repeats = new Set();
112809
+ for (const [index, cmd] of commands.entries()) {
112810
+ if (coveredBy.has(index)) continue;
112811
+ const first = commands.findIndex(other => other.command === cmd.command && other.cwd === cmd.cwd);
112812
+ if (first === index) continue;
112813
+ const prerequisites = cmd.dependsOn.map(id => commands.findIndex(dependency => dependency.id === id));
112814
+ if (!prerequisites.every(dependency => dependency === first || dependsOn(first, dependency))) continue;
112815
+ coveredBy.set(index, coveredBy.get(first) ?? first);
112816
+ repeats.add(index);
112817
+ }
112640
112818
  const pending = new Set(commands.map((_, i) => i));
112641
112819
  while ((pending.size || tasks.size) && !stopping) {
112642
112820
  for (const index of Array.from(pending)) {
@@ -112652,7 +112830,7 @@ async function main() {
112652
112830
  if (coveringIndex !== undefined) {
112653
112831
  const covering = run.results[coveringIndex];
112654
112832
  if (["pending", "queued", "running"].includes(covering.status)) continue;
112655
- Object.assign(run.results[index], { status: covering.status === "passed" ? "skipped" : "incomplete", limitation: covering.status === "passed" ? "Covered by " + covering.id + " (repository-declared coverage)" : "Covering validation " + covering.id + " did not pass" });
112833
+ Object.assign(run.results[index], { status: covering.status === "passed" ? "skipped" : "incomplete", limitation: covering.status === "passed" ? "Covered by " + covering.id + (repeats.has(index) ? " (identical command)" : " (repository-declared coverage)") : "Covering validation " + covering.id + " did not pass" });
112656
112834
  pending.delete(index);
112657
112835
  persist();
112658
112836
  continue;
@@ -113178,11 +113356,13 @@ async function main() {
113178
113356
  let timedOut = false;
113179
113357
  let cancelled = false;
113180
113358
  let killTimer = null;
113359
+ // Tests may only shorten the TERM-to-KILL grace; production uses 5s.
113360
+ const killGraceMs = Math.max(100, Math.min(5000, Number(process.env.ORKESTRATOR_TEST_EXEC_KILL_GRACE_MS) || 5000));
113181
113361
  const terminate = () => {
113182
113362
  if (!pgid) return;
113183
113363
  signalGroup(pgid, "SIGTERM");
113184
113364
  if (!killTimer) {
113185
- killTimer = setTimeout(() => signalGroup(pgid, "SIGKILL"), 5000);
113365
+ killTimer = setTimeout(() => signalGroup(pgid, "SIGKILL"), killGraceMs);
113186
113366
  }
113187
113367
  };
113188
113368
  const pump = (stream, fd, name) => stream.on("data", (chunk) => {
@@ -129964,6 +130144,7 @@ var NODE_EXCLUDED_COMMANDS = new Set([
129964
130144
  "detach_controller_here",
129965
130145
  "allow_controller",
129966
130146
  "list_controllers",
130147
+ "sync_controller_settings",
129967
130148
  "set_command_node_enabled",
129968
130149
  "create_client_grant",
129969
130150
  "renew_client_grant",
@@ -130481,9 +130662,113 @@ class NodePortal {
130481
130662
  }
130482
130663
  }
130483
130664
 
130665
+ // ../../apps/backend/src/core/index.ts
130666
+ init_controller_settings();
130667
+
130484
130668
  // ../../apps/backend/src/core/nodes/node-service.ts
130485
130669
  init_command_nodes();
130486
130670
  init_federation();
130671
+ init_controller_settings();
130672
+
130673
+ // ../../apps/backend/src/core/nodes/node-controller-settings.ts
130674
+ init_controller_settings();
130675
+ init_federation();
130676
+ import { isDeepStrictEqual } from "util";
130677
+
130678
+ class LeafControllerSettings {
130679
+ options;
130680
+ store = { version: 1, entries: [] };
130681
+ mutations = Promise.resolve();
130682
+ serialize(run) {
130683
+ const next = this.mutations.then(run);
130684
+ this.mutations = next.catch(() => {
130685
+ return;
130686
+ });
130687
+ return next;
130688
+ }
130689
+ constructor(options2) {
130690
+ this.options = options2;
130691
+ }
130692
+ async start() {
130693
+ this.store = await this.options.storage.getControllerSettings();
130694
+ }
130695
+ async receive(args) {
130696
+ return this.serialize(() => this.receiveSettings(args));
130697
+ }
130698
+ async receiveSettings(args) {
130699
+ const controllerId = args.controllerId;
130700
+ if (!isBackendId(controllerId))
130701
+ throw new Error("Expected controllerId to be a node identity.");
130702
+ if (this.options.attachedName(controllerId) === null) {
130703
+ throw new Error("That command node is not attached to this node.");
130704
+ }
130705
+ const settings = parseInheritedGlobalSettings(args.settings);
130706
+ const before = this.source();
130707
+ const changedAt = new Date(this.options.now()).toISOString();
130708
+ const changed = await this.options.storage.mutateControllerSettings((current) => {
130709
+ const existing = current.entries.find((entry) => entry.controllerId === controllerId);
130710
+ if (existing && isDeepStrictEqual(existing.settings, settings)) {
130711
+ return { next: null, result: { value: current, changed: false } };
130712
+ }
130713
+ const value = {
130714
+ version: 1,
130715
+ entries: [
130716
+ ...current.entries.filter((entry) => entry.controllerId !== controllerId),
130717
+ { controllerId, settings, changedAt }
130718
+ ]
130719
+ };
130720
+ return { next: value, result: { value, changed: true } };
130721
+ });
130722
+ this.store = changed.value;
130723
+ if (changed.changed || !isDeepStrictEqual(before, this.source()))
130724
+ this.options.onChange();
130725
+ return { applied: this.source()?.controllerId === controllerId };
130726
+ }
130727
+ async forget(controllerId) {
130728
+ return this.serialize(() => this.forgetSettings(controllerId));
130729
+ }
130730
+ async forgetSettings(controllerId) {
130731
+ const hadEntry = this.store.entries.some((entry) => entry.controllerId === controllerId);
130732
+ this.store = {
130733
+ ...this.store,
130734
+ entries: this.store.entries.filter((entry) => entry.controllerId !== controllerId)
130735
+ };
130736
+ if (hadEntry)
130737
+ this.options.onChange();
130738
+ const removed = await this.options.storage.mutateControllerSettings((current) => {
130739
+ const entries = current.entries.filter((entry) => entry.controllerId !== controllerId);
130740
+ if (entries.length === current.entries.length) {
130741
+ return { next: null, result: { value: current, removed: false } };
130742
+ }
130743
+ const value = { version: 1, entries };
130744
+ return { next: value, result: { value, removed: true } };
130745
+ });
130746
+ this.store = removed.value;
130747
+ }
130748
+ current() {
130749
+ return this.followed()?.settings ?? null;
130750
+ }
130751
+ source() {
130752
+ const entry = this.followed();
130753
+ if (!entry)
130754
+ return;
130755
+ return {
130756
+ controllerId: entry.controllerId,
130757
+ displayName: this.options.attachedName(entry.controllerId) ?? "",
130758
+ changedAt: entry.changedAt
130759
+ };
130760
+ }
130761
+ followed() {
130762
+ let best = null;
130763
+ for (const entry of this.store.entries) {
130764
+ if (this.options.attachedName(entry.controllerId) === null)
130765
+ continue;
130766
+ if (!best || entry.changedAt >= best.changedAt)
130767
+ best = entry;
130768
+ }
130769
+ return best;
130770
+ }
130771
+ }
130487
130772
 
130488
130773
  // ../../apps/backend/src/core/nodes/node-ssh-prompts.ts
130489
130774
  init_command_nodes();
@@ -130562,12 +130847,25 @@ class NodeService {
130562
130847
  changeListeners = new Set;
130563
130848
  detachListeners = new Set;
130564
130849
  changeQueued = false;
130850
+ inheritedSettings;
130851
+ sentSettings = new WeakMap;
130852
+ unsupportedSettings = new WeakMap;
130853
+ settingsSends = new WeakMap;
130565
130854
  constructor(options2) {
130566
130855
  this.options = options2;
130567
130856
  this.storage = options2.storage;
130568
130857
  this.emit = options2.emit;
130569
130858
  this.now = options2.now ?? Date.now;
130570
130859
  this.prompts = new NodeSshPrompts(options2.emit);
130860
+ this.inheritedSettings = new LeafControllerSettings({
130861
+ storage: options2.storage,
130862
+ now: this.now,
130863
+ attachedName: (controllerId) => this.attachments.controllers.find((entry) => entry.controllerId === controllerId)?.displayName ?? null,
130864
+ onChange: () => {
130865
+ this.options.onInheritedSettingsChanged?.();
130866
+ this.emitStatus();
130867
+ }
130868
+ });
130571
130869
  }
130572
130870
  async start() {
130573
130871
  if (this.started)
@@ -130576,6 +130874,7 @@ class NodeService {
130576
130874
  await this.self();
130577
130875
  this.attachments = await this.storage.getNodeAttachments();
130578
130876
  this.registry = await this.storage.getNodeRegistry();
130877
+ await this.inheritedSettings.start();
130579
130878
  this.lastMode = this.mode();
130580
130879
  this.applyMode();
130581
130880
  this.retryDetaches();
@@ -130628,6 +130927,9 @@ class NodeService {
130628
130927
  const recent = this.mostRecentControllerId();
130629
130928
  if (recent)
130630
130929
  status.mostRecentControllerId = recent;
130930
+ const inherited = this.inheritedSettings.source();
130931
+ if (inherited)
130932
+ status.inheritedSettingsFrom = inherited;
130631
130933
  return status;
130632
130934
  }
130633
130935
  mode() {
@@ -130702,6 +131004,9 @@ class NodeService {
130702
131004
  if (registry.nodes.some((node) => node.nodeId === parsed.controllerId)) {
130703
131005
  throw new NodeRefusalError(NODE_REFUSAL_CODES.controllerIsSubordinate, "That command node is registered under this node. Forget it here first; only two levels are allowed.");
130704
131006
  }
131007
+ if (!this.attachments.controllers.some((entry) => entry.controllerId === parsed.controllerId)) {
131008
+ await this.inheritedSettings.forget(parsed.controllerId);
131009
+ }
130705
131010
  const timestamp = new Date(this.now()).toISOString();
130706
131011
  const next = await this.storage.mutateNodeAttachments((current) => {
130707
131012
  if (current.detachedControllers.some((entry) => entry.controllerId === parsed.controllerId)) {
@@ -130765,6 +131070,7 @@ class NodeService {
130765
131070
  this.applyMode();
130766
131071
  this.emitStatus();
130767
131072
  }
131073
+ await this.inheritedSettings.forget(controllerId);
130768
131074
  return { detached: result.detached };
130769
131075
  }
130770
131076
  listControllers() {
@@ -130865,6 +131171,51 @@ class NodeService {
130865
131171
  console.warn("[nodes] Could not record the latest controller contact:", errorCode(error));
130866
131172
  }
130867
131173
  }
131174
+ syncSettingsToLeaves() {
131175
+ for (const link of this.links.values())
131176
+ this.syncSettingsTo(link);
131177
+ }
131178
+ syncSettingsTo(link) {
131179
+ const source = this.options.inheritableSettings;
131180
+ if (!source || !link.connected || this.isLeaf())
131181
+ return;
131182
+ const nodeId = link.nodeId;
131183
+ const previous = this.settingsSends.get(link) ?? Promise.resolve();
131184
+ const next = previous.then(async () => {
131185
+ if (!link.connected || this.links.get(nodeId) !== link)
131186
+ return;
131187
+ const epoch = link.view.epoch;
131188
+ if (this.unsupportedSettings.get(link) === epoch)
131189
+ return;
131190
+ const settings = await source();
131191
+ if (!link.connected || this.links.get(nodeId) !== link || link.view.epoch !== epoch)
131192
+ return;
131193
+ const serialized = JSON.stringify(settings);
131194
+ const sent = this.sentSettings.get(link);
131195
+ if (sent && sent.epoch === epoch && sent.settings === serialized)
131196
+ return;
131197
+ try {
131198
+ await link.invoke(SYNC_CONTROLLER_SETTINGS_COMMAND, {
131199
+ controllerId: this.selfId,
131200
+ settings
131201
+ });
131202
+ this.sentSettings.set(link, { epoch, settings: serialized });
131203
+ } catch (error) {
131204
+ if (error instanceof Error && error.message === `Unknown command: ${SYNC_CONTROLLER_SETTINGS_COMMAND}`) {
131205
+ this.unsupportedSettings.set(link, epoch);
131206
+ }
131207
+ console.warn("[nodes] Could not send settings to a node:", errorCode(error));
131208
+ }
131209
+ });
131210
+ const settled = next.catch(() => {
131211
+ return;
131212
+ });
131213
+ this.settingsSends.set(link, settled);
131214
+ settled.then(() => {
131215
+ if (this.settingsSends.get(link) === settled)
131216
+ this.settingsSends.delete(link);
131217
+ });
131218
+ }
130868
131219
  async listNodes() {
130869
131220
  const registry = await this.currentRegistry();
130870
131221
  return {
@@ -131216,7 +131567,10 @@ class NodeService {
131216
131567
  ssh: this.sshFor(nodeId),
131217
131568
  ...this.options.timers ? { timers: this.options.timers } : {},
131218
131569
  ...this.options.heartbeatMs ? { heartbeatMs: this.options.heartbeatMs } : {},
131219
- onChange: () => this.publishNodes(),
131570
+ onChange: () => {
131571
+ this.publishNodes();
131572
+ this.syncSettingsTo(link);
131573
+ },
131220
131574
  onVerified: (update) => void this.recordVerified(link.nodeId, update),
131221
131575
  onIdentity: (installationId) => this.adoptIdentity(link.nodeId, installationId)
131222
131576
  });
@@ -158040,6 +158394,7 @@ function registerBackendProfileCommands(register) {
158040
158394
 
158041
158395
  // ../../apps/backend/src/core/commands-registry-nodes.ts
158042
158396
  init_command_nodes();
158397
+ init_controller_settings();
158043
158398
  init_federation();
158044
158399
  init_commands_validation();
158045
158400
  init_node_files();
@@ -158098,6 +158453,10 @@ function registerNodeCommands(register) {
158098
158453
  assertOnlyKeys(args, ["controllerId"], "arguments");
158099
158454
  return nodes(context).detachController(args.controllerId, { byUser: true });
158100
158455
  });
158456
+ register(SYNC_CONTROLLER_SETTINGS_COMMAND, async (args, context) => {
158457
+ assertOnlyKeys(args, ["controllerId", "settings"], "arguments");
158458
+ return nodes(context).inheritedSettings.receive(args);
158459
+ });
158101
158460
  register("allow_controller", async (args, context) => {
158102
158461
  assertOnlyKeys(args, ["controllerId"], "arguments");
158103
158462
  return nodes(context).allowController(args.controllerId);
@@ -177662,8 +178021,13 @@ function registerProjectCommands(register, dependencies) {
177662
178021
  const force = args.force === undefined ? false : asRequiredBoolean(args.force, "force");
177663
178022
  return planUsageReader(context, platform, { force });
177664
178023
  });
177665
- register("update_global_config", async ({ global: global2 }, { storage }) => {
177666
- const updated = await storage.updateGlobalConfig(stripRendererCredentials(asRecord3(global2, "global")), { preserveCredentials: true });
178024
+ register("update_global_config", async ({ global: global2, preserveInheritedSettings }, { storage }) => {
178025
+ const updated = await storage.updateGlobalConfig(stripRendererCredentials(asRecord3(global2, "global")), {
178026
+ preserveCredentials: true,
178027
+ ...preserveInheritedSettings === undefined ? {} : {
178028
+ preserveInheritedSettings: asRequiredBoolean(preserveInheritedSettings, "preserveInheritedSettings")
178029
+ }
178030
+ });
177667
178031
  configureTerminalHistoryRetention({
177668
178032
  enabled: updated.global.terminalHistoryEnabled,
177669
178033
  sessionMb: updated.global.terminalHistoryRetentionMb,
@@ -228563,9 +228927,12 @@ class OrkestratorBackend {
228563
228927
  ...options2.nodeLinks?.fetchImpl ? { fetchImpl: options2.nodeLinks.fetchImpl } : {},
228564
228928
  ...options2.nodeLinks?.timers ? { timers: options2.nodeLinks.timers } : {},
228565
228929
  ...options2.nodeLinks?.heartbeatMs ? { heartbeatMs: options2.nodeLinks.heartbeatMs } : {},
228566
- ...options2.nodeLinks?.createSsh ?? createNodeSshFactory(options2.dataDir) ? { createSsh: options2.nodeLinks?.createSsh ?? createNodeSshFactory(options2.dataDir) } : {}
228930
+ ...options2.nodeLinks?.createSsh ?? createNodeSshFactory(options2.dataDir) ? { createSsh: options2.nodeLinks?.createSsh ?? createNodeSshFactory(options2.dataDir) } : {},
228931
+ inheritableSettings: async () => pickInheritedGlobalSettings((await storage.loadConfig()).global),
228932
+ onInheritedSettingsChanged: () => storage.announceInheritedSettingsChanged()
228567
228933
  });
228568
228934
  context.nodes = this.nodes;
228935
+ storage.inheritedGlobalSettings = () => this.nodes.inheritedSettings.current();
228569
228936
  this.nodePortal = new NodePortal({
228570
228937
  nodes: this.nodes,
228571
228938
  emit: (event, payload) => options2.emit(event, payload),
@@ -228865,6 +229232,8 @@ class OrkestratorBackend {
228865
229232
  }
228866
229233
  if (change.resource === "prompt-queue")
228867
229234
  this.activityJobs?.wake("tmux-queues");
229235
+ if (change.resource === "config")
229236
+ this.nodes.syncSettingsToLeaves();
228868
229237
  });
228869
229238
  this.reapPidServers = options2.startupReapers?.localServers ?? reapOrphanedLocalServers;
228870
229239
  this.reapTmuxRuntimes = options2.startupReapers?.claudeTmuxRuntimes ?? reapOrphanedClaudeTmuxRuntimes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orkestrator",
3
- "version": "2.26.1-alpha",
3
+ "version": "2.26.2-beta",
4
4
  "description": "Run the Orkestrator backend service without the desktop application",
5
5
  "homepage": "https://orkestrator.dev",
6
6
  "repository": {
@@ -39,8 +39,8 @@
39
39
  "sharp": "0.35.5"
40
40
  },
41
41
  "devDependencies": {
42
- "@orkestrator/protocol": "2.26.1-alpha",
43
- "@orkestrator/toolchain": "2.26.1-alpha"
42
+ "@orkestrator/protocol": "2.26.2-beta",
43
+ "@orkestrator/toolchain": "2.26.2-beta"
44
44
  },
45
45
  "engines": {
46
46
  "bun": ">=1.4.3"