tibodog 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -54,16 +54,21 @@ Any RSS/Atom feed of the account works: a Nitter instance, self-hosted [RSSHub](
54
54
  (`/twitter/user/thsottiaux`), rss.app, and so on.
55
55
 
56
56
  ```bash
57
- npx tibodog --rss "https://your-rsshub.example.com/twitter/user/thsottiaux"
57
+ npx tibodog --rss "https://nitter.kareem.one/thsottiaux/rss"
58
58
  ```
59
59
 
60
+ That Nitter instance served Tibo's feed when this was written (Oct 2026); most public ones are blocked or
61
+ gone (nitter.net, xcancel.com, rsshub.app all refused). Mirrors come and go, so if it stops working, find
62
+ another or self-host RSSHub. They're run by volunteers: keep the default 5-minute interval.
63
+ tibodog only ever opens the post on x.com, never the mirror's own links.
64
+
60
65
  ## 옵션 / Options
61
66
 
62
67
  | flag | default | |
63
68
  |---|---|---|
64
69
  | `--user <handle>` | `thsottiaux` | 감시할 계정 / account to watch |
65
70
  | `--rss <url>` | | use an RSS feed instead of the X API |
66
- | `--interval <sec>` | 60 (RSS) / 300 (X) | poll interval |
71
+ | `--interval <sec>` | 300 | poll interval (min 15) |
67
72
  | `--keyword <text>` | | extra trigger phrase, repeatable |
68
73
  | `--barks <n>` | 1 | 짖는 소리 반복 횟수 / times to play the bark |
69
74
  | `--sound <file.wav\|synth>` | real recording | bring your own dog, or `synth` for the synthesized bark |
@@ -75,6 +75,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
75
75
  func applicationDidFinishLaunching(_ notification: Notification) {
76
76
  NSApp.setActivationPolicy(.accessory)
77
77
  quitOnSignals()
78
+ // Writing to a helper that just exited (codex, perl) must be an error we handle, not a SIGPIPE
79
+ // that kills the app.
80
+ signal(SIGPIPE, SIG_IGN)
78
81
  let store = UsageStore.shared
79
82
  store.start()
80
83
  rebuildPanel()
@@ -158,8 +161,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
158
161
 
159
162
  private func rebuildPanel() {
160
163
  let store = UsageStore.shared
161
- let geo = NotchGeometry.detect()
162
- guard store.showNotch else {
164
+ // No notch wanted, or no screen right now: hide it; the next screen change brings it back.
165
+ guard store.showNotch, let geo = NotchGeometry.detect() else {
163
166
  panel?.orderOut(nil); panel = nil; geometry = nil
164
167
  return
165
168
  }
@@ -58,8 +58,11 @@ enum CodexUsageAPI {
58
58
  try process.run()
59
59
  let pid = process.processIdentifier
60
60
  defer {
61
+ // codex may start helpers of its own (MCP servers): they go when it does.
62
+ let helpers = children(of: pid)
61
63
  try? input.fileHandleForWriting.close()
62
64
  if process.isRunning { process.terminate() }
65
+ for child in helpers { kill(child, SIGTERM) }
63
66
  // Do not let a hung child accumulate across refreshes.
64
67
  let deadline = Date().addingTimeInterval(1)
65
68
  while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) }
@@ -87,8 +90,9 @@ enum CodexUsageAPI {
87
90
  while let newline = buffer.firstIndex(of: 10) {
88
91
  let line = buffer.prefix(upTo: newline)
89
92
  buffer.removeSubrange(...newline)
90
- guard let message = try JSONSerialization.jsonObject(with: line) as? [String: Any] else { continue }
91
- guard let id = message["id"] as? Int else { continue }
93
+ // Skip anything that isn't a reply to us: log lines, blank lines, codex's own requests.
94
+ guard let message = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any],
95
+ message["method"] == nil, let id = message["id"] as? Int else { continue }
92
96
  guard message["error"] == nil else { throw Failure.unavailable }
93
97
  if id == 1 {
94
98
  try send(["method": "initialized"])
@@ -104,6 +108,12 @@ enum CodexUsageAPI {
104
108
  throw Failure.timeout
105
109
  }
106
110
 
111
+ private static func children(of pid: pid_t) -> [pid_t] {
112
+ var buffer = [pid_t](repeating: 0, count: 64)
113
+ let count = proc_listchildpids(pid, &buffer, Int32(MemoryLayout<pid_t>.size * buffer.count))
114
+ return count > 0 ? Array(buffer.prefix(Int(count))) : []
115
+ }
116
+
107
117
  static func decode(_ result: [String: Any]) throws -> (usage: Usage, plan: String?, resetCredits: Int?) {
108
118
  let buckets = result["rateLimitsByLimitId"] as? [String: Any]
109
119
  // Never substitute an unrelated model bucket for the Codex quota.
@@ -64,7 +64,7 @@ struct Credentials {
64
64
 
65
65
  enum CredentialSource: CustomStringConvertible {
66
66
  case keychain // direct SecItem access (may prompt for permission)
67
- case securityCLI(String) // /usr/bin/security (account name), no per-app prompt
67
+ case securityCLI(String?) // /usr/bin/security (account name, if it could be read), no per-app prompt
68
68
  case file(URL)
69
69
 
70
70
  var description: String {
@@ -80,6 +80,7 @@ enum CredentialError: LocalizedError {
80
80
  case notFound
81
81
  case signedOut
82
82
  case malformed
83
+ case unknownAccount
83
84
  case keychain(OSStatus)
84
85
 
85
86
  var errorDescription: String? {
@@ -87,6 +88,7 @@ enum CredentialError: LocalizedError {
87
88
  case .notFound: return "Claude Code credentials not found. Run `claude` and sign in first."
88
89
  case .signedOut: return "Claude Code is signed out. Run `claude` in Terminal and /login."
89
90
  case .malformed: return "Credentials found but not in the expected format."
91
+ case .unknownAccount: return "Couldn't tell which Keychain account holds the Claude login, so it wasn't updated."
90
92
  case .keychain(let s):
91
93
  let msg = SecCopyErrorMessageString(s, nil) as String? ?? "OSStatus \(s)"
92
94
  return "Keychain error: \(msg)"
@@ -122,7 +124,31 @@ enum CredentialStore {
122
124
  }
123
125
  }
124
126
 
127
+ // A refreshed login that couldn't be written back yet. The refresh token rotates on refresh, so
128
+ // dropping it would sign Claude Code out; it's used (and saved again) until the store catches up.
129
+ private static let pendingLock = NSLock()
130
+ nonisolated(unsafe) private static var pending: (replaces: String?, fresh: Credentials, source: CredentialSource)?
131
+
132
+ static func holdUnsaved(_ fresh: Credentials, replacing old: Credentials, source: CredentialSource) {
133
+ pendingLock.lock(); defer { pendingLock.unlock() }
134
+ pending = (old.refreshToken, fresh, source)
135
+ }
136
+
125
137
  static func load() throws -> (Credentials, CredentialSource) {
138
+ let (stored, source) = try loadStored()
139
+ pendingLock.lock(); defer { pendingLock.unlock() }
140
+ guard let p = pending else { return (stored, source) }
141
+ // The store still has the login we refreshed from: ours is newer, try writing it again.
142
+ if stored.refreshToken == p.replaces {
143
+ if (try? save(p.fresh, to: p.source)) != nil { pending = nil; Log.info("refreshed token written back") }
144
+ return (p.fresh, p.source)
145
+ }
146
+ // Someone else (Claude Code, a new /login) changed it since: theirs wins.
147
+ pending = nil
148
+ return (stored, source)
149
+ }
150
+
151
+ private static func loadStored() throws -> (Credentials, CredentialSource) {
126
152
  // 1) /usr/bin/security: an Apple-signed tool already trusted for this item on most
127
153
  // machines, so it reads the token without raising a per-app Keychain prompt.
128
154
  if let (data, account) = readViaSecurityCLI() {
@@ -159,12 +185,16 @@ enum CredentialStore {
159
185
  /// Runs `security` with stdout captured through a temp file. Using a file (not a Pipe)
160
186
  /// means we never have to drain a pipe while blocking a thread, so this is safe to call
161
187
  /// synchronously from the main actor without risking a pipe-buffer deadlock.
162
- private static func runSecurity(_ args: [String]) -> (Int32, Data) {
188
+ /// `input` goes to its stdin; nothing secret ever goes in `args` (other users can see argv in `ps`).
189
+ /// Gives up after 10s: a Keychain dialog nobody answers must not stall refreshes forever.
190
+ private static func runSecurity(_ args: [String], input: Data? = nil) -> (Int32, Data) {
163
191
  let proc = Process()
164
192
  proc.executableURL = URL(fileURLWithPath: "/usr/bin/security")
165
193
  proc.arguments = args
194
+ let stdin = Pipe()
195
+ proc.standardInput = input == nil ? FileHandle.nullDevice : stdin
166
196
  let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
167
- .appendingPathComponent("islet-sec-\(UUID().uuidString)")
197
+ .appendingPathComponent("tibodog-sec-\(UUID().uuidString)")
168
198
  FileManager.default.createFile(atPath: tmp.path, contents: nil)
169
199
  guard let handle = try? FileHandle(forWritingTo: tmp) else { return (-1, Data()) }
170
200
  defer { try? FileManager.default.removeItem(at: tmp) }
@@ -175,14 +205,23 @@ enum CredentialStore {
175
205
  let done = DispatchSemaphore(value: 0)
176
206
  proc.terminationHandler = { _ in done.signal() }
177
207
  do { try proc.run() } catch { try? handle.close(); return (-1, Data()) }
178
- done.wait()
208
+ if let input {
209
+ try? stdin.fileHandleForWriting.write(contentsOf: input)
210
+ try? stdin.fileHandleForWriting.close()
211
+ }
212
+ if done.wait(timeout: .now() + 10) == .timedOut {
213
+ proc.terminate()
214
+ _ = done.wait(timeout: .now() + 2)
215
+ try? handle.close()
216
+ return (-1, Data())
217
+ }
179
218
  try? handle.close()
180
219
  let data = (try? Data(contentsOf: tmp)) ?? Data()
181
220
  return (proc.terminationStatus, data)
182
221
  }
183
222
 
184
223
  /// Reads the raw JSON blob and the account name it is stored under, if the tool succeeds.
185
- private static func readViaSecurityCLI() -> (Data, String)? {
224
+ private static func readViaSecurityCLI() -> (Data, String?)? {
186
225
  let (code, data) = runSecurity(["find-generic-password", "-s", service, "-w"])
187
226
  guard code == 0 else { return nil }
188
227
  var blob = data
@@ -190,7 +229,7 @@ enum CredentialStore {
190
229
  guard !blob.isEmpty else { return nil }
191
230
  // The account is needed to update the same item in place later.
192
231
  let (mcode, meta) = runSecurity(["find-generic-password", "-s", service, "-g"])
193
- var account = NSUserName()
232
+ var account: String?
194
233
  if mcode == 0, let text = String(data: meta, encoding: .utf8),
195
234
  let range = text.range(of: "\"acct\"<blob>=\"") {
196
235
  let rest = text[range.upperBound...]
@@ -199,10 +238,17 @@ enum CredentialStore {
199
238
  return (blob, account)
200
239
  }
201
240
 
202
- private static func writeViaSecurityCLI(_ data: Data, account: String) throws {
203
- // -U updates the existing item in place, preserving its access control.
204
- guard let json = String(data: data, encoding: .utf8) else { throw CredentialError.malformed }
205
- let (code, _) = runSecurity(["add-generic-password", "-U", "-s", service, "-a", account, "-w", json])
241
+ private static func writeViaSecurityCLI(_ data: Data, account: String?) throws {
242
+ // Guessing the account would add a second item beside Claude Code's instead of updating it.
243
+ guard let account else { throw CredentialError.unknownAccount }
244
+ guard let json = String(data: data, encoding: .utf8), !json.contains("\n") else { throw CredentialError.malformed }
245
+ // -U updates the existing item in place, preserving its access control. The command goes in
246
+ // through `security -i` on stdin, so the token never shows up in a process list.
247
+ func quoted(_ s: String) -> String {
248
+ "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
249
+ }
250
+ let command = "add-generic-password -U -s \(quoted(service)) -a \(quoted(account)) -w \(quoted(json))\n"
251
+ let (code, _) = runSecurity(["-i"], input: Data(command.utf8))
206
252
  guard code == 0 else { throw CredentialError.keychain(errSecAuthFailed) }
207
253
  }
208
254
 
@@ -22,53 +22,83 @@ func L(_ ko: String, _ en: String) -> String {
22
22
  Locale.preferredLanguages.first?.hasPrefix("ko") == true ? ko : en
23
23
  }
24
24
 
25
+ /// Only a post on x.com/twitter.com over https: the link comes from a feed, which must not be able to
26
+ /// open a file, an app or another app's URL scheme.
27
+ func isPostURL(_ url: URL) -> Bool {
28
+ guard url.scheme == "https", let host = url.host?.lowercased() else { return false }
29
+ let hosts = ["x.com", "twitter.com", "www.x.com", "www.twitter.com", "mobile.twitter.com", "mobile.x.com"]
30
+ return hosts.contains(host) && url.path.contains("/status/")
31
+ }
32
+
25
33
  /// The dog widget: from the moment tibodog sees Tibo reset the limits, a dog barks beside the notch until
26
- /// the person opens the island and reads the post. Read every two seconds from tibodog's folder; once read,
27
- /// the post's id goes to alert-seen there so the dog stays quiet after a restart.
34
+ /// the person opens the island and reads the post. Read every two seconds from tibodog's folder: the CLI
35
+ /// writes alert.json, the widget writes credit.json when a reset credit comes in. Read ids go to
36
+ /// alert-seen there (one per line) so the dog stays quiet after a restart.
28
37
  @MainActor
29
38
  final class DogMonitor: ObservableObject {
30
39
  static let shared = DogMonitor()
31
40
 
32
- /// The reset not read yet, or nil.
41
+ /// The reset not read yet, or nil (a post on X first, then a reset credit).
33
42
  @Published private(set) var alert: DogAlert?
43
+ /// Whether the dog is moving right now: all the time for the first minute, then a short bark every
44
+ /// ten seconds, so an alert left unread for days doesn't redraw the notch 30 times a second.
45
+ @Published private(set) var barking = false
34
46
 
35
47
  private var timer: Timer?
48
+ private var shownAt = Date()
36
49
  private let dir = ProcessInfo.processInfo.environment["TIBODOG_HOME"]
37
50
  ?? (NSHomeDirectory() as NSString).appendingPathComponent(".tibodog")
38
- private var alertPath: String { (dir as NSString).appendingPathComponent("alert.json") }
39
- private var seenPath: String { (dir as NSString).appendingPathComponent("alert-seen") }
51
+ private func path(_ name: String) -> String { (dir as NSString).appendingPathComponent(name) }
40
52
 
41
53
  private init() {
42
- timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
43
- Task { @MainActor in self?.poll() }
54
+ timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
55
+ Task { @MainActor in self?.tick() }
44
56
  }
45
57
  poll()
46
58
  }
47
59
 
48
- func poll() {
60
+ private var ticks = 0
61
+ private func tick() {
62
+ ticks += 1
63
+ if ticks % 4 == 0 { poll() }
64
+ let since = Date().timeIntervalSince(shownAt)
65
+ let now = alert != nil && (since < 60 || since.truncatingRemainder(dividingBy: 10) < 2)
66
+ if now != barking { barking = now }
67
+ }
68
+
69
+ private func read(_ name: String) -> DogAlert? {
49
70
  let decoder = JSONDecoder()
50
71
  decoder.dateDecodingStrategy = .secondsSince1970
51
- guard let data = FileManager.default.contents(atPath: alertPath),
52
- let a = try? decoder.decode(DogAlert.self, from: data) else {
53
- if alert != nil { alert = nil }
54
- return
55
- }
56
- let seen = (try? String(contentsOfFile: seenPath, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines)
57
- let unread = seen == a.id ? nil : a
72
+ guard let data = FileManager.default.contents(atPath: path(name)) else { return nil }
73
+ return try? decoder.decode(DogAlert.self, from: data)
74
+ }
75
+
76
+ private var seen: Set<String> {
77
+ let text = (try? String(contentsOfFile: path("alert-seen"), encoding: .utf8)) ?? ""
78
+ return Set(text.split(whereSeparator: \.isNewline).map(String.init))
79
+ }
80
+
81
+ func poll() {
82
+ let seen = seen
83
+ let unread = [read("alert.json"), read("credit.json")].compactMap { $0 }.first { !seen.contains($0.id) }
58
84
  if unread != alert {
85
+ if unread?.id != alert?.id { shownAt = Date() }
59
86
  alert = unread
60
87
  // A new reset: check right away whether it reached this Codex account.
61
- if unread != nil, !unread!.isCredit { ResetTracker.shared.check() }
88
+ if let a = unread, !a.isCredit { ResetTracker.shared.check() }
62
89
  }
63
90
  }
64
91
 
65
- func markRead() {
66
- guard let a = alert else { return }
67
- try? a.id.write(toFile: seenPath, atomically: true, encoding: .utf8)
68
- alert = nil
92
+ /// Marks that alert read (only the one that was actually on screen, not whatever came in since).
93
+ func markRead(_ id: String) {
94
+ let kept = (try? String(contentsOfFile: path("alert-seen"), encoding: .utf8))?
95
+ .split(whereSeparator: \.isNewline).map(String.init).suffix(50) ?? []
96
+ let lines = (Array(kept) + [id]).joined(separator: "\n") + "\n"
97
+ try? lines.write(toFile: path("alert-seen"), atomically: true, encoding: .utf8)
98
+ poll()
69
99
  }
70
100
 
71
- /// A reset credit came in: the dog barks about that too.
101
+ /// A reset credit came in: the dog barks about that too (beside, not over, an unread post).
72
102
  func creditArrived(from before: Int, to now: Int) {
73
103
  let card: [String: Any] = [
74
104
  "id": "credit-\(now)-\(Int(Date().timeIntervalSince1970))",
@@ -81,14 +111,14 @@ final class DogMonitor: ObservableObject {
81
111
  ]
82
112
  guard let data = try? JSONSerialization.data(withJSONObject: card) else { return }
83
113
  try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
84
- try? data.write(to: URL(fileURLWithPath: alertPath), options: .atomic)
114
+ try? data.write(to: URL(fileURLWithPath: path("credit.json")), options: .atomic)
85
115
  poll()
86
116
  }
87
117
 
88
118
  /// The post on X; opening it counts as reading it.
89
119
  func openPost() {
90
- guard let s = alert?.url, let url = URL(string: s) else { return }
91
- NSWorkspace.shared.open(url)
92
- markRead()
120
+ guard let a = alert else { return }
121
+ if let s = a.url, let url = URL(string: s), isPostURL(url) { NSWorkspace.shared.open(url) }
122
+ markRead(a.id)
93
123
  }
94
124
  }
@@ -20,8 +20,8 @@ struct UsageWindow: Equatable {
20
20
  return "\(m)M"
21
21
  }
22
22
 
23
- var fraction: Double { min(max(utilization / 100, 0), 1) }
24
- var percentText: String { "\(Int(utilization.rounded()))%" }
23
+ var fraction: Double { utilization.isFinite ? min(max(utilization / 100, 0), 1) : 0 }
24
+ var percentText: String { utilization.isFinite ? "\(Int(min(max(utilization, -999), 9999).rounded()))%" : "–" }
25
25
  }
26
26
 
27
27
  /// Snapshot of every window the endpoint returned.
@@ -7,9 +7,12 @@ struct NotchGeometry: Equatable {
7
7
  let notchWidth: CGFloat
8
8
  let notchHeight: CGFloat
9
9
 
10
- static func detect() -> NotchGeometry {
10
+ /// nil while there's no screen at all (lid closed with nothing attached, mid-reconfiguration).
11
+ static func detect() -> NotchGeometry? {
11
12
  let screens = NSScreen.screens
12
- let screen = screens.first { $0.safeAreaInsets.top > 0 } ?? NSScreen.main ?? screens[0]
13
+ guard let screen = screens.first(where: { $0.safeAreaInsets.top > 0 }) ?? NSScreen.main ?? screens.first else {
14
+ return nil
15
+ }
13
16
  let inset = screen.safeAreaInsets.top
14
17
  if inset > 0, let l = screen.auxiliaryTopLeftArea, let r = screen.auxiliaryTopRightArea {
15
18
  let w = screen.frame.width - l.width - r.width
@@ -11,8 +11,9 @@ struct NotchView: View {
11
11
 
12
12
  @State private var hovering = ProcessInfo.processInfo.environment["TIBODOG_EXPANDED"] == "1"
13
13
  @State private var collapseTask: Task<Void, Never>?
14
- /// When the island opened: open long enough, the dog's post counts as read when it closes.
15
- @State private var openedAt: Date?
14
+ /// The alert on screen while the island is open, and since when: shown long enough, it counts as read
15
+ /// when the island closes (one that came in at the last moment doesn't).
16
+ @State private var shown: (id: String, since: Date)?
16
17
 
17
18
  private let expandedWidth: CGFloat = 440
18
19
  // Beside the notch, each side is the gauge and what's next to it (the barking dog, the album art, the
@@ -54,22 +55,27 @@ struct NotchView: View {
54
55
  )
55
56
  .contentShape(Rectangle())
56
57
  .contextMenu { TiboDogMenu() }
58
+ .onChange(of: dog.alert?.id) { _, id in
59
+ if hovering { shown = id.map { ($0, Date()) } }
60
+ }
57
61
  .onHover { over in
58
62
  collapseTask?.cancel()
59
63
  if over {
60
- if !hovering { openedAt = Date() }
64
+ if !hovering { shown = dog.alert.map { ($0.id, Date()) } }
61
65
  withAnimation(.spring(response: 0.36, dampingFraction: 0.82)) { hovering = true }
62
66
  } else {
63
67
  collapseTask = Task {
64
68
  try? await Task.sleep(for: .milliseconds(180))
65
69
  guard !Task.isCancelled else { return }
66
70
  // A quick brush doesn't count: the dog keeps barking until the post was open for a moment.
67
- let read = openedAt.map { Date().timeIntervalSince($0) >= 1 } ?? false
71
+ let read = shown.flatMap { s in
72
+ s.id == dog.alert?.id && Date().timeIntervalSince(s.since) >= 1 ? s.id : nil
73
+ }
68
74
  withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) {
69
75
  hovering = false
70
- if read { dog.markRead() }
76
+ if let id = read { dog.markRead(id) }
71
77
  }
72
- openedAt = nil
78
+ shown = nil
73
79
  }
74
80
  }
75
81
  }
@@ -322,7 +328,7 @@ struct NowPlayingRow: View {
322
328
  }
323
329
 
324
330
  private func mmss(_ s: Double) -> String {
325
- let v = max(0, Int(s))
331
+ let v = s.isFinite ? Int(min(max(s, 0), 360_000)) : 0
326
332
  return String(format: "%d:%02d", v / 60, v % 60)
327
333
  }
328
334
  }
@@ -394,10 +400,11 @@ func barkStrength(_ t: TimeInterval) -> Double {
394
400
  struct BarkingDog: View {
395
401
  static let width: CGFloat = 30
396
402
  var size: CGFloat = 18
403
+ @ObservedObject private var dog = DogMonitor.shared
397
404
 
398
405
  var body: some View {
399
- TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in
400
- let bark = barkStrength(ctx.date.timeIntervalSinceReferenceDate)
406
+ TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !dog.barking)) { ctx in
407
+ let bark = dog.barking ? barkStrength(ctx.date.timeIntervalSinceReferenceDate) : 0
401
408
  HStack(spacing: 0) {
402
409
  Text("🐕")
403
410
  .font(.system(size: size))
@@ -425,10 +432,11 @@ struct BarkingDog: View {
425
432
  struct Woof: View {
426
433
  static let width: CGFloat = 24
427
434
  let text: String
435
+ @ObservedObject private var dog = DogMonitor.shared
428
436
 
429
437
  var body: some View {
430
- TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in
431
- let bark = barkStrength(ctx.date.timeIntervalSinceReferenceDate)
438
+ TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !dog.barking)) { ctx in
439
+ let bark = dog.barking ? barkStrength(ctx.date.timeIntervalSinceReferenceDate) : 0
432
440
  Text(text)
433
441
  .font(.system(size: 11, weight: .heavy, design: .rounded))
434
442
  .foregroundStyle(.yellow)
@@ -488,6 +496,9 @@ struct ResetCheck: View {
488
496
  case .windowRestarted(let start):
489
497
  return (L("✅ 우리 계정에도 들어왔어요 · 주간 사용량이 \(clockText(start))에 새로 시작",
490
498
  "✅ It reached your account · weekly usage started over at \(clockText(start))"), usageColor(0))
499
+ case .windowRestartedLikely(let start):
500
+ return (L("✅ 들어온 것 같아요 · 주간 사용량이 \(clockText(start))에 새로 시작 (그 전 기록이 없어 추정)",
501
+ "✅ Looks like it reached you · weekly usage started over at \(clockText(start)) (no earlier record to compare)"), usageColor(0))
491
502
  case .creditAdded(let from, let to):
492
503
  return (L("🎟️ 리셋권으로 들어왔어요 · \(from)개 → \(to)개", "🎟️ It came as a reset credit · \(from) → \(to)"), usageColor(0))
493
504
  case .pending:
@@ -85,7 +85,8 @@ final class NowPlayingMonitor: ObservableObject {
85
85
  open STDOUT, '>', '/dev/null'; open STDIN, '<', '/dev/null';
86
86
  while (1) {
87
87
  sleep 1;
88
- exit 0 unless kill 0, $adapter;
88
+ # The adapter is our parent: once it's gone we're reparented, and its pid may be reused.
89
+ exit 0 if getppid() != $adapter;
89
90
  if (!kill 0, $app) { kill 'TERM', $adapter; exit 0 }
90
91
  }
91
92
  }
@@ -124,8 +125,8 @@ final class NowPlayingMonitor: ObservableObject {
124
125
  guard !data.isEmpty else { return }
125
126
  Task { @MainActor in self?.ingest(data) }
126
127
  }
127
- proc.terminationHandler = { [weak self] _ in
128
- Task { @MainActor in self?.processDied() }
128
+ proc.terminationHandler = { [weak self] dead in
129
+ Task { @MainActor in self?.processDied(dead) }
129
130
  }
130
131
  do {
131
132
  try proc.run()
@@ -149,7 +150,9 @@ final class NowPlayingMonitor: ObservableObject {
149
150
  lastArtworkB64 = nil
150
151
  }
151
152
 
152
- private func processDied() {
153
+ private func processDied(_ dead: Process) {
154
+ // A late notice about an adapter we already replaced: not the one running now.
155
+ guard dead === process else { return }
153
156
  let wasRunning = process != nil
154
157
  tearDown()
155
158
  guard enabled, wasRunning else { return }
@@ -2,8 +2,11 @@ import Foundation
2
2
 
3
3
  /// Whether a reset Tibo announced actually reached this Codex account.
4
4
  enum ResetVerdict: Equatable {
5
- /// The weekly window started over after the post.
5
+ /// The weekly window started over after the post, while the old one still had time left.
6
6
  case windowRestarted(Date)
7
+ /// It started over after the post, but there's no record from before it to rule out the old window
8
+ /// simply running out.
9
+ case windowRestartedLikely(Date)
7
10
  /// A reset credit came in after the post.
8
11
  case creditAdded(from: Int, to: Int)
9
12
  /// Nothing yet; Codex is checked every few minutes.
@@ -24,6 +27,7 @@ final class ResetTracker: ObservableObject {
24
27
  var credits: Int?
25
28
  var weeklyStart: Date?
26
29
  var weeklyUsed: Double?
30
+ var weeklyMinutes: Int?
27
31
  }
28
32
 
29
33
  @Published private(set) var credits: Int?
@@ -32,6 +36,8 @@ final class ResetTracker: ObservableObject {
32
36
  /// Spending a reset credit right now, and what Codex said last time.
33
37
  @Published private(set) var redeeming = false
34
38
  @Published private(set) var lastOutcome: String?
39
+ /// When a credit was spent from here: that restart is ours, not Tibo's.
40
+ private var spentAt: Date?
35
41
 
36
42
  private var timer: Timer?
37
43
  private var checking = false
@@ -59,7 +65,8 @@ final class ResetTracker: ObservableObject {
59
65
  // TIBODOG_MOCK=1 reads the history from TIBODOG_HOME and never asks Codex.
60
66
  guard !checking, !UsageStore.mock else { return }
61
67
  checking = true
62
- Task.detached(priority: .utility) {
68
+ // A plain GCD thread, not the Swift concurrency pool: this blocks for up to 25s.
69
+ DispatchQueue.global(qos: .utility).async {
63
70
  let result = try? CodexUsageAPI.fetch()
64
71
  DispatchQueue.main.async {
65
72
  MainActor.assumeIsolated {
@@ -83,12 +90,13 @@ final class ResetTracker: ObservableObject {
83
90
  self.redeeming = false
84
91
  self.lastOutcome = "reset"
85
92
  self.credits = max(0, (self.credits ?? 1) - 1)
93
+ self.spentAt = Date()
86
94
  UsageStore.shared.mockReset()
87
95
  }
88
96
  return
89
97
  }
90
98
  let key = UUID().uuidString
91
- Task.detached(priority: .userInitiated) {
99
+ DispatchQueue.global(qos: .userInitiated).async {
92
100
  var outcome = (try? CodexUsageAPI.consumeResetCredit(idempotencyKey: key)) ?? "error"
93
101
  // A dropped connection may still have gone through: asking again with the same key is safe.
94
102
  if outcome == "error" { outcome = (try? CodexUsageAPI.consumeResetCredit(idempotencyKey: key)) ?? "error" }
@@ -97,6 +105,7 @@ final class ResetTracker: ObservableObject {
97
105
  let tracker = ResetTracker.shared
98
106
  tracker.redeeming = false
99
107
  tracker.lastOutcome = outcome
108
+ if outcome == "reset" { tracker.spentAt = Date() }
100
109
  tracker.check()
101
110
  UsageStore.shared.refresh(force: true)
102
111
  }
@@ -111,7 +120,8 @@ final class ResetTracker: ObservableObject {
111
120
  guard let r = w.resetsAt, let m = w.durationMinutes else { return nil }
112
121
  return r.addingTimeInterval(-Double(m) * 60)
113
122
  }
114
- let snap = Snapshot(at: Date(), credits: newCredits, weeklyStart: start, weeklyUsed: weekly?.utilization)
123
+ let snap = Snapshot(at: Date(), credits: newCredits, weeklyStart: start, weeklyUsed: weekly?.utilization,
124
+ weeklyMinutes: weekly?.durationMinutes)
115
125
  let last = history.last
116
126
  credits = newCredits
117
127
  // Only keep changes: a new credit count or a weekly window that started over.
@@ -129,8 +139,15 @@ final class ResetTracker: ObservableObject {
129
139
  guard let latest = history.last else { return reachable ? .pending : .unknown }
130
140
  // A global reset starts the weekly window over (Codex's windows start at the reset, or at the
131
141
  // first use after it). Allow a little slack for clocks and propagation.
132
- if let start = latest.weeklyStart, start >= postedAt.addingTimeInterval(-30 * 60) {
133
- return .windowRestarted(start)
142
+ if let start = latest.weeklyStart, start >= postedAt.addingTimeInterval(-30 * 60),
143
+ !(spentAt.map { abs($0.timeIntervalSince(start)) < 10 * 60 } ?? false) {
144
+ // With a record from before the post, a reset is told apart from the old window running out.
145
+ if let before = history.last(where: { $0.at < postedAt }), let oldStart = before.weeklyStart {
146
+ let oldEnd = oldStart.addingTimeInterval(Double(before.weeklyMinutes ?? 10_080) * 60)
147
+ if !sameWindow(oldStart, start), oldEnd > start.addingTimeInterval(60) { return .windowRestarted(start) }
148
+ } else {
149
+ return .windowRestartedLikely(start)
150
+ }
134
151
  }
135
152
  let before = history.last { $0.at < postedAt }?.credits
136
153
  if let b = before, let after = history.filter({ $0.at >= postedAt }).compactMap(\.credits).max(), after > b {
@@ -151,10 +151,6 @@ final class UsageStore: ObservableObject {
151
151
  } catch UsageAPI.Failure.unauthorized where !refreshed {
152
152
  creds = try await refreshAndSave(creds, source, allowRefresh: allowRefresh)
153
153
  result = try await UsageAPI.fetch(token: creds.accessToken)
154
- } catch UsageAPI.Failure.rateLimited where !refreshed && creds.canRefresh && allowRefresh {
155
- // The endpoint answers 429 (not 401) to stale tokens; one refresh usually clears it.
156
- creds = try await refreshAndSave(creds, source, allowRefresh: allowRefresh)
157
- result = try await UsageAPI.fetch(token: creds.accessToken)
158
154
  }
159
155
  Log.info("usage ok: 5h=\(result.fiveHour?.percentText ?? "-") 7d=\(result.sevenDay?.percentText ?? "-")")
160
156
  publish(for: provider) { $0.usage = result; $0.lastUpdated = Date(); $0.backoffUntil = nil; $0.errorMessage = nil }
@@ -175,9 +171,20 @@ final class UsageStore: ObservableObject {
175
171
  guard allowRefresh else { throw StoreError.tokenExpired }
176
172
  Log.info("refreshing token…")
177
173
  let fresh = try await OAuth.refresh(creds)
178
- do { try await CredentialStore.saveAsync(fresh, to: source) }
179
- catch { Log.error("token saved in memory but not written back: \(error.localizedDescription)") }
180
- Log.info("token refreshed and saved to \(source)")
174
+ // Claude Code (or a new /login) may have changed the stored login while we waited: theirs wins.
175
+ if let (stored, _) = try? await CredentialStore.loadAsync(),
176
+ stored.refreshToken != creds.refreshToken || stored.accessToken != creds.accessToken {
177
+ Log.info("stored login changed during refresh; using that one")
178
+ return stored
179
+ }
180
+ do {
181
+ try await CredentialStore.saveAsync(fresh, to: source)
182
+ Log.info("token refreshed and saved to \(source)")
183
+ } catch {
184
+ // The old refresh token is spent now: keep the new one until it can be written.
185
+ CredentialStore.holdUnsaved(fresh, replacing: creds, source: source)
186
+ Log.error("token refreshed but not written back yet: \(error.localizedDescription)")
187
+ }
181
188
  return fresh
182
189
  }
183
190