@gajae-code/coding-agent 0.11.10 → 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 (249) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/cli.d.ts +8 -1
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-profile-activation.d.ts +1 -0
  8. package/dist/types/config/model-profile-contract.d.ts +2 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/provider-auth-health.d.ts +14 -0
  11. package/dist/types/config/provider-ranking.d.ts +59 -0
  12. package/dist/types/config/settings-schema.d.ts +86 -3
  13. package/dist/types/config/settings.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/types.d.ts +43 -2
  15. package/dist/types/extensibility/shared-events.d.ts +6 -0
  16. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  17. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  18. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  19. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  20. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  21. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  22. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  23. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +112 -1
  24. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  25. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  26. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  28. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  29. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  30. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  31. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  32. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  33. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  34. package/dist/types/main.d.ts +1 -1
  35. package/dist/types/memories/index.d.ts +2 -0
  36. package/dist/types/modes/acp/acp-agent.d.ts +22 -9
  37. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  38. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  39. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  40. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  41. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  42. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  43. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  44. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  45. package/dist/types/modes/components/welcome.d.ts +1 -0
  46. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  47. package/dist/types/modes/interactive-mode.d.ts +7 -1
  48. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  49. package/dist/types/modes/types.d.ts +2 -0
  50. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  51. package/dist/types/runtime/memory-domain.d.ts +9 -0
  52. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  53. package/dist/types/runtime/memory-guard.d.ts +39 -0
  54. package/dist/types/runtime/memory-limit.d.ts +11 -0
  55. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  56. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  57. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  58. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  59. package/dist/types/sdk/broker/lifecycle.d.ts +7 -0
  60. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  61. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  62. package/dist/types/sdk/bus/index.d.ts +18 -2
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  65. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  66. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  67. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
  68. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
  69. package/dist/types/sdk/host/control/operations.d.ts +2 -1
  70. package/dist/types/sdk/host/host.d.ts +2 -0
  71. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  72. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  73. package/dist/types/sdk/startup-capability.d.ts +6 -0
  74. package/dist/types/session/agent-session.d.ts +51 -19
  75. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  76. package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
  77. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  78. package/dist/types/session/session-manager.d.ts +59 -13
  79. package/dist/types/session/streaming-output.d.ts +60 -5
  80. package/dist/types/skill-state/active-state.d.ts +0 -4
  81. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  82. package/dist/types/task/executor.d.ts +6 -0
  83. package/dist/types/task/types.d.ts +36 -0
  84. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  85. package/dist/types/tools/browser/launch.d.ts +7 -0
  86. package/dist/types/tools/fetch.d.ts +17 -1
  87. package/dist/types/tools/output-meta.d.ts +21 -1
  88. package/dist/types/tools/read-internals.d.ts +12 -0
  89. package/dist/types/tools/read.d.ts +45 -1
  90. package/dist/types/tools/resource-gc.d.ts +23 -0
  91. package/dist/types/tools/tool-result.d.ts +7 -1
  92. package/package.json +9 -13
  93. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  94. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  95. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  96. package/scripts/run-test-manifest.ts +6 -4
  97. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  98. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  99. package/src/capability/index.ts +8 -0
  100. package/src/cli/args.ts +14 -8
  101. package/src/cli/fast-help.ts +1 -1
  102. package/src/cli/read-cli.ts +2 -1
  103. package/src/cli.ts +49 -2
  104. package/src/commands/deep-interview.ts +29 -9
  105. package/src/commands/harness.ts +35 -12
  106. package/src/commands/read.ts +11 -2
  107. package/src/commands/sdk.ts +91 -7
  108. package/src/commands/team.ts +1 -1
  109. package/src/config/model-profile-activation.ts +1 -0
  110. package/src/config/model-profile-contract.ts +13 -0
  111. package/src/config/model-profiles.ts +24 -0
  112. package/src/config/model-registry.ts +21 -2
  113. package/src/config/provider-auth-health.ts +42 -0
  114. package/src/config/provider-ranking.ts +119 -0
  115. package/src/config/settings-schema.ts +84 -3
  116. package/src/config/settings.ts +254 -108
  117. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  118. package/src/defaults/gjc/skills/ralplan/SKILL.md +78 -26
  119. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  120. package/src/defaults/gjc/skills/ultragoal/SKILL.md +26 -16
  121. package/src/eval/py/executor.ts +7 -2
  122. package/src/extensibility/extensions/runner.ts +4 -0
  123. package/src/extensibility/extensions/types.ts +42 -4
  124. package/src/extensibility/shared-events.ts +5 -0
  125. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -68
  126. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  127. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  128. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  129. package/src/gjc-runtime/deep-interview-state.ts +12 -921
  130. package/src/gjc-runtime/gc-render.ts +1 -0
  131. package/src/gjc-runtime/gc-runtime.ts +11 -1
  132. package/src/gjc-runtime/linux-proc.ts +70 -36
  133. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  134. package/src/gjc-runtime/ralplan-runtime.ts +979 -71
  135. package/src/gjc-runtime/state-runtime.ts +50 -12
  136. package/src/gjc-runtime/state-writer.ts +70 -222
  137. package/src/gjc-runtime/team-launch.ts +34 -5
  138. package/src/gjc-runtime/team-runtime.ts +970 -33
  139. package/src/gjc-runtime/team-store.ts +32 -0
  140. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  141. package/src/gjc-runtime/team-workers.ts +1 -0
  142. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  143. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  144. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  145. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  146. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  147. package/src/hooks/native-skill-hook.ts +17 -4
  148. package/src/hooks/skill-state.ts +1 -23
  149. package/src/internal-urls/docs-index.generated.ts +10 -11
  150. package/src/internal-urls/local-protocol.ts +23 -4
  151. package/src/internal-urls/local-root-gc.ts +127 -0
  152. package/src/main.ts +9 -5
  153. package/src/memories/index.ts +10 -0
  154. package/src/modes/DESIGN.md +47 -0
  155. package/src/modes/acp/acp-agent.ts +597 -67
  156. package/src/modes/acp/acp-event-mapper.ts +106 -12
  157. package/src/modes/acp/acp-mode.ts +39 -3
  158. package/src/modes/components/assistant-message.ts +123 -22
  159. package/src/modes/components/irc-sidebar.ts +176 -62
  160. package/src/modes/components/model-selector.ts +76 -5
  161. package/src/modes/components/notifications-settings-editor.ts +1 -1
  162. package/src/modes/components/oauth-selector.ts +42 -12
  163. package/src/modes/components/queue-pane.ts +55 -9
  164. package/src/modes/components/queued-message-selector.ts +53 -10
  165. package/src/modes/components/read-tool-group.ts +82 -14
  166. package/src/modes/components/session-observer-overlay.ts +187 -32
  167. package/src/modes/components/settings-selector.ts +60 -18
  168. package/src/modes/components/tool-execution.ts +138 -27
  169. package/src/modes/components/tool-status-header.ts +1 -4
  170. package/src/modes/components/welcome.ts +18 -3
  171. package/src/modes/controllers/command-controller.ts +4 -0
  172. package/src/modes/controllers/event-controller.ts +109 -27
  173. package/src/modes/controllers/extension-ui-controller.ts +23 -12
  174. package/src/modes/controllers/input-controller.ts +62 -6
  175. package/src/modes/controllers/selector-controller.ts +83 -37
  176. package/src/modes/interactive-mode.ts +127 -29
  177. package/src/modes/irc-observation-ledger.ts +71 -15
  178. package/src/modes/runtime-init.ts +20 -2
  179. package/src/modes/types.ts +2 -0
  180. package/src/modes/utils/ui-helpers.ts +94 -1
  181. package/src/prompts/agents/architect.md +9 -1
  182. package/src/prompts/agents/critic.md +9 -0
  183. package/src/prompts/agents/executor.md +1 -1
  184. package/src/prompts/system/system-prompt.md +0 -1
  185. package/src/prompts/tools/read.md +4 -2
  186. package/src/runtime/memory-domain.ts +66 -0
  187. package/src/runtime/memory-guard-contract.ts +73 -0
  188. package/src/runtime/memory-guard.ts +292 -0
  189. package/src/runtime/memory-limit.ts +50 -0
  190. package/src/runtime-mcp/manager.ts +26 -6
  191. package/src/runtime-mcp/smithery-auth.ts +17 -2
  192. package/src/runtime-mcp/smithery-connect.ts +11 -1
  193. package/src/sdk/acp/adapter.ts +8 -1
  194. package/src/sdk/acp/final-text.ts +40 -0
  195. package/src/sdk/acp/mcp.ts +26 -0
  196. package/src/sdk/broker/lifecycle.ts +107 -7
  197. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  198. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  199. package/src/sdk/bus/config.ts +1 -1
  200. package/src/sdk/bus/control-drain-lease.ts +104 -0
  201. package/src/sdk/bus/index.ts +503 -60
  202. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  203. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  204. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  205. package/src/sdk/bus/reconciliation-store.ts +224 -0
  206. package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
  207. package/src/sdk/bus/telegram-daemon.ts +531 -53
  208. package/src/sdk/host/control/dispatch.ts +7 -1
  209. package/src/sdk/host/control/operations.ts +2 -1
  210. package/src/sdk/host/host.ts +25 -3
  211. package/src/sdk/host/query/handlers.ts +55 -0
  212. package/src/sdk/lifecycle-session.ts +19 -3
  213. package/src/sdk/mcp/server.ts +46 -18
  214. package/src/sdk/protocol/operation-inventory.generated.json +76 -0
  215. package/src/sdk/protocol/operation-registry.ts +8 -2
  216. package/src/sdk/session.ts +25 -2
  217. package/src/sdk/startup-capability.ts +6 -0
  218. package/src/session/agent-session.ts +720 -190
  219. package/src/session/fallback-chain-controller.ts +47 -8
  220. package/src/session/internal/managed-session-scope.ts +40 -10
  221. package/src/session/internal/native-publish-outcome.ts +8 -3
  222. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  223. package/src/session/session-manager.ts +1372 -42
  224. package/src/session/streaming-output.ts +262 -43
  225. package/src/setup/provider-onboarding.ts +19 -4
  226. package/src/skill-state/active-state.ts +0 -10
  227. package/src/skill-state/workflow-hud.ts +25 -2
  228. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  229. package/src/task/executor.ts +11 -3
  230. package/src/task/index.ts +2 -0
  231. package/src/task/types.ts +22 -11
  232. package/src/task/ultragoal-redteam-activation.ts +75 -0
  233. package/src/tools/ask.ts +89 -69
  234. package/src/tools/browser/launch.ts +41 -6
  235. package/src/tools/fetch.ts +114 -23
  236. package/src/tools/output-meta.ts +176 -9
  237. package/src/tools/path-utils.ts +5 -1
  238. package/src/tools/read-internals.ts +44 -0
  239. package/src/tools/read.ts +1542 -310
  240. package/src/tools/resource-gc.ts +665 -125
  241. package/src/tools/tool-result.ts +14 -1
  242. package/src/web/search/providers/codex.ts +1 -1
  243. package/src/workflow/workflow-intent-diff.ts +4 -1
  244. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  245. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  246. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  247. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  248. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  249. 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";
@@ -363,6 +365,7 @@ export class Settings implements NotificationSettingsReader {
363
365
  /** Pending debounced ordinary save; its queue slot is reserved immediately. */
364
366
  #saveTimer?: NodeJS.Timeout;
365
367
  #savePromise?: Promise<void>;
368
+ #changeListeners = new Set<(path: SettingPath) => void>();
366
369
  #pendingSaveSlot?: PendingSaveSlot;
367
370
 
368
371
  /** Legacy fallback migration warnings emitted once per settings instance. */
@@ -373,6 +376,8 @@ export class Settings implements NotificationSettingsReader {
373
376
  /** A newer config schema must never be rewritten by legacy migrations. */
374
377
  #futureSchemaVersion = false;
375
378
  #hasMalformedConfigRoot = false;
379
+ /** YAML syntax was unrecoverable, so the loaded defaults are read-only until config.yml is repaired. */
380
+ #hasRecoveredConfigSyntax = false;
376
381
  #hasInvalidNotificationConfiguration = false;
377
382
  #notificationValidationGeneration = 0;
378
383
  /** Notification subtree fingerprint from the last raw durable config read. */
@@ -384,7 +389,7 @@ export class Settings implements NotificationSettingsReader {
384
389
  private constructor(options: SettingsOptions = {}) {
385
390
  this.#cwd = path.normalize(options.cwd ?? getProjectDir());
386
391
  this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
387
- this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
392
+ this.#configPath = options.inMemory ? null : path.resolve(this.#agentDir, "config.yml");
388
393
  this.#persist = !options.inMemory;
389
394
 
390
395
  if (options.overrides) {
@@ -522,6 +527,16 @@ export class Settings implements NotificationSettingsReader {
522
527
  return structuredClone(this.#schemaReport);
523
528
  }
524
529
 
530
+ onChanged(listener: (path: SettingPath) => void): () => void {
531
+ this.#changeListeners.add(listener);
532
+ return () => this.#changeListeners.delete(listener);
533
+ }
534
+
535
+ /** Whether durable settings mutations are permitted for the loaded configuration. */
536
+ canWriteDurableConfig(): boolean {
537
+ return !this.#persist || !this.#hasRecoveredConfigSyntax;
538
+ }
539
+
525
540
  /**
526
541
  * Set a setting value (sync).
527
542
  * Updates global settings and reserves its background persistence slot before
@@ -532,6 +547,7 @@ export class Settings implements NotificationSettingsReader {
532
547
  this.unset(path);
533
548
  return;
534
549
  }
550
+ this.#assertDurableConfigWritable();
535
551
  this.#set(path, value, true);
536
552
  }
537
553
 
@@ -555,6 +571,7 @@ export class Settings implements NotificationSettingsReader {
555
571
 
556
572
  const hook = SETTING_HOOKS[path];
557
573
  if (hook) hook(value, prev);
574
+ for (const listener of this.#changeListeners) listener(path);
558
575
  }
559
576
 
560
577
  /**
@@ -562,6 +579,7 @@ export class Settings implements NotificationSettingsReader {
562
579
  * `undefined` value. Defaults/project settings become visible immediately.
563
580
  */
564
581
  unset<P extends SettingPath>(path: P): void {
582
+ this.#assertDurableConfigWritable();
565
583
  const prev = this.get(path);
566
584
  const patch: SettingsPatch = {
567
585
  path,
@@ -579,6 +597,7 @@ export class Settings implements NotificationSettingsReader {
579
597
 
580
598
  const hook = SETTING_HOOKS[path];
581
599
  if (hook) hook(this.get(path), prev);
600
+ for (const listener of this.#changeListeners) listener(path);
582
601
  }
583
602
 
584
603
  /**
@@ -586,6 +605,7 @@ export class Settings implements NotificationSettingsReader {
586
605
  * {@link set}, canonical state and hooks change only after the rename succeeds.
587
606
  */
588
607
  async commitAtomicBatch(patches: readonly SettingsAtomicPatch[]): Promise<CasReceipt> {
608
+ this.#assertDurableConfigWritable();
589
609
  if (!this.#persist || !this.#configPath) {
590
610
  const notificationValidationGuard = this.#notificationValidationRestoreGuard();
591
611
  const changes = new Map<string, { before: unknown; beforeHash: string; afterHash: string }>();
@@ -686,13 +706,16 @@ export class Settings implements NotificationSettingsReader {
686
706
  }));
687
707
  for (const entry of revisions) this.#pathRevisions.set(entry.patch.path, entry.revision);
688
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);
689
716
  try {
690
- const receipt = await applyAtomicYamlPatches(this.#configPath, durablePatches, {
691
- validateRoot: (root, currentPatches) =>
692
- this.#rejectAtomicNotificationRepairForMalformedRoot(currentPatches, root),
693
- onRestored: restoredPatches =>
694
- this.#applyRestoredDurableBatch(revisions, restoredPatches, notificationValidationGuard),
695
- });
717
+ const receipt = await commit;
718
+ await failureRefresh;
696
719
  const appliedNotificationMutation = this.#applyDurableBatch(revisions);
697
720
  this.#recordNotificationValidationBatchApply(notificationValidationGuard, appliedNotificationMutation);
698
721
  return receipt;
@@ -703,6 +726,7 @@ export class Settings implements NotificationSettingsReader {
703
726
  else this.#pathRevisions.set(entry.patch.path, entry.previousRevision);
704
727
  }
705
728
  }
729
+ await failureRefresh;
706
730
  if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
707
731
  throw error;
708
732
  }
@@ -714,6 +738,7 @@ export class Settings implements NotificationSettingsReader {
714
738
  current: Readonly<RawSettings>,
715
739
  ) => Promise<readonly SettingsAtomicPatch[]> | readonly SettingsAtomicPatch[],
716
740
  ): Promise<CasReceipt> {
741
+ this.#assertDurableConfigWritable();
717
742
  if (!this.#persist || !this.#configPath) {
718
743
  const patches = await buildPatches(structuredClone(this.#global));
719
744
  return this.commitAtomicBatch(patches);
@@ -722,38 +747,41 @@ export class Settings implements NotificationSettingsReader {
722
747
  this.#releasePendingSaveSlot();
723
748
  let revisions: DurableBatchRevision[] = [];
724
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);
725
782
  try {
726
- const receipt = await applyAtomicYamlPatchesWithCurrent(
727
- this.#configPath,
728
- async current => {
729
- const patches = await buildPatches(structuredClone(current));
730
- const durablePatches: AtomicYamlPatch[] = patches.map(patch => {
731
- if (!isAtomicSettingsPath(patch.path)) {
732
- throw new Error(`Unknown setting path for atomic batch: ${patch.path}`);
733
- }
734
- if (patch.op === "unset") return { path: patch.path, op: "unset" };
735
- if (patch.value === undefined) {
736
- throw new TypeError(
737
- `Settings set patch for ${patch.path} cannot carry undefined; use unset instead.`,
738
- );
739
- }
740
- return { path: patch.path, op: "set", value: structuredClone(patch.value) };
741
- });
742
- revisions = durablePatches.map(patch => ({
743
- patch,
744
- revision: ++this.#nextRevision,
745
- previousRevision: this.#pathRevisions.get(patch.path),
746
- }));
747
- for (const entry of revisions) this.#pathRevisions.set(entry.patch.path, entry.revision);
748
- return durablePatches;
749
- },
750
- {
751
- validateRoot: (root, currentPatches) =>
752
- this.#rejectAtomicNotificationRepairForMalformedRoot(currentPatches, root),
753
- onRestored: restoredPatches =>
754
- this.#applyRestoredDurableBatch(revisions, restoredPatches, notificationValidationGuard),
755
- },
756
- );
783
+ const receipt = await commit;
784
+ await failureRefresh;
757
785
  const appliedNotificationMutation = this.#applyDurableBatch(revisions);
758
786
  this.#recordNotificationValidationBatchApply(notificationValidationGuard, appliedNotificationMutation);
759
787
  return receipt;
@@ -764,6 +792,7 @@ export class Settings implements NotificationSettingsReader {
764
792
  else this.#pathRevisions.set(entry.patch.path, entry.previousRevision);
765
793
  }
766
794
  }
795
+ await failureRefresh;
767
796
  if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
768
797
  throw error;
769
798
  }
@@ -818,6 +847,15 @@ export class Settings implements NotificationSettingsReader {
818
847
  }
819
848
  }
820
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
+ }
821
859
  }
822
860
 
823
861
  /** Like {@link flush}, but reports a durable save failure to the caller. */
@@ -825,8 +863,20 @@ export class Settings implements NotificationSettingsReader {
825
863
  this.#releasePendingSaveSlot();
826
864
  if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
827
865
  this.#releasePendingSaveSlot();
828
- await this.#savePromise;
866
+ let saveError: unknown;
867
+ try {
868
+ await this.#savePromise;
869
+ } catch (error) {
870
+ saveError = error;
871
+ }
829
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;
830
880
  }
831
881
 
832
882
  async cloneForCwd(cwd: string): Promise<Settings> {
@@ -840,15 +890,23 @@ export class Settings implements NotificationSettingsReader {
840
890
  inMemory: !this.#persist,
841
891
  });
842
892
  cloned.#storage = this.#storage;
893
+ cloned.#schemaReport = structuredClone(this.#schemaReport);
894
+ cloned.#schemaMigrationPending = this.#schemaMigrationPending;
843
895
  cloned.#futureSchemaVersion = this.#futureSchemaVersion;
844
-
896
+ cloned.#hasMalformedConfigRoot = this.#hasMalformedConfigRoot;
897
+ cloned.#hasRecoveredConfigSyntax = this.#hasRecoveredConfigSyntax;
898
+ cloned.#hasInvalidNotificationConfiguration = this.#hasInvalidNotificationConfiguration;
899
+ cloned.#notificationValidationGeneration = this.#notificationValidationGeneration;
845
900
  cloned.#global = structuredClone(this.#global);
846
901
  cloned.#rawNotificationConfig = structuredClone(this.#rawNotificationConfig);
847
902
  cloned.#durableRawNotificationConfig = structuredClone(this.#durableRawNotificationConfig);
848
903
  cloned.#durableNotificationFingerprint = this.#durableNotificationFingerprint;
849
904
  cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
850
905
  cloned.#overrides = structuredClone(this.#overrides);
851
- await cloned.#normalizeAfterLoad();
906
+ if (cloned.#hasRecoveredConfigSyntax) {
907
+ cloned.#sanitizeModelSelectorRecords();
908
+ cloned.#rebuildMerged();
909
+ } else await cloned.#normalizeAfterLoad();
852
910
  cloned.#fireAllHooks();
853
911
  return cloned;
854
912
  }
@@ -940,6 +998,7 @@ export class Settings implements NotificationSettingsReader {
940
998
  }
941
999
 
942
1000
  setGlobalModelRole(role: ModelRole | string, modelId: ModelSelectorValue | undefined): void {
1001
+ this.#assertDurableConfigWritable();
943
1002
  const revision = ++this.#nextRevision;
944
1003
  const patch: SettingsPatch = {
945
1004
  path: "modelRoles",
@@ -973,13 +1032,27 @@ export class Settings implements NotificationSettingsReader {
973
1032
  }
974
1033
 
975
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
+ }
976
1039
  this.#global = current;
977
1040
  for (const patch of this.#pendingPatchesInGenerationOrder()) {
978
1041
  applySettingsPatch(this.#global, { ...patch, value: structuredClone(patch.value) });
979
- this.#applyNotificationMutationToRaw(patch.path, patch.value);
1042
+ if (this.#rawNotificationConfig !== undefined) {
1043
+ this.#applyNotificationMutationToRaw(patch.path, patch.value);
1044
+ }
980
1045
  }
981
1046
  this.#rebuildMerged();
982
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
+ }
983
1056
  }
984
1057
  /**
985
1058
  * Set an agent model override while keeping any live runtime override aligned.
@@ -1073,60 +1146,97 @@ export class Settings implements NotificationSettingsReader {
1073
1146
  }
1074
1147
  }
1075
1148
 
1076
- async #loadYaml(filePath: string): Promise<RawSettings> {
1149
+ #resetYamlLoadState(): void {
1077
1150
  this.#hasMalformedConfigRoot = false;
1151
+ this.#hasRecoveredConfigSyntax = false;
1078
1152
  this.#hasInvalidNotificationConfiguration = false;
1153
+ this.#schemaReport = { issues: [], valid: true };
1154
+ this.#schemaMigrationPending = false;
1155
+ this.#futureSchemaVersion = false;
1079
1156
  this.#captureRawNotificationConfig({});
1080
- try {
1081
- const content = await Bun.file(filePath).text();
1082
- const parsed = YAML.parse(content);
1083
- if (parsed === undefined) return {};
1084
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1085
- this.#hasMalformedConfigRoot = true;
1086
- this.#captureRawNotificationConfig(undefined);
1087
- return {};
1088
- }
1089
- const parsedRaw = parsed as RawSettings;
1090
- if (filePath === this.#configPath) this.#captureRawNotificationConfig(parsedRaw);
1091
- if (filePath === this.#configPath) {
1092
- try {
1093
- parseNotificationSettingsSnapshot(parsedRaw);
1094
- } catch (error) {
1095
- if (!(error instanceof Error) || error.message !== "gjc_notify_daemon_invalid_configuration")
1096
- throw error;
1097
- this.#hasInvalidNotificationConfiguration = true;
1098
- }
1099
- }
1100
- this.#futureSchemaVersion =
1101
- filePath === this.#configPath &&
1102
- typeof parsedRaw.configSchemaVersion === "number" &&
1103
- parsedRaw.configSchemaVersion > CONFIG_SCHEMA_VERSION;
1157
+ }
1104
1158
 
1105
- const configSchemaVersion = parsedRaw.configSchemaVersion;
1106
- if (
1107
- filePath === this.#configPath &&
1108
- (typeof configSchemaVersion !== "number" || configSchemaVersion < CONFIG_SCHEMA_VERSION)
1109
- ) {
1110
- this.#schemaMigrationPending = true;
1111
- }
1112
- const migrated = this.#migrateRawSettings(parsedRaw);
1113
- const reconciled = reconcileSettingsSchema(migrated);
1114
- if (typeof configSchemaVersion === "number" && configSchemaVersion > CONFIG_SCHEMA_VERSION) {
1115
- reconciled.report.issues.push({
1116
- path: "configSchemaVersion",
1117
- kind: "pending-migration",
1118
- detail: `Configuration requires schema version ${configSchemaVersion}.`,
1119
- });
1120
- }
1121
- this.#schemaReport = reconciled.report;
1122
- return reconciled.settings;
1159
+ async #loadYaml(filePath: string): Promise<RawSettings> {
1160
+ let content: string;
1161
+ try {
1162
+ content = await Bun.file(filePath).text();
1123
1163
  } catch (error) {
1124
1164
  if (isEnoent(error)) {
1125
- this.#captureRawNotificationConfig({});
1165
+ this.#resetYamlLoadState();
1126
1166
  return {};
1127
1167
  }
1128
1168
  throw error;
1129
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;
1130
1240
  }
1131
1241
 
1132
1242
  async #loadProjectSettings(): Promise<RawSettings> {
@@ -1519,7 +1629,7 @@ export class Settings implements NotificationSettingsReader {
1519
1629
  // ─────────────────────────────────────────────────────────────────────────
1520
1630
 
1521
1631
  #queueSave(): void {
1522
- if (!this.#persist || !this.#configPath) return;
1632
+ if (!this.#persist || !this.#configPath || this.#hasRecoveredConfigSyntax) return;
1523
1633
 
1524
1634
  const currentSlot = this.#pendingSaveSlot;
1525
1635
  if (currentSlot && !currentSlot.captured && !currentSlot.released) {
@@ -1586,25 +1696,33 @@ export class Settings implements NotificationSettingsReader {
1586
1696
  this.#recomputeNotificationValidationFromRaw();
1587
1697
  },
1588
1698
  };
1589
- }).then(() => undefined);
1590
- this.#savePromise = save;
1591
- void save.catch(error => {
1592
- logger.warn("Settings: background save failed", { error: String(error) });
1593
- for (const patch of captured) {
1594
- const key = settingsPatchKey(patch);
1595
- if (this.#modified.get(key)?.generation === patch.generation) this.#modified.set(key, patch);
1596
- }
1597
- if (durableBeforeWrite) {
1598
- this.#global = durableBeforeWrite;
1599
- this.#captureRawNotificationConfig(durableBeforeWrite);
1600
- for (const patch of this.#pendingPatchesInGenerationOrder()) {
1601
- applySettingsPatch(this.#global, { ...patch, value: structuredClone(patch.value) });
1602
- 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);
1603
1706
  }
1604
- this.#rebuildMerged();
1605
- this.#recomputeNotificationValidationFromRaw();
1606
- }
1607
- });
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(() => {});
1608
1726
  this.#armSaveTimer(slot);
1609
1727
  }
1610
1728
 
@@ -1708,13 +1826,41 @@ export class Settings implements NotificationSettingsReader {
1708
1826
  return applicable.some(patch => isNotificationSettingsPath(patch.path));
1709
1827
  }
1710
1828
 
1711
- async #refreshDurableSettings(): Promise<void> {
1712
- 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> {
1713
1847
  const previousFingerprint = this.#durableNotificationFingerprint;
1714
- const current = await this.#loadYaml(this.#configPath);
1848
+ const current = await this.#loadYaml(canonicalPath);
1715
1849
  if (previousFingerprint !== this.#durableNotificationFingerprint) this.#notificationValidationGeneration++;
1716
1850
  this.#replaceGlobalWithDurable(current);
1717
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
+ }
1718
1864
 
1719
1865
  // ─────────────────────────────────────────────────────────────────────────
1720
1866
  // Utilities