drafted 1.11.6 → 1.11.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.
package/install-mcp.sh CHANGED
@@ -625,6 +625,8 @@ import AppKit
625
625
 
626
626
  @main
627
627
  struct DraftedUpdaterApp: App {
628
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
629
+
628
630
  init() {
629
631
  Telemetry.report(event: "drafted_update_helper_started", updateHelperStatus: "running")
630
632
  Telemetry.report(event: "drafted_heartbeat", updateHelperStatus: "running")
@@ -632,6 +634,7 @@ struct DraftedUpdaterApp: App {
632
634
 
633
635
  var body: some Scene {
634
636
  MenuBarExtra {
637
+ Button("Check for Updates…") { Actions.checkForUpdate(userInitiated: true) }
635
638
  Button("Update Drafted") { Actions.updateDrafted() }
636
639
  Button("View Logs") { Actions.viewLogs() }
637
640
  Button("Open Drafted") { Actions.openDrafted() }
@@ -651,6 +654,24 @@ struct DraftedUpdaterApp: App {
651
654
  }
652
655
  }
653
656
 
657
+ // Drives the genuine auto-update: a background check shortly after login and
658
+ // every 6 hours while the menu-bar helper is resident. When a newer published
659
+ // version exists (and the user hasn't skipped it), it prompts Update / Skip.
660
+ final class AppDelegate: NSObject, NSApplicationDelegate {
661
+ private var timer: Timer?
662
+
663
+ func applicationDidFinishLaunching(_ notification: Notification) {
664
+ DispatchQueue.main.asyncAfter(deadline: .now() + 25) {
665
+ Actions.checkForUpdate(userInitiated: false)
666
+ }
667
+ let t = Timer(timeInterval: 6 * 3600, repeats: true) { _ in
668
+ Actions.checkForUpdate(userInitiated: false)
669
+ }
670
+ RunLoop.main.add(t, forMode: .common)
671
+ timer = t
672
+ }
673
+ }
674
+
654
675
 
655
676
  final class UpdateStatusWindow: NSWindowController {
656
677
  private let stack = NSStackView()
@@ -734,6 +755,8 @@ final class UpdateStatusWindow: NSWindowController {
734
755
  }
735
756
 
736
757
  struct Actions {
758
+ static let home = FileManager.default.homeDirectoryForCurrentUser
759
+
737
760
  static func logoImage() -> NSImage? {
738
761
  guard
739
762
  let url = Bundle.main.url(forResource: "logo", withExtension: "svg"),
@@ -747,6 +770,141 @@ struct Actions {
747
770
  private static var isUpdating = false
748
771
  private static var updateWindow: UpdateStatusWindow?
749
772
 
773
+ // MARK: - Auto-update check
774
+
775
+ // Compare the installed CLI version to npm's published `latest`. Prompt only
776
+ // when newer and not skipped (background), or always report (user-initiated).
777
+ static func checkForUpdate(userInitiated: Bool) {
778
+ let installed = installedVersion()
779
+ latestVersion { latest in
780
+ DispatchQueue.main.async {
781
+ guard let latest = latest else {
782
+ if userInitiated {
783
+ showInfo(title: "Couldn’t check for updates", body: "Please check your connection and try again.")
784
+ }
785
+ return
786
+ }
787
+ guard let installed = installed, isNewer(latest, than: installed) else {
788
+ if userInitiated {
789
+ showInfo(title: "Drafted is up to date", body: "You’re on \(installed ?? "the latest") version.")
790
+ }
791
+ return
792
+ }
793
+ if !userInitiated && skippedVersion() == latest { return }
794
+ presentUpdateAlert(latest: latest, installed: installed)
795
+ }
796
+ }
797
+ }
798
+
799
+ static func presentUpdateAlert(latest: String, installed: String) {
800
+ Telemetry.report(event: "drafted_update_available", updateHelperStatus: "running")
801
+ let alert = NSAlert()
802
+ alert.messageText = "Drafted \(latest) is available"
803
+ alert.informativeText = "You have \(installed). Update now? Your editor needs to be restarted afterward to load the new MCP tools."
804
+ alert.addButton(withTitle: "Update")
805
+ alert.addButton(withTitle: "Skip This Version")
806
+ alert.addButton(withTitle: "Later")
807
+ if let logo = logoImage() { alert.icon = logo }
808
+ NSApp.activate(ignoringOtherApps: true)
809
+ switch alert.runModal() {
810
+ case .alertFirstButtonReturn:
811
+ updateDrafted()
812
+ case .alertSecondButtonReturn:
813
+ setSkippedVersion(latest)
814
+ Telemetry.report(event: "drafted_update_skipped", updateHelperStatus: "running")
815
+ default:
816
+ break
817
+ }
818
+ }
819
+
820
+ static func showInfo(title: String, body: String) {
821
+ let alert = NSAlert()
822
+ alert.messageText = title
823
+ alert.informativeText = body
824
+ alert.addButton(withTitle: "OK")
825
+ if let logo = logoImage() { alert.icon = logo }
826
+ NSApp.activate(ignoringOtherApps: true)
827
+ alert.runModal()
828
+ }
829
+
830
+ // Installed version via the CLI, run through a login shell so the npm-global
831
+ // bin is on PATH (the same shell the manual update uses).
832
+ static func installedVersion() -> String? {
833
+ let process = Process()
834
+ process.executableURL = URL(fileURLWithPath: "/bin/bash")
835
+ process.arguments = ["-lc", "drafted --version 2>/dev/null"]
836
+ let pipe = Pipe()
837
+ process.standardOutput = pipe
838
+ process.standardError = FileHandle.nullDevice
839
+ do { try process.run(); process.waitUntilExit() } catch { return nil }
840
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
841
+ guard let out = String(data: data, encoding: .utf8) else { return nil }
842
+ return firstSemver(in: out)
843
+ }
844
+
845
+ static func latestVersion(_ completion: @escaping (String?) -> Void) {
846
+ guard let url = URL(string: "https://registry.npmjs.org/drafted/latest") else { completion(nil); return }
847
+ var request = URLRequest(url: url)
848
+ request.timeoutInterval = 15
849
+ URLSession.shared.dataTask(with: request) { data, _, _ in
850
+ guard
851
+ let data = data,
852
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
853
+ let version = json["version"] as? String
854
+ else { completion(nil); return }
855
+ completion(version)
856
+ }.resume()
857
+ }
858
+
859
+ static func skippedVersion() -> String? {
860
+ let path = home.appendingPathComponent(".drafted/install.json")
861
+ guard
862
+ let data = try? Data(contentsOf: path),
863
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
864
+ else { return nil }
865
+ return json["skippedVersion"] as? String
866
+ }
867
+
868
+ static func setSkippedVersion(_ version: String) {
869
+ let path = home.appendingPathComponent(".drafted/install.json")
870
+ var json = ((try? Data(contentsOf: path)).flatMap { try? JSONSerialization.jsonObject(with: $0) } as? [String: Any]) ?? [:]
871
+ json["skippedVersion"] = version
872
+ if let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {
873
+ try? out.write(to: path)
874
+ }
875
+ }
876
+
877
+ // First dotted-numeric token in the text, e.g. "1.11.7" from "drafted 1.11.7".
878
+ static func firstSemver(in text: String) -> String? {
879
+ var current = ""
880
+ for ch in text {
881
+ if ch.isNumber || ch == "." {
882
+ current.append(ch)
883
+ } else {
884
+ if isSemver(current) { return current }
885
+ current = ""
886
+ }
887
+ }
888
+ return isSemver(current) ? current : nil
889
+ }
890
+
891
+ static func isSemver(_ s: String) -> Bool {
892
+ let parts = s.split(separator: ".")
893
+ return parts.count >= 2 && parts.allSatisfy { !$0.isEmpty && $0.allSatisfy { $0.isNumber } }
894
+ }
895
+
896
+ // Numeric, dot-separated comparison (our versions have no prerelease tags).
897
+ static func isNewer(_ a: String, than b: String) -> Bool {
898
+ let pa = a.split(separator: ".").map { Int($0) ?? 0 }
899
+ let pb = b.split(separator: ".").map { Int($0) ?? 0 }
900
+ for i in 0..<max(pa.count, pb.count) {
901
+ let x = i < pa.count ? pa[i] : 0
902
+ let y = i < pb.count ? pb[i] : 0
903
+ if x != y { return x > y }
904
+ }
905
+ return false
906
+ }
907
+
750
908
  static func updateDrafted() {
751
909
  if isUpdating {
752
910
  updateWindow?.showUpdating()
package/mcp/server.mjs CHANGED
@@ -85,6 +85,7 @@ function getOrCreateSessionState(sid) {
85
85
  s = {
86
86
  activeProjectId: null,
87
87
  activeProjectMeta: null,
88
+ boundOrgId: null,
88
89
  loadedSkillIds: new Set(),
89
90
  gates: createGateState(),
90
91
  cachedOrgId: null,
@@ -699,6 +700,7 @@ async function cloneSession() {
699
700
  const data = await res.json();
700
701
  if (data.sessionId) {
701
702
  getState().sessionId = data.sessionId;
703
+ await restoreBoundOrg(data.orgId);
702
704
  return true;
703
705
  }
704
706
  }
@@ -706,6 +708,33 @@ async function cloneSession() {
706
708
  return false;
707
709
  }
708
710
 
711
+ // A fresh clone inherits the ROOT login's current org. If this MCP session was
712
+ // already working in a different org (bound via project open or get_org switch),
713
+ // re-assert it on the new session so a WS-drop / 401 / server-bounce recovery is
714
+ // transparent. Otherwise the recovered session silently lands on the root's org,
715
+ // the next request appends the active project's id while scoped to the wrong org,
716
+ // the API returns "project not found", and the api() error handler clears the
717
+ // active project — the churn that shows up as active-project "flapping" after a
718
+ // server restart. Best-effort: a raw fetch (not api()) avoids recursing back
719
+ // through cloneSession on 401; if it fails, that stale-project clear is the
720
+ // backstop, same as before this fix.
721
+ async function restoreBoundOrg(clonedOrgId) {
722
+ const sess = getSessionState();
723
+ const want = sess.boundOrgId;
724
+ if (!want || want === clonedOrgId) return;
725
+ try {
726
+ const res = await fetch(`${getServerUrl()}/auth/switch-org`, {
727
+ method: 'POST',
728
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
729
+ body: JSON.stringify({ orgId: want }),
730
+ });
731
+ if (res.ok) {
732
+ sess.cachedOrgId = null;
733
+ sess.cachedOrgIdTime = 0;
734
+ }
735
+ } catch { /* best-effort; the api() stale-project clear is the backstop */ }
736
+ }
737
+
709
738
  async function ensureSession() {
710
739
  if (getState().sessionId) return;
711
740
  // A pending device-code login (from `auth get_link`) takes priority; consuming
@@ -922,6 +951,11 @@ function setMcpActiveProject(projectId, meta = null) {
922
951
  const sess = getSessionState();
923
952
  sess.activeProjectId = projectId;
924
953
  sess.activeProjectMeta = meta;
954
+ // Remember the org this session is working in so session recovery (a re-clone
955
+ // after a WS drop / 401 / server bounce) can restore it instead of passively
956
+ // inheriting the root login's current org. Only set when known — a (null,null)
957
+ // clear must NOT wipe the bound org (the switch handler sets it explicitly).
958
+ if (meta?.orgId) sess.boundOrgId = meta.orgId;
925
959
  }
926
960
 
927
961
  // Clear the active project if its orgId no longer matches the current org.
@@ -1840,6 +1874,9 @@ tool('get_org', {
1840
1874
  // Clear active project too — projects are scoped to orgs, so the
1841
1875
  // previous one isn't valid in the new org.
1842
1876
  setMcpActiveProject(null, null);
1877
+ // Bind this session to the chosen org so session recovery re-asserts it
1878
+ // (set AFTER the clear above, which would otherwise leave it unchanged).
1879
+ sess.boundOrgId = args.orgId;
1843
1880
  const me = await api('GET', '/auth/me');
1844
1881
  const orgs = (await api('GET', '/api/orgs')).orgs || [];
1845
1882
  const activeOrg = (orgs || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name })).find(o => o.id === me?.orgId) || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.11.6",
3
+ "version": "1.11.8",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [