relay-codex 0.5.0 → 0.5.2

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/docs/commands.md CHANGED
@@ -97,6 +97,8 @@ The watch view waits for local file changes and redraws only when useful status
97
97
  changes. Heartbeats do not repeatedly print the table. Watching uses no model
98
98
  allowance and does not fetch account allowance. `refresh` explicitly checks it.
99
99
  The menu uses one local status stream, a template icon, and native session actions.
100
+ Opening it reads a fresh local snapshot, and visible rows update in place.
101
+ Short-window and weekly allowance are labeled separately.
100
102
  It opens at Mac login after `relay menubar`; disable that in its menu or with
101
103
  `relay menubar --no-startup --configure-only`.
102
104
 
@@ -122,6 +124,10 @@ A workspace saves the same supervised session IDs, rather than starting fresh
122
124
  conversations. Restore restarts stopped jobs and prints reconnect commands; live
123
125
  jobs are retained. Save specific sessions by appending their IDs to `workspace save`.
124
126
 
127
+ Foreground jobs close after their launcher disappears and current activity is
128
+ idle. Explicit background tasks and takeovers remain running. An interrupted action
129
+ is retained for review rather than being silently retried.
130
+
125
131
  The normal command remains available after reboot. Background task recovery at
126
132
  login is a separate opt-in setting; enabling the menu does not enable task startup.
127
133
  Uncertain work still pauses for review after recovery.
@@ -8,8 +8,8 @@ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex
8
8
  The GitHub repository is enough for friends to install or try Relay:
9
9
 
10
10
  ```sh
11
- npm install -g github:blackspider-ops/relay#v0.5.0
12
- npx --yes github:blackspider-ops/relay#v0.5.0 --version
11
+ npm install -g github:blackspider-ops/relay#v0.5.2
12
+ npx --yes github:blackspider-ops/relay#v0.5.2 --version
13
13
  ```
14
14
 
15
15
  `npx` runs packages; it is not a separate publishing service. Publishing
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Keep your Codex sessions moving between your own accounts.",
5
5
  "license": "MIT",
6
6
  "author": "blackspider-ops",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/blackspider-ops/relay.git"
9
+ "url": "git+https://github.com/blackspider-ops/relay.git"
10
10
  },
11
11
  "homepage": "https://github.com/blackspider-ops/relay#readme",
12
12
  "bugs": {
package/python/Menu.swift CHANGED
@@ -8,6 +8,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
8
8
  var message: String?
9
9
  var reconnect: DispatchWorkItem?
10
10
  var stopping = false
11
+ var snapshotInFlight = false
11
12
  let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
12
13
  let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
13
14
 
@@ -61,10 +62,56 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
61
62
  while let newline = buffer.firstIndex(of: 10) {
62
63
  let line = buffer.prefix(upTo: newline); buffer.removeSubrange(...newline)
63
64
  if let value = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] {
64
- current = value; message = nil; render()
65
+ if (value["at"] as? Double ?? 0) >= (current["at"] as? Double ?? 0) { current = value; message = nil; render() }
65
66
  }
66
67
  }
67
68
  }
69
+ func refreshSnapshot() {
70
+ guard !snapshotInFlight, !stopping else { return }
71
+ snapshotInFlight = true
72
+ let proc = process(["status", "--json"])
73
+ let pipe = Pipe(); proc.standardOutput = pipe; proc.standardError = FileHandle.nullDevice
74
+ DispatchQueue.global(qos: .utility).async { [weak self] in
75
+ var snapshot: [String: Any]?
76
+ do {
77
+ try proc.run()
78
+ let timeout = DispatchWorkItem { if proc.isRunning { proc.terminate() } }
79
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 8, execute: timeout)
80
+ let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit(); timeout.cancel()
81
+ if proc.terminationStatus == 0 { snapshot = (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] }
82
+ } catch { }
83
+ let result = snapshot
84
+ DispatchQueue.main.async {
85
+ guard let self = self else { return }
86
+ self.snapshotInFlight = false
87
+ if let value = result {
88
+ let at = value["at"] as? Double ?? 0
89
+ if at >= (self.current["at"] as? Double ?? 0) { self.current = value; self.render() }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ static func synchronise(_ live: NSMenu, with desired: NSMenu) {
95
+ let incoming = desired.items
96
+ while live.items.count > incoming.count { live.removeItem(at: live.items.count - 1) }
97
+ for (index, fresh) in incoming.enumerated() {
98
+ if index >= live.items.count {
99
+ desired.removeItem(fresh); live.addItem(fresh); continue
100
+ }
101
+ let existing = live.items[index]
102
+ if existing.isSeparatorItem != fresh.isSeparatorItem || existing.identifier != fresh.identifier {
103
+ live.removeItem(at: index); desired.removeItem(fresh); live.insertItem(fresh, at: index); continue
104
+ }
105
+ existing.title = fresh.title; existing.state = fresh.state
106
+ existing.target = fresh.target; existing.action = fresh.action
107
+ existing.keyEquivalent = fresh.keyEquivalent; existing.isEnabled = fresh.isEnabled
108
+ existing.representedObject = fresh.representedObject; existing.toolTip = fresh.toolTip
109
+ if let child = fresh.submenu {
110
+ if let previous = existing.submenu { synchronise(previous, with: child) }
111
+ else { fresh.submenu = nil; existing.submenu = child }
112
+ } else { existing.submenu = nil }
113
+ }
114
+ }
68
115
  func updateIcon(attention: Bool) {
69
116
  let symbol = attention ? "exclamationmark.circle.fill" : "arrow.triangle.branch"
70
117
  let image = NSImage(systemSymbolName: symbol, accessibilityDescription: attention ? "Relay needs attention" : "Relay")
@@ -72,11 +119,13 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
72
119
  item.button?.image = image
73
120
  }
74
121
  func text(_ title: String, into menu: NSMenu) {
75
- menu.addItem(NSMenuItem(title: title, action: nil, keyEquivalent: ""))
122
+ let entry = NSMenuItem(title: title, action: nil, keyEquivalent: "")
123
+ entry.isEnabled = false; menu.addItem(entry)
76
124
  }
77
125
  func action(_ title: String, args: [String], into menu: NSMenu) {
78
126
  let entry = NSMenuItem(title: title, action: #selector(runAction(_:)), keyEquivalent: "")
79
- entry.target = self; entry.representedObject = args; menu.addItem(entry)
127
+ entry.target = self; entry.representedObject = args
128
+ entry.identifier = NSUserInterfaceItemIdentifier("action:" + args.joined(separator: "|")); menu.addItem(entry)
80
129
  }
81
130
  func date(_ value: Any?) -> String {
82
131
  guard let seconds = value as? Double else { return "unknown" }
@@ -110,23 +159,28 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
110
159
  for account in accounts {
111
160
  let name = account["name"] as? String ?? "Account"
112
161
  let primary = account["primary"] as? [String: Any]
113
- let remaining = (primary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))% left" } ?? "allowance unknown"
114
- let label = (account["enabled"] as? Bool ?? true) ? remaining : "disabled"
162
+ let short = account["short_remaining"] as? String ?? (primary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
163
+ let remaining = short == "?" ? "allowance unknown" : short + " left"
164
+ let secondary = account["secondary"] as? [String: Any]
165
+ let weekly = account["weekly_remaining"] as? String ?? (secondary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
166
+ let label = (account["enabled"] as? Bool ?? true) ? "Short \(short) · Week \(weekly)" : "disabled"
115
167
  let submenu = NSMenu()
116
168
  if let email = account["email"] as? String { text(email, into: submenu) }
117
169
  text("Short window: \(remaining)", into: submenu)
118
- if let secondary = account["secondary"] as? [String: Any], let used = secondary["usedPercent"] as? NSNumber { text("Weekly window: \(max(0, 100 - used.intValue))% left", into: submenu) }
170
+ if weekly != "?" { text("Weekly window: \(weekly) left", into: submenu) }
119
171
  if let reset = account["next_check"] { countdown("Next check", value: reset, into: submenu) }
120
172
  text("Last checked: \(date(account["usage_checked_at"]))", into: submenu)
121
- let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: ""); entry.submenu = submenu; menu.addItem(entry)
173
+ let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: "")
174
+ entry.identifier = NSUserInterfaceItemIdentifier("account:" + (account["id"] as? String ?? name))
175
+ entry.submenu = submenu; menu.addItem(entry)
122
176
  }
123
- menu.addItem(.separator())
177
+ if !sessions.isEmpty { menu.addItem(.separator()) }
124
178
  for session in sessions {
125
179
  let id = session["id"] as? String ?? ""
126
180
  let name = session["name"] as? String ?? String(id.prefix(10))
127
181
  let status = (session["state"] as? String ?? "unknown").replacingOccurrences(of: "_", with: " ")
128
182
  let submenu = NSMenu()
129
- text("Account: \(session["account"] as? String ?? "unknown")", into: submenu)
183
+ text("Account: \(session["account_name"] as? String ?? session["account"] as? String ?? "unknown")", into: submenu)
130
184
  text("Model: \(session["model"] as? String ?? "unknown")", into: submenu)
131
185
  if let next = session["next_wake"] { countdown("Next action", value: next, into: submenu) }
132
186
  if let reason = session["reason"] as? String { text(reason.replacingOccurrences(of: "_", with: " "), into: submenu) }
@@ -134,7 +188,9 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
134
188
  action("Pause recovery", args: ["pause", id, "--wait"], into: submenu)
135
189
  action("Resume recovery", args: ["unpause", id, "--wait"], into: submenu)
136
190
  action("Review and retry…", args: ["confirm-retry", id], into: submenu)
137
- let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: ""); entry.submenu = submenu; menu.addItem(entry)
191
+ let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: "")
192
+ entry.identifier = NSUserInterfaceItemIdentifier("session:" + id)
193
+ entry.submenu = submenu; menu.addItem(entry)
138
194
  }
139
195
  menu.addItem(.separator())
140
196
  action("Copy dashboard command", args: ["copy", "relay status --watch"], into: menu)
@@ -143,9 +199,11 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
143
199
  login.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
144
200
  menu.addItem(login)
145
201
  let quit = NSMenuItem(title: "Quit Relay menu", action: #selector(quitMenu), keyEquivalent: "q"); quit.target = self; menu.addItem(quit)
146
- item.menu = menu
202
+ if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
203
+ else { item.menu = menu }
147
204
  }
148
205
  func menuWillOpen(_ menu: NSMenu) {
206
+ refreshSnapshot()
149
207
  updateTimes(menu)
150
208
  for entry in menu.items where entry.title == "Open at login" {
151
209
  entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
@@ -181,7 +239,19 @@ if CommandLine.arguments.contains("--self-check") {
181
239
  let normal = NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: "Relay")
182
240
  let attention = NSImage(systemSymbolName: "exclamationmark.circle.fill", accessibilityDescription: "Relay needs attention")
183
241
  print("Relay menu symbols available: \(normal != nil && attention != nil)")
184
- exit(normal != nil && attention != nil ? 0 : 1)
242
+ let visible = NSMenu(); visible.addItem(NSMenuItem(title: "Short 83%", action: nil, keyEquivalent: ""))
243
+ let existing = visible.items[0]
244
+ let updated = NSMenu(); updated.addItem(NSMenuItem(title: "Short 30%", action: nil, keyEquivalent: ""))
245
+ MenuController.synchronise(visible, with: updated)
246
+ let liveUpdate = visible.items[0] === existing && existing.title == "Short 30%"
247
+ print("Visible menu update preserved: \(liveUpdate)")
248
+ existing.identifier = NSUserInterfaceItemIdentifier("session:old")
249
+ let replacement = NSMenu(); let newItem = NSMenuItem(title: "New task", action: nil, keyEquivalent: "")
250
+ newItem.identifier = NSUserInterfaceItemIdentifier("session:new"); replacement.addItem(newItem)
251
+ MenuController.synchronise(visible, with: replacement)
252
+ let safeIdentity = visible.items[0] !== existing && visible.items[0].identifier == newItem.identifier
253
+ print("Session action identity preserved: \(safeIdentity)")
254
+ exit(normal != nil && attention != nil && liveUpdate && safeIdentity ? 0 : 1)
185
255
  } else {
186
256
  let app = NSApplication.shared
187
257
  let delegate = MenuController(); app.delegate = delegate; app.run()
@@ -68,6 +68,13 @@ def resolve_label(state, value):
68
68
  raise RouterError("Unknown or ambiguous account name")
69
69
 
70
70
 
71
+ def remaining_percent(window):
72
+ if not window or not isinstance(window.get("usedPercent"), (int, float)):
73
+ return "?"
74
+ value = max(0, min(100, 100 - window["usedPercent"]))
75
+ return f"{value:.1f}".rstrip("0").rstrip(".") + "%"
76
+
77
+
71
78
  def snapshot(state, include_inactive=False):
72
79
  from router import labels, VERSION
73
80
 
@@ -99,6 +106,8 @@ def snapshot(state, include_inactive=False):
99
106
  "sessions": sum(s["account"] == label for s in sessions),
100
107
  "primary": windows.get("primary"),
101
108
  "secondary": windows.get("secondary"),
109
+ "short_remaining": remaining_percent(windows.get("primary")),
110
+ "weekly_remaining": remaining_percent(windows.get("secondary")),
102
111
  "usage_known": usage is not None,
103
112
  "usage_checked_at": pool.checked_at(label),
104
113
  "maintenance": options.get("maintenance", False),
@@ -110,6 +119,13 @@ def snapshot(state, include_inactive=False):
110
119
  )
111
120
  with pool.db() as db:
112
121
  for session in sessions:
122
+ account = next(
123
+ (a for a in accounts if a["id"] == session.get("account")), {}
124
+ )
125
+ session["account_name"] = account.get(
126
+ "name", session.get("account", "")
127
+ )
128
+ session["account_email"] = account.get("email")
113
129
  saved_intent = db.execute(
114
130
  "SELECT details FROM intents WHERE owner=? AND thread=?",
115
131
  (session["id"], session.get("thread", "")),
@@ -165,10 +181,6 @@ def snapshot(state, include_inactive=False):
165
181
 
166
182
 
167
183
  def render(value):
168
- def percent(window):
169
- return (
170
- "?" if not window else str(max(0, 100 - window.get("usedPercent", 0))) + "%"
171
- )
172
184
 
173
185
  lines = [
174
186
  f"Relay · {len(value['sessions'])} sessions · {len(value['accounts'])} accounts",
@@ -193,16 +205,16 @@ def render(value):
193
205
  else "—"
194
206
  )
195
207
  lines.append(
196
- f"{label:<39}{state:<13}{percent(account['primary']):<13}{percent(account['secondary']):<13}{account['sessions']:<10}{next_at}"
208
+ f"{label:<39}{state:<13}{account.get('short_remaining', remaining_percent(account['primary'])):<13}{account.get('weekly_remaining', remaining_percent(account['secondary'])):<13}{account['sessions']:<10}{next_at}"
197
209
  )
198
210
  if value["sessions"]:
199
211
  lines += [
200
212
  "",
201
- f"{'SESSION':<12}{'ACCOUNT':<14}{'STATE':<23}{'MODEL':<23}PROJECT / NAME",
213
+ f"{'SESSION':<12}{'ACCOUNT':<32}{'STATE':<23}{'MODEL':<23}PROJECT / NAME",
202
214
  ]
203
215
  for row in value["sessions"]:
204
216
  lines.append(
205
- f"{row['id'][:10]:<12}{row.get('account', ''):<14}{str(row.get('state', 'unknown'))[:22]:<23}{str(row.get('model') or '?')[:22]:<23}{row.get('cwd') or row.get('name') or ''}"
217
+ f"{row['id'][:10]:<12}{str(row.get('account_name') or row.get('account', '')):<32}{str(row.get('state', 'unknown'))[:22]:<23}{str(row.get('model') or '?')[:22]:<23}{row.get('cwd') or row.get('name') or ''}"
206
218
  )
207
219
  if row.get("next_wake"):
208
220
  lines.append(
package/python/router.py CHANGED
@@ -24,7 +24,7 @@ from pool import Pool, blocked, reset_time, failure_kind, file_lock
24
24
  from launch import translate, RESUME_FIELDS
25
25
  import settings
26
26
 
27
- VERSION = "0.5.0"
27
+ VERSION = "0.5.2"
28
28
  _support = Path.home() / "Library/Application Support"
29
29
  _legacy = _support / "Codex Accounts"
30
30
  _default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
@@ -427,6 +427,8 @@ class Controller:
427
427
  self.stopping = asyncio.Event()
428
428
  self.extra_accounts = None
429
429
  self.starting = False
430
+ self.lifecycle_check = None
431
+ self.exit_state = "stopped"
430
432
  self.on_thread = None
431
433
  self.on_detach = None
432
434
  self.project = os.getcwd()
@@ -435,6 +437,17 @@ class Controller:
435
437
  self.awake_process = None
436
438
  self.fallback_tried = {}
437
439
 
440
+ def account_name(self):
441
+ if self.pool:
442
+ from commands import account_identity
443
+
444
+ return (
445
+ account_identity(self.pool.state, self.account.label).get("email")
446
+ or self.pool.options(self.account.label).get("name")
447
+ or self.account.label
448
+ )
449
+ return self.account.label
450
+
438
451
  def attached(self):
439
452
  return bool(self.client) or self.background or self.starting
440
453
 
@@ -457,7 +470,7 @@ class Controller:
457
470
  if self.preferences.get("notifications"):
458
471
  from notifications import notify
459
472
 
460
- notify(kind, self.account.label)
473
+ notify(kind, self.account_name())
461
474
 
462
475
  def merge_settings(self, thread, params):
463
476
  allowed = {
@@ -514,6 +527,8 @@ class Controller:
514
527
 
515
528
  def clear_review(self, thread):
516
529
  self.paused_uncertain.discard(thread)
530
+ if not self.paused_uncertain:
531
+ self.exit_state = "stopped"
517
532
  self.persist(thread, review_required=False, review_reason=None)
518
533
 
519
534
  def can_continue(self, thread, generation):
@@ -670,6 +685,10 @@ class Controller:
670
685
  except Exception:
671
686
  self.pool.ack(command, "failed")
672
687
  self.note("control_failed", operation=op)
688
+ if self.lifecycle_check:
689
+ await self.lifecycle_check()
690
+ if self.stopping.is_set():
691
+ break
673
692
  if self.preferences.get("keep_awake") and self.active_threads:
674
693
  if (
675
694
  self.awake_process is None
@@ -727,6 +746,7 @@ class Controller:
727
746
  self.wake.set()
728
747
  self.note("resumed")
729
748
  elif op == "stop":
749
+ self.exit_state = "stopped"
730
750
  self.stopping.set()
731
751
  elif op == "detach":
732
752
  if self.pool:
@@ -792,6 +812,8 @@ class Controller:
792
812
  self.note("switched", to=label)
793
813
  elif op == "retry":
794
814
  self.paused = False
815
+ if thread:
816
+ self.clear_review(thread)
795
817
  self.wake.set()
796
818
  if self.recovery and not self.recovery.done():
797
819
  self.force_retry.set()
@@ -803,7 +825,6 @@ class Controller:
803
825
  )
804
826
  self.pool.invalidate(self.account.label)
805
827
  if thread and (not self.recovery or self.recovery.done()):
806
- self.clear_review(thread)
807
828
  self.recovery = asyncio.create_task(
808
829
  self.continue_thread(thread, self.user_generation)
809
830
  )
@@ -1173,7 +1194,7 @@ class Controller:
1173
1194
  raise
1174
1195
  except (RouterError, asyncio.TimeoutError):
1175
1196
  return await self.uncertain(thread, generation, baseline)
1176
- self.report(f"Continued the saved thread with {self.account.label}.")
1197
+ self.report(f"Continued the saved thread with {self.account_name()}.")
1177
1198
 
1178
1199
  async def restore_recovery(self, thread, persisted):
1179
1200
  """Honor an accepted retry's original deadline after a process restart."""
@@ -1383,7 +1404,7 @@ class Controller:
1383
1404
  episode["attempt"] += 1
1384
1405
  target = episode["start"] + episode["attempt"] * self.retry_interval
1385
1406
  self.report(
1386
- f"Service/authentication failure on {self.account.label}. Retry {episode['attempt']}/{self.retry_attempts} at {time.strftime('%H:%M:%S', time.localtime(target))}."
1407
+ f"Service/authentication failure on {self.account_name()}. Retry {episode['attempt']}/{self.retry_attempts} at {time.strftime('%H:%M:%S', time.localtime(target))}."
1387
1408
  )
1388
1409
  self.note(
1389
1410
  "retry_scheduled",
@@ -1416,7 +1437,7 @@ class Controller:
1416
1437
  )
1417
1438
  self.episodes.pop(thread, None)
1418
1439
  self.report(
1419
- f"Selecting another account after {kind} failure on {self.account.label}."
1440
+ f"Selecting another account after {kind} failure on {self.account_name()}."
1420
1441
  )
1421
1442
  self.note("selecting_account", thread=thread, reason=kind)
1422
1443
  await self.mark(self.index, model, kind)
package/python/service.py CHANGED
@@ -51,6 +51,10 @@ def start(
51
51
  "interactive": interactive,
52
52
  "binary": binary,
53
53
  "relay_runtime": str(Path(__file__).parent.resolve()),
54
+ "foreground_owner": {"pid": os.getpid(), "birth": process_birth(os.getpid())}
55
+ if interactive
56
+ else None,
57
+ "background_launch": not interactive,
54
58
  }
55
59
  if not Path(spec["cwd"]).is_dir():
56
60
  raise ValueError("Working directory does not exist")
@@ -155,6 +159,21 @@ async def attach(
155
159
  spec_path = root / (ident + ".job.json")
156
160
  spec = json.loads(spec_path.read_text())
157
161
  binary = spec.get("binary") or binary
162
+ owner = spec.get("foreground_owner")
163
+ if (
164
+ spec.get("interactive")
165
+ and not spec.get("background_launch")
166
+ and owner
167
+ and not (
168
+ Pool.alive(owner["pid"])
169
+ and process_birth(owner["pid"]) == owner.get("birth")
170
+ )
171
+ ):
172
+ spec["foreground_owner"] = {
173
+ "pid": os.getpid(),
174
+ "birth": process_birth(os.getpid()),
175
+ }
176
+ atomic_json(spec_path, spec)
158
177
  meta = root / (ident + ".connection.json")
159
178
  invocation_cwd = cwd
160
179
  reconnect = False
@@ -353,7 +372,7 @@ def restart(state, prefix):
353
372
  spec = json.loads(path.read_text())
354
373
  if not Path(spec["cwd"]).is_dir():
355
374
  raise RouterError("The project folder no longer exists")
356
- spec.update(state="starting")
375
+ spec.update(state="starting", background_launch=True, foreground_owner=None)
357
376
  spec.pop("last_error", None)
358
377
  atomic_json(path, spec)
359
378
  process = spawn(
@@ -378,6 +397,54 @@ def restart(state, prefix):
378
397
  return ident
379
398
 
380
399
 
400
+ def foreground_owner_gone(spec, preferences, controller, *, now=None):
401
+ """Foreground jobs must not outlive a vanished launcher unless explicitly detached."""
402
+ if (
403
+ not spec.get("interactive")
404
+ or spec.get("background_launch")
405
+ or spec.get("takeover")
406
+ or preferences.get("background_on_disconnect")
407
+ or controller.client
408
+ ):
409
+ return False
410
+ owner = spec.get("foreground_owner")
411
+ if owner:
412
+ return not (
413
+ Pool.alive(owner["pid"])
414
+ and process_birth(owner["pid"]) == owner.get("birth")
415
+ )
416
+ # Earlier jobs did not record a launcher. Only retire a definitely empty,
417
+ # unattached picker; retain older conversations whose ownership is uncertain.
418
+ return (
419
+ not spec.get("resume")
420
+ and not controller.last_turn
421
+ and (time.time() if now is None else now) - spec.get("created", time.time())
422
+ > 60
423
+ )
424
+
425
+
426
+ async def close_orphaned_foreground(controller, spec_path):
427
+ spec = json.loads(spec_path.read_text())
428
+ if not foreground_owner_gone(spec, controller.preferences, controller):
429
+ return
430
+ if (
431
+ controller.active_threads
432
+ or controller.active_items
433
+ or controller.server_requests
434
+ ):
435
+ for thread in controller.last_turn:
436
+ controller.hold_for_review(thread, "terminal_closed_during_action")
437
+ controller.exit_state = "needs_attention"
438
+ if not getattr(controller, "orphan_reported", False):
439
+ controller.note("needs_attention", reason="terminal_closed_during_action")
440
+ controller.orphan_reported = True
441
+ return
442
+ controller.note("foreground_closed", reason="launcher_exited")
443
+ spec["state"] = controller.exit_state
444
+ atomic_json(spec_path, spec)
445
+ controller.stopping.set()
446
+
447
+
381
448
  async def _serve(state, ident, binary):
382
449
  from websockets.asyncio.server import serve as ws_serve
383
450
  from router import (
@@ -456,6 +523,9 @@ async def _serve(state, ident, binary):
456
523
  return new or old
457
524
 
458
525
  controller.extra_accounts = reload_accounts
526
+ controller.lifecycle_check = lambda: close_orphaned_foreground(
527
+ controller, spec_path
528
+ )
459
529
  controller.preferences = settings.load(state, cwd, spec.get("profile"))
460
530
  controller.retry_interval = controller.preferences["retry_interval"]
461
531
  controller.retry_attempts = controller.preferences["retry_attempts"]
@@ -513,6 +583,9 @@ async def _serve(state, ident, binary):
513
583
  pool.touch(state="waiting_for_reset", next_wake=deadline)
514
584
  await controller.wait_until(deadline, controller.user_generation)
515
585
  if controller.stopping.is_set():
586
+ latest = json.loads(spec_path.read_text())
587
+ latest["state"] = controller.exit_state
588
+ atomic_json(spec_path, latest)
516
589
  return 0
517
590
  controller.backend_factory = factory
518
591
  thread_id = old_thread or spec.get("resume")
@@ -688,7 +761,7 @@ async def _serve(state, ident, binary):
688
761
  # have been reconciled. A frontend must not race recovery setup.
689
762
  atomic_json(connection, meta)
690
763
  await controller.stopping.wait()
691
- spec["state"] = "stopped"
764
+ spec["state"] = controller.exit_state
692
765
  atomic_json(spec_path, spec)
693
766
  return 0
694
767
  finally:
@@ -45,6 +45,31 @@ class RecoveryRegressions(unittest.IsolatedAsyncioTestCase):
45
45
  self.assertTrue(any(method == "turn/start" for method, _ in backend.calls))
46
46
  pool.close()
47
47
 
48
+ async def test_explicit_retry_clears_a_review_hold_during_an_existing_timer(self):
49
+ with tempfile.TemporaryDirectory() as folder:
50
+ pool = Pool(Path(folder))
51
+ backend = FakeBackend()
52
+ c = Controller(
53
+ [FakeAccount("one")], backend, pool=pool, report=lambda _: None
54
+ )
55
+ c.client = FakeClient()
56
+ c.last_turn["thread-1"] = {"threadId": "thread-1"}
57
+ c.recovery = asyncio.create_task(c.recover("thread-1", 0, "capacity"))
58
+ try:
59
+ while not c.episodes:
60
+ await asyncio.sleep(0)
61
+ c.hold_for_review("thread-1")
62
+ await c.control("retry", {"thread": "thread-1"})
63
+ await asyncio.wait_for(c.recovery, 1)
64
+ self.assertFalse(pool.intent("thread-1")["review_required"])
65
+ self.assertEqual(
66
+ sum(method == "turn/start" for method, _ in backend.calls), 1
67
+ )
68
+ finally:
69
+ c.recovery.cancel()
70
+ await asyncio.gather(c.recovery, return_exceptions=True)
71
+ pool.close()
72
+
48
73
  async def test_picker_permission_control_applies_before_thread_selection(self):
49
74
  c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
50
75
  c.launch_overrides = {
@@ -318,3 +343,89 @@ class AttachmentRegressions(unittest.IsolatedAsyncioTestCase):
318
343
  self.assertFalse(
319
344
  parse_arguments(["attach", "task", "--", "--takeover"]).takeover
320
345
  )
346
+
347
+
348
+ class ForegroundLifecycleTests(unittest.IsolatedAsyncioTestCase):
349
+ async def test_closed_launcher_retires_an_empty_idle_foreground_job(self):
350
+ with tempfile.TemporaryDirectory() as folder:
351
+ path = Path(folder) / "job.json"
352
+ job = {
353
+ "interactive": True,
354
+ "foreground_owner": {"pid": 99999999, "birth": "gone"},
355
+ "state": "starting",
356
+ }
357
+ atomic_json(path, job)
358
+ c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
359
+ await service.close_orphaned_foreground(c, path)
360
+ self.assertTrue(c.stopping.is_set())
361
+ self.assertEqual(json.loads(path.read_text())["state"], "stopped")
362
+
363
+ async def test_real_launcher_background_preference_and_takeover_are_preserved(self):
364
+ c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
365
+ job = {
366
+ "interactive": True,
367
+ "foreground_owner": {
368
+ "pid": os.getpid(),
369
+ "birth": process_birth(os.getpid()),
370
+ },
371
+ }
372
+ self.assertFalse(service.foreground_owner_gone(job, c.preferences, c))
373
+ dead = {**job, "foreground_owner": {"pid": 99999999, "birth": "gone"}}
374
+ for field in ("background_launch", "takeover"):
375
+ self.assertFalse(
376
+ service.foreground_owner_gone({**dead, field: True}, c.preferences, c)
377
+ )
378
+ self.assertFalse(
379
+ service.foreground_owner_gone(
380
+ dead, {**c.preferences, "background_on_disconnect": True}, c
381
+ )
382
+ )
383
+ c.client = FakeClient()
384
+ self.assertFalse(service.foreground_owner_gone(dead, c.preferences, c))
385
+
386
+ async def test_orphaned_active_action_requires_review_and_is_not_killed(self):
387
+ with tempfile.TemporaryDirectory() as folder:
388
+ state = Path(folder)
389
+ path = state / "job.json"
390
+ pool = Pool(state)
391
+ atomic_json(
392
+ path,
393
+ {
394
+ "interactive": True,
395
+ "foreground_owner": {"pid": 99999999, "birth": "gone"},
396
+ },
397
+ )
398
+ c = Controller(
399
+ [FakeAccount("one")], FakeBackend(), pool=pool, report=lambda _: None
400
+ )
401
+ c.last_turn["thread"] = {"threadId": "thread"}
402
+ c.active_items.add("command")
403
+ await service.close_orphaned_foreground(c, path)
404
+ self.assertFalse(c.stopping.is_set())
405
+ self.assertTrue(pool.intent("thread")["review_required"])
406
+ c.active_items.clear()
407
+ await service.close_orphaned_foreground(c, path)
408
+ self.assertTrue(c.stopping.is_set())
409
+ self.assertEqual(json.loads(path.read_text())["state"], "needs_attention")
410
+ pool.close()
411
+
412
+ async def test_explicit_review_or_stop_clears_orphan_exit_status(self):
413
+ c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
414
+ c.hold_for_review("thread")
415
+ c.exit_state = "needs_attention"
416
+ c.clear_review("thread")
417
+ self.assertEqual(c.exit_state, "stopped")
418
+ c.exit_state = "needs_attention"
419
+ await c.control("stop", {})
420
+ self.assertEqual(c.exit_state, "stopped")
421
+
422
+ def test_legacy_empty_picker_expires_but_saved_conversation_is_retained(self):
423
+ c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
424
+ job = {"interactive": True, "created": 1000}
425
+ self.assertFalse(service.foreground_owner_gone(job, c.preferences, c, now=1005))
426
+ self.assertTrue(service.foreground_owner_gone(job, c.preferences, c, now=1100))
427
+ self.assertFalse(
428
+ service.foreground_owner_gone(
429
+ {**job, "resume": "saved"}, c.preferences, c, now=1100
430
+ )
431
+ )
@@ -85,6 +85,31 @@ class ManagementTests(unittest.TestCase):
85
85
  with self.assertRaises(ValueError):
86
86
  prepare_worktree(["resume", "--worktree"], state, str(repo))
87
87
 
88
+ def test_both_displays_receive_the_same_fractional_allowance(self):
89
+ with tempfile.TemporaryDirectory() as folder:
90
+ state = Path(folder)
91
+ home = state / "accounts" / "one"
92
+ home.mkdir(parents=True)
93
+ (home / "enrolled").write_text("{}")
94
+ pool = Pool(state)
95
+ pool.save(
96
+ "one",
97
+ {
98
+ "rateLimits": {
99
+ "primary": {"usedPercent": 70.2},
100
+ "secondary": {"usedPercent": 41.5},
101
+ }
102
+ },
103
+ )
104
+ value = commands.snapshot(state)
105
+ account = value["accounts"][0]
106
+ self.assertEqual(account["short_remaining"], "29.8%")
107
+ self.assertEqual(account["weekly_remaining"], "58.5%")
108
+ text = commands.render(value)
109
+ self.assertIn(account["short_remaining"], text)
110
+ self.assertIn(account["weekly_remaining"], text)
111
+ pool.close()
112
+
88
113
  def test_account_emails_are_displayed_and_resolve_without_reading_tokens(self):
89
114
  with tempfile.TemporaryDirectory() as folder:
90
115
  state = Path(folder)