@appchy/jarvis 0.1.101 → 0.1.102

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/dist/bin.js CHANGED
@@ -10260,7 +10260,7 @@ import { createRequire as createRequire2 } from "module";
10260
10260
  var _require = createRequire2(import.meta.url);
10261
10261
  var VERSION2 = _require("../package.json").version ?? "0.0.0";
10262
10262
  var IS_DEV = String(_require("../package.json").name ?? "").endsWith("-dev");
10263
- var SHA = "1a10c0d";
10263
+ var SHA = "2ef6df4";
10264
10264
  var BUILT = "2026-09-11";
10265
10265
  var BUILD = SHA ?? "source";
10266
10266
  var BUILD_LABEL = `${SHA ? `${VERSION2} (${SHA}${BUILT ? ` ${BUILT}` : ""})` : `${VERSION2} (source)`}${IS_DEV ? " \u2014 development build" : ""}`;
@@ -677,13 +677,22 @@ def gate(root, task, accept: str = "", owner: str = "") -> list:
677
677
  if loose:
678
678
  shown = ", ".join(loose[:5])
679
679
  more = f" and {len(loose) - 5} more" if len(loose) > 5 else ""
680
+ # WHO ELSE IS HERE, on the one refusal most likely to be about somebody
681
+ # else's file. A shared checkout is normal now, and the advice above
682
+ # ("commit them, or stash what is not this task's") is advice about
683
+ # another session's unsaved work — so a session that acts on it alone
684
+ # destroys work nobody can recover. Naming the peers turns "go and find
685
+ # out who exists" into one message. It cannot say WHICH of them touched
686
+ # a file: git does not record that and neither does anything else here.
687
+ others = peers.sharing(root.parent)
680
688
  reasons.append(
681
689
  f"{len(loose)} uncommitted change(s) outside the board — the gates "
682
690
  f"ran against this working tree, so completing now would record "
683
691
  f"shipped for code no commit contains: {shown}{more}. Commit them "
684
692
  f"(or stash or gitignore what is not this task's), then re-run "
685
693
  f"`jarvis work verify --task {task.name}` — committing moves HEAD, "
686
- f"so the evidence has to be taken on the tree that shipped.")
694
+ f"so the evidence has to be taken on the tree that shipped."
695
+ + (f" {others}" if others else ""))
687
696
  else:
688
697
  reasons.append("no `verify` commands configured — an unconfigured repo "
689
698
  "cannot prove anything, so nothing in it can complete. Set "
@@ -60,9 +60,14 @@ DEFAULT_SESSIONS = "~/.claude/sessions"
60
60
  _RUNNING = {}
61
61
 
62
62
 
63
- def running():
64
- """Every agent session live on THIS machine as `{session id: name}`, or `None`
65
- when nothing here publishes that at all.
63
+ def _live():
64
+ """Every agent session live on THIS machine, keyed by session id, as the client
65
+ published it — or `None` when nothing here publishes that at all.
66
+
67
+ The whole entry rather than one field of it, because two questions are asked of
68
+ this directory now: who is running, and who is running IN THIS CHECKOUT. They
69
+ want the same scan, the same pid check and the same cache, and reading the
70
+ directory twice would let one answer be true while the other was stale.
66
71
 
67
72
  The `None` is the whole point and must not be flattened into an empty dict. No
68
73
  directory means *this client does not say*, which is where Codex and Gemini sit
@@ -119,11 +124,82 @@ def running():
119
124
  continue
120
125
  except PermissionError:
121
126
  pass # alive and owned by somebody else, which is still alive
122
- out[run] = str(entry.get("name", "")).strip()
127
+ out[run] = entry
123
128
  _RUNNING[key] = out
124
129
  return out
125
130
 
126
131
 
132
+ def running():
133
+ """Every agent session live on THIS machine as `{session id: name}`.
134
+
135
+ `None` and `{}` stay as far apart here as they are in `_live`: no directory
136
+ means *this client does not say*, and an empty mapping means something
137
+ published a list this session was not on.
138
+ """
139
+ live = _live()
140
+ if live is None:
141
+ return None
142
+ return {run: str(entry.get("name", "")).strip() for run, entry in live.items()}
143
+
144
+
145
+ def _inside(where: str, root) -> bool:
146
+ """Is `where` the same place as `root`, or somewhere under it?
147
+
148
+ Both sides are resolved because a session publishes the path it was started
149
+ with and macOS hands out two names for the same directory — `/tmp/x` and
150
+ `/private/tmp/x` — so comparing the strings reports two sessions in one
151
+ checkout as being in different ones.
152
+ """
153
+ if not where:
154
+ return False
155
+ try:
156
+ here_ = Path(where).resolve()
157
+ there = Path(root).resolve()
158
+ except (OSError, ValueError):
159
+ return False
160
+ return here_ == there or there in here_.parents
161
+
162
+
163
+ def sharing(root) -> str:
164
+ """The OTHER live sessions working inside `root`, as one line, or `""`.
165
+
166
+ Empty when nothing publishes a session list, when this session is the only one
167
+ here, or when the peers here publish no name — in each case there is nobody a
168
+ reader could go and talk to, and a line saying so is noise on a message that is
169
+ already refusing something.
170
+
171
+ **It names who is here, never who touched what.** Git cannot attribute an
172
+ uncommitted file to a session; nothing on this machine can. So the line says
173
+ which sessions share this checkout and leaves the asking to the person or the
174
+ agent, which is the honest half and the one that was missing — the cost being
175
+ paid today is not that the answer is unknowable, it is that a session refused
176
+ for somebody else's file has to go and find out who else exists before it can
177
+ even ask.
178
+ """
179
+ live = _live()
180
+ if not live:
181
+ return ""
182
+ mine = me()
183
+ names = []
184
+ for run, entry in live.items():
185
+ if run == mine or not _inside(str(entry.get("cwd", "")), root):
186
+ continue
187
+ name = str(entry.get("name", "")).strip()
188
+ # A session with no published name cannot be addressed, so naming it would
189
+ # send a reader looking for something `SendMessage` will not take.
190
+ if name:
191
+ names.append(name)
192
+ names.sort()
193
+ if not names:
194
+ return ""
195
+ shown = ", ".join(f"`{n}`" for n in names)
196
+ one = len(names) == 1
197
+ return (f"{len(names)} other session{'' if one else 's'} "
198
+ f"{'is' if one else 'are'} live in this checkout — {shown}. "
199
+ f"`SendMessage` reaches {'it' if one else 'them'}; ask before you stash "
200
+ f"or commit anything you did not write.")
201
+
202
+
127
203
  def describe(instance: str, host: str = "") -> str:
128
204
  """`instance` (and the machine it sits on) as a line that says what to do next.
129
205
 
@@ -4400,6 +4400,75 @@ def test_a_held_task_names_a_session_you_can_actually_reach():
4400
4400
  os.environ.pop(key, None)
4401
4401
 
4402
4402
 
4403
+ def test_a_refusal_about_a_shared_tree_names_who_else_is_in_it():
4404
+ # The completion gate refuses on uncommitted code and then advises stashing it
4405
+ # — which, in a checkout several sessions share, is advice about somebody
4406
+ # else's unsaved work. A session acting on it alone destroys work with no
4407
+ # reflog entry. So the refusal names the peers; finding out who exists was the
4408
+ # cost being paid, not the answer being unknowable.
4409
+ with tempfile.TemporaryDirectory() as tmp:
4410
+ try:
4411
+ here = Path(tmp) / "repo"
4412
+ (here / "src").mkdir(parents=True)
4413
+ elsewhere = Path(tmp) / "other-repo"
4414
+ elsewhere.mkdir()
4415
+ reg = Path(tmp) / "sessions"
4416
+ reg.mkdir()
4417
+ os.environ["WORK_INSTANCE"] = "mine-0000"
4418
+ os.environ["WORK_MACHINE"] = "this-box"
4419
+ os.environ["WORK_SESSIONS_DIR"] = str(reg)
4420
+ peers._RUNNING.clear()
4421
+
4422
+ def publish(n, run, name, cwd, pid=None):
4423
+ (reg / f"{n}.json").write_text(json.dumps(
4424
+ {"sessionId": run, "name": name, "cwd": str(cwd),
4425
+ "pid": os.getpid() if pid is None else pid}))
4426
+
4427
+ # Me, so I am never named to myself; a peer deeper inside the same
4428
+ # checkout, because a session started in a subdirectory shares the tree
4429
+ # exactly as much; a session in a different repo; a dead one; and one
4430
+ # live here that publishes no name, which cannot be addressed.
4431
+ publish(1, "mine-0000", "repo-me", here / "src")
4432
+ publish(2, "peer-1111", "repo-a8", here / "src")
4433
+ publish(3, "away-2222", "repo-zz", elsewhere)
4434
+ publish(4, "ghost-3333", "repo-b7", here, pid=2 ** 22)
4435
+ publish(5, "mute-4444", "", here)
4436
+
4437
+ line = peers.sharing(here)
4438
+ assert "repo-a8" in line, "a live peer in this checkout has to be named"
4439
+ assert "1 other session is" in line, \
4440
+ "me, another repo, a ghost and an unaddressable one are all not peers here"
4441
+ for absent in ("repo-me", "repo-zz", "repo-b7"):
4442
+ assert absent not in line, f"{absent} is not somebody to ask"
4443
+
4444
+ # A second live peer makes it plural, and they are named in a stable
4445
+ # order — a message that reshuffles reads as new information.
4446
+ publish(6, "peer-5555", "repo-c9", here)
4447
+ peers._RUNNING.clear()
4448
+ both = peers.sharing(here)
4449
+ assert "2 other sessions are" in both and both.index("repo-a8") < both.index("repo-c9")
4450
+
4451
+ # A checkout nobody else is in is SILENCE, not a line saying so: this
4452
+ # rides on a message that is already refusing something.
4453
+ empty = Path(tmp) / "nobody-here"
4454
+ empty.mkdir()
4455
+ peers._RUNNING.clear()
4456
+ assert peers.sharing(empty) == ""
4457
+ # …and `elsewhere` is not silent, because a session really is in it.
4458
+ # The scoping is by checkout, not by "anywhere but mine".
4459
+ assert "repo-zz" in peers.sharing(elsewhere)
4460
+
4461
+ # And a client that publishes nothing at all must not read as an empty
4462
+ # room — it is the same `None` that stops every peer looking dead.
4463
+ os.environ["WORK_SESSIONS_DIR"] = str(Path(tmp) / "nothing-here")
4464
+ peers._RUNNING.clear()
4465
+ assert peers.running() is None and peers.sharing(here) == ""
4466
+ finally:
4467
+ for key in ("WORK_INSTANCE", "WORK_MACHINE", "WORK_SESSIONS_DIR"):
4468
+ os.environ.pop(key, None)
4469
+ peers._RUNNING.clear()
4470
+
4471
+
4403
4472
  def test_how_long_ago_is_said_in_the_unit_that_changes_the_decision():
4404
4473
  # The question this answers is *is anybody on this*, and the reader is deciding
4405
4474
  # whether to take the work. "6h ago" settles that; a timestamp makes them do
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.101",
3
+ "version": "0.1.102",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -57,16 +57,16 @@
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
59
  "@jarvis/agents": "1.0.0",
60
- "@jarvis/data": "0.1.0",
61
- "@jarvis/anthropic": "1.0.0",
62
60
  "@jarvis/board": "0.1.0",
61
+ "@jarvis/anthropic": "1.0.0",
63
62
  "@jarvis/errors": "1.0.0",
64
63
  "@jarvis/logger": "1.0.0",
64
+ "@jarvis/data": "0.1.0",
65
65
  "@jarvis/rpc": "1.0.0",
66
66
  "@jarvis/types": "1.0.0",
67
- "@jarvis/ui": "0.1.0",
67
+ "@jarvis/typescript-config": "1.0.0",
68
68
  "@jarvis/vitest-config": "1.0.0",
69
- "@jarvis/typescript-config": "1.0.0"
69
+ "@jarvis/ui": "0.1.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",