webbee 0.2.2__tar.gz → 0.3.0__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 (76) hide show
  1. {webbee-0.2.2 → webbee-0.3.0}/CHANGELOG.md +21 -0
  2. {webbee-0.2.2 → webbee-0.3.0}/PKG-INFO +1 -1
  3. {webbee-0.2.2 → webbee-0.3.0}/pyproject.toml +1 -1
  4. webbee-0.3.0/src/webbee/__init__.py +1 -0
  5. webbee-0.3.0/src/webbee/checkpoints.py +213 -0
  6. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/commands.py +6 -0
  7. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/frames.py +9 -1
  8. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/render.py +14 -2
  9. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/repl.py +36 -3
  10. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/session.py +5 -2
  11. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/tools.py +110 -4
  12. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/tui.py +20 -11
  13. webbee-0.3.0/tests/test_checkpoints.py +251 -0
  14. {webbee-0.2.2 → webbee-0.3.0}/tests/test_commands.py +25 -0
  15. {webbee-0.2.2 → webbee-0.3.0}/tests/test_marathon.py +17 -2
  16. {webbee-0.2.2 → webbee-0.3.0}/tests/test_repl.py +7 -6
  17. {webbee-0.2.2 → webbee-0.3.0}/tests/test_session.py +1 -1
  18. webbee-0.3.0/tests/test_tools.py +285 -0
  19. {webbee-0.2.2 → webbee-0.3.0}/tests/test_tui.py +44 -14
  20. webbee-0.2.2/src/webbee/__init__.py +0 -1
  21. webbee-0.2.2/tests/test_tools.py +0 -110
  22. {webbee-0.2.2 → webbee-0.3.0}/.github/workflows/publish.yml +0 -0
  23. {webbee-0.2.2 → webbee-0.3.0}/.gitignore +0 -0
  24. {webbee-0.2.2 → webbee-0.3.0}/LICENSE +0 -0
  25. {webbee-0.2.2 → webbee-0.3.0}/README.md +0 -0
  26. {webbee-0.2.2 → webbee-0.3.0}/install.sh +0 -0
  27. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/account.py +0 -0
  28. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/banner_art.py +0 -0
  29. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/cli.py +0 -0
  30. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/clipboard.py +0 -0
  31. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/config.py +0 -0
  32. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/details.py +0 -0
  33. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/events.py +0 -0
  34. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/__init__.py +0 -0
  35. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/chunker.py +0 -0
  36. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/embed.py +0 -0
  37. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/graph.py +0 -0
  38. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/indexer.py +0 -0
  39. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/models.py +0 -0
  40. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/query.py +0 -0
  41. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/service.py +0 -0
  42. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/store.py +0 -0
  43. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/vectors.py +0 -0
  44. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/intel/watch.py +0 -0
  45. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/output_pane.py +0 -0
  46. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/repo.py +0 -0
  47. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/sessions.py +0 -0
  48. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/stream.py +0 -0
  49. {webbee-0.2.2 → webbee-0.3.0}/src/webbee/update.py +0 -0
  50. {webbee-0.2.2 → webbee-0.3.0}/tests/__init__.py +0 -0
  51. {webbee-0.2.2 → webbee-0.3.0}/tests/test_account.py +0 -0
  52. {webbee-0.2.2 → webbee-0.3.0}/tests/test_cli.py +0 -0
  53. {webbee-0.2.2 → webbee-0.3.0}/tests/test_clipboard.py +0 -0
  54. {webbee-0.2.2 → webbee-0.3.0}/tests/test_config.py +0 -0
  55. {webbee-0.2.2 → webbee-0.3.0}/tests/test_cpc_contract_stable.py +0 -0
  56. {webbee-0.2.2 → webbee-0.3.0}/tests/test_details.py +0 -0
  57. {webbee-0.2.2 → webbee-0.3.0}/tests/test_events.py +0 -0
  58. {webbee-0.2.2 → webbee-0.3.0}/tests/test_freeze_fix.py +0 -0
  59. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_chunker.py +0 -0
  60. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_embed.py +0 -0
  61. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_graph.py +0 -0
  62. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_indexer.py +0 -0
  63. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_no_numpy.py +0 -0
  64. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_query.py +0 -0
  65. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_service.py +0 -0
  66. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_store.py +0 -0
  67. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_vectors.py +0 -0
  68. {webbee-0.2.2 → webbee-0.3.0}/tests/test_intel_watch.py +0 -0
  69. {webbee-0.2.2 → webbee-0.3.0}/tests/test_packaging.py +0 -0
  70. {webbee-0.2.2 → webbee-0.3.0}/tests/test_render.py +0 -0
  71. {webbee-0.2.2 → webbee-0.3.0}/tests/test_repo.py +0 -0
  72. {webbee-0.2.2 → webbee-0.3.0}/tests/test_sessions.py +0 -0
  73. {webbee-0.2.2 → webbee-0.3.0}/tests/test_stream.py +0 -0
  74. {webbee-0.2.2 → webbee-0.3.0}/tests/test_tui_hardening.py +0 -0
  75. {webbee-0.2.2 → webbee-0.3.0}/tests/test_update.py +0 -0
  76. {webbee-0.2.2 → webbee-0.3.0}/tests/test_version.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - **The agent plans in the open.** A live todo checklist (📋) rendered from the
6
+ kernel's todo facts — you see what Webbee intends and how far it's got.
7
+ - **Precise editing.** `edit_file` requires a unique match (or an explicit
8
+ `replace_all`), and a new atomic `multi_edit` applies coordinated changes
9
+ across files all-or-nothing.
10
+ - **The time machine.** Every change auto-checkpoints into a shadow git — your
11
+ own `.git` is never touched; `/checkpoints` lists them and `/rollback`
12
+ restores, and the agent can checkpoint/diff/rollback too. A wrong step is
13
+ now undoable.
14
+
15
+ ## 0.2.3
16
+
17
+ - **Readable big numbers.** Token and credit counts in the toolbar now scale
18
+ (`1.5M tok`, `2M credits`) instead of an awkward `1500.0k`.
19
+ - **Esc really stops a turn now.** Previously only Ctrl-C worked; Esc now cancels
20
+ the running turn too, matching the "Esc/Ctrl-C to stop" hint.
21
+ - **The `❯` prompt takes your mode's colour** — cyan (default), purple (plan),
22
+ yellow (autopilot) — so the current mode is obvious from the input line.
23
+
3
24
  ## 0.2.2
4
25
 
5
26
  - **See Webbee's reasoning as a distinct 💭 block.** Before each step, Webbee now
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,213 @@
1
+ """The session's TIME MACHINE (whole-mind Phase 4, I-WEBBEE-CODE-REVERSIBLE).
2
+
3
+ A shadow git repository whose GIT_DIR lives in the webbee cache and whose
4
+ worktree is the user's workspace (the classic detached-git-dir pattern).
5
+ The user's own VCS -- .git, index, refs, hooks -- is NEVER touched:
6
+ reversibility is ours, not theirs. Every write tool auto-checkpoints before
7
+ it runs (tools.py); `rollback` snapshots the pre-rollback state first, so a
8
+ rollback is itself undoable. Fail-soft everywhere: no git binary / init
9
+ failure => available=False and every operation degrades to an honest
10
+ message -- the time machine must never block the work."""
11
+ import os
12
+ import subprocess
13
+
14
+ # Identity + safety for shadow commits only (never the user's config/hooks).
15
+ _GIT_CFG = ["-c", "user.name=webbee", "-c", "user.email=webbee@imperal.io",
16
+ "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null"]
17
+
18
+
19
+ def shadow_key(workspace_root: str) -> str:
20
+ """Shadow identity is the WORKTREE, never the remote: clones and linked
21
+ git-worktrees of one origin must each get their OWN time machine
22
+ (final-review F3 -- a shared shadow rolls checkout B onto checkout A's
23
+ snapshot). sha256 of the realpath, 12 hex chars (intel keeps its own
24
+ content-addressed repo_key; this one is deliberately different)."""
25
+ import hashlib
26
+ real = os.path.realpath(workspace_root)
27
+ return hashlib.sha256(real.encode("utf-8")).hexdigest()[:12]
28
+
29
+
30
+ def _scrubbed_env() -> dict:
31
+ """A GIT_*-free copy of the environment (final-review F2): git exports
32
+ GIT_INDEX_FILE / GIT_DIR / GIT_OBJECT_DIRECTORY / GIT_COMMON_DIR into hook
33
+ and rebase contexts -- inherited, they silently redirect SHADOW operations
34
+ into the USER's repository. Explicit --git-dir/--work-tree flags override
35
+ only two of them; scrub them all."""
36
+ return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
37
+
38
+
39
+ class ShadowGit:
40
+ _DIFF_CAP = 20000
41
+
42
+ def __init__(self, workspace_root: str, repo_key: str, cache_dir: str = "") -> None:
43
+ self.root = os.path.realpath(workspace_root)
44
+ base = cache_dir or os.path.expanduser("~/.cache/webbee")
45
+ self.git_dir = os.path.join(base, "shadow", repo_key)
46
+ self.available = False
47
+ self.auto_ok = True # latched False after a failed AUTO snapshot (F8)
48
+
49
+ def _git(self, *args: str) -> "subprocess.CompletedProcess":
50
+ cmd = (["git", "--git-dir", self.git_dir, "--work-tree", self.root]
51
+ + _GIT_CFG + list(args))
52
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=120,
53
+ env=_scrubbed_env())
54
+
55
+ def ensure(self) -> bool:
56
+ """Create/open the shadow. Returns availability; never raises.
57
+ Refuses a workspace that IS a bare git repository (final-review F9:
58
+ tracking its refs/objects and resetting them would rewind the USER's
59
+ VCS -- the one thing the shadow must never do)."""
60
+ try:
61
+ if (os.path.exists(os.path.join(self.root, "HEAD"))
62
+ and os.path.isdir(os.path.join(self.root, "objects"))
63
+ and os.path.isdir(os.path.join(self.root, "refs"))):
64
+ self.available = False
65
+ return False
66
+ os.makedirs(self.git_dir, mode=0o700, exist_ok=True)
67
+ os.chmod(self.git_dir, 0o700)
68
+ if not os.path.exists(os.path.join(self.git_dir, "HEAD")):
69
+ r = subprocess.run(["git"] + _GIT_CFG + ["init", "--bare", self.git_dir],
70
+ capture_output=True, text=True, timeout=60,
71
+ env=_scrubbed_env())
72
+ if r.returncode != 0:
73
+ self.available = False
74
+ return False
75
+ # Detached-git-dir mode: bare layout + explicit --work-tree.
76
+ self._git("config", "core.bare", "false")
77
+ excl = os.path.join(self.git_dir, "info", "exclude")
78
+ os.makedirs(os.path.dirname(excl), exist_ok=True)
79
+ with open(excl, "a", encoding="utf-8") as f:
80
+ f.write("\n.git/\n")
81
+ self.available = self._git("rev-parse", "--git-dir").returncode == 0
82
+ except Exception:
83
+ self.available = False
84
+ return self.available
85
+
86
+ # -- internals ---------------------------------------------------------
87
+
88
+ def _count(self) -> int:
89
+ r = self._git("for-each-ref", "--format=%(refname)", "refs/webbee/")
90
+ return len([ln for ln in r.stdout.splitlines() if ln.strip()])
91
+
92
+ def _resolve(self, ref: str) -> "str | None":
93
+ ref = (ref or "").strip()
94
+ if not ref:
95
+ return None
96
+ if ref.isdigit():
97
+ cand = f"refs/webbee/cp-{ref}"
98
+ elif ref.startswith("cp-"):
99
+ cand = f"refs/webbee/{ref}"
100
+ else:
101
+ cand = ref
102
+ r = self._git("rev-parse", "--verify", "--quiet", cand + "^{commit}")
103
+ return r.stdout.strip() if r.returncode == 0 else None
104
+
105
+ # -- operations ---------------------------------------------------------
106
+
107
+ def checkpoint(self, label: str = "", force: bool = False) -> "dict | None":
108
+ """Commit the workspace state to the shadow history.
109
+ {"id","n","label","changed"} or None when unavailable/failed.
110
+ `force=True` always records a new checkpoint even with nothing dirty
111
+ -- used by rollback() for its mandatory pre-rollback safety snapshot,
112
+ which must exist even when the workspace already matches HEAD."""
113
+ if not self.available:
114
+ return None
115
+ try:
116
+ if self._git("add", "-A").returncode != 0:
117
+ return None # a partial add must never masquerade as a snapshot
118
+ dirty = self._git("status", "--porcelain").stdout.strip()
119
+ head = self._git("rev-parse", "--short", "HEAD")
120
+ if not dirty and head.returncode == 0 and not force:
121
+ return {"id": head.stdout.strip(), "n": self._count(),
122
+ "label": "(no changes)", "changed": False}
123
+ msg = (label or "checkpoint").strip()[:200] or "checkpoint"
124
+ args = ["commit", "-m", msg, "--no-verify"]
125
+ if head.returncode != 0 or not dirty:
126
+ args.append("--allow-empty") # first-ever baseline / forced snapshot
127
+ c = self._git(*args)
128
+ if c.returncode != 0:
129
+ return None
130
+ sha = self._git("rev-parse", "--short", "HEAD").stdout.strip()
131
+ n = self._count() + 1
132
+ self._git("update-ref", f"refs/webbee/cp-{n}", "HEAD")
133
+ return {"id": sha, "n": n, "label": msg, "changed": True}
134
+ except Exception:
135
+ return None
136
+
137
+ def list_checkpoints(self, limit: int = 10) -> list:
138
+ """Newest-first [{'n','id','label','when'}] from the refs/webbee ledger."""
139
+ if not self.available:
140
+ return []
141
+ try:
142
+ r = self._git(
143
+ "for-each-ref",
144
+ "--format=%(refname:short)|%(objectname:short)|%(contents:subject)|%(creatordate:relative)",
145
+ "refs/webbee/")
146
+ rows = []
147
+ for line in r.stdout.splitlines():
148
+ parts = line.split("|", 3)
149
+ if len(parts) == 4 and parts[0].startswith("webbee/cp-"):
150
+ rows.append({"n": int(parts[0].rsplit("-", 1)[1]),
151
+ "id": parts[1], "label": parts[2], "when": parts[3]})
152
+ rows.sort(key=lambda x: x["n"], reverse=True)
153
+ return rows[:max(1, int(limit))]
154
+ except Exception:
155
+ return []
156
+
157
+ def diff(self, since: str = "") -> str:
158
+ """Workspace changes since a checkpoint (default: the last one).
159
+ Bounded with an HONEST truncation note -- never a silent cut."""
160
+ if not self.available:
161
+ return "reversibility is off (git unavailable)"
162
+ try:
163
+ self._git("add", "-A") # stage into the SHADOW index so new files show
164
+ target = self._resolve(since) or "HEAD"
165
+ stat = self._git("diff", "--cached", "--stat", target).stdout
166
+ body = self._git("diff", "--cached", target).stdout
167
+ out = (stat + "\n" + body).strip() or "(no changes)"
168
+ if len(out) > self._DIFF_CAP:
169
+ out = (out[:self._DIFF_CAP]
170
+ + f"\n… (diff truncated at {self._DIFF_CAP} chars -- pass a "
171
+ f"narrower `since` or inspect files directly)")
172
+ return out
173
+ except Exception as e:
174
+ return f"diff failed: {type(e).__name__}: {e}"
175
+
176
+ def rollback(self, to: str) -> dict:
177
+ """Restore every checkpointed file to checkpoint `to`. The current
178
+ state is checkpointed FIRST ("pre-rollback"), so a rollback is itself
179
+ undoable. Files never checkpointed are not removed."""
180
+ if not self.available:
181
+ return {"ok": False, "content": "reversibility is off (git unavailable)"}
182
+ target = self._resolve(to)
183
+ if not target:
184
+ return {"ok": False, "content":
185
+ f"unknown checkpoint '{to}' -- use an id, cp-N or N "
186
+ f"(see the checkpoint list)"}
187
+ undo = self.checkpoint("pre-rollback", force=True)
188
+ if not undo:
189
+ # The safety snapshot is MANDATORY: without it a reset would
190
+ # destroy the current state unrecoverably (final-review F1).
191
+ return {"ok": False, "content":
192
+ "rollback refused: the pre-rollback safety snapshot could not "
193
+ "be created (shadow git error), so rolling back would destroy "
194
+ "the current state unrecoverably. Fix the shadow (disk space / "
195
+ "permissions) or copy your changes out first."}
196
+ r = self._git("reset", "--hard", target)
197
+ if r.returncode != 0:
198
+ return {"ok": False, "content": f"rollback failed: {r.stderr.strip()[:500]}"}
199
+ return {"ok": True, "content":
200
+ (f"restored the workspace to checkpoint {to} ({target[:9]}). "
201
+ f"The previous state -- including any files this rollback removed -- "
202
+ f"was saved first as checkpoint cp-{(undo or {}).get('n', '?')} "
203
+ f"({(undo or {}).get('id', '?')}), so this rollback is itself undoable.")}
204
+
205
+ def describe(self) -> str:
206
+ """One printable block for the /checkpoints command."""
207
+ if not self.available:
208
+ return "Reversibility is off (git unavailable)."
209
+ rows = self.list_checkpoints(limit=10)
210
+ if not rows:
211
+ return "No checkpoints yet."
212
+ lines = [f"cp-{r['n']} {r['id']} {r['when']:>16} {r['label']}" for r in rows]
213
+ return "Checkpoints (newest first) — /rollback <id|cp-N|N>:\n" + "\n".join(lines)
@@ -11,6 +11,8 @@ _HELP = """Commands:
11
11
  /cost (=/usage) tokens + credits this session
12
12
  /status cwd · git · surface · tokens · version
13
13
  /steps [N] last turn's steps; N expands one (also: Up/Down + Enter)
14
+ /checkpoints the reversibility time machine — list workspace checkpoints
15
+ /rollback <ref> restore the workspace to a checkpoint (id, cp-N or N)
14
16
  /sessions list active sessions (this + other devices)
15
17
  /sessions revoke <#> revoke a session by its number
16
18
  /logout-others sign out every session except this one
@@ -79,6 +81,10 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
79
81
  if args:
80
82
  return SlashResult(handled=True, action="step_detail", arg=args[0])
81
83
  return SlashResult(handled=True, action="steps")
84
+ if cmd == "/checkpoints":
85
+ return SlashResult(handled=True, action="checkpoints")
86
+ if cmd == "/rollback":
87
+ return SlashResult(handled=True, action="rollback", arg=" ".join(args))
82
88
  if cmd == "/mode":
83
89
  if not args:
84
90
  return SlashResult(handled=True, action="mode", new_mode=None,
@@ -129,7 +129,7 @@ def handle_action_frame(frame: dict, sink, started: set, finished: set, steps: l
129
129
  # composes ONE human-readable line per fact (I-FRAMES-FACTS-ONLY). Defensive:
130
130
  # unknown / missing fields degrade to a bare label, never crash.
131
131
 
132
- _MARATHON_FACT_TYPES = ("marathon_plan", "milestone", "marathon_paused")
132
+ _MARATHON_FACT_TYPES = ("marathon_plan", "milestone", "marathon_paused", "todo")
133
133
 
134
134
 
135
135
  def marathon_note(frame: dict) -> str:
@@ -154,4 +154,12 @@ def marathon_note(frame: dict) -> str:
154
154
  if ftype == "marathon_paused":
155
155
  reason = str(frame.get("reason") or frame.get("summary") or "").strip()
156
156
  return f"⏸ Marathon paused: {reason}" if reason else "⏸ Marathon paused"
157
+ if ftype == "todo":
158
+ todos = frame.get("todos") if isinstance(frame.get("todos"), list) else []
159
+ total = frame.get("total", len(todos))
160
+ done = frame.get("completed", 0)
161
+ current = next((str(t.get("content", "")) for t in todos
162
+ if isinstance(t, dict) and t.get("status") == "in_progress"), "")
163
+ head = f"📋 Todos {done}/{total}"
164
+ return f"{head} — now: {current}" if current else head
157
165
  return str(frame.get("type", ""))
@@ -27,8 +27,20 @@ _ACCENT = "cyan" # interactive chrome ONLY — live caret / mode / panel ur
27
27
 
28
28
 
29
29
  def _fmt_tokens(n: int) -> str:
30
- """Compact token count: 2100 -> '2.1k', 900 -> '900'."""
31
- return f"{n / 1000:.1f}k" if n >= 1000 else str(int(n))
30
+ """Compact count for the live toolbar: 900 -> '900', 2_100 -> '2.1k',
31
+ 1_500_000 -> '1.5M', 2_000_000 -> '2M', 3_200_000_000 -> '3.2B'. Used for
32
+ both token counts and (integer) credits so big numbers stay readable."""
33
+ try:
34
+ n = int(n or 0)
35
+ except (TypeError, ValueError):
36
+ return "0"
37
+ a = abs(n)
38
+ if a < 1000:
39
+ return str(n)
40
+ for div, suf in ((1_000_000_000, "B"), (1_000_000, "M"), (1000, "k")):
41
+ if a >= div:
42
+ return f"{n / div:.1f}".rstrip("0").rstrip(".") + suf
43
+ return str(n)
32
44
 
33
45
 
34
46
  def _salient_arg(args: dict) -> str:
@@ -80,6 +80,17 @@ async def run_marathon(cfg, mode: str, goal: str, *, sink=None, auth=None,
80
80
  return text
81
81
 
82
82
 
83
+ def _default_shadow_factory(cfg, workspace: str):
84
+ """The reversibility shadow git. Guarded like intel: any failure (no git
85
+ binary, cache not writable) degrades to None -- coding still works, just
86
+ without the time machine."""
87
+ from webbee.checkpoints import ShadowGit, shadow_key
88
+ from webbee.repo import find_repo_root
89
+ root = find_repo_root(workspace)
90
+ sg = ShadowGit(root, shadow_key(root), cache_dir=cfg.cache_dir)
91
+ return sg if sg.ensure() else None
92
+
93
+
83
94
  def _default_intel_factory(cfg, workspace: str):
84
95
  """Lazy/guarded -- a base install (no tree-sitter/watchfiles extra) must
85
96
  never fail to import here; `_boot` wraps the whole intel boot in
@@ -93,7 +104,7 @@ def _default_intel_factory(cfg, workspace: str):
93
104
 
94
105
  async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
95
106
  agent_factory=None, auth=None, account_fetcher=None,
96
- sessions_client=None, intel_factory=None) -> None:
107
+ sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
97
108
  """Interactive coding REPL. Production (a real tty, no injected sink) runs
98
109
  the persistent prompt_toolkit dock (`tui.run_session`): the bordered input
99
110
  box is pinned at the bottom, turn output scrolls above it (patch_stdout →
@@ -103,7 +114,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
103
114
  from imperal_mcp import auth as _auth
104
115
  auth = _auth
105
116
  if agent_factory is None:
106
- agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m, intel=intel) # noqa: E731
117
+ agent_factory = lambda c, tp, ws, m: AgentSession(c, tp, ws, m, intel=intel, shadow=shadow) # noqa: E731
107
118
  if account_fetcher is None:
108
119
  from webbee.account import fetch_account as account_fetcher
109
120
  if sessions_client is None:
@@ -121,6 +132,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
121
132
  _sink = None # assigned by _boot
122
133
  agent = None # assigned by _boot
123
134
  intel = None # assigned by _boot -- IntelService, or None (off/base-install/boot failure)
135
+ shadow = None # assigned by _boot -- ShadowGit, or None (git unavailable / boot failure)
124
136
  watcher_task = None # assigned by _boot -- background watchfiles task, cancelled on exit
125
137
 
126
138
  def _cycle() -> None:
@@ -211,6 +223,21 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
211
223
  else:
212
224
  _sink.note("Detail unavailable (expired or not recorded).")
213
225
  return "continue"
226
+ if res.action == "checkpoints":
227
+ if shadow is None:
228
+ _sink.note("Reversibility is off (git unavailable).")
229
+ else:
230
+ _sink.note(await asyncio.to_thread(shadow.describe))
231
+ return "continue"
232
+ if res.action == "rollback":
233
+ if shadow is None:
234
+ _sink.note("Reversibility is off (git unavailable).")
235
+ elif not res.arg:
236
+ _sink.note("Usage: /rollback <id|cp-N|N> (see /checkpoints)")
237
+ else:
238
+ _r = await asyncio.to_thread(shadow.rollback, res.arg)
239
+ _sink.note(str(_r.get("content", "")))
240
+ return "continue"
214
241
  if res.action == "clear":
215
242
  _sink.clear()
216
243
  _sink.note(res.message)
@@ -238,7 +265,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
238
265
  return "continue"
239
266
 
240
267
  async def _boot(s) -> None:
241
- nonlocal _sink, agent, intel, watcher_task
268
+ nonlocal _sink, agent, intel, watcher_task, shadow
242
269
  _sink = s
243
270
  # Cache git branch OFF the event loop (subprocess.run blocks it). Only
244
271
  # /status reads it; recomputing it per input line froze the dock.
@@ -260,6 +287,12 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
260
287
  except Exception:
261
288
  intel = None
262
289
  watcher_task = None
290
+ # Whole-mind P4: the reversibility shadow (never the user's VCS);
291
+ # guarded -- boot must not fail over the time machine.
292
+ try:
293
+ shadow = await asyncio.to_thread(shadow_factory or _default_shadow_factory, cfg, workspace)
294
+ except Exception:
295
+ shadow = None
263
296
  agent = agent_factory(cfg, token_provider, workspace, state["mode"])
264
297
 
265
298
  if use_dock:
@@ -142,7 +142,8 @@ class AgentSession:
142
142
  P1: one POST per turn (server reloads the shared webbee-terminal thread,
143
143
  so context carries across turns). Persistent signal-based sessions are P3."""
144
144
 
145
- def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default", intel=None) -> None:
145
+ def __init__(self, cfg, token_provider, workspace_root: str, mode: str = "default", intel=None,
146
+ shadow=None) -> None:
146
147
  self.cfg = cfg
147
148
  self.token_provider = token_provider
148
149
  self.workspace_root = workspace_root
@@ -151,6 +152,7 @@ class AgentSession:
151
152
  self.steps: list = []
152
153
  self._task_id: str = ""
153
154
  self._intel = intel # IntelService, or None (base install / boot failure)
155
+ self._shadow = shadow # ShadowGit, or None (git unavailable / boot failure)
154
156
 
155
157
  async def _headers(self) -> dict:
156
158
  token = await self.token_provider()
@@ -173,7 +175,8 @@ class AgentSession:
173
175
  verify_cmd = await asyncio.to_thread(detect_verify_cmd, root)
174
176
  coding_context = {**coding_context, "verify_cmd": verify_cmd}
175
177
  imperal_id = await ImperalClient(self.cfg, self.token_provider).whoami()
176
- executor = LocalToolExecutor(self.workspace_root, indexer=self._intel)
178
+ executor = LocalToolExecutor(self.workspace_root, indexer=self._intel,
179
+ shadow=self._shadow)
177
180
 
178
181
  body = {"user_id": imperal_id, "task": task, "coding_context": coding_context}
179
182
  if marathon:
@@ -3,14 +3,20 @@ import re
3
3
  import subprocess
4
4
 
5
5
 
6
+ # Tools that MUTATE the workspace -> auto-checkpoint before each run
7
+ # (mirrors the kernel write tier; rollback snapshots itself, so not listed).
8
+ _WRITE_TIER = {"write_file", "edit_file", "multi_edit", "bash"}
9
+
10
+
6
11
  class OutsideWorkspaceError(Exception):
7
12
  pass
8
13
 
9
14
 
10
15
  class LocalToolExecutor:
11
- def __init__(self, workspace_root: str, indexer=None) -> None:
16
+ def __init__(self, workspace_root: str, indexer=None, shadow=None) -> None:
12
17
  self.root = os.path.realpath(workspace_root)
13
18
  self.indexer = indexer # IntelService (or None on a base install) -- Task 5's _t_<verb> shims read this
19
+ self.shadow = shadow # ShadowGit (or None) -- the reversibility time machine
14
20
 
15
21
  def resolve_in_workspace(self, path: str) -> str:
16
22
  full = os.path.realpath(os.path.join(self.root, path))
@@ -28,6 +34,18 @@ class LocalToolExecutor:
28
34
  args = {}
29
35
  if not isinstance(args, dict):
30
36
  args = {}
37
+ if (tool in _WRITE_TIER and self.shadow is not None
38
+ and getattr(self.shadow, "auto_ok", True)):
39
+ # The time machine snapshots BEFORE every mutation -- and must
40
+ # never block or fail the actual work. One failed AUTO snapshot
41
+ # latches auto-checkpointing OFF for this session (final-review
42
+ # F8: a huge un-ignored tree would otherwise re-stall every write
43
+ # tool); manual checkpoint/rollback still try.
44
+ try:
45
+ if self.shadow.checkpoint(f"pre:{tool}") is None:
46
+ self.shadow.auto_ok = False
47
+ except Exception:
48
+ self.shadow.auto_ok = False
31
49
  try:
32
50
  fn = getattr(self, f"_t_{tool}", None)
33
51
  if fn is None:
@@ -82,11 +100,99 @@ class LocalToolExecutor:
82
100
  return {"ok": False, "content": "edit_file requires 'old' (the text to replace)"}
83
101
  with open(p, "r", encoding="utf-8") as f:
84
102
  text = f.read()
85
- if old not in text:
103
+ n = text.count(old)
104
+ if n == 0:
86
105
  return {"ok": False, "content": "old string not found"}
106
+ _ra = a.get("replace_all")
107
+ replace_all = (_ra.strip().lower() in ("true", "1", "yes")
108
+ if isinstance(_ra, str) else bool(_ra))
109
+ if n > 1 and not replace_all:
110
+ return {"ok": False, "content":
111
+ f"old string occurs {n} times; add surrounding context to make it "
112
+ f"unique, or pass replace_all=true to replace every occurrence"}
87
113
  with open(p, "w", encoding="utf-8") as f:
88
- f.write(text.replace(old, new, 1))
89
- return {"ok": True, "content": f"edited {rel}"}
114
+ f.write(text.replace(old, new) if replace_all else text.replace(old, new, 1))
115
+ note = f" ({n} occurrences)" if replace_all and n > 1 else ""
116
+ return {"ok": True, "content": f"edited {rel}{note}"}
117
+
118
+ def _t_multi_edit(self, a: dict) -> dict:
119
+ edits = a.get("edits")
120
+ if not isinstance(edits, list) or not edits:
121
+ return {"ok": False, "content": "multi_edit requires a non-empty 'edits' list"}
122
+ # Validate EVERYTHING first -- all-or-nothing (a half-applied batch is
123
+ # worse than a failed one).
124
+ staged = []
125
+ problems = []
126
+ for i, e in enumerate(edits):
127
+ if not isinstance(e, dict):
128
+ problems.append(f"edit {i}: not an object")
129
+ continue
130
+ try:
131
+ rel = self._rel(e)
132
+ p = self.resolve_in_workspace(rel)
133
+ except (ValueError, OutsideWorkspaceError) as err:
134
+ problems.append(f"edit {i}: {err}")
135
+ continue
136
+ old = e.get("old", e.get("old_string", ""))
137
+ new = e.get("new", e.get("new_string", ""))
138
+ if not old:
139
+ problems.append(f"edit {i} ({rel}): 'old' is required")
140
+ continue
141
+ try:
142
+ with open(p, "r", encoding="utf-8") as f:
143
+ text = f.read()
144
+ except OSError as err:
145
+ problems.append(f"edit {i} ({rel}): {type(err).__name__}: {err}")
146
+ continue
147
+ n = text.count(old)
148
+ if n != 1:
149
+ problems.append(f"edit {i} ({rel}): 'old' occurs {n} times (must be exactly 1)")
150
+ continue
151
+ if not os.access(p, os.W_OK):
152
+ problems.append(f"edit {i} ({rel}): file is not writable")
153
+ continue
154
+ staged.append((p, rel, old, new))
155
+ if problems:
156
+ return {"ok": False, "content":
157
+ "multi_edit applied NOTHING -- fix these and retry:\n" + "\n".join(problems)}
158
+ # Apply sequentially, re-reading so multiple edits to the SAME file
159
+ # compose; if an earlier edit invalidated a later one, stop honestly.
160
+ applied = []
161
+ for p, rel, old, new in staged:
162
+ with open(p, "r", encoding="utf-8") as f:
163
+ text = f.read()
164
+ if text.count(old) != 1:
165
+ return {"ok": False, "content":
166
+ f"multi_edit stopped at {rel}: an earlier edit in this batch changed "
167
+ f"the text around 'old' (applied so far: {', '.join(applied) or 'none'}); "
168
+ f"re-read the file and retry the remaining edits"}
169
+ with open(p, "w", encoding="utf-8") as f:
170
+ f.write(text.replace(old, new, 1))
171
+ applied.append(rel)
172
+ return {"ok": True, "content": f"applied {len(applied)} edits: " + ", ".join(applied)}
173
+
174
+ def _t_checkpoint(self, a: dict) -> dict:
175
+ if self.shadow is None:
176
+ return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
177
+ cp = self.shadow.checkpoint(str(a.get("label", "") or "manual"))
178
+ if cp is None:
179
+ return {"ok": False, "content": "checkpoint failed (shadow git error)"}
180
+ note = "created" if cp.get("changed") else "no changes since the last checkpoint"
181
+ return {"ok": True, "content": f"checkpoint cp-{cp.get('n')} ({cp.get('id')}): {note}"}
182
+
183
+ def _t_diff(self, a: dict) -> dict:
184
+ if self.shadow is None:
185
+ return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
186
+ return {"ok": True, "content": self.shadow.diff(str(a.get("since", "") or ""))}
187
+
188
+ def _t_rollback(self, a: dict) -> dict:
189
+ if self.shadow is None:
190
+ return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
191
+ to = str(a.get("checkpoint", "") or a.get("to", "") or "")
192
+ if not to:
193
+ return {"ok": False, "content":
194
+ "rollback requires 'checkpoint' (an id, cp-N or N -- see diff/checkpoint output)"}
195
+ return self.shadow.rollback(to)
90
196
 
91
197
  def _t_bash(self, a: dict) -> dict:
92
198
  timeout = min(int(a.get("timeout", 120) or 120), 3600)
@@ -44,15 +44,19 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
44
44
  return [("class:tb.dim", " mode: "),
45
45
  (f"class:tb.mode.{mode}", mode),
46
46
  ("class:tb.dim",
47
- f" · {_fmt_tokens(tokens)} tok · {credits} credits · Shift + TAB: switch mode")]
48
-
49
-
50
- def _escape_action(sel: dict, is_busy, stop_turn, event) -> None:
51
- """Esc key binding (P5g). While a turn is running, ask the server to stop
52
- it (`stop_turn` posts the cancel; fail-soft, best-effort) and leave the
53
- step-selection untouched while idle (unchanged), clear the selection."""
54
- if is_busy() and stop_turn is not None:
55
- event.app.create_background_task(stop_turn())
47
+ f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits · Shift + TAB: switch mode")]
48
+
49
+
50
+ def _escape_action(sel: dict, turn: dict, is_busy, stop_turn, event) -> None:
51
+ """Esc key binding (P5g). While a turn is running, STOP it cancel the LOCAL
52
+ turn task (what actually tears the turn down, same as Ctrl-C) AND ask the
53
+ server to stop (so it stops spending). While idle, clear the step-selection."""
54
+ if is_busy():
55
+ t = turn.get("task")
56
+ if t is not None and not t.done():
57
+ if stop_turn is not None:
58
+ event.app.create_background_task(stop_turn())
59
+ t.cancel() # cancel the running turn; dock survives
56
60
  return
57
61
  sel["i"] = None
58
62
  event.app.invalidate()
@@ -157,7 +161,7 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
157
161
 
158
162
  @kb.add("escape")
159
163
  def _step_clear(event):
160
- _escape_action(sel, is_busy, stop_turn, event)
164
+ _escape_action(sel, turn, is_busy, stop_turn, event)
161
165
 
162
166
  @kb.add("pageup")
163
167
  def _pgup(event):
@@ -190,8 +194,13 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
190
194
  rows = sum(max(1, -(-len(ln) // cols)) for ln in text.split("\n"))
191
195
  return min(10, max(1, rows))
192
196
 
197
+ def _prompt_fragments():
198
+ # The ❯ takes the CURRENT mode's colour (same classes the toolbar uses)
199
+ # so the mode is obvious from the input line itself, not just the toolbar.
200
+ return [(f"class:tb.mode.{mode_getter()}", "❯ ")]
201
+
193
202
  input_win = Window(
194
- BufferControl(buffer=buf, input_processors=[BeforeInput("❯ ", style="class:prompt")]),
203
+ BufferControl(buffer=buf, input_processors=[BeforeInput(_prompt_fragments)]),
195
204
  height=_input_height, wrap_lines=True)
196
205
  toolbar = Window(FormattedTextControl(_toolbar), height=1, always_hide_cursor=True)
197
206
  root = HSplit([pane.window, Frame(input_win), toolbar])