sneakoscope 6.1.2 → 6.2.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 (64) hide show
  1. package/README.md +11 -4
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/dist/cli/install-helpers.js +8 -3
  5. package/dist/config/skills-manifest.json +58 -58
  6. package/dist/core/agents/agent-effort-policy.js +28 -17
  7. package/dist/core/agents/agent-schema.js +1 -1
  8. package/dist/core/codex/codex-config-guard.js +178 -7
  9. package/dist/core/codex/codex-config-readability.js +21 -8
  10. package/dist/core/codex/codex-config-toml.js +14 -11
  11. package/dist/core/codex-app/mcp-manager.js +679 -0
  12. package/dist/core/codex-app/sks-menubar.js +405 -6
  13. package/dist/core/codex-control/codex-lb-launch-recovery.js +15 -0
  14. package/dist/core/codex-native/core-skill-manifest.js +1 -1
  15. package/dist/core/commands/mad-sks-command.js +44 -3
  16. package/dist/core/commands/menubar-command.js +94 -0
  17. package/dist/core/commands/naruto-command.js +3 -1
  18. package/dist/core/commands/wiki-command.js +11 -5
  19. package/dist/core/fsx.js +1 -0
  20. package/dist/core/hooks-runtime/code-pack-freshness-preflight.js +14 -8
  21. package/dist/core/hooks-runtime/naruto-decision-gate.js +184 -0
  22. package/dist/core/hooks-runtime/stop-repeat-guard.js +89 -0
  23. package/dist/core/hooks-runtime.js +36 -105
  24. package/dist/core/init/skills.js +4 -4
  25. package/dist/core/init.js +1 -1
  26. package/dist/core/managed-assets/managed-assets-manifest.js +178 -29
  27. package/dist/core/preflight/parallel-preflight-engine.js +16 -3
  28. package/dist/core/proof/route-adapter.js +1 -1
  29. package/dist/core/proof/selftest-proof-fixtures.js +3 -5
  30. package/dist/core/provider/model-router.js +15 -6
  31. package/dist/core/release/package-size-budget.js +4 -6
  32. package/dist/core/research/research-plan-markdown.js +123 -0
  33. package/dist/core/research/research-super-search.js +8 -4
  34. package/dist/core/research.js +4 -119
  35. package/dist/core/retention.js +70 -2
  36. package/dist/core/routes/dollar-manifest-lite.js +1 -1
  37. package/dist/core/routes.js +75 -22
  38. package/dist/core/subagents/agent-catalog.js +87 -15
  39. package/dist/core/subagents/model-policy.js +189 -48
  40. package/dist/core/subagents/naruto-help-contract.js +11 -4
  41. package/dist/core/subagents/official-subagent-preparation.js +55 -9
  42. package/dist/core/subagents/official-subagent-prompt.js +45 -23
  43. package/dist/core/subagents/thread-budget.js +1 -1
  44. package/dist/core/subagents/triwiki-attention.js +117 -14
  45. package/dist/core/triwiki/code-pack-head-freshness.js +291 -0
  46. package/dist/core/version.js +1 -1
  47. package/dist/core/zellij/zellij-launcher.js +12 -2
  48. package/dist/core/zellij/zellij-pane-proof.js +9 -1
  49. package/dist/core/zellij/zellij-update.js +14 -1
  50. package/dist/scripts/canonical-test-runner.js +7 -1
  51. package/dist/scripts/codex-lb-fast-mode-truth-check.js +11 -1
  52. package/dist/scripts/codex-lb-fast-ui-preservation-check.js +9 -2
  53. package/dist/scripts/codex-lb-gpt56-fast-profile-check.js +13 -2
  54. package/dist/scripts/codex-native-agent-role-content-check.js +13 -1
  55. package/dist/scripts/codex-sdk-backend-router-check.js +2 -0
  56. package/dist/scripts/lib/codex-sdk-gate-lib.js +4 -0
  57. package/dist/scripts/official-subagent-workflow-check.js +1 -1
  58. package/dist/scripts/packlist-performance-check.js +1 -18
  59. package/dist/scripts/python-codex-sdk-all-pipelines-check.js +8 -0
  60. package/dist/scripts/sks-menubar-install-check.js +6 -1
  61. package/dist/scripts/super-search-provider-interface-check.js +31 -18
  62. package/package.json +3 -1
  63. package/dist/scripts/codex-0139-feature-probes-check.js +0 -30
  64. package/dist/scripts/codex-0139-marketplace-source-check.js +0 -13
@@ -22,6 +22,7 @@ const MENU_ITEMS = [
22
22
  'Update SKS Now',
23
23
  'Codex CLI Version',
24
24
  'Update Codex CLI Now',
25
+ 'Manage MCP Servers',
25
26
  'Run sks doctor --fix',
26
27
  'Open Dashboard',
27
28
  'Open Codex Settings',
@@ -961,7 +962,12 @@ func humanizeSksCode(_ code: String) -> String {
961
962
  "setup_needed": "codex-lb is not configured yet.",
962
963
  "cancelled": "Setup was cancelled.",
963
964
  "process_only_cancelled": "Setup was cancelled (process-only mode was not confirmed).",
964
- "process_only_requires_yes": "This setup would only be kept for the current session — nothing durable was saved."
965
+ "process_only_requires_yes": "This setup would only be kept for the current session — nothing durable was saved.",
966
+ "codex_mcp_config_read_failed": "Could not read ~/.codex/config.toml. Check its ownership and permissions; no MCP change was made.",
967
+ "codex_mcp_config_symlink_unsupported": "~/.codex/config.toml is a symbolic link. The MCP manager left it unchanged to avoid replacing the link.",
968
+ "codex_mcp_config_not_regular_file": "~/.codex/config.toml is not a regular file. The MCP manager left it unchanged.",
969
+ "codex_mcp_config_toml_parse_failed": "~/.codex/config.toml contains invalid TOML. Fix it before managing MCP servers.",
970
+ "codex_mcp_config_busy": "Another MCP configuration change is in progress. Try again shortly."
965
971
  ]
966
972
  if let mapped = known[code] { return mapped }
967
973
  let words = code.split(separator: "_").joined(separator: " ")
@@ -1011,6 +1017,55 @@ func promptChoice(title: String, message: String, options: [String]) -> String?
1011
1017
  return popup.titleOfSelectedItem
1012
1018
  }
1013
1019
 
1020
+ func promptTextAllowEmpty(title: String, message: String, placeholder: String = "", secure: Bool = false) -> String? {
1021
+ NSApp.activate(ignoringOtherApps: true)
1022
+ let alert = NSAlert()
1023
+ alert.messageText = title
1024
+ alert.informativeText = message
1025
+ alert.addButton(withTitle: "OK")
1026
+ alert.addButton(withTitle: "Cancel")
1027
+ let field: NSTextField = secure ? NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24)) : NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
1028
+ field.placeholderString = placeholder
1029
+ alert.accessoryView = field
1030
+ let response = alert.runModal()
1031
+ if response != .alertFirstButtonReturn { return nil }
1032
+ return field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1033
+ }
1034
+
1035
+ func promptMultiline(title: String, message: String, placeholder: String = "") -> String? {
1036
+ NSApp.activate(ignoringOtherApps: true)
1037
+ let alert = NSAlert()
1038
+ alert.messageText = title
1039
+ alert.informativeText = message
1040
+ alert.addButton(withTitle: "OK")
1041
+ alert.addButton(withTitle: "Cancel")
1042
+ let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 420, height: 120))
1043
+ scroll.hasVerticalScroller = true
1044
+ scroll.borderType = .bezelBorder
1045
+ let textView = NSTextView(frame: scroll.bounds)
1046
+ textView.isRichText = false
1047
+ textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
1048
+ textView.string = ""
1049
+ textView.setAccessibilityLabel(title)
1050
+ textView.setAccessibilityHelp(placeholder)
1051
+ scroll.documentView = textView
1052
+ alert.accessoryView = scroll
1053
+ let response = alert.runModal()
1054
+ if response != .alertFirstButtonReturn { return nil }
1055
+ return textView.string.trimmingCharacters(in: .whitespacesAndNewlines)
1056
+ }
1057
+
1058
+ func confirmAction(title: String, message: String, destructive: Bool = false) -> Bool {
1059
+ NSApp.activate(ignoringOtherApps: true)
1060
+ let alert = NSAlert()
1061
+ alert.messageText = title
1062
+ alert.informativeText = message
1063
+ alert.alertStyle = destructive ? .warning : .informational
1064
+ alert.addButton(withTitle: destructive ? "Remove" : "OK")
1065
+ alert.addButton(withTitle: "Cancel")
1066
+ return alert.runModal() == .alertFirstButtonReturn
1067
+ }
1068
+
1014
1069
  func parseJsonObject(_ text: String) -> [String: Any]? {
1015
1070
  guard let data = text.data(using: .utf8) else { return nil }
1016
1071
  return (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any]
@@ -1029,13 +1084,17 @@ func runProcess(_ executable: String, _ args: [String] = [], stdinText: String?
1029
1084
  process.standardInput = pipe
1030
1085
  inputPipe = pipe
1031
1086
  }
1032
- process.terminationHandler = { proc in
1033
- let data = output.fileHandleForReading.readDataToEndOfFile()
1034
- let text = String(data: data, encoding: .utf8) ?? ""
1035
- completion?(proc.terminationStatus, text)
1036
- }
1037
1087
  do {
1038
1088
  try process.run()
1089
+ output.fileHandleForWriting.closeFile()
1090
+ DispatchQueue.global(qos: .utility).async {
1091
+ let data = output.fileHandleForReading.readDataToEndOfFile()
1092
+ process.waitUntilExit()
1093
+ let text = String(data: data, encoding: .utf8) ?? ""
1094
+ DispatchQueue.main.async {
1095
+ completion?(process.terminationStatus, text)
1096
+ }
1097
+ }
1039
1098
  if let stdinText = stdinText, let inputPipe = inputPipe {
1040
1099
  inputPipe.fileHandleForWriting.write(Data(stdinText.utf8))
1041
1100
  inputPipe.fileHandleForWriting.closeFile()
@@ -1103,6 +1162,339 @@ struct MenuBarConfig {
1103
1162
  let quitWithCodex: Bool
1104
1163
  }
1105
1164
 
1165
+ struct McpServerRow {
1166
+ let name: String
1167
+ let enabled: Bool
1168
+ let transport: String
1169
+ let summary: String
1170
+ }
1171
+
1172
+ final class McpManagerController: NSObject, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate {
1173
+ weak var owner: AppDelegate?
1174
+ var panel: NSPanel!
1175
+ var tableView: NSTableView!
1176
+ var statusLabel: NSTextField!
1177
+ var removeButton: NSButton!
1178
+ var toggleButton: NSButton!
1179
+ var refreshButton: NSButton!
1180
+ var addButton: NSButton!
1181
+ var rows: [McpServerRow] = []
1182
+ var working = false
1183
+
1184
+ init(owner: AppDelegate) {
1185
+ self.owner = owner
1186
+ super.init()
1187
+ }
1188
+
1189
+ func show() {
1190
+ if panel == nil { buildPanel() }
1191
+ if panel.isVisible {
1192
+ panel.makeKeyAndOrderFront(nil)
1193
+ return
1194
+ }
1195
+ NSApp.activate(ignoringOtherApps: true)
1196
+ panel.center()
1197
+ panel.makeKeyAndOrderFront(nil)
1198
+ refresh()
1199
+ NSApp.runModal(for: panel)
1200
+ }
1201
+
1202
+ func buildPanel() {
1203
+ panel = NSPanel(
1204
+ contentRect: NSRect(x: 0, y: 0, width: 760, height: 440),
1205
+ styleMask: [.titled, .closable, .resizable, .utilityWindow],
1206
+ backing: .buffered,
1207
+ defer: false
1208
+ )
1209
+ panel.title = "MCP Servers"
1210
+ panel.minSize = NSSize(width: 640, height: 360)
1211
+ panel.isFloatingPanel = true
1212
+ panel.delegate = self
1213
+
1214
+ let content = NSView(frame: panel.contentView?.bounds ?? .zero)
1215
+ panel.contentView = content
1216
+
1217
+ let heading = NSTextField(labelWithString: "Global Codex MCP servers")
1218
+ heading.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
1219
+ heading.translatesAutoresizingMaskIntoConstraints = false
1220
+
1221
+ let scope = NSTextField(labelWithString: "Changes are written safely to ~/.codex/config.toml and apply to new Codex sessions.")
1222
+ scope.textColor = .secondaryLabelColor
1223
+ scope.font = NSFont.systemFont(ofSize: 11)
1224
+ scope.translatesAutoresizingMaskIntoConstraints = false
1225
+
1226
+ tableView = NSTableView()
1227
+ tableView.delegate = self
1228
+ tableView.dataSource = self
1229
+ tableView.usesAlternatingRowBackgroundColors = true
1230
+ tableView.allowsMultipleSelection = false
1231
+ tableView.rowSizeStyle = .medium
1232
+ tableView.setAccessibilityLabel("Configured MCP servers")
1233
+ let statusColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("status"))
1234
+ statusColumn.title = "State"
1235
+ statusColumn.width = 70
1236
+ statusColumn.minWidth = 64
1237
+ statusColumn.maxWidth = 88
1238
+ let nameColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name"))
1239
+ nameColumn.title = "Name"
1240
+ nameColumn.width = 180
1241
+ nameColumn.minWidth = 120
1242
+ let transportColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("transport"))
1243
+ transportColumn.title = "Transport"
1244
+ transportColumn.width = 90
1245
+ transportColumn.minWidth = 80
1246
+ let detailColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("summary"))
1247
+ detailColumn.title = "Configuration"
1248
+ detailColumn.width = 360
1249
+ detailColumn.minWidth = 220
1250
+ tableView.addTableColumn(statusColumn)
1251
+ tableView.addTableColumn(nameColumn)
1252
+ tableView.addTableColumn(transportColumn)
1253
+ tableView.addTableColumn(detailColumn)
1254
+
1255
+ let scroll = NSScrollView()
1256
+ scroll.documentView = tableView
1257
+ scroll.hasVerticalScroller = true
1258
+ scroll.hasHorizontalScroller = false
1259
+ scroll.borderType = .bezelBorder
1260
+ scroll.translatesAutoresizingMaskIntoConstraints = false
1261
+
1262
+ statusLabel = NSTextField(labelWithString: "Loading MCP configuration…")
1263
+ statusLabel.textColor = .secondaryLabelColor
1264
+ statusLabel.font = NSFont.systemFont(ofSize: 11)
1265
+ statusLabel.lineBreakMode = .byTruncatingMiddle
1266
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
1267
+
1268
+ addButton = makeButton("Add…", #selector(addServer))
1269
+ removeButton = makeButton("Remove", #selector(removeServer))
1270
+ toggleButton = makeButton("Disable", #selector(toggleServer))
1271
+ refreshButton = makeButton("Refresh", #selector(refresh))
1272
+ let closeButton = makeButton("Close", #selector(closePanel))
1273
+ closeButton.keyEquivalent = "\\u{1b}"
1274
+
1275
+ let buttonStack = NSStackView(views: [addButton, removeButton, toggleButton, refreshButton, NSView(), closeButton])
1276
+ buttonStack.orientation = .horizontal
1277
+ buttonStack.spacing = 8
1278
+ buttonStack.distribution = .fill
1279
+ buttonStack.translatesAutoresizingMaskIntoConstraints = false
1280
+
1281
+ content.addSubview(heading)
1282
+ content.addSubview(scope)
1283
+ content.addSubview(scroll)
1284
+ content.addSubview(statusLabel)
1285
+ content.addSubview(buttonStack)
1286
+ NSLayoutConstraint.activate([
1287
+ heading.topAnchor.constraint(equalTo: content.topAnchor, constant: 18),
1288
+ heading.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 18),
1289
+ heading.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -18),
1290
+ scope.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 4),
1291
+ scope.leadingAnchor.constraint(equalTo: heading.leadingAnchor),
1292
+ scope.trailingAnchor.constraint(equalTo: heading.trailingAnchor),
1293
+ scroll.topAnchor.constraint(equalTo: scope.bottomAnchor, constant: 12),
1294
+ scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 18),
1295
+ scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -18),
1296
+ scroll.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -10),
1297
+ statusLabel.leadingAnchor.constraint(equalTo: scroll.leadingAnchor),
1298
+ statusLabel.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor),
1299
+ statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: buttonStack.leadingAnchor, constant: -12),
1300
+ buttonStack.trailingAnchor.constraint(equalTo: scroll.trailingAnchor),
1301
+ buttonStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
1302
+ buttonStack.heightAnchor.constraint(equalToConstant: 30)
1303
+ ])
1304
+ updateControls()
1305
+ }
1306
+
1307
+ func makeButton(_ title: String, _ selector: Selector) -> NSButton {
1308
+ let button = NSButton(title: title, target: self, action: selector)
1309
+ button.bezelStyle = .rounded
1310
+ button.setAccessibilityLabel(title)
1311
+ return button
1312
+ }
1313
+
1314
+ func numberOfRows(in tableView: NSTableView) -> Int {
1315
+ return rows.count
1316
+ }
1317
+
1318
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
1319
+ guard row >= 0, row < rows.count, let tableColumn = tableColumn else { return nil }
1320
+ let identifier = tableColumn.identifier
1321
+ let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView ?? NSTableCellView()
1322
+ cell.identifier = identifier
1323
+ let field: NSTextField
1324
+ if let existing = cell.textField {
1325
+ field = existing
1326
+ } else {
1327
+ field = NSTextField(labelWithString: "")
1328
+ field.translatesAutoresizingMaskIntoConstraints = false
1329
+ field.lineBreakMode = .byTruncatingTail
1330
+ cell.textField = field
1331
+ cell.addSubview(field)
1332
+ NSLayoutConstraint.activate([
1333
+ field.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
1334
+ field.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
1335
+ field.centerYAnchor.constraint(equalTo: cell.centerYAnchor)
1336
+ ])
1337
+ }
1338
+ let server = rows[row]
1339
+ switch identifier.rawValue {
1340
+ case "status":
1341
+ field.stringValue = server.enabled ? "Enabled" : "Disabled"
1342
+ field.textColor = server.enabled ? .systemGreen : .secondaryLabelColor
1343
+ field.font = NSFont.systemFont(ofSize: 11, weight: .medium)
1344
+ case "name":
1345
+ field.stringValue = server.name
1346
+ field.textColor = .labelColor
1347
+ field.font = NSFont.systemFont(ofSize: 12, weight: .medium)
1348
+ case "transport":
1349
+ field.stringValue = server.transport == "url" ? "Remote" : server.transport == "stdio" ? "Local" : "Unknown"
1350
+ field.textColor = .labelColor
1351
+ field.font = NSFont.systemFont(ofSize: 12)
1352
+ default:
1353
+ field.stringValue = server.summary
1354
+ field.textColor = .secondaryLabelColor
1355
+ field.font = NSFont.systemFont(ofSize: 11)
1356
+ }
1357
+ return cell
1358
+ }
1359
+
1360
+ func tableViewSelectionDidChange(_ notification: Notification) {
1361
+ updateControls()
1362
+ }
1363
+
1364
+ func selectedServer() -> McpServerRow? {
1365
+ let selected = tableView.selectedRow
1366
+ return selected >= 0 && selected < rows.count ? rows[selected] : nil
1367
+ }
1368
+
1369
+ func setWorking(_ value: Bool, status: String? = nil) {
1370
+ working = value
1371
+ if let status = status { statusLabel.stringValue = status }
1372
+ updateControls()
1373
+ }
1374
+
1375
+ func updateControls() {
1376
+ let selected = selectedServer()
1377
+ addButton?.isEnabled = !working
1378
+ refreshButton?.isEnabled = !working
1379
+ removeButton?.isEnabled = !working && selected != nil
1380
+ toggleButton?.isEnabled = !working && selected != nil
1381
+ toggleButton?.title = selected?.enabled == true ? "Disable" : "Enable"
1382
+ }
1383
+
1384
+ @objc func refresh() {
1385
+ guard !working else { return }
1386
+ guard let owner = owner else { return }
1387
+ setWorking(true, status: "Loading MCP configuration…")
1388
+ owner.runSksSilent(["menubar", "mcp", "list", "--json"]) { [weak self] code, output in
1389
+ guard let self = self else { return }
1390
+ guard code == 0,
1391
+ let json = parseJsonObject(output),
1392
+ json["schema"] as? String == "sks.menubar-mcp-list.v1",
1393
+ json["ok"] as? Bool == true,
1394
+ let servers = json["servers"] as? [[String: Any]] else {
1395
+ self.rows = []
1396
+ self.tableView.reloadData()
1397
+ self.setWorking(false, status: "Could not read MCP configuration")
1398
+ showAlert("MCP Manager", informative: humanizeSksFailure(output))
1399
+ return
1400
+ }
1401
+ self.rows = servers.compactMap { server in
1402
+ guard let name = server["name"] as? String else { return nil }
1403
+ return McpServerRow(
1404
+ name: name,
1405
+ enabled: server["enabled"] as? Bool != false,
1406
+ transport: server["transport"] as? String ?? "unknown",
1407
+ summary: server["summary"] as? String ?? "Configured"
1408
+ )
1409
+ }
1410
+ self.tableView.reloadData()
1411
+ if !self.rows.isEmpty { self.tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) }
1412
+ let enabled = self.rows.filter { $0.enabled }.count
1413
+ self.setWorking(false, status: "\(self.rows.count) configured · \(enabled) enabled · Global")
1414
+ }
1415
+ }
1416
+
1417
+ @objc func addServer() {
1418
+ guard !working else { return }
1419
+ guard let transport = promptChoice(title: "Add MCP Server", message: "Choose how Codex connects to this server.", options: ["Remote URL", "Local command"]) else { return }
1420
+ guard let name = promptText(title: "MCP Server Name", message: "Use a unique name (letters, numbers, dots, dashes, or underscores).", placeholder: "context7") else { return }
1421
+ var payload: [String: Any] = ["name": name]
1422
+ if transport == "Remote URL" {
1423
+ guard let url = promptText(title: "Remote MCP URL", message: "Enter the full HTTP or HTTPS MCP endpoint.", placeholder: "https://mcp.example.com/mcp") else { return }
1424
+ guard let bearer = promptTextAllowEmpty(title: "Bearer Token Environment Variable", message: "Optional. Enter the environment variable name that contains the bearer token, or leave blank.", placeholder: "MCP_ACCESS_TOKEN") else { return }
1425
+ payload["transport"] = "url"
1426
+ payload["url"] = url
1427
+ if !bearer.isEmpty { payload["bearer_token_env_var"] = bearer }
1428
+ } else {
1429
+ guard let command = promptText(title: "Local MCP Command", message: "Enter the executable used to start the MCP server.", placeholder: "npx") else { return }
1430
+ guard let arguments = promptMultiline(title: "Command Arguments", message: "Optional. Enter one argument per line.", placeholder: "-y\\n@upstash/context7-mcp") else { return }
1431
+ guard let environment = promptMultiline(title: "Environment Variables", message: "Optional. Enter KEY=VALUE pairs, one per line. Values are written only to Codex config and never shown in the MCP list.", placeholder: "API_TOKEN=...") else { return }
1432
+ payload["transport"] = "stdio"
1433
+ payload["command"] = command
1434
+ payload["args"] = arguments.split(separator: "\\n").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
1435
+ var env: [String: String] = [:]
1436
+ for line in environment.split(separator: "\\n") {
1437
+ let text = String(line)
1438
+ guard let separator = text.firstIndex(of: "=") else {
1439
+ showAlert("Invalid environment variable", informative: "Use KEY=VALUE, one entry per line.")
1440
+ return
1441
+ }
1442
+ let key = String(text[..<separator]).trimmingCharacters(in: .whitespacesAndNewlines)
1443
+ let value = String(text[text.index(after: separator)...]).trimmingCharacters(in: .whitespacesAndNewlines)
1444
+ env[key] = value
1445
+ }
1446
+ if !env.isEmpty { payload["env"] = env }
1447
+ }
1448
+ guard JSONSerialization.isValidJSONObject(payload),
1449
+ let data = try? JSONSerialization.data(withJSONObject: payload),
1450
+ let json = String(data: data, encoding: .utf8) else {
1451
+ showAlert("Add MCP Server", informative: "Could not prepare the MCP configuration payload.")
1452
+ return
1453
+ }
1454
+ runMutation(["menubar", "mcp", "add", "--stdin-json", "--json"], title: "Add MCP Server", stdinText: json + "\\n")
1455
+ }
1456
+
1457
+ @objc func removeServer() {
1458
+ guard !working, let server = selectedServer() else { return }
1459
+ guard confirmAction(title: "Remove \(server.name)?", message: "This removes the global MCP configuration and creates a protected backup first.", destructive: true) else { return }
1460
+ runMutation(["menubar", "mcp", "remove", server.name, "--json"], title: "Remove MCP Server")
1461
+ }
1462
+
1463
+ @objc func toggleServer() {
1464
+ guard !working, let server = selectedServer() else { return }
1465
+ let action = server.enabled ? "disable" : "enable"
1466
+ runMutation(["menubar", "mcp", action, server.name, "--json"], title: server.enabled ? "Disable MCP Server" : "Enable MCP Server")
1467
+ }
1468
+
1469
+ func runMutation(_ args: [String], title: String, stdinText: String? = nil) {
1470
+ guard let owner = owner else { return }
1471
+ setWorking(true, status: title + "…")
1472
+ owner.runSksCapture(args, title: title, stdinText: stdinText, notify: false) { [weak self] code, output in
1473
+ guard let self = self else { return }
1474
+ guard code == 0,
1475
+ let json = parseJsonObject(output),
1476
+ json["schema"] as? String == "sks.menubar-mcp-mutation.v1",
1477
+ json["ok"] as? Bool == true else {
1478
+ self.setWorking(false, status: title + " failed")
1479
+ showAlert(title + " failed", informative: humanizeSksFailure(output))
1480
+ return
1481
+ }
1482
+ self.setWorking(false, status: title + " complete · restart or open a new Codex session to apply")
1483
+ self.refresh()
1484
+ }
1485
+ }
1486
+
1487
+ @objc func closePanel() {
1488
+ if NSApp.modalWindow === panel { NSApp.stopModal() }
1489
+ panel.orderOut(nil)
1490
+ }
1491
+
1492
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
1493
+ closePanel()
1494
+ return false
1495
+ }
1496
+ }
1497
+
1106
1498
  final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1107
1499
  var statusItem: NSStatusItem!
1108
1500
  var statusLineItem: NSMenuItem!
@@ -1112,6 +1504,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1112
1504
  var fastModeOffItem: NSMenuItem!
1113
1505
  var codexCliVersionItem: NSMenuItem!
1114
1506
  var codexCliUpdateItem: NSMenuItem!
1507
+ var mcpManager: McpManagerController?
1115
1508
  var timer: Timer?
1116
1509
  var busy = false
1117
1510
  var lastFailure = false
@@ -1153,6 +1546,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1153
1546
  codexCliVersionItem.isEnabled = false
1154
1547
  menu.addItem(codexCliVersionItem)
1155
1548
  codexCliUpdateItem = add(menu, "Update Codex CLI Now", #selector(updateCodexCliNow))
1549
+ add(menu, "Manage MCP Servers…", #selector(manageMcpServers))
1156
1550
  add(menu, "Run sks doctor --fix", #selector(runDoctorFix))
1157
1551
  menu.addItem(NSMenuItem.separator())
1158
1552
  add(menu, "Open Dashboard", #selector(openDashboard))
@@ -1474,6 +1868,11 @@ ${codexLifecycleSource}
1474
1868
  }
1475
1869
  }
1476
1870
 
1871
+ @objc func manageMcpServers() {
1872
+ if mcpManager == nil { mcpManager = McpManagerController(owner: self) }
1873
+ mcpManager?.show()
1874
+ }
1875
+
1477
1876
  @objc func runDoctorFix() {
1478
1877
  runSksBackground(["doctor", "--fix", "--global-only", "--json"], title: "Run sks doctor --fix") { [weak self] code, _ in
1479
1878
  if code == 0 {
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { parseCodexConfigToml } from '../codex/codex-config-toml.js';
4
4
  import { readText } from '../fsx.js';
5
5
  import { codexLbToolOutputRecoveryNotChecked, codexLbToolOutputRecoveryNotSelected, codexLbToolOutputRecoveryOverrideAcknowledged, probeCodexLbToolOutputRecovery } from '../codex-lb/codex-lb-tool-output-recovery.js';
6
+ import { normalizeCodexLbBaseUrl } from '../codex-lb/codex-lb-env.js';
6
7
  export async function inspectCodexLbSdkLaunchRecovery(input) {
7
8
  if (String(input.config.model_provider || '') !== 'codex-lb') {
8
9
  return codexLbToolOutputRecoveryNotSelected();
@@ -71,6 +72,9 @@ export async function inspectCodexLbCliLaunchRecovery(input) {
71
72
  ?? profileConfig.baseUrl
72
73
  ?? userConfig.baseUrl
73
74
  ?? String(env.CODEX_LB_BASE_URL || '');
75
+ const verifiedProbe = reusableVerifiedProbeForBase(input.verifiedProbe, baseUrl);
76
+ if (verifiedProbe)
77
+ return verifiedProbe;
74
78
  return probeCodexLbToolOutputRecovery({
75
79
  baseUrl,
76
80
  ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
@@ -79,6 +83,17 @@ export async function inspectCodexLbCliLaunchRecovery(input) {
79
83
  || codexLbToolOutputRecoveryOverrideAcknowledged({ args, env })
80
84
  });
81
85
  }
86
+ function reusableVerifiedProbeForBase(value, selectedBaseUrl) {
87
+ if (!value || value.schema !== 'sks.codex-lb-tool-output-recovery.v1')
88
+ return null;
89
+ if (value.ok !== true || value.required !== true || value.verified !== true || value.supports_interrupted_tool_output_recovery !== true)
90
+ return null;
91
+ const verifiedBaseUrl = normalizeCodexLbBaseUrl(value.base_url);
92
+ const effectiveBaseUrl = normalizeCodexLbBaseUrl(selectedBaseUrl);
93
+ if (!verifiedBaseUrl || !effectiveBaseUrl || verifiedBaseUrl !== effectiveBaseUrl)
94
+ return null;
95
+ return value;
96
+ }
82
97
  export async function withCodexLbCliLaunchRecovery(input, launch) {
83
98
  const toolOutputRecovery = await inspectCodexLbCliLaunchRecovery(input);
84
99
  if (!toolOutputRecovery.ok) {
@@ -22,7 +22,7 @@ const CORE_SKILL_DEFINITIONS = [
22
22
  route: '$Naruto',
23
23
  purpose: 'run a Codex official subagent workflow with official agent threads while parent integration remains owner.',
24
24
  when: 'Use when the user explicitly invokes $Naruto or the selected route requires bounded parallel delegation.',
25
- workflow: 'Use sks naruto run "<task>" [--agents N] [--max-threads N] [--json]. Automatic fan-out is one by default, two only for an explicit parallel request or independent risk domains, and at most three for critical multi-domain risk; an explicit operator --agents N remains authoritative. The GPT-5.6 Sol Max parent selects the narrow project-scoped custom role whose description matches each defensible slice: GPT-5.6 Luna Max for clear bounded work and exploration, GPT-5.6 Sol Max for complex implementation, UI, debugging, testing, architecture, integration, security, DB, research, release, and other judgment-sensitive work. Read-only roles declare sandbox_mode="read-only"; write roles inherit the parent permission mode. Treat Codex [agents].max_threads as the concurrency authority, keep max_depth=1, avoid duplicate or overlapping write slices, wait for every requested subagent, reuse only bounded TriWiki attention.use_first anchors with on-demand source hydration, treat SubagentStart/SubagentStop as lifecycle-only evidence, require subagent-parent-summary.json with one structured outcome per thread, and let the parent own integration and final judgment.',
25
+ workflow: 'Use sks naruto run "<task>" [--agents N] [--max-threads N] [--json]. Automatic fan-out is two for non-trivial work and at most three for critical multi-domain risk; an explicit operator --agents N remains authoritative. The GPT-5.6 Sol Max parent selects the narrow project-scoped custom role whose description matches each defensible slice. Model policy is fixed: Luna Max only for tiny short-context mechanical work; Sol High for ordinary UI, logic, backend, and native implementation; Sol Max for review, debugging, planning, architecture, security, DB, research, release, ambiguity, and judgment; Terra Medium for long-context analysis plus direct Computer Use, Browser/Chrome, and image-generation execution. Split mixed work into execution and judgment slices when possible; if one slice cannot be split safely, Sol Max judgment wins. Read-only roles declare sandbox_mode="read-only"; write roles inherit the parent permission mode. Treat Codex [agents].max_threads as the concurrency authority, keep max_depth=1, avoid duplicate or overlapping write slices, wait for every requested subagent, reuse only four ordinary or six complex query-aware TriWiki trust/hydration anchors, treat SubagentStart/SubagentStop as lifecycle-only evidence, require subagent-parent-summary.json with one structured outcome per thread, and let the parent own integration and final judgment.',
26
26
  safety: 'Preserve user-authored content, inherit the parent permission mode, do not spawn nested subagents, do not inject the full pack or the full TriWiki context into every child, and do not fall back to another model, process swarm, custom scheduler, or worker pool. The historical Naruto process runtime is removed; stop with explicit blocker evidence when the official path is unavailable.',
27
27
  cli: 'sks naruto run "<task>" [--agents N] [--max-threads N] [--json]; sks naruto status|subagents|proof [--mission <id>] [--json]',
28
28
  evidence: 'subagent-plan.json, subagent-events.jsonl, subagent-parent-summary.json, subagent-evidence.json, naruto-summary.json, and naruto-gate.json.',
@@ -142,7 +142,8 @@ export async function madHighCommand(args = [], deps = {}) {
142
142
  selfHealOnMissing: true,
143
143
  autoApprove: rawArgs.includes('--yes') || rawArgs.includes('-y'),
144
144
  installHomebrew: rawArgs.includes('--install-homebrew'),
145
- allowHeadlessFallback: headlessZellij
145
+ allowHeadlessFallback: headlessZellij,
146
+ deferUpdateCheck: true
146
147
  }).catch(() => ({ status: 'error', command: 'sks doctor --fix --yes' }))
147
148
  : await repairZellijForSks({
148
149
  root: launchRoot,
@@ -198,7 +199,10 @@ export async function madHighCommand(args = [], deps = {}) {
198
199
  sandbox: 'danger-full-access',
199
200
  serviceTier: 'fast',
200
201
  skipCodexLbToolOutputRecovery: glmMadLaunch,
201
- allowUnverifiedToolOutputRecovery
202
+ allowUnverifiedToolOutputRecovery,
203
+ ...(zellijUpdate.deferred === true && zellijUpdate.capability
204
+ ? { zellijCapability: zellijUpdate.capability }
205
+ : {})
202
206
  };
203
207
  let launchPreflight = await runCodexLaunchPreflight(launchRoot, launchPreflightOpts);
204
208
  // Fresh-project bootstrap: when the ONLY blocker is that the managed Codex config does
@@ -300,6 +304,12 @@ export async function madHighCommand(args = [], deps = {}) {
300
304
  };
301
305
  const explicitWorkspace = readOption(cleanArgs, '--workspace', readOption(cleanArgs, '--session', null));
302
306
  const launchProfile = glmRuntime?.profile || profile;
307
+ const verifiedCodexLbToolOutputRecovery = launchPreflight.codex_lb_tool_output_recovery?.selected === true
308
+ && launchPreflight.codex_lb_tool_output_recovery?.tool_output_recovery?.ok === true
309
+ && launchPreflight.codex_lb_tool_output_recovery?.tool_output_recovery?.verified === true
310
+ && launchPreflight.codex_lb_tool_output_recovery?.tool_output_recovery?.supports_interrupted_tool_output_recovery === true
311
+ ? launchPreflight.codex_lb_tool_output_recovery.tool_output_recovery
312
+ : undefined;
303
313
  const launchOpts = glmRuntime
304
314
  ? buildGlmMadLaunchOpts(cleanArgs, {
305
315
  codexArgs: launchProfile.launch_args,
@@ -318,7 +328,18 @@ export async function madHighCommand(args = [], deps = {}) {
318
328
  const workspace = explicitWorkspace || launchOpts.session || `sks-mad-${sanitizeZellijSessionName(process.cwd())}`;
319
329
  const launch = headlessZellij
320
330
  ? await writeMadHeadlessZellijFallback(madLaunch, workspace)
321
- : await launchMadZellijUi([...cleanArgs, '--workspace', workspace], { ...launchOpts, missionId: madLaunch.mission_id, root: madLaunch.root, cwd: process.cwd(), ledgerRoot: path.join(madLaunch.dir, 'agents'), slotCount: 0, freshSession: autoDerivedMadSession, requireZellij: process.env.SKS_REQUIRE_ZELLIJ === '1' });
331
+ : await launchMadZellijUi([...cleanArgs, '--workspace', workspace], {
332
+ ...launchOpts,
333
+ missionId: madLaunch.mission_id,
334
+ root: madLaunch.root,
335
+ cwd: process.cwd(),
336
+ ledgerRoot: path.join(madLaunch.dir, 'agents'),
337
+ slotCount: 0,
338
+ freshSession: autoDerivedMadSession,
339
+ requireZellij: process.env.SKS_REQUIRE_ZELLIJ === '1',
340
+ ...(launchPreflight.zellij_capability ? { zellijCapability: launchPreflight.zellij_capability } : {}),
341
+ ...(verifiedCodexLbToolOutputRecovery ? { verifiedCodexLbToolOutputRecovery } : {})
342
+ });
322
343
  const afterLaunchUi = beforeUi ? await writeCodexAppUiSnapshot(launchRoot, `mad-after-launch-${uiSnapshotId}`).catch(() => null) : null;
323
344
  const launchUiDiff = beforeUi && afterLaunchUi ? diffCodexAppUiSnapshots(beforeUi, afterLaunchUi) : null;
324
345
  if (launchUiDiff) {
@@ -346,6 +367,26 @@ export async function madHighCommand(args = [], deps = {}) {
346
367
  ui_architecture: 'monitor_plus_viewports',
347
368
  right_column_mode: 'monitor-plus-viewports'
348
369
  });
370
+ if (zellijUpdate.deferred === true && deps.maybePromptZellijUpdateForLaunch) {
371
+ const zellijUpdateNoticePromise = deps.maybePromptZellijUpdateForLaunch([...args, '--no-question'], {
372
+ label: glmMadLaunch ? 'GLM MAD launch' : 'MAD launch',
373
+ root: launchRoot,
374
+ missionDir: madLaunch.dir,
375
+ selfHealOnMissing: false,
376
+ allowHeadlessFallback: headlessZellij
377
+ }).then((notice) => appendJsonlBounded(path.join(madLaunch.dir, 'events.jsonl'), {
378
+ ts: nowIso(),
379
+ type: 'mad_sks.zellij_update_refreshed_background',
380
+ non_blocking: true,
381
+ status: notice?.status || 'unknown',
382
+ update_available: notice?.status === 'available'
383
+ })).catch((err) => appendJsonlBounded(path.join(madLaunch.dir, 'events.jsonl'), {
384
+ ts: nowIso(),
385
+ type: 'mad_sks.zellij_update_background_failed',
386
+ error: err?.message || String(err)
387
+ }));
388
+ launchLifecycleTasks.push(zellijUpdateNoticePromise);
389
+ }
349
390
  const madNativeSwarmPromise = startMadNativeSwarm(madLaunch.root, madLaunch, args, launchProfile, {
350
391
  env: {
351
392
  ...madSksEnv,