@gajae-code/coding-agent 0.11.11 → 0.12.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 (182) hide show
  1. package/CHANGELOG.md +49 -3
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/commands/deep-interview.d.ts +9 -0
  4. package/dist/types/commands/harness.d.ts +22 -0
  5. package/dist/types/commands/read.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/provider-auth-health.d.ts +14 -0
  8. package/dist/types/config/provider-ranking.d.ts +59 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +22 -2
  12. package/dist/types/extensibility/shared-events.d.ts +6 -0
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  14. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  15. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  16. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  17. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  19. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +52 -1
  20. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  21. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  23. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  24. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  26. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  27. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  28. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  29. package/dist/types/main.d.ts +1 -1
  30. package/dist/types/memories/index.d.ts +2 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +8 -0
  32. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  35. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  36. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  38. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  39. package/dist/types/modes/interactive-mode.d.ts +2 -0
  40. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  41. package/dist/types/modes/types.d.ts +2 -0
  42. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  43. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  44. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  45. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  46. package/dist/types/sdk/bus/index.d.ts +15 -1
  47. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  49. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  50. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  51. package/dist/types/sdk/host/host.d.ts +2 -0
  52. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +44 -10
  54. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  55. package/dist/types/session/session-manager.d.ts +25 -13
  56. package/dist/types/session/streaming-output.d.ts +60 -5
  57. package/dist/types/skill-state/active-state.d.ts +0 -4
  58. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  59. package/dist/types/tools/browser/launch.d.ts +7 -0
  60. package/dist/types/tools/fetch.d.ts +17 -1
  61. package/dist/types/tools/output-meta.d.ts +21 -1
  62. package/dist/types/tools/read-internals.d.ts +12 -0
  63. package/dist/types/tools/read.d.ts +45 -1
  64. package/dist/types/tools/resource-gc.d.ts +4 -0
  65. package/dist/types/tools/tool-result.d.ts +7 -1
  66. package/package.json +9 -13
  67. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  68. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  69. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  71. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  72. package/src/capability/index.ts +8 -0
  73. package/src/cli/read-cli.ts +2 -1
  74. package/src/cli.ts +1 -0
  75. package/src/commands/deep-interview.ts +29 -9
  76. package/src/commands/harness.ts +35 -12
  77. package/src/commands/read.ts +11 -2
  78. package/src/commands/team.ts +1 -1
  79. package/src/config/model-registry.ts +21 -2
  80. package/src/config/provider-auth-health.ts +42 -0
  81. package/src/config/provider-ranking.ts +119 -0
  82. package/src/config/settings-schema.ts +23 -1
  83. package/src/config/settings.ts +246 -108
  84. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  85. package/src/defaults/gjc/skills/ralplan/SKILL.md +58 -25
  86. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  87. package/src/defaults/gjc/skills/ultragoal/SKILL.md +25 -15
  88. package/src/eval/py/executor.ts +7 -2
  89. package/src/extensibility/extensions/types.ts +28 -4
  90. package/src/extensibility/shared-events.ts +5 -0
  91. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  92. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  93. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  94. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  95. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  96. package/src/gjc-runtime/gc-render.ts +1 -0
  97. package/src/gjc-runtime/gc-runtime.ts +11 -1
  98. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  99. package/src/gjc-runtime/ralplan-runtime.ts +742 -85
  100. package/src/gjc-runtime/state-runtime.ts +2 -1
  101. package/src/gjc-runtime/state-writer.ts +70 -222
  102. package/src/gjc-runtime/team-launch.ts +34 -5
  103. package/src/gjc-runtime/team-runtime.ts +970 -33
  104. package/src/gjc-runtime/team-store.ts +32 -0
  105. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  106. package/src/gjc-runtime/team-workers.ts +1 -0
  107. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  108. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  109. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  110. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  111. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  112. package/src/hooks/native-skill-hook.ts +17 -4
  113. package/src/hooks/skill-state.ts +1 -23
  114. package/src/internal-urls/docs-index.generated.ts +8 -9
  115. package/src/internal-urls/local-root-gc.ts +127 -0
  116. package/src/main.ts +9 -5
  117. package/src/memories/index.ts +10 -0
  118. package/src/modes/DESIGN.md +47 -0
  119. package/src/modes/acp/acp-agent.ts +43 -7
  120. package/src/modes/acp/acp-event-mapper.ts +14 -7
  121. package/src/modes/acp/acp-mode.ts +39 -3
  122. package/src/modes/components/assistant-message.ts +123 -22
  123. package/src/modes/components/irc-sidebar.ts +176 -62
  124. package/src/modes/components/model-selector.ts +61 -4
  125. package/src/modes/components/oauth-selector.ts +42 -12
  126. package/src/modes/components/read-tool-group.ts +82 -14
  127. package/src/modes/components/session-observer-overlay.ts +187 -32
  128. package/src/modes/components/settings-selector.ts +60 -18
  129. package/src/modes/components/tool-execution.ts +138 -27
  130. package/src/modes/controllers/event-controller.ts +109 -27
  131. package/src/modes/controllers/extension-ui-controller.ts +19 -2
  132. package/src/modes/controllers/input-controller.ts +18 -3
  133. package/src/modes/controllers/selector-controller.ts +83 -37
  134. package/src/modes/interactive-mode.ts +62 -3
  135. package/src/modes/irc-observation-ledger.ts +71 -15
  136. package/src/modes/runtime-init.ts +19 -2
  137. package/src/modes/types.ts +2 -0
  138. package/src/modes/utils/ui-helpers.ts +94 -1
  139. package/src/prompts/agents/architect.md +9 -1
  140. package/src/prompts/agents/critic.md +9 -0
  141. package/src/prompts/system/system-prompt.md +0 -1
  142. package/src/prompts/tools/read.md +4 -2
  143. package/src/runtime-mcp/smithery-auth.ts +17 -2
  144. package/src/runtime-mcp/smithery-connect.ts +11 -1
  145. package/src/sdk/broker/lifecycle.ts +25 -6
  146. package/src/sdk/bus/control-drain-lease.ts +104 -0
  147. package/src/sdk/bus/index.ts +341 -38
  148. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  149. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  150. package/src/sdk/bus/reconciliation-store.ts +224 -0
  151. package/src/sdk/host/control/dispatch.ts +5 -1
  152. package/src/sdk/host/control/operations.ts +1 -1
  153. package/src/sdk/host/host.ts +23 -1
  154. package/src/sdk/host/query/handlers.ts +55 -0
  155. package/src/sdk/mcp/server.ts +46 -18
  156. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  157. package/src/sdk/protocol/operation-registry.ts +7 -2
  158. package/src/sdk/session.ts +13 -1
  159. package/src/session/agent-session.ts +466 -50
  160. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  161. package/src/session/session-manager.ts +764 -17
  162. package/src/session/streaming-output.ts +262 -43
  163. package/src/setup/provider-onboarding.ts +19 -4
  164. package/src/skill-state/active-state.ts +0 -10
  165. package/src/skill-state/workflow-hud.ts +25 -2
  166. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  167. package/src/tools/ask.ts +89 -69
  168. package/src/tools/browser/launch.ts +41 -6
  169. package/src/tools/fetch.ts +114 -23
  170. package/src/tools/output-meta.ts +176 -9
  171. package/src/tools/path-utils.ts +5 -1
  172. package/src/tools/read-internals.ts +44 -0
  173. package/src/tools/read.ts +1542 -310
  174. package/src/tools/resource-gc.ts +182 -18
  175. package/src/tools/tool-result.ts +14 -1
  176. package/src/web/search/providers/codex.ts +1 -1
  177. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  178. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  179. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  180. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  181. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  182. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -13,6 +13,7 @@
13
13
  import * as fs from "node:fs";
14
14
  import * as os from "node:os";
15
15
  import * as path from "node:path";
16
+ import * as util from "node:util";
16
17
  import {
17
18
  getAgentDbPath,
18
19
  getAgentDir,
@@ -42,6 +43,7 @@ import {
42
43
  atomicYamlPathHash,
43
44
  type CasReceipt,
44
45
  deleteByPath,
46
+ enqueueAtomicYamlOperation,
45
47
  reserveAtomicYamlUpdateSlot,
46
48
  setByPath,
47
49
  } from "./atomic-yaml-patch";
@@ -374,6 +376,8 @@ export class Settings implements NotificationSettingsReader {
374
376
  /** A newer config schema must never be rewritten by legacy migrations. */
375
377
  #futureSchemaVersion = false;
376
378
  #hasMalformedConfigRoot = false;
379
+ /** YAML syntax was unrecoverable, so the loaded defaults are read-only until config.yml is repaired. */
380
+ #hasRecoveredConfigSyntax = false;
377
381
  #hasInvalidNotificationConfiguration = false;
378
382
  #notificationValidationGeneration = 0;
379
383
  /** Notification subtree fingerprint from the last raw durable config read. */
@@ -385,7 +389,7 @@ export class Settings implements NotificationSettingsReader {
385
389
  private constructor(options: SettingsOptions = {}) {
386
390
  this.#cwd = path.normalize(options.cwd ?? getProjectDir());
387
391
  this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
388
- this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
392
+ this.#configPath = options.inMemory ? null : path.resolve(this.#agentDir, "config.yml");
389
393
  this.#persist = !options.inMemory;
390
394
 
391
395
  if (options.overrides) {
@@ -528,6 +532,11 @@ export class Settings implements NotificationSettingsReader {
528
532
  return () => this.#changeListeners.delete(listener);
529
533
  }
530
534
 
535
+ /** Whether durable settings mutations are permitted for the loaded configuration. */
536
+ canWriteDurableConfig(): boolean {
537
+ return !this.#persist || !this.#hasRecoveredConfigSyntax;
538
+ }
539
+
531
540
  /**
532
541
  * Set a setting value (sync).
533
542
  * Updates global settings and reserves its background persistence slot before
@@ -538,6 +547,7 @@ export class Settings implements NotificationSettingsReader {
538
547
  this.unset(path);
539
548
  return;
540
549
  }
550
+ this.#assertDurableConfigWritable();
541
551
  this.#set(path, value, true);
542
552
  }
543
553
 
@@ -569,6 +579,7 @@ export class Settings implements NotificationSettingsReader {
569
579
  * `undefined` value. Defaults/project settings become visible immediately.
570
580
  */
571
581
  unset<P extends SettingPath>(path: P): void {
582
+ this.#assertDurableConfigWritable();
572
583
  const prev = this.get(path);
573
584
  const patch: SettingsPatch = {
574
585
  path,
@@ -594,6 +605,7 @@ export class Settings implements NotificationSettingsReader {
594
605
  * {@link set}, canonical state and hooks change only after the rename succeeds.
595
606
  */
596
607
  async commitAtomicBatch(patches: readonly SettingsAtomicPatch[]): Promise<CasReceipt> {
608
+ this.#assertDurableConfigWritable();
597
609
  if (!this.#persist || !this.#configPath) {
598
610
  const notificationValidationGuard = this.#notificationValidationRestoreGuard();
599
611
  const changes = new Map<string, { before: unknown; beforeHash: string; afterHash: string }>();
@@ -694,13 +706,16 @@ export class Settings implements NotificationSettingsReader {
694
706
  }));
695
707
  for (const entry of revisions) this.#pathRevisions.set(entry.patch.path, entry.revision);
696
708
 
709
+ const commit = applyAtomicYamlPatches(this.#configPath, durablePatches, {
710
+ validateRoot: (root, currentPatches) =>
711
+ this.#rejectAtomicNotificationRepairForMalformedRoot(currentPatches, root),
712
+ onRestored: restoredPatches =>
713
+ this.#applyRestoredDurableBatch(revisions, restoredPatches, notificationValidationGuard),
714
+ });
715
+ const failureRefresh = this.#reserveAtomicFailureRefresh(commit);
697
716
  try {
698
- const receipt = await applyAtomicYamlPatches(this.#configPath, durablePatches, {
699
- validateRoot: (root, currentPatches) =>
700
- this.#rejectAtomicNotificationRepairForMalformedRoot(currentPatches, root),
701
- onRestored: restoredPatches =>
702
- this.#applyRestoredDurableBatch(revisions, restoredPatches, notificationValidationGuard),
703
- });
717
+ const receipt = await commit;
718
+ await failureRefresh;
704
719
  const appliedNotificationMutation = this.#applyDurableBatch(revisions);
705
720
  this.#recordNotificationValidationBatchApply(notificationValidationGuard, appliedNotificationMutation);
706
721
  return receipt;
@@ -711,6 +726,7 @@ export class Settings implements NotificationSettingsReader {
711
726
  else this.#pathRevisions.set(entry.patch.path, entry.previousRevision);
712
727
  }
713
728
  }
729
+ await failureRefresh;
714
730
  if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
715
731
  throw error;
716
732
  }
@@ -722,6 +738,7 @@ export class Settings implements NotificationSettingsReader {
722
738
  current: Readonly<RawSettings>,
723
739
  ) => Promise<readonly SettingsAtomicPatch[]> | readonly SettingsAtomicPatch[],
724
740
  ): Promise<CasReceipt> {
741
+ this.#assertDurableConfigWritable();
725
742
  if (!this.#persist || !this.#configPath) {
726
743
  const patches = await buildPatches(structuredClone(this.#global));
727
744
  return this.commitAtomicBatch(patches);
@@ -730,38 +747,41 @@ export class Settings implements NotificationSettingsReader {
730
747
  this.#releasePendingSaveSlot();
731
748
  let revisions: DurableBatchRevision[] = [];
732
749
  const notificationValidationGuard = this.#notificationValidationRestoreGuard();
750
+ const commit = applyAtomicYamlPatchesWithCurrent(
751
+ this.#configPath,
752
+ async current => {
753
+ const patches = await buildPatches(structuredClone(current));
754
+ const durablePatches: AtomicYamlPatch[] = patches.map(patch => {
755
+ if (!isAtomicSettingsPath(patch.path)) {
756
+ throw new Error(`Unknown setting path for atomic batch: ${patch.path}`);
757
+ }
758
+ if (patch.op === "unset") return { path: patch.path, op: "unset" };
759
+ if (patch.value === undefined) {
760
+ throw new TypeError(
761
+ `Settings set patch for ${patch.path} cannot carry undefined; use unset instead.`,
762
+ );
763
+ }
764
+ return { path: patch.path, op: "set", value: structuredClone(patch.value) };
765
+ });
766
+ revisions = durablePatches.map(patch => ({
767
+ patch,
768
+ revision: ++this.#nextRevision,
769
+ previousRevision: this.#pathRevisions.get(patch.path),
770
+ }));
771
+ for (const entry of revisions) this.#pathRevisions.set(entry.patch.path, entry.revision);
772
+ return durablePatches;
773
+ },
774
+ {
775
+ validateRoot: (root, currentPatches) =>
776
+ this.#rejectAtomicNotificationRepairForMalformedRoot(currentPatches, root),
777
+ onRestored: restoredPatches =>
778
+ this.#applyRestoredDurableBatch(revisions, restoredPatches, notificationValidationGuard),
779
+ },
780
+ );
781
+ const failureRefresh = this.#reserveAtomicFailureRefresh(commit);
733
782
  try {
734
- const receipt = await applyAtomicYamlPatchesWithCurrent(
735
- this.#configPath,
736
- async current => {
737
- const patches = await buildPatches(structuredClone(current));
738
- const durablePatches: AtomicYamlPatch[] = patches.map(patch => {
739
- if (!isAtomicSettingsPath(patch.path)) {
740
- throw new Error(`Unknown setting path for atomic batch: ${patch.path}`);
741
- }
742
- if (patch.op === "unset") return { path: patch.path, op: "unset" };
743
- if (patch.value === undefined) {
744
- throw new TypeError(
745
- `Settings set patch for ${patch.path} cannot carry undefined; use unset instead.`,
746
- );
747
- }
748
- return { path: patch.path, op: "set", value: structuredClone(patch.value) };
749
- });
750
- revisions = durablePatches.map(patch => ({
751
- patch,
752
- revision: ++this.#nextRevision,
753
- previousRevision: this.#pathRevisions.get(patch.path),
754
- }));
755
- for (const entry of revisions) this.#pathRevisions.set(entry.patch.path, entry.revision);
756
- return durablePatches;
757
- },
758
- {
759
- validateRoot: (root, currentPatches) =>
760
- this.#rejectAtomicNotificationRepairForMalformedRoot(currentPatches, root),
761
- onRestored: restoredPatches =>
762
- this.#applyRestoredDurableBatch(revisions, restoredPatches, notificationValidationGuard),
763
- },
764
- );
783
+ const receipt = await commit;
784
+ await failureRefresh;
765
785
  const appliedNotificationMutation = this.#applyDurableBatch(revisions);
766
786
  this.#recordNotificationValidationBatchApply(notificationValidationGuard, appliedNotificationMutation);
767
787
  return receipt;
@@ -772,6 +792,7 @@ export class Settings implements NotificationSettingsReader {
772
792
  else this.#pathRevisions.set(entry.patch.path, entry.previousRevision);
773
793
  }
774
794
  }
795
+ await failureRefresh;
775
796
  if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
776
797
  throw error;
777
798
  }
@@ -826,6 +847,15 @@ export class Settings implements NotificationSettingsReader {
826
847
  }
827
848
  }
828
849
  await this.#refreshDurableSettings();
850
+ if (this.#modified.size > 0 && !this.#pendingSaveSlot) {
851
+ this.#queueSave();
852
+ this.#releasePendingSaveSlot();
853
+ try {
854
+ await this.#savePromise;
855
+ } catch {
856
+ // Keep dirty state for a later explicit flush or mutation.
857
+ }
858
+ }
829
859
  }
830
860
 
831
861
  /** Like {@link flush}, but reports a durable save failure to the caller. */
@@ -833,8 +863,20 @@ export class Settings implements NotificationSettingsReader {
833
863
  this.#releasePendingSaveSlot();
834
864
  if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
835
865
  this.#releasePendingSaveSlot();
836
- await this.#savePromise;
866
+ let saveError: unknown;
867
+ try {
868
+ await this.#savePromise;
869
+ } catch (error) {
870
+ saveError = error;
871
+ }
837
872
  await this.#refreshDurableSettings();
873
+ if (this.#modified.size > 0 && !this.#pendingSaveSlot) {
874
+ this.#queueSave();
875
+ this.#releasePendingSaveSlot();
876
+ await this.#savePromise;
877
+ return;
878
+ }
879
+ if (saveError !== undefined) throw saveError;
838
880
  }
839
881
 
840
882
  async cloneForCwd(cwd: string): Promise<Settings> {
@@ -848,15 +890,23 @@ export class Settings implements NotificationSettingsReader {
848
890
  inMemory: !this.#persist,
849
891
  });
850
892
  cloned.#storage = this.#storage;
893
+ cloned.#schemaReport = structuredClone(this.#schemaReport);
894
+ cloned.#schemaMigrationPending = this.#schemaMigrationPending;
851
895
  cloned.#futureSchemaVersion = this.#futureSchemaVersion;
852
-
896
+ cloned.#hasMalformedConfigRoot = this.#hasMalformedConfigRoot;
897
+ cloned.#hasRecoveredConfigSyntax = this.#hasRecoveredConfigSyntax;
898
+ cloned.#hasInvalidNotificationConfiguration = this.#hasInvalidNotificationConfiguration;
899
+ cloned.#notificationValidationGeneration = this.#notificationValidationGeneration;
853
900
  cloned.#global = structuredClone(this.#global);
854
901
  cloned.#rawNotificationConfig = structuredClone(this.#rawNotificationConfig);
855
902
  cloned.#durableRawNotificationConfig = structuredClone(this.#durableRawNotificationConfig);
856
903
  cloned.#durableNotificationFingerprint = this.#durableNotificationFingerprint;
857
904
  cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
858
905
  cloned.#overrides = structuredClone(this.#overrides);
859
- await cloned.#normalizeAfterLoad();
906
+ if (cloned.#hasRecoveredConfigSyntax) {
907
+ cloned.#sanitizeModelSelectorRecords();
908
+ cloned.#rebuildMerged();
909
+ } else await cloned.#normalizeAfterLoad();
860
910
  cloned.#fireAllHooks();
861
911
  return cloned;
862
912
  }
@@ -948,6 +998,7 @@ export class Settings implements NotificationSettingsReader {
948
998
  }
949
999
 
950
1000
  setGlobalModelRole(role: ModelRole | string, modelId: ModelSelectorValue | undefined): void {
1001
+ this.#assertDurableConfigWritable();
951
1002
  const revision = ++this.#nextRevision;
952
1003
  const patch: SettingsPatch = {
953
1004
  path: "modelRoles",
@@ -981,13 +1032,27 @@ export class Settings implements NotificationSettingsReader {
981
1032
  }
982
1033
 
983
1034
  #replaceGlobalWithDurable(current: RawSettings): void {
1035
+ const previous = new Map<SettingPath, unknown>();
1036
+ for (const settingPath of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
1037
+ previous.set(settingPath, structuredClone(this.get(settingPath)));
1038
+ }
984
1039
  this.#global = current;
985
1040
  for (const patch of this.#pendingPatchesInGenerationOrder()) {
986
1041
  applySettingsPatch(this.#global, { ...patch, value: structuredClone(patch.value) });
987
- this.#applyNotificationMutationToRaw(patch.path, patch.value);
1042
+ if (this.#rawNotificationConfig !== undefined) {
1043
+ this.#applyNotificationMutationToRaw(patch.path, patch.value);
1044
+ }
988
1045
  }
989
1046
  this.#rebuildMerged();
990
1047
  this.#recomputeNotificationValidationFromRaw();
1048
+ for (const settingPath of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
1049
+ const previousValue = previous.get(settingPath);
1050
+ const nextValue = this.get(settingPath);
1051
+ if (util.isDeepStrictEqual(previousValue, nextValue)) continue;
1052
+ const hook = SETTING_HOOKS[settingPath];
1053
+ if (hook) hook(nextValue, previousValue);
1054
+ for (const listener of this.#changeListeners) listener(settingPath);
1055
+ }
991
1056
  }
992
1057
  /**
993
1058
  * Set an agent model override while keeping any live runtime override aligned.
@@ -1081,60 +1146,97 @@ export class Settings implements NotificationSettingsReader {
1081
1146
  }
1082
1147
  }
1083
1148
 
1084
- async #loadYaml(filePath: string): Promise<RawSettings> {
1149
+ #resetYamlLoadState(): void {
1085
1150
  this.#hasMalformedConfigRoot = false;
1151
+ this.#hasRecoveredConfigSyntax = false;
1086
1152
  this.#hasInvalidNotificationConfiguration = false;
1153
+ this.#schemaReport = { issues: [], valid: true };
1154
+ this.#schemaMigrationPending = false;
1155
+ this.#futureSchemaVersion = false;
1087
1156
  this.#captureRawNotificationConfig({});
1088
- try {
1089
- const content = await Bun.file(filePath).text();
1090
- const parsed = YAML.parse(content);
1091
- if (parsed === undefined) return {};
1092
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1093
- this.#hasMalformedConfigRoot = true;
1094
- this.#captureRawNotificationConfig(undefined);
1095
- return {};
1096
- }
1097
- const parsedRaw = parsed as RawSettings;
1098
- if (filePath === this.#configPath) this.#captureRawNotificationConfig(parsedRaw);
1099
- if (filePath === this.#configPath) {
1100
- try {
1101
- parseNotificationSettingsSnapshot(parsedRaw);
1102
- } catch (error) {
1103
- if (!(error instanceof Error) || error.message !== "gjc_notify_daemon_invalid_configuration")
1104
- throw error;
1105
- this.#hasInvalidNotificationConfiguration = true;
1106
- }
1107
- }
1108
- this.#futureSchemaVersion =
1109
- filePath === this.#configPath &&
1110
- typeof parsedRaw.configSchemaVersion === "number" &&
1111
- parsedRaw.configSchemaVersion > CONFIG_SCHEMA_VERSION;
1157
+ }
1112
1158
 
1113
- const configSchemaVersion = parsedRaw.configSchemaVersion;
1114
- if (
1115
- filePath === this.#configPath &&
1116
- (typeof configSchemaVersion !== "number" || configSchemaVersion < CONFIG_SCHEMA_VERSION)
1117
- ) {
1118
- this.#schemaMigrationPending = true;
1119
- }
1120
- const migrated = this.#migrateRawSettings(parsedRaw);
1121
- const reconciled = reconcileSettingsSchema(migrated);
1122
- if (typeof configSchemaVersion === "number" && configSchemaVersion > CONFIG_SCHEMA_VERSION) {
1123
- reconciled.report.issues.push({
1124
- path: "configSchemaVersion",
1125
- kind: "pending-migration",
1126
- detail: `Configuration requires schema version ${configSchemaVersion}.`,
1127
- });
1128
- }
1129
- this.#schemaReport = reconciled.report;
1130
- return reconciled.settings;
1159
+ async #loadYaml(filePath: string): Promise<RawSettings> {
1160
+ let content: string;
1161
+ try {
1162
+ content = await Bun.file(filePath).text();
1131
1163
  } catch (error) {
1132
1164
  if (isEnoent(error)) {
1133
- this.#captureRawNotificationConfig({});
1165
+ this.#resetYamlLoadState();
1134
1166
  return {};
1135
1167
  }
1136
1168
  throw error;
1137
1169
  }
1170
+ this.#resetYamlLoadState();
1171
+ if (content.trim() === "") return {};
1172
+ let parsed: unknown;
1173
+ try {
1174
+ parsed = YAML.parse(content);
1175
+ } catch {
1176
+ this.#hasRecoveredConfigSyntax = true;
1177
+ this.#hasMalformedConfigRoot = true;
1178
+ this.#schemaReport = {
1179
+ valid: false,
1180
+ issues: [
1181
+ {
1182
+ path: "config.yml",
1183
+ kind: "invalid",
1184
+ detail: "Configuration YAML syntax is invalid; repair config.yml before changing settings.",
1185
+ },
1186
+ ],
1187
+ };
1188
+ this.#captureRawNotificationConfig(undefined);
1189
+ return {};
1190
+ }
1191
+ if (parsed === undefined) return {};
1192
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1193
+ this.#hasMalformedConfigRoot = true;
1194
+ this.#schemaReport = {
1195
+ valid: false,
1196
+ issues: [
1197
+ {
1198
+ path: "config.yml",
1199
+ kind: "invalid",
1200
+ detail: "Configuration root must be a YAML mapping.",
1201
+ },
1202
+ ],
1203
+ };
1204
+ this.#captureRawNotificationConfig(undefined);
1205
+ return {};
1206
+ }
1207
+ const parsedRaw = parsed as RawSettings;
1208
+ if (filePath === this.#configPath) this.#captureRawNotificationConfig(parsedRaw);
1209
+ if (filePath === this.#configPath) {
1210
+ try {
1211
+ parseNotificationSettingsSnapshot(parsedRaw);
1212
+ } catch (error) {
1213
+ if (!(error instanceof Error) || error.message !== "gjc_notify_daemon_invalid_configuration") throw error;
1214
+ this.#hasInvalidNotificationConfiguration = true;
1215
+ }
1216
+ }
1217
+ this.#futureSchemaVersion =
1218
+ filePath === this.#configPath &&
1219
+ typeof parsedRaw.configSchemaVersion === "number" &&
1220
+ parsedRaw.configSchemaVersion > CONFIG_SCHEMA_VERSION;
1221
+
1222
+ const configSchemaVersion = parsedRaw.configSchemaVersion;
1223
+ if (
1224
+ filePath === this.#configPath &&
1225
+ (typeof configSchemaVersion !== "number" || configSchemaVersion < CONFIG_SCHEMA_VERSION)
1226
+ ) {
1227
+ this.#schemaMigrationPending = true;
1228
+ }
1229
+ const migrated = this.#migrateRawSettings(parsedRaw);
1230
+ const reconciled = reconcileSettingsSchema(migrated);
1231
+ if (typeof configSchemaVersion === "number" && configSchemaVersion > CONFIG_SCHEMA_VERSION) {
1232
+ reconciled.report.issues.push({
1233
+ path: "configSchemaVersion",
1234
+ kind: "pending-migration",
1235
+ detail: `Configuration requires schema version ${configSchemaVersion}.`,
1236
+ });
1237
+ }
1238
+ this.#schemaReport = reconciled.report;
1239
+ return reconciled.settings;
1138
1240
  }
1139
1241
 
1140
1242
  async #loadProjectSettings(): Promise<RawSettings> {
@@ -1527,7 +1629,7 @@ export class Settings implements NotificationSettingsReader {
1527
1629
  // ─────────────────────────────────────────────────────────────────────────
1528
1630
 
1529
1631
  #queueSave(): void {
1530
- if (!this.#persist || !this.#configPath) return;
1632
+ if (!this.#persist || !this.#configPath || this.#hasRecoveredConfigSyntax) return;
1531
1633
 
1532
1634
  const currentSlot = this.#pendingSaveSlot;
1533
1635
  if (currentSlot && !currentSlot.captured && !currentSlot.released) {
@@ -1594,25 +1696,33 @@ export class Settings implements NotificationSettingsReader {
1594
1696
  this.#recomputeNotificationValidationFromRaw();
1595
1697
  },
1596
1698
  };
1597
- }).then(() => undefined);
1598
- this.#savePromise = save;
1599
- void save.catch(error => {
1600
- logger.warn("Settings: background save failed", { error: String(error) });
1601
- for (const patch of captured) {
1602
- const key = settingsPatchKey(patch);
1603
- if (this.#modified.get(key)?.generation === patch.generation) this.#modified.set(key, patch);
1604
- }
1605
- if (durableBeforeWrite) {
1606
- this.#global = durableBeforeWrite;
1607
- this.#captureRawNotificationConfig(durableBeforeWrite);
1608
- for (const patch of this.#pendingPatchesInGenerationOrder()) {
1609
- applySettingsPatch(this.#global, { ...patch, value: structuredClone(patch.value) });
1610
- this.#applyNotificationMutationToRaw(patch.path, patch.value);
1699
+ })
1700
+ .then(() => undefined)
1701
+ .catch(async error => {
1702
+ logger.warn("Settings: background save failed", { error: String(error) });
1703
+ for (const patch of captured) {
1704
+ const key = settingsPatchKey(patch);
1705
+ if (this.#modified.get(key)?.generation === patch.generation) this.#modified.set(key, patch);
1611
1706
  }
1612
- this.#rebuildMerged();
1613
- this.#recomputeNotificationValidationFromRaw();
1614
- }
1615
- });
1707
+ if (durableBeforeWrite) {
1708
+ this.#global = durableBeforeWrite;
1709
+ this.#captureRawNotificationConfig(durableBeforeWrite);
1710
+ for (const patch of this.#pendingPatchesInGenerationOrder()) {
1711
+ applySettingsPatch(this.#global, { ...patch, value: structuredClone(patch.value) });
1712
+ this.#applyNotificationMutationToRaw(patch.path, patch.value);
1713
+ }
1714
+ this.#rebuildMerged();
1715
+ this.#recomputeNotificationValidationFromRaw();
1716
+ }
1717
+ try {
1718
+ await this.#refreshDurableSettings();
1719
+ } catch (refreshError) {
1720
+ logger.warn("Settings: refresh after background save failure failed", { error: String(refreshError) });
1721
+ }
1722
+ throw error;
1723
+ });
1724
+ this.#savePromise = save;
1725
+ void save.catch(() => {});
1616
1726
  this.#armSaveTimer(slot);
1617
1727
  }
1618
1728
 
@@ -1716,13 +1826,41 @@ export class Settings implements NotificationSettingsReader {
1716
1826
  return applicable.some(patch => isNotificationSettingsPath(patch.path));
1717
1827
  }
1718
1828
 
1719
- async #refreshDurableSettings(): Promise<void> {
1720
- if (!this.#persist || !this.#configPath) return;
1829
+ #reserveAtomicFailureRefresh(commit: Promise<unknown>): Promise<void> {
1830
+ if (!this.#persist || !this.#configPath) return Promise.resolve();
1831
+ return enqueueAtomicYamlOperation(this.#configPath, async canonicalPath => {
1832
+ try {
1833
+ await commit;
1834
+ return;
1835
+ } catch {
1836
+ // The original commit error remains authoritative. Recovery failures
1837
+ // are diagnostic only and must not replace it.
1838
+ }
1839
+ try {
1840
+ await this.#refreshDurableSettingsUnderQueue(canonicalPath);
1841
+ } catch (refreshError) {
1842
+ logger.warn("Settings: refresh after atomic batch failure failed", { error: String(refreshError) });
1843
+ }
1844
+ });
1845
+ }
1846
+ async #refreshDurableSettingsUnderQueue(canonicalPath: string): Promise<void> {
1721
1847
  const previousFingerprint = this.#durableNotificationFingerprint;
1722
- const current = await this.#loadYaml(this.#configPath);
1848
+ const current = await this.#loadYaml(canonicalPath);
1723
1849
  if (previousFingerprint !== this.#durableNotificationFingerprint) this.#notificationValidationGeneration++;
1724
1850
  this.#replaceGlobalWithDurable(current);
1725
1851
  }
1852
+ async #refreshDurableSettings(): Promise<void> {
1853
+ if (!this.#persist || !this.#configPath) return;
1854
+ await enqueueAtomicYamlOperation(this.#configPath, canonicalPath =>
1855
+ this.#refreshDurableSettingsUnderQueue(canonicalPath),
1856
+ );
1857
+ }
1858
+ #assertDurableConfigWritable(): void {
1859
+ if (this.canWriteDurableConfig()) return;
1860
+ throw new Error(
1861
+ "Cannot change settings while config.yml has invalid YAML syntax. Repair config.yml and reload settings.",
1862
+ );
1863
+ }
1726
1864
 
1727
1865
  // ─────────────────────────────────────────────────────────────────────────
1728
1866
  // Utilities