sneakoscope 10.3.7 → 10.3.8

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 (63) hide show
  1. package/README.md +12 -181
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/dist/commands/bridge.js +61 -1
  5. package/dist/commands/codex-app.js +4 -1
  6. package/dist/config/skills-manifest.json +1 -1
  7. package/dist/core/agents/native-worker-backend-router.js +55 -0
  8. package/dist/core/codex-app/codex-app-restart-policy.js +20 -0
  9. package/dist/core/codex-app/codex-context-window.js +61 -24
  10. package/dist/core/codex-app/menubar/constants.js +4 -0
  11. package/dist/core/codex-compat/codex-hook-output-builders.js +3 -1
  12. package/dist/core/codex-lb/bridge-cli-contract.js +2 -0
  13. package/dist/core/codex-lb/desktop-bridge/exclusive-provider-guard.js +59 -0
  14. package/dist/core/codex-lb/desktop-bridge/http-forward.js +23 -15
  15. package/dist/core/codex-lb/desktop-bridge/index.js +1 -0
  16. package/dist/core/codex-lb/desktop-bridge/responses-websocket.js +10 -3
  17. package/dist/core/codex-lb/desktop-bridge/security.js +63 -1
  18. package/dist/core/codex-lb/desktop-bridge/server.js +5 -0
  19. package/dist/core/codex-lb/desktop-bridge/state.js +3 -0
  20. package/dist/core/codex-lb/desktop-bridge/websocket-forward.js +5 -1
  21. package/dist/core/codex-lb/desktop-controller-v3/catalog.js +31 -5
  22. package/dist/core/codex-lb/desktop-controller-v3/lifecycle-commands.js +23 -4
  23. package/dist/core/codex-lb/desktop-controller-v3/openrouter-only-catalog.js +39 -0
  24. package/dist/core/codex-lb/desktop-controller-v3/openrouter-only-commands.js +286 -0
  25. package/dist/core/codex-lb/desktop-controller-v3/openrouter-only-main-model.js +101 -0
  26. package/dist/core/codex-lb/desktop-controller-v3/openrouter-only-status.js +68 -0
  27. package/dist/core/codex-lb/desktop-controller-v3/status.js +8 -5
  28. package/dist/core/codex-lb/desktop-controller-v3.js +26 -2
  29. package/dist/core/codex-lb/desktop-service.js +13 -2
  30. package/dist/core/commands/naruto-command.js +1 -1
  31. package/dist/core/decisions/child-model-choice.js +107 -0
  32. package/dist/core/decisions/cli.js +19 -2
  33. package/dist/core/decisions/routing.js +5 -4
  34. package/dist/core/hooks-runtime/hook-context.js +21 -7
  35. package/dist/core/hooks-runtime/hook-io.js +3 -2
  36. package/dist/core/hooks-runtime/jev-spawn-routing.js +83 -5
  37. package/dist/core/hooks-runtime/official-subagent-lifecycle.js +3 -2
  38. package/dist/core/hooks-runtime/parent-orchestration-gate.js +18 -1
  39. package/dist/core/hooks-runtime/subagent-context.js +13 -2
  40. package/dist/core/hooks-runtime/subagent-spawn-policy.js +49 -6
  41. package/dist/core/hooks-runtime.js +12 -5
  42. package/dist/core/managed-assets/managed-assets-manifest.js +9 -4
  43. package/dist/core/subagents/child-model-allowlist.js +179 -0
  44. package/dist/core/subagents/child-model-plan.js +117 -0
  45. package/dist/core/subagents/naruto-host-credentials.js +82 -13
  46. package/dist/core/subagents/official-subagent-config.js +66 -15
  47. package/dist/core/subagents/official-subagent-preparation.js +45 -12
  48. package/dist/core/subagents/official-subagent-prompt.js +82 -11
  49. package/dist/core/subagents/official-subagent-runner.js +3 -0
  50. package/dist/core/subagents/read-only-list-role.js +73 -0
  51. package/dist/core/subagents/role-model-preferences.js +54 -17
  52. package/dist/core/version.js +1 -1
  53. package/dist/native/sks-menubar/Sources/ControlCenterWindowController.swift +2 -0
  54. package/dist/native/sks-menubar/Sources/OpenRouterOnlyState.swift +314 -0
  55. package/dist/native/sks-menubar/Sources/ProcessClient.swift +2 -1
  56. package/dist/native/sks-menubar/Sources/ProvidersModelExposure.swift +15 -3
  57. package/dist/native/sks-menubar/Sources/ProvidersOpenRouterOnly.swift +162 -0
  58. package/dist/native/sks-menubar/Sources/ProvidersViewController.swift +36 -18
  59. package/dist/native/sks-menubar/Sources/SettingsViewController.swift +10 -4
  60. package/dist/native/sks-menubar/Sources/SidebarItem.swift +2 -0
  61. package/dist/native/sks-menubar/Sources/SubagentModelsModels.swift +201 -0
  62. package/dist/native/sks-menubar/Sources/SubagentModelsViewController.swift +373 -0
  63. package/package.json +1 -1
@@ -23,6 +23,13 @@ final class ProvidersViewController: NSViewController, ControlCenterPage, NSText
23
23
  var authPriorityEnabled: Bool?
24
24
  var authPriorityBusy = false
25
25
  var authPriorityGeneration = 0
26
+ // OpenRouter Only shares the Default connection card; its logic lives in ProvidersOpenRouterOnly.swift.
27
+ let openRouterOnlyToggle = NSSwitch()
28
+ let openRouterOnlyStatus = NativeView.detail("Checking OpenRouter Only…")
29
+ let openRouterOnlyDetail = NativeView.detail("")
30
+ var openRouterOnlyEnabled: Bool?
31
+ var openRouterOnlyBusy = false
32
+ var openRouterOnlyGeneration = 0
26
33
  let providerStatus = NativeView.detail("Desktop Bridge status has not loaded.")
27
34
  let bridgeServiceStatus = NativeView.detail("Service: checking…")
28
35
  let bridgeHttpStatus = NativeView.detail("HTTP probe: not attempted")
@@ -116,64 +123,67 @@ final class ProvidersViewController: NSViewController, ControlCenterPage, NSText
116
123
  return NativeView.card(title: "Default connection", subtitle: "", views: [
117
124
  NativeView.row([authPriorityToggle, label]), authPriorityStatus,
118
125
  NativeView.detail("Use the saved Codex-LB connection first for eligible models. Explicit provider choices and session pins still apply.")
119
- ])
126
+ ] + makeOpenRouterOnlyRows())
120
127
  }
121
128
 
122
- func refreshAuthPriority(afterUnconfirmedMutation: Bool = false) {
123
- guard !authPriorityBusy else { return }
129
+ func refreshAuthPriority(notice: String? = nil) {
130
+ guard !routingModeBusy else { return }
124
131
  authPriorityGeneration += 1
125
132
  let generation = authPriorityGeneration
126
133
  processClient.run(["bridge", "auth-priority", "status", "--json"], timeout: NativeView.statusTimeout) { [weak self] result in
127
- guard let self = self, !self.authPriorityBusy, generation == self.authPriorityGeneration else { return }
134
+ guard let self = self, !self.routingModeBusy, generation == self.authPriorityGeneration else { return }
128
135
  guard !result.timedOut, !result.truncated,
129
136
  let payload = self.json(result.output),
130
137
  let priority = AuthPriorityState.decode(payload) else {
131
138
  self.authPriorityEnabled = nil
132
- self.authPriorityToggle.isEnabled = false
139
+ self.updateRoutingModeSwitches()
133
140
  self.authPriorityToggle.isHidden = true
134
141
  self.authPriorityStatus.stringValue = "Saved preference could not be confirmed. Reopen Connections to check again."
135
142
  self.authPriorityStatus.textColor = .systemOrange
136
143
  return
137
144
  }
138
145
  self.renderAuthPriority(priority)
139
- if afterUnconfirmedMutation {
140
- self.authPriorityStatus.stringValue += "\nSaved preference rechecked. The earlier operation did not finish normally."
146
+ if let notice = notice {
147
+ self.authPriorityStatus.stringValue += "\n" + notice
141
148
  self.authPriorityStatus.textColor = .systemOrange
142
149
  }
143
150
  }
144
151
  }
145
152
 
146
- private func renderAuthPriority(_ state: AuthPriorityState) {
153
+ func renderAuthPriority(_ state: AuthPriorityState) {
147
154
  authPriorityEnabled = state.enabled
148
155
  authPriorityToggle.isHidden = false
149
156
  authPriorityToggle.state = state.enabled ? .on : .off
150
- authPriorityToggle.isEnabled = !busy && !authPriorityBusy
157
+ updateRoutingModeSwitches()
151
158
  authPriorityStatus.stringValue = state.message
152
159
  authPriorityStatus.textColor = state.state == "active" ? .systemGreen : state.state == "unavailable" ? .systemOrange : .secondaryLabelColor
153
160
  }
154
161
 
155
162
  @objc func toggleAuthPriority() {
156
- guard !authPriorityBusy, let previous = authPriorityEnabled else { return }
163
+ guard !routingModeBusy, let previous = authPriorityEnabled else { return }
157
164
  let desired = authPriorityToggle.state == .on
158
165
  guard let operation = operations.begin(kind: "bridge-auth-priority", mutationGroup: "codex-config", summary: "Change Codex-LB preference") else {
159
166
  authPriorityToggle.state = previous ? .on : .off
160
167
  authPriorityStatus.stringValue = "Another configuration change is running. Try again when it finishes."
161
168
  return
162
169
  }
163
- authPriorityGeneration += 1
164
170
  authPriorityBusy = true
165
- authPriorityToggle.isEnabled = false
171
+ beginRoutingModeMutation()
166
172
  authPriorityStatus.stringValue = "Saving preference…"
167
173
  _ = operations.update(operation, state: .running, stage: "saving", progress: nil, summary: "Saving Codex-LB preference")
168
174
  processClient.run(["bridge", "auth-priority", desired ? "on" : "off", "--json"], timeout: NativeView.mutationTimeout) { [weak self] result in
169
175
  guard let self = self else { return }
170
176
  self.authPriorityBusy = false
171
177
  let payload = self.json(result.output)
178
+ let complete = !result.truncated && !result.timedOut
172
179
  let outcome = AuthPriorityMutationOutcome.resolve(
173
180
  payload: payload, desired: desired,
174
- commandSucceeded: result.code == 0 && payload?["ok"] as? Bool == true,
175
- responseComplete: !result.truncated && !result.timedOut
181
+ commandSucceeded: result.code == 0 && payload?["ok"] as? Bool == true
182
+ && OpenRouterOnlyReceipt.decode(payload).blockers.isEmpty,
183
+ responseComplete: complete
176
184
  )
185
+ self.renderOpenRouterOnly(from: payload, responseComplete: complete)
186
+ let issue = OpenRouterOnlyReceipt.decode(payload).primaryIssue.map(ProviderSecretRedactor.redact)
177
187
  let operationState: OperationState
178
188
  switch outcome {
179
189
  case .saved: operationState = .succeeded
@@ -184,17 +194,19 @@ final class ProvidersViewController: NSViewController, ControlCenterPage, NSText
184
194
  if let state = outcome.observedState {
185
195
  self.renderAuthPriority(state)
186
196
  if operationState != .succeeded {
187
- self.authPriorityStatus.stringValue = outcome.operationSummary + "\n" + state.message
197
+ self.authPriorityStatus.stringValue = [outcome.operationSummary, issue].compactMap { $0 }.joined(separator: " · ") + "\n" + state.message
188
198
  self.authPriorityStatus.textColor = .systemOrange
189
199
  }
190
200
  } else {
191
201
  self.authPriorityEnabled = nil
192
- self.authPriorityToggle.isEnabled = false
202
+ self.updateRoutingModeSwitches()
193
203
  self.authPriorityToggle.isHidden = true
194
204
  self.authPriorityStatus.stringValue = "Operation result is uncertain. Checking the saved preference…"
195
205
  self.authPriorityStatus.textColor = .systemOrange
196
- self.refreshAuthPriority(afterUnconfirmedMutation: true)
197
206
  }
207
+ // Turning Codex-LB on turns OpenRouter Only off: re-read both modes.
208
+ let notice = outcome.observedState == nil ? "Saved preference rechecked. The earlier operation did not finish normally." : operationState == .succeeded ? nil : outcome.operationSummary
209
+ self.rereadRoutingModes(authPriorityNotice: notice, openRouterOnlyNotice: nil)
198
210
  }
199
211
  }
200
212
 
@@ -221,7 +233,7 @@ final class ProvidersViewController: NSViewController, ControlCenterPage, NSText
221
233
 
222
234
  func setBusy(_ value: Bool) {
223
235
  busy = value
224
- authPriorityToggle.isEnabled = !value && !authPriorityBusy && authPriorityEnabled != nil
236
+ updateRoutingModeSwitches()
225
237
  for button in actionButtons where !providerButtons.values.flatMap({ $0 }).contains(where: { $0 === button }) { button.isEnabled = !value }
226
238
  value ? globalSpinner.startAnimation(nil) : globalSpinner.stopAnimation(nil)
227
239
  }
@@ -235,6 +247,12 @@ final class ProvidersViewController: NSViewController, ControlCenterPage, NSText
235
247
  func refresh() {
236
248
  refreshCredentialHealth()
237
249
  refreshAuthPriority()
250
+ refreshOpenRouterOnly()
251
+ refreshBridgeStatus()
252
+ }
253
+
254
+ /// Runtime, profiles, combined catalog and routes from one v3 status read.
255
+ func refreshBridgeStatus() {
238
256
  processClient.run(["bridge", "status", "--json"], timeout: NativeView.statusTimeout) { [weak self] result in
239
257
  guard let self = self, let json = self.json(result.output),
240
258
  let status = try? DesktopBridgeStatusV3Truth.decode(from: json),
@@ -56,7 +56,7 @@ final class SettingsViewController: NSViewController, ControlCenterPage {
56
56
  )
57
57
  let contextCard = NativeView.card(
58
58
  title: "Codex 1M Context",
59
- subtitle: "Allow up to 1M tokens for GPT-5.6 Sol. New sessions use this setting; running Codex restarts. Requests over 272K input tokens use the long-context rate for the entire request.",
59
+ subtitle: "Let every Codex model use its largest context window, up to 1M tokens. New sessions use this setting; running Codex restarts. Requests over 272K input tokens use the long-context rate for the entire request.",
60
60
  views: [NativeView.row([contextToggleButton]), contextStatus]
61
61
  )
62
62
  let notificationsCard = NativeView.card(
@@ -173,12 +173,18 @@ final class SettingsViewController: NSViewController, ControlCenterPage {
173
173
  self.contextToggleButton.title = enabled ? "Disable 1M Context" : "Enable 1M Context"
174
174
  guard !preserveStatusText else { return }
175
175
  let model = json["model"] as? String
176
+ let window = json["model_window"] as? [String: Any]
177
+ let effective = window?["effective_window"] as? Int
176
178
  var text = enabled
177
- ? "Enabled · window 1,000,000 · auto-compact 900,000 · applies to new sessions only."
179
+ ? "Enabled · applies to new sessions only."
178
180
  : "Disabled · Codex uses its tuned default context window."
179
181
  var tone: NSColor = enabled ? .systemGreen : .secondaryLabelColor
180
- if enabled, let model = model, model != "gpt-5.6-sol" {
181
- text += " Active model is \(model); the 1M window is documented for gpt-5.6-sol."
182
+ if let model = model, let effective = effective {
183
+ let size = NumberFormatter.localizedString(from: NSNumber(value: effective), number: .decimal)
184
+ text += enabled ? " \(model) window \(size) tokens." : " \(model) can use up to \(size) tokens."
185
+ }
186
+ if enabled, let model = model, window?["extends"] as? Bool == false {
187
+ text += " \(model) has a fixed window, so this setting does not enlarge it."
182
188
  tone = .systemOrange
183
189
  }
184
190
  self.contextStatus.stringValue = text
@@ -5,6 +5,7 @@ import Foundation
5
5
  enum SidebarItem: String, CaseIterable {
6
6
  case overview = "Overview"
7
7
  case providers = "Providers"
8
+ case subagentModels = "Subagent Models"
8
9
  case remoteCoding = "Remote Coding"
9
10
  case updates = "Updates"
10
11
  case mcpServers = "MCP Servers"
@@ -19,6 +20,7 @@ enum SidebarItem: String, CaseIterable {
19
20
  switch self {
20
21
  case .overview: return "gauge"
21
22
  case .providers: return "network"
23
+ case .subagentModels: return "person.2"
22
24
  case .updates: return "arrow.down.circle"
23
25
  case .mcpServers: return "server.rack"
24
26
  case .localDecision: return "cpu"
@@ -0,0 +1,201 @@
1
+ import Foundation
2
+
3
+ /// One `available` row of `sks bridge subagent-models list --json`: an
4
+ /// OpenRouter model the bridge catalog knows.
5
+ struct SubagentModelOption: Equatable {
6
+ let publicId: String
7
+ let displayName: String
8
+ /// false for a saved or drafted model the catalog no longer lists.
9
+ var listed = true
10
+
11
+ var menuTitle: String {
12
+ guard listed else { return "\(publicId) · not in the current catalog" }
13
+ return displayName == publicId ? publicId : "\(displayName) · \(publicId)"
14
+ }
15
+ }
16
+
17
+ /// `subagent-models.list` / `subagent-models.set` answers: the mode with its
18
+ /// saved list, and (when reported) the models the row popups can offer.
19
+ struct SubagentModelsSnapshot: Equatable {
20
+ static let operations: Set<String> = ["subagent-models.list", "subagent-models.set"]
21
+
22
+ let mode: OpenRouterOnlyState
23
+ /// nil when the answer did not include `available` (keep the previous list).
24
+ let available: [SubagentModelOption]?
25
+
26
+ static func decode(_ payload: [String: Any]) -> SubagentModelsSnapshot? {
27
+ guard payload["schema"] as? String == OpenRouterOnlyCommand.resultSchema,
28
+ let operation = payload["operation"] as? String, operations.contains(operation),
29
+ let mode = OpenRouterOnlyState.decode(payload) else { return nil }
30
+ let result = payload["result"] as? [String: Any]
31
+ var seen = Set<String>()
32
+ let available = (result?["available"] as? [Any]).map { rows in
33
+ rows.compactMap { value -> SubagentModelOption? in
34
+ guard let row = value as? [String: Any],
35
+ let id = OpenRouterOnlyJSON.text(row["public_id"]),
36
+ SubagentModelRules.isModelId(id),
37
+ seen.insert(id.lowercased()).inserted else { return nil }
38
+ return SubagentModelOption(publicId: id, displayName: OpenRouterOnlyJSON.text(row["display_name"]) ?? id)
39
+ }
40
+ }
41
+ return SubagentModelsSnapshot(mode: mode, available: available)
42
+ }
43
+ }
44
+
45
+ /// Pure edits of the unapplied list. The page keeps one draft array and
46
+ /// always sends the complete list, never a patch.
47
+ enum SubagentModelDraft {
48
+ static let effortTitles = ["Default", "low", "medium", "high", "xhigh"]
49
+
50
+ /// Popup rows: every available model, then saved or drafted models the CLI
51
+ /// no longer lists, so a popup never misstates a choice.
52
+ static func popupOptions(available: [SubagentModelOption], entries: [SubagentModelEntry]) -> [SubagentModelOption] {
53
+ var seen = Set(available.map { $0.publicId.lowercased() })
54
+ let missing = entries.filter { seen.insert($0.model.lowercased()).inserted }
55
+ .map { SubagentModelOption(publicId: $0.model, displayName: $0.model, listed: false) }
56
+ return available + missing
57
+ }
58
+
59
+ /// A new row takes the first available model not already on the list.
60
+ static func adding(_ entries: [SubagentModelEntry], available: [SubagentModelOption]) -> [SubagentModelEntry]? {
61
+ guard entries.count < SubagentModelRules.maxModels else { return nil }
62
+ let used = Set(entries.map { $0.model.lowercased() })
63
+ guard let next = available.first(where: { !used.contains($0.publicId.lowercased()) }) else { return nil }
64
+ return entries + [SubagentModelEntry(model: next.publicId, criteria: "", reasoningEffort: nil, isDefault: entries.isEmpty, routable: nil)]
65
+ }
66
+
67
+ static func removing(_ entries: [SubagentModelEntry], at index: Int) -> [SubagentModelEntry] {
68
+ guard entries.indices.contains(index) else { return entries }
69
+ var next = entries
70
+ next.remove(at: index)
71
+ return withOneDefault(next)
72
+ }
73
+
74
+ static func settingDefault(_ entries: [SubagentModelEntry], at index: Int) -> [SubagentModelEntry] {
75
+ guard entries.indices.contains(index) else { return entries }
76
+ return entries.enumerated().map { offset, entry in
77
+ var copy = entry
78
+ copy.isDefault = offset == index
79
+ return copy
80
+ }
81
+ }
82
+
83
+ /// Same rule as the CLI: the first default wins, else the first row.
84
+ static func withOneDefault(_ entries: [SubagentModelEntry]) -> [SubagentModelEntry] {
85
+ let first = entries.firstIndex(where: \.isDefault) ?? 0
86
+ return entries.enumerated().map { offset, entry in
87
+ var copy = entry
88
+ copy.isDefault = offset == first
89
+ return copy
90
+ }
91
+ }
92
+
93
+ static func effort(title: String?) -> String? {
94
+ guard let title, SubagentModelRules.efforts.contains(title) else { return nil }
95
+ return title
96
+ }
97
+
98
+ /// The CLI caps criteria at 240 UTF-16 units (JavaScript `slice`). Capping
99
+ /// whole characters to the same budget keeps its slice a no-op, so the
100
+ /// saved text reads back exactly. While typing, spaces are kept.
101
+ static func limitCriteria(_ text: String) -> String {
102
+ let limit = SubagentModelRules.maxCriteriaCharacters
103
+ guard text.utf16.count > limit else { return text }
104
+ var kept = ""
105
+ for character in text {
106
+ guard kept.utf16.count + String(character).utf16.count <= limit else { break }
107
+ kept.append(character)
108
+ }
109
+ return kept
110
+ }
111
+
112
+ /// JavaScript `\s` plus the C0 controls and DEL the CLI folds into spaces.
113
+ private static let criteriaSeparators: CharacterSet = {
114
+ var set = CharacterSet(charactersIn: "\u{0}"..."\u{20}")
115
+ set.insert(charactersIn: "\u{7F}\u{A0}\u{1680}\u{2028}\u{2029}\u{202F}\u{205F}\u{3000}\u{FEFF}")
116
+ set.insert(charactersIn: "\u{2000}"..."\u{200A}")
117
+ return set
118
+ }()
119
+
120
+ /// Same cleanup as the CLI's cleanCriteria: controls and whitespace runs
121
+ /// become one space, then trim and cap.
122
+ static func cleanCriteria(_ text: String) -> String {
123
+ let collapsed = text.components(separatedBy: criteriaSeparators)
124
+ .filter { !$0.isEmpty }
125
+ .joined(separator: " ")
126
+ return limitCriteria(collapsed)
127
+ }
128
+
129
+ static func normalizedForSubmit(_ entries: [SubagentModelEntry]) -> [SubagentModelEntry] {
130
+ withOneDefault(entries.map { entry in
131
+ var copy = entry.submitted
132
+ copy.model = entry.model.trimmingCharacters(in: .whitespacesAndNewlines)
133
+ copy.criteria = cleanCriteria(entry.criteria)
134
+ return copy
135
+ })
136
+ }
137
+
138
+ /// Local mirror of normalizeSubagentModelList; codes carry the row index.
139
+ static func issues(_ entries: [SubagentModelEntry]) -> [String] {
140
+ var seen = Set<String>()
141
+ var issues: [String] = []
142
+ for (index, entry) in entries.enumerated() {
143
+ let model = entry.model.trimmingCharacters(in: .whitespacesAndNewlines)
144
+ if !SubagentModelRules.isModelId(model) {
145
+ issues.append("subagent_model_id_invalid:\(index)")
146
+ } else if !seen.insert(model.lowercased()).inserted {
147
+ issues.append("subagent_model_duplicate:\(index)")
148
+ } else if let effort = entry.reasoningEffort, !SubagentModelRules.efforts.contains(effort) {
149
+ issues.append("subagent_model_effort_invalid:\(index)")
150
+ } else if entry.criteriaUnreadable {
151
+ issues.append("subagent_model_criteria_redacted:\(index)")
152
+ } else if index >= SubagentModelRules.maxModels {
153
+ issues.append("subagent_model_list_too_long:\(index)")
154
+ }
155
+ }
156
+ return issues
157
+ }
158
+
159
+ /// A redacted answer row takes the text this page already holds when that
160
+ /// text redacts to exactly the answer; any other redacted row stays
161
+ /// unreadable, and `issues` keeps it from being applied.
162
+ static func restoringCriteria(_ incoming: [SubagentModelEntry], known: [SubagentModelEntry], redact: (String) -> String) -> [SubagentModelEntry] {
163
+ incoming.map { entry in
164
+ guard entry.criteriaUnreadable, let match = known.first(where: {
165
+ $0.model.lowercased() == entry.model.lowercased() && !$0.criteriaUnreadable
166
+ && (redact(cleanCriteria($0.criteria)) == entry.criteria || fitsRedacted(cleanCriteria($0.criteria), answer: entry.criteria))
167
+ }) else { return entry }
168
+ var copy = entry
169
+ copy.criteria = cleanCriteria(match.criteria)
170
+ return copy
171
+ }
172
+ }
173
+
174
+ /// The CLI and Control Center redact with different patterns; either way
175
+ /// each "[redacted]" stands for some text the known criteria must supply.
176
+ static func fitsRedacted(_ text: String, answer: String) -> Bool {
177
+ let marker = NSRegularExpression.escapedPattern(for: "[redacted]")
178
+ let pattern = "^" + NSRegularExpression.escapedPattern(for: answer).replacingOccurrences(of: marker, with: ".+?") + "$"
179
+ guard answer.contains("[redacted]"), let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return false }
180
+ return regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil
181
+ }
182
+
183
+ static func isDirty(draft: [SubagentModelEntry], saved: [SubagentModelEntry]) -> Bool {
184
+ normalizedForSubmit(draft) != normalizedForSubmit(saved)
185
+ }
186
+
187
+ /// stdin for `bridge subagent-models set --stdin --json`.
188
+ static func stdinPayload(_ entries: [SubagentModelEntry]) -> String? {
189
+ let rows: [[String: Any]] = normalizedForSubmit(entries).map { entry in
190
+ [
191
+ "model": entry.model,
192
+ "criteria": entry.criteria,
193
+ "reasoning_effort": entry.reasoningEffort.map { $0 as Any } ?? NSNull(),
194
+ "default": entry.isDefault
195
+ ]
196
+ }
197
+ guard let data = try? JSONSerialization.data(withJSONObject: ["subagent_models": rows], options: [.sortedKeys, .withoutEscapingSlashes]),
198
+ let text = String(data: data, encoding: .utf8) else { return nil }
199
+ return text + "\n"
200
+ }
201
+ }