devops-bot-sdk 1.4.29__tar.gz → 1.4.33__tar.gz

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 (38) hide show
  1. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/client.py +53 -1
  6. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/run_auto.py +191 -21
  7. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/README.md +0 -0
  8. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  9. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  10. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  11. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/devops_bot_sdk.egg-info/requires.txt +0 -0
  12. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  13. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/collectors/__init__.py +0 -0
  14. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/collectors/files.py +0 -0
  15. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/collectors/process.py +0 -0
  16. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/collectors/screenshot.py +0 -0
  17. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/config.py +0 -0
  18. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/crucial.py +0 -0
  19. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/exceptions.py +0 -0
  20. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/git_ops.py +0 -0
  21. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/ipc/handlers.py +0 -0
  27. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/local_exec.py +0 -0
  28. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/models/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/models/envelope.py +0 -0
  30. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/models/requests.py +0 -0
  31. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/models/responses.py +0 -0
  32. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/models/snapshots.py +0 -0
  33. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/py.typed +0 -0
  34. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.29 → devops_bot_sdk-1.4.33}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.29
3
+ Version: 1.4.33
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.29
3
+ Version: 1.4.33
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.29"
7
+ version = "1.4.33"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.29
3
+ Version: 1.4.33
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.29"
33
+ __version__ = "1.4.33"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -36,7 +36,29 @@ from sdk.sse import _check_status, stream_with_reconnect
36
36
 
37
37
  logger = logging.getLogger(__name__)
38
38
 
39
- SDK_VERSION = "1.4.29"
39
+ # ── Ticket status-change observers ────────────────────────────────────────
40
+ # Best-effort, synchronous hooks fired when the client changes a ticket's backend
41
+ # status (In Progress / Backlog / To Do). The CLI loop registers one to track
42
+ # which tickets the run has parked in Backlog, so `agentos-auto stop` can restore
43
+ # exactly those to To Do. Kept generic here — the client never touches CLI files.
44
+ _status_observers: list = []
45
+
46
+
47
+ def register_status_observer(cb) -> None:
48
+ """Register cb(task_id: str, status: str) — status ∈ {in_progress, backlog, to_do}."""
49
+ if cb not in _status_observers:
50
+ _status_observers.append(cb)
51
+
52
+
53
+ def _notify_status(task_id: str, status: str) -> None:
54
+ for cb in list(_status_observers):
55
+ try:
56
+ cb(task_id, status)
57
+ except Exception: # noqa: BLE001 — observers must never break a request
58
+ pass
59
+
60
+
61
+ SDK_VERSION = "1.4.33"
40
62
  _POLL_INTERVAL = 3.0
41
63
  _POLL_TIMEOUT = 600.0
42
64
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -686,11 +708,40 @@ class BackendClient:
686
708
  task_id, resp.status_code, resp.text[:200])
687
709
  return False
688
710
  await self.log_activity(f"Task {task_id}: status → Backlog")
711
+ _notify_status(task_id, "backlog")
689
712
  return True
690
713
  except Exception as exc: # noqa: BLE001
691
714
  logger.warning("client.move_to_backlog error task=%s err=%s", task_id, exc)
692
715
  return False
693
716
 
717
+ async def move_to_todo(self, task_id: str) -> bool:
718
+ """PUT {BE}/tasks-ml/{id} status="To Do" (best-effort).
719
+
720
+ Used by `agentos-auto stop` to return the in-flight ticket to the unstarted
721
+ queue so a later run re-picks it up from scratch (rather than leaving it
722
+ In Progress). Returns True on success; logs and returns False otherwise.
723
+ """
724
+ from sdk.config import BE_DEFAULT_URL
725
+
726
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml/{task_id}"
727
+ headers = {
728
+ k: v for k, v in self._headers.items() if k.lower() != "content-type"
729
+ }
730
+ headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
731
+ try:
732
+ async with httpx.AsyncClient(timeout=20.0) as client:
733
+ resp = await client.put(url, files={"status": (None, "To Do")}, headers=headers)
734
+ if resp.status_code >= 400:
735
+ logger.warning("client.move_to_todo failed task=%s status=%s body=%s",
736
+ task_id, resp.status_code, resp.text[:200])
737
+ return False
738
+ await self.log_activity(f"Task {task_id}: status → To Do")
739
+ _notify_status(task_id, "to_do")
740
+ return True
741
+ except Exception as exc: # noqa: BLE001
742
+ logger.warning("client.move_to_todo error task=%s err=%s", task_id, exc)
743
+ return False
744
+
694
745
  async def mark_in_progress(self, task_id: str) -> bool:
695
746
  """PUT {BE}/tasks-ml/{id} status=In Progress (best-effort).
696
747
 
@@ -712,6 +763,7 @@ class BackendClient:
712
763
  task_id, resp.status_code, resp.text[:200])
713
764
  return False
714
765
  await self.log_activity(f"Task {task_id}: status → In Progress")
766
+ _notify_status(task_id, "in_progress")
715
767
  return True
716
768
  except Exception as exc: # noqa: BLE001
717
769
  logger.warning("client.mark_in_progress error task=%s err=%s", task_id, exc)
@@ -28,11 +28,13 @@ Auto-update: on `run`/`resume`, the SDK checks for a newer version and upgrades
28
28
  itself (then re-runs on the new code) BEFORE detaching, so non-technical users
29
29
  never reinstall.
30
30
 
31
- Stop/resume model: the loop marks each task In Progress/Done on the backend as
32
- it goes, so the backend task status IS the saved state. `stop` writes a flag the
33
- running loop checks between tasks (so a stop takes effect after the in-flight
34
- task finishes); `resume` (or just re-running) clears the flag and continues with
35
- whatever is still To Do.
31
+ Stop model: `stop` is immediate it kills the loop's whole process group (the
32
+ daemon and any child `claude`), then returns to "To Do" both the in-flight ticket
33
+ AND every ticket this run parked in Backlog (e.g. 15-min approval timeouts), so a
34
+ later run re-attempts them; human-parked Backlog tickets are left untouched. The
35
+ in-flight id is kept in ~/.agentos/auto.current and the run's Backlog set in
36
+ ~/.agentos/auto.backlogged (maintained via a client status observer). `resume`
37
+ (or just re-running) starts fresh over whatever is still To Do.
36
38
  """
37
39
  from __future__ import annotations
38
40
 
@@ -46,6 +48,13 @@ _AGENTOS_DIR = Path.home() / ".agentos"
46
48
  _STOP_FILE = _AGENTOS_DIR / "auto.stop"
47
49
  _PID_FILE = _AGENTOS_DIR / "auto.pid"
48
50
  _LOG_FILE = _AGENTOS_DIR / "auto.log"
51
+ # Backend id (UUID) of the ticket currently being processed. `stop` reads it to
52
+ # return the in-flight ticket to "To Do" before killing the loop.
53
+ _CURRENT_FILE = _AGENTOS_DIR / "auto.current"
54
+ # Backend ids the CURRENT run has moved to Backlog (e.g. 15-min approval
55
+ # timeouts). `stop` restores exactly these to "To Do" too — not human-parked
56
+ # Backlog tickets. Maintained live via a client status observer.
57
+ _BACKLOGGED_FILE = _AGENTOS_DIR / "auto.backlogged"
49
58
 
50
59
 
51
60
  def _stop_requested() -> bool:
@@ -64,6 +73,64 @@ def _clear_stop() -> None:
64
73
  pass
65
74
 
66
75
 
76
+ def _write_current(task_id: str) -> None:
77
+ try:
78
+ _AGENTOS_DIR.mkdir(parents=True, exist_ok=True)
79
+ _CURRENT_FILE.write_text(task_id)
80
+ except Exception: # noqa: BLE001
81
+ pass
82
+
83
+
84
+ def _read_current() -> str | None:
85
+ try:
86
+ val = _CURRENT_FILE.read_text().strip()
87
+ return val or None
88
+ except FileNotFoundError:
89
+ return None
90
+
91
+
92
+ def _clear_current() -> None:
93
+ try:
94
+ _CURRENT_FILE.unlink()
95
+ except FileNotFoundError:
96
+ pass
97
+
98
+
99
+ def _read_backlogged() -> set[str]:
100
+ try:
101
+ return set(json.loads(_BACKLOGGED_FILE.read_text()))
102
+ except (FileNotFoundError, ValueError, TypeError):
103
+ return set()
104
+
105
+
106
+ def _write_backlogged(ids: set[str]) -> None:
107
+ try:
108
+ _AGENTOS_DIR.mkdir(parents=True, exist_ok=True)
109
+ _BACKLOGGED_FILE.write_text(json.dumps(sorted(ids)))
110
+ except Exception: # noqa: BLE001
111
+ pass
112
+
113
+
114
+ def _clear_backlogged() -> None:
115
+ try:
116
+ _BACKLOGGED_FILE.unlink()
117
+ except FileNotFoundError:
118
+ pass
119
+
120
+
121
+ def _track_status(task_id: str, status: str) -> None:
122
+ """Client status observer (runs in the daemon): keep the set of tickets THIS
123
+ run has parked in Backlog current, so `stop` can restore them to To Do."""
124
+ if not task_id:
125
+ return
126
+ ids = _read_backlogged()
127
+ if status == "backlog":
128
+ ids.add(task_id)
129
+ else: # in_progress / to_do → no longer backlogged by us
130
+ ids.discard(task_id)
131
+ _write_backlogged(ids)
132
+
133
+
67
134
  # ── Background-daemon support ─────────────────────────────────────────────
68
135
  # `agentos-auto` must keep running after the terminal is closed and stop ONLY on
69
136
  # `agentos-auto stop`. We fully detach from the controlling terminal (double-fork
@@ -157,9 +224,24 @@ def _parse_workspace_root(argv: list[str]) -> str | None:
157
224
  return None
158
225
 
159
226
 
227
+ _current_task: str | None = None
228
+
229
+
160
230
  async def _send(env: dict) -> None:
161
231
  etype = env.get("type", "")
162
232
  data = env.get("data", {}) or {}
233
+
234
+ # Track the in-flight ticket (backend UUID) so `stop` can return it to To Do.
235
+ # The handler tags per-task envelopes with jira_task_id / task_id (both = the
236
+ # backend UUID here). Persist only on change to avoid churning the file.
237
+ global _current_task
238
+ tid = data.get("jira_task_id") or data.get("task_id")
239
+ if tid and tid != _current_task:
240
+ _current_task = tid
241
+ _write_current(tid)
242
+ if etype == "done":
243
+ _clear_current()
244
+
163
245
  if etype == "pipeline_started":
164
246
  print(f"\n▶ orchestrate-auto (local) — {data.get('task_count', 0)} task(s)\n")
165
247
  elif etype == "step_update":
@@ -183,7 +265,7 @@ async def _send(env: dict) -> None:
183
265
 
184
266
  def _run_loop(workspace_root: str | None) -> None:
185
267
  from sdk import local_exec
186
- from sdk.client import BackendClient
268
+ from sdk.client import BackendClient, register_status_observer
187
269
  from sdk.ipc import handlers
188
270
 
189
271
  if not local_exec.is_available():
@@ -202,6 +284,8 @@ def _run_loop(workspace_root: str | None) -> None:
202
284
  payload["workspace_root"] = workspace_root
203
285
 
204
286
  _clear_stop() # a fresh run/resume clears any stale stop flag
287
+ _clear_backlogged() # session-scoped: only track Backlog moves from THIS run
288
+ register_status_observer(_track_status) # keep the run's Backlog set current
205
289
  try:
206
290
  asyncio.run(
207
291
  handlers.handle_orchestrate_auto_local_run(
@@ -213,12 +297,15 @@ def _run_loop(workspace_root: str | None) -> None:
213
297
  print("\n⏸ interrupted — stop flag set. run `agentos-auto resume` to continue.")
214
298
  finally:
215
299
  # Whether the loop finished, paused, or crashed, drop our PID file so
216
- # `status` doesn't report a dead daemon as running.
300
+ # `status` doesn't report a dead daemon as running, and clear the in-flight
301
+ # + backlog markers (a clean finish leaves nothing for `stop` to restore).
217
302
  if _read_pid() == os.getpid():
218
303
  try:
219
304
  _PID_FILE.unlink()
220
305
  except FileNotFoundError:
221
306
  pass
307
+ _clear_current()
308
+ _clear_backlogged()
222
309
 
223
310
 
224
311
  def main() -> None:
@@ -226,20 +313,98 @@ def main() -> None:
226
313
  cmd = argv[0] if argv and not argv[0].startswith("-") else "run"
227
314
 
228
315
  if cmd == "update":
229
- from sdk.updater import auto_update
230
- updated, cur, latest = auto_update(quiet=False)
231
- if not updated:
232
- print(f"already up to date (v{cur})" if latest else f"v{cur} (could not reach PyPI)")
316
+ # Update-only: upgrade the installed SDK package to the latest version and
317
+ # report before → after. Does NOT start, stop, or touch the running loop —
318
+ # a running daemon keeps its current code until it is restarted. Honours
319
+ # AGENTOS_NO_UPDATE=1 (auto_update no-ops).
320
+ from sdk.updater import auto_update, current_version
321
+ before = current_version()
322
+ print("checking for SDK updates …")
323
+ auto_update(quiet=True)
324
+ after = current_version()
325
+ if after != before:
326
+ print(f"✓ updated {before} → {after}")
327
+ pid = _daemon_running()
328
+ if pid:
329
+ print("note: a background loop is running the OLD version — "
330
+ "`agentos-auto stop` then `agentos-auto` to run the new one.")
331
+ else:
332
+ print(f"already up to date (v{after})")
233
333
  return
234
334
 
235
335
  if cmd == "stop":
336
+ # Hard stop: halt the current task immediately, return it to To Do so it
337
+ # restarts cleanly next run, and kill the whole loop (not a cooperative
338
+ # pause after the current task).
236
339
  _set_stop()
237
340
  pid = _daemon_running()
341
+ cur = _read_current()
342
+ backlogged = _read_backlogged() # tickets THIS run parked in Backlog
343
+
344
+ # 1. Kill the loop's ENTIRE process group first (daemon + any child
345
+ # `claude` it spawned) so nothing keeps mutating the ticket. The daemon
346
+ # is a session leader (setsid), so its PGID == its PID.
238
347
  if pid:
239
- print(f"stop requested — the background loop (pid {pid}) will pause after "
240
- "the current task, then exit.")
348
+ import signal
349
+ import time as _t
350
+ # Kill the whole process group ONLY when pid is its own group leader
351
+ # (the daemon, via setsid) — that also reaps the child `claude`. For a
352
+ # foreground run (shares the shell's group) fall back to a single kill
353
+ # so we don't take down the user's terminal.
354
+ try:
355
+ is_group_leader = os.getpgid(pid) == pid
356
+ except ProcessLookupError:
357
+ is_group_leader = False
358
+ killed = False
359
+ for sig in (signal.SIGTERM, signal.SIGKILL):
360
+ try:
361
+ if is_group_leader:
362
+ os.killpg(pid, sig)
363
+ else:
364
+ os.kill(pid, sig)
365
+ except ProcessLookupError:
366
+ killed = True
367
+ break
368
+ except Exception: # noqa: BLE001
369
+ pass
370
+ _t.sleep(1.5)
371
+ if not _pid_alive(pid):
372
+ killed = True
373
+ break
374
+ print(f"stopped background loop (pid {pid})" if killed
375
+ else f"sent stop signals to pid {pid}")
241
376
  else:
242
- print("stop requested a running `agentos-auto` will pause after the current task.")
377
+ print("no background loop running stop flag set for any foreground run.")
378
+
379
+ # 2. Now that the loop is dead, move the in-flight ticket AND every ticket
380
+ # this run parked in Backlog (e.g. 15-min approval timeouts) back to
381
+ # To Do, so a later run re-attempts them. Human-parked Backlog tickets
382
+ # are untouched — only ids this run recorded are restored.
383
+ to_restore = [t for t in ({cur} | backlogged) if t]
384
+ if to_restore:
385
+ async def _restore(ids: list[str]) -> int:
386
+ from sdk.client import BackendClient
387
+ client = BackendClient.from_config()
388
+ done = 0
389
+ for t in ids:
390
+ try:
391
+ if await client.move_to_todo(t):
392
+ done += 1
393
+ except Exception: # noqa: BLE001
394
+ pass
395
+ return done
396
+ try:
397
+ n = asyncio.run(_restore(to_restore))
398
+ print(f"restored {n}/{len(to_restore)} ticket(s) → To Do")
399
+ except Exception as exc: # noqa: BLE001
400
+ print(f"warning: could not restore tickets to To Do ({exc})")
401
+
402
+ # 3. Clean up state files.
403
+ for f in (_PID_FILE, _CURRENT_FILE, _BACKLOGGED_FILE):
404
+ try:
405
+ f.unlink()
406
+ except FileNotFoundError:
407
+ pass
243
408
  return
244
409
 
245
410
  if cmd == "status":
@@ -247,12 +412,24 @@ def main() -> None:
247
412
  if pid:
248
413
  print(f"background loop: running (pid {pid})")
249
414
  print(f"logs: {_LOG_FILE}")
415
+ cur = _read_current()
416
+ if cur:
417
+ print(f"current ticket: {cur}")
250
418
  else:
251
419
  print("background loop: not running")
252
420
  print("stop pending: yes" if _stop_requested() else "stop pending: no")
253
421
  return
254
422
 
255
423
  if cmd in ("run", "resume"):
424
+ # Self-update FIRST (before the already-running check) so the installed
425
+ # package is refreshed even when a daemon is already running — otherwise a
426
+ # long-lived old daemon would pin `stop`/`status` to a stale version. Runs
427
+ # in the FOREGROUND so the user sees update output; re-execs on the new
428
+ # code. Skipped with --no-update or AGENTOS_NO_UPDATE=1.
429
+ if "--no-update" not in argv:
430
+ from sdk.updater import auto_update_and_reexec
431
+ auto_update_and_reexec("sdk.run_auto", sys.argv[1:])
432
+
256
433
  # Refuse to start a second loop — one background daemon owns the To Do queue.
257
434
  pid = _daemon_running()
258
435
  if pid:
@@ -261,13 +438,6 @@ def main() -> None:
261
438
  print(" stop: agentos-auto stop")
262
439
  return
263
440
 
264
- # Keep non-technical users current: self-update, then re-run on the new
265
- # code. Runs in the FOREGROUND (before detaching) so the user sees update
266
- # output. Skipped with --no-update or AGENTOS_NO_UPDATE=1.
267
- if "--no-update" not in argv:
268
- from sdk.updater import auto_update_and_reexec
269
- auto_update_and_reexec("sdk.run_auto", sys.argv[1:])
270
-
271
441
  workspace_root = _parse_workspace_root(argv)
272
442
 
273
443
  # Background by default: detach so closing the terminal (SIGHUP) can't kill