@tiens.nguyen/gu-cli 1.0.840 → 1.0.841

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.
@@ -9511,6 +9511,52 @@ def _shell_exit_code(s: dict):
9511
9511
  return (None, None)
9512
9512
 
9513
9513
 
9514
+ def _shells_add_new(rec: dict) -> None:
9515
+ """Append one record to shells.json under an exclusive lock, RE-READING the file inside the
9516
+ lock so two overlapping turns do not overwrite each other's record (#387). _to_background used
9517
+ to _shells_load() OUTSIDE any lock then _shells_save() the grown list — a concurrent reader's
9518
+ own save in between silently dropped this record, and the repl's "N shells running (click)"
9519
+ link disappeared for a live background task."""
9520
+ import fcntl # macOS/Linux posix lock; the workspace here is the user's Mac
9521
+ import json as _json
9522
+ import os
9523
+ import time as _t
9524
+ path = _shells_path()
9525
+ lock_fd = os.open(path + ".lock", os.O_RDWR | os.O_CREAT, 0o644)
9526
+ try:
9527
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
9528
+ # Re-read INSIDE the lock (the dict envelope {"shells": [...]} matches _shells_save).
9529
+ try:
9530
+ with open(path, encoding="utf-8") as fh:
9531
+ shells = _json.load(fh).get("shells", [])
9532
+ except Exception: # noqa: BLE001
9533
+ shells = []
9534
+ shells = [s for s in shells if isinstance(s, dict) and s.get("pid")]
9535
+ shells.append(rec)
9536
+ # Same prune policy as _shells_save so the file stays bounded.
9537
+ now = _t.time()
9538
+ kept = []
9539
+ for s in shells:
9540
+ st = _shell_state(s, now=now)
9541
+ if st["state"] != "running" and now - st["ended"] > _SHELLS_KEEP_DONE_S:
9542
+ continue
9543
+ kept.append(s)
9544
+ kept = kept[-_SHELLS_KEEP:]
9545
+ tmp = f"{path}.{os.getpid()}.tmp"
9546
+ with open(tmp, "w", encoding="utf-8") as fh:
9547
+ _json.dump({"shells": kept}, fh, indent=2)
9548
+ fh.flush()
9549
+ os.fsync(fh.fileno())
9550
+ os.replace(tmp, path) # the terminal polls this file — never let it read half of it
9551
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
9552
+ finally:
9553
+ try:
9554
+ os.close(lock_fd)
9555
+ except OSError:
9556
+ pass
9557
+ _log(f"shells.json +1 → pid {rec.get('pid')} registered (#387)")
9558
+
9559
+
9514
9560
  def _shell_state(s: dict, alive=None, now=None) -> dict:
9515
9561
  """{state, code, runtime, ended} for a shell record. state: running | exited (0) | failed |
9516
9562
  killed | ended (gone, exit code unknown). `alive` is injectable so tests need no process."""
@@ -15575,14 +15621,12 @@ def run_agent_chat(cfg):
15575
15621
 
15576
15622
  def _to_background(why: str) -> str:
15577
15623
  """Leave the command running as a background shell; say so to the model."""
15578
- shells = _shells_load()
15579
- shells.append({
15624
+ _shells_add_new({
15580
15625
  "id": str(proc.pid), "pid": proc.pid, "pgid": proc.pid,
15581
15626
  "command": command, "workdir": wd, "log": log_path,
15582
15627
  "exit_file": exit_path if _use_shell else "",
15583
15628
  "started": start, "started_at": _t.strftime("%Y-%m-%d %H:%M:%S"),
15584
15629
  })
15585
- _shells_save(shells)
15586
15630
  _emit({"type": "step", "text":
15587
15631
  f"Running in background → {_trim_words(command)} (shell {proc.pid})"})
15588
15632
  tail = _ws_distill_output(_read_log())
@@ -15673,6 +15717,15 @@ def run_agent_chat(cfg):
15673
15717
  # waiting for it like any command; it hands off at the deadline if needed.
15674
15718
  if ports and all(_ws_is_ephemeral_port(p_) for p_ in ports):
15675
15719
  ports = []
15720
+ if ports and background:
15721
+ # AN EXPLICIT background=True STILL LANDS IN shells.json (#387, GitHub
15722
+ # #177). A shell that opens a port in its first 3s used to be filed ONLY
15723
+ # as a server here — so the repl bar read shells.json, found nothing
15724
+ # running, and showed no "N shells running (click)" link for a build the
15725
+ # model had just told the user was in the background.
15726
+ msg = _to_background("started (also listening)")
15727
+ _last_obs["text"] = msg
15728
+ return msg
15676
15729
  if ports:
15677
15730
  servers = _ws_load_servers()
15678
15731
  servers.append({
@@ -15697,6 +15750,14 @@ def run_agent_chat(cfg):
15697
15750
  # no port, so it would otherwise hit the timeout kill below and its
15698
15751
  # window would be destroyed). Returning fast here also dodges the
15699
15752
  # smolagents 60s executor cap that was killing the tool call.
15753
+ if background:
15754
+ # The window check above would classify it as an app and skip
15755
+ # shells.json entirely — the same missing-link bug as the port
15756
+ # branch (#387, GitHub #177). background=True asked for a shell,
15757
+ # so a shell it is.
15758
+ msg = _to_background("started (window open)")
15759
+ _last_obs["text"] = msg
15760
+ return msg
15700
15761
  if _ws_gui_pids(proc.pid):
15701
15762
  servers = _ws_load_servers()
15702
15763
  servers.append({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.840",
3
+ "version": "1.0.841",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",