webbee 0.2.3__tar.gz → 0.3.1__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.3 → webbee-0.3.1}/CHANGELOG.md +18 -0
  2. {webbee-0.2.3 → webbee-0.3.1}/PKG-INFO +1 -1
  3. {webbee-0.2.3 → webbee-0.3.1}/pyproject.toml +1 -1
  4. webbee-0.3.1/src/webbee/__init__.py +1 -0
  5. webbee-0.3.1/src/webbee/checkpoints.py +234 -0
  6. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/commands.py +6 -0
  7. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/frames.py +9 -1
  8. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/repl.py +36 -3
  9. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/session.py +5 -2
  10. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/tools.py +114 -4
  11. webbee-0.3.1/tests/test_checkpoints.py +278 -0
  12. {webbee-0.2.3 → webbee-0.3.1}/tests/test_commands.py +25 -0
  13. {webbee-0.2.3 → webbee-0.3.1}/tests/test_marathon.py +17 -2
  14. {webbee-0.2.3 → webbee-0.3.1}/tests/test_repl.py +7 -6
  15. {webbee-0.2.3 → webbee-0.3.1}/tests/test_session.py +1 -1
  16. webbee-0.3.1/tests/test_tools.py +309 -0
  17. webbee-0.2.3/src/webbee/__init__.py +0 -1
  18. webbee-0.2.3/tests/test_tools.py +0 -110
  19. {webbee-0.2.3 → webbee-0.3.1}/.github/workflows/publish.yml +0 -0
  20. {webbee-0.2.3 → webbee-0.3.1}/.gitignore +0 -0
  21. {webbee-0.2.3 → webbee-0.3.1}/LICENSE +0 -0
  22. {webbee-0.2.3 → webbee-0.3.1}/README.md +0 -0
  23. {webbee-0.2.3 → webbee-0.3.1}/install.sh +0 -0
  24. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/account.py +0 -0
  25. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/banner_art.py +0 -0
  26. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/cli.py +0 -0
  27. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/clipboard.py +0 -0
  28. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/config.py +0 -0
  29. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/details.py +0 -0
  30. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/events.py +0 -0
  31. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/__init__.py +0 -0
  32. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/chunker.py +0 -0
  33. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/embed.py +0 -0
  34. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/graph.py +0 -0
  35. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/indexer.py +0 -0
  36. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/models.py +0 -0
  37. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/query.py +0 -0
  38. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/service.py +0 -0
  39. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/store.py +0 -0
  40. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/vectors.py +0 -0
  41. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/intel/watch.py +0 -0
  42. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/output_pane.py +0 -0
  43. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/render.py +0 -0
  44. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/repo.py +0 -0
  45. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/sessions.py +0 -0
  46. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/stream.py +0 -0
  47. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/tui.py +0 -0
  48. {webbee-0.2.3 → webbee-0.3.1}/src/webbee/update.py +0 -0
  49. {webbee-0.2.3 → webbee-0.3.1}/tests/__init__.py +0 -0
  50. {webbee-0.2.3 → webbee-0.3.1}/tests/test_account.py +0 -0
  51. {webbee-0.2.3 → webbee-0.3.1}/tests/test_cli.py +0 -0
  52. {webbee-0.2.3 → webbee-0.3.1}/tests/test_clipboard.py +0 -0
  53. {webbee-0.2.3 → webbee-0.3.1}/tests/test_config.py +0 -0
  54. {webbee-0.2.3 → webbee-0.3.1}/tests/test_cpc_contract_stable.py +0 -0
  55. {webbee-0.2.3 → webbee-0.3.1}/tests/test_details.py +0 -0
  56. {webbee-0.2.3 → webbee-0.3.1}/tests/test_events.py +0 -0
  57. {webbee-0.2.3 → webbee-0.3.1}/tests/test_freeze_fix.py +0 -0
  58. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_chunker.py +0 -0
  59. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_embed.py +0 -0
  60. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_graph.py +0 -0
  61. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_indexer.py +0 -0
  62. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_no_numpy.py +0 -0
  63. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_query.py +0 -0
  64. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_service.py +0 -0
  65. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_store.py +0 -0
  66. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_vectors.py +0 -0
  67. {webbee-0.2.3 → webbee-0.3.1}/tests/test_intel_watch.py +0 -0
  68. {webbee-0.2.3 → webbee-0.3.1}/tests/test_packaging.py +0 -0
  69. {webbee-0.2.3 → webbee-0.3.1}/tests/test_render.py +0 -0
  70. {webbee-0.2.3 → webbee-0.3.1}/tests/test_repo.py +0 -0
  71. {webbee-0.2.3 → webbee-0.3.1}/tests/test_sessions.py +0 -0
  72. {webbee-0.2.3 → webbee-0.3.1}/tests/test_stream.py +0 -0
  73. {webbee-0.2.3 → webbee-0.3.1}/tests/test_tui.py +0 -0
  74. {webbee-0.2.3 → webbee-0.3.1}/tests/test_tui_hardening.py +0 -0
  75. {webbee-0.2.3 → webbee-0.3.1}/tests/test_update.py +0 -0
  76. {webbee-0.2.3 → webbee-0.3.1}/tests/test_version.py +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - Auto-checkpointing no longer disables itself after a single transient
6
+ shadow-git error — it only pauses after repeated failures, and
7
+ `/checkpoints` shows when it's paused.
8
+
9
+ ## 0.3.0
10
+
11
+ - **The agent plans in the open.** A live todo checklist (📋) rendered from the
12
+ kernel's todo facts — you see what Webbee intends and how far it's got.
13
+ - **Precise editing.** `edit_file` requires a unique match (or an explicit
14
+ `replace_all`), and a new atomic `multi_edit` applies coordinated changes
15
+ across files all-or-nothing.
16
+ - **The time machine.** Every change auto-checkpoints into a shadow git — your
17
+ own `.git` is never touched; `/checkpoints` lists them and `/rollback`
18
+ restores, and the agent can checkpoint/diff/rollback too. A wrong step is
19
+ now undoable.
20
+
3
21
  ## 0.2.3
4
22
 
5
23
  - **Readable big numbers.** Token and credit counts in the toolbar now scale
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.2.3
3
+ Version: 0.3.1
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.3"
3
+ version = "0.3.1"
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.1"
@@ -0,0 +1,234 @@
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 # auto-checkpointing enabled (F8 latch below)
48
+ self._auto_fail_streak = 0 # consecutive failed AUTO snapshots
49
+
50
+ def _git(self, *args: str) -> "subprocess.CompletedProcess":
51
+ cmd = (["git", "--git-dir", self.git_dir, "--work-tree", self.root]
52
+ + _GIT_CFG + list(args))
53
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=120,
54
+ env=_scrubbed_env())
55
+
56
+ def ensure(self) -> bool:
57
+ """Create/open the shadow. Returns availability; never raises.
58
+ Refuses a workspace that IS a bare git repository (final-review F9:
59
+ tracking its refs/objects and resetting them would rewind the USER's
60
+ VCS -- the one thing the shadow must never do)."""
61
+ try:
62
+ if (os.path.exists(os.path.join(self.root, "HEAD"))
63
+ and os.path.isdir(os.path.join(self.root, "objects"))
64
+ and os.path.isdir(os.path.join(self.root, "refs"))):
65
+ self.available = False
66
+ return False
67
+ os.makedirs(self.git_dir, mode=0o700, exist_ok=True)
68
+ os.chmod(self.git_dir, 0o700)
69
+ if not os.path.exists(os.path.join(self.git_dir, "HEAD")):
70
+ r = subprocess.run(["git"] + _GIT_CFG + ["init", "--bare", self.git_dir],
71
+ capture_output=True, text=True, timeout=60,
72
+ env=_scrubbed_env())
73
+ if r.returncode != 0:
74
+ self.available = False
75
+ return False
76
+ # Detached-git-dir mode: bare layout + explicit --work-tree.
77
+ self._git("config", "core.bare", "false")
78
+ excl = os.path.join(self.git_dir, "info", "exclude")
79
+ os.makedirs(os.path.dirname(excl), exist_ok=True)
80
+ with open(excl, "a", encoding="utf-8") as f:
81
+ f.write("\n.git/\n")
82
+ self.available = self._git("rev-parse", "--git-dir").returncode == 0
83
+ except Exception:
84
+ self.available = False
85
+ return self.available
86
+
87
+ # -- internals ---------------------------------------------------------
88
+
89
+ def _count(self) -> int:
90
+ r = self._git("for-each-ref", "--format=%(refname)", "refs/webbee/")
91
+ return len([ln for ln in r.stdout.splitlines() if ln.strip()])
92
+
93
+ def _resolve(self, ref: str) -> "str | None":
94
+ ref = (ref or "").strip()
95
+ if not ref:
96
+ return None
97
+ if ref.isdigit():
98
+ cand = f"refs/webbee/cp-{ref}"
99
+ elif ref.startswith("cp-"):
100
+ cand = f"refs/webbee/{ref}"
101
+ else:
102
+ cand = ref
103
+ r = self._git("rev-parse", "--verify", "--quiet", cand + "^{commit}")
104
+ return r.stdout.strip() if r.returncode == 0 else None
105
+
106
+ # -- operations ---------------------------------------------------------
107
+
108
+ def checkpoint(self, label: str = "", force: bool = False) -> "dict | None":
109
+ """Commit the workspace state to the shadow history.
110
+ {"id","n","label","changed"} or None when unavailable/failed.
111
+ `force=True` always records a new checkpoint even with nothing dirty
112
+ -- used by rollback() for its mandatory pre-rollback safety snapshot,
113
+ which must exist even when the workspace already matches HEAD."""
114
+ if not self.available:
115
+ return None
116
+ try:
117
+ if self._git("add", "-A").returncode != 0:
118
+ return None # a partial add must never masquerade as a snapshot
119
+ dirty = self._git("status", "--porcelain").stdout.strip()
120
+ head = self._git("rev-parse", "--short", "HEAD")
121
+ if not dirty and head.returncode == 0 and not force:
122
+ return {"id": head.stdout.strip(), "n": self._count(),
123
+ "label": "(no changes)", "changed": False}
124
+ msg = (label or "checkpoint").strip()[:200] or "checkpoint"
125
+ args = ["commit", "-m", msg, "--no-verify"]
126
+ if head.returncode != 0 or not dirty:
127
+ args.append("--allow-empty") # first-ever baseline / forced snapshot
128
+ c = self._git(*args)
129
+ if c.returncode != 0:
130
+ return None
131
+ sha = self._git("rev-parse", "--short", "HEAD").stdout.strip()
132
+ n = self._count() + 1
133
+ self._git("update-ref", f"refs/webbee/cp-{n}", "HEAD")
134
+ return {"id": sha, "n": n, "label": msg, "changed": True}
135
+ except Exception:
136
+ return None
137
+
138
+ def list_checkpoints(self, limit: int = 10) -> list:
139
+ """Newest-first [{'n','id','label','when'}] from the refs/webbee ledger."""
140
+ if not self.available:
141
+ return []
142
+ try:
143
+ r = self._git(
144
+ "for-each-ref",
145
+ "--format=%(refname:short)|%(objectname:short)|%(contents:subject)|%(creatordate:relative)",
146
+ "refs/webbee/")
147
+ rows = []
148
+ for line in r.stdout.splitlines():
149
+ parts = line.split("|", 3)
150
+ if len(parts) == 4 and parts[0].startswith("webbee/cp-"):
151
+ rows.append({"n": int(parts[0].rsplit("-", 1)[1]),
152
+ "id": parts[1], "label": parts[2], "when": parts[3]})
153
+ rows.sort(key=lambda x: x["n"], reverse=True)
154
+ return rows[:max(1, int(limit))]
155
+ except Exception:
156
+ return []
157
+
158
+ def diff(self, since: str = "") -> str:
159
+ """Workspace changes since a checkpoint (default: the last one).
160
+ Bounded with an HONEST truncation note -- never a silent cut."""
161
+ if not self.available:
162
+ return "reversibility is off (git unavailable)"
163
+ try:
164
+ self._git("add", "-A") # stage into the SHADOW index so new files show
165
+ target = self._resolve(since) or "HEAD"
166
+ stat = self._git("diff", "--cached", "--stat", target).stdout
167
+ body = self._git("diff", "--cached", target).stdout
168
+ out = (stat + "\n" + body).strip() or "(no changes)"
169
+ if len(out) > self._DIFF_CAP:
170
+ out = (out[:self._DIFF_CAP]
171
+ + f"\n… (diff truncated at {self._DIFF_CAP} chars -- pass a "
172
+ f"narrower `since` or inspect files directly)")
173
+ return out
174
+ except Exception as e:
175
+ return f"diff failed: {type(e).__name__}: {e}"
176
+
177
+ def rollback(self, to: str) -> dict:
178
+ """Restore every checkpointed file to checkpoint `to`. The current
179
+ state is checkpointed FIRST ("pre-rollback"), so a rollback is itself
180
+ undoable. Files never checkpointed are not removed."""
181
+ if not self.available:
182
+ return {"ok": False, "content": "reversibility is off (git unavailable)"}
183
+ target = self._resolve(to)
184
+ if not target:
185
+ return {"ok": False, "content":
186
+ f"unknown checkpoint '{to}' -- use an id, cp-N or N "
187
+ f"(see the checkpoint list)"}
188
+ undo = self.checkpoint("pre-rollback", force=True)
189
+ if not undo:
190
+ # The safety snapshot is MANDATORY: without it a reset would
191
+ # destroy the current state unrecoverably (final-review F1).
192
+ return {"ok": False, "content":
193
+ "rollback refused: the pre-rollback safety snapshot could not "
194
+ "be created (shadow git error), so rolling back would destroy "
195
+ "the current state unrecoverably. Fix the shadow (disk space / "
196
+ "permissions) or copy your changes out first."}
197
+ r = self._git("reset", "--hard", target)
198
+ if r.returncode != 0:
199
+ return {"ok": False, "content": f"rollback failed: {r.stderr.strip()[:500]}"}
200
+ return {"ok": True, "content":
201
+ (f"restored the workspace to checkpoint {to} ({target[:9]}). "
202
+ f"The previous state -- including any files this rollback removed -- "
203
+ f"was saved first as checkpoint cp-{(undo or {}).get('n', '?')} "
204
+ f"({(undo or {}).get('id', '?')}), so this rollback is itself undoable.")}
205
+
206
+ _AUTO_FAIL_LATCH = 3 # consecutive AUTO failures before pausing auto-checkpointing
207
+
208
+ def note_auto_result(self, ok: bool) -> None:
209
+ """Record an AUTO (pre-write) snapshot outcome. A single transient
210
+ failure must NOT disable the session's time machine (final-review F8):
211
+ latch auto-checkpointing OFF only after _AUTO_FAIL_LATCH CONSECUTIVE
212
+ failures; any success resets the streak and re-enables it."""
213
+ if ok:
214
+ self._auto_fail_streak = 0
215
+ self.auto_ok = True
216
+ else:
217
+ self._auto_fail_streak += 1
218
+ if self._auto_fail_streak >= self._AUTO_FAIL_LATCH:
219
+ self.auto_ok = False
220
+
221
+ def describe(self) -> str:
222
+ """One printable block for the /checkpoints command."""
223
+ if not self.available:
224
+ return "Reversibility is off (git unavailable)."
225
+ rows = self.list_checkpoints(limit=10)
226
+ if not rows:
227
+ body = "No checkpoints yet."
228
+ else:
229
+ lines = [f"cp-{r['n']} {r['id']} {r['when']:>16} {r['label']}" for r in rows]
230
+ body = "Checkpoints (newest first) — /rollback <id|cp-N|N>:\n" + "\n".join(lines)
231
+ if not self.auto_ok:
232
+ body = ("⚠ Auto-checkpointing is paused after repeated shadow errors — "
233
+ "manual /checkpoint + /rollback still work.\n") + body
234
+ return body
@@ -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", ""))
@@ -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,22 @@ 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 never
40
+ # block or fail the actual work. A SINGLE transient failure must not
41
+ # disable the session (final-review F8): note_auto_result latches
42
+ # auto-checkpointing OFF only after several CONSECUTIVE failures; a
43
+ # success re-enables it. Manual checkpoint/rollback always try.
44
+ try:
45
+ _cp = self.shadow.checkpoint(f"pre:{tool}")
46
+ _note = getattr(self.shadow, "note_auto_result", None)
47
+ if _note is not None:
48
+ _note(_cp is not None)
49
+ except Exception:
50
+ _note = getattr(self.shadow, "note_auto_result", None)
51
+ if _note is not None:
52
+ _note(False)
31
53
  try:
32
54
  fn = getattr(self, f"_t_{tool}", None)
33
55
  if fn is None:
@@ -82,11 +104,99 @@ class LocalToolExecutor:
82
104
  return {"ok": False, "content": "edit_file requires 'old' (the text to replace)"}
83
105
  with open(p, "r", encoding="utf-8") as f:
84
106
  text = f.read()
85
- if old not in text:
107
+ n = text.count(old)
108
+ if n == 0:
86
109
  return {"ok": False, "content": "old string not found"}
110
+ _ra = a.get("replace_all")
111
+ replace_all = (_ra.strip().lower() in ("true", "1", "yes")
112
+ if isinstance(_ra, str) else bool(_ra))
113
+ if n > 1 and not replace_all:
114
+ return {"ok": False, "content":
115
+ f"old string occurs {n} times; add surrounding context to make it "
116
+ f"unique, or pass replace_all=true to replace every occurrence"}
87
117
  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}"}
118
+ f.write(text.replace(old, new) if replace_all else text.replace(old, new, 1))
119
+ note = f" ({n} occurrences)" if replace_all and n > 1 else ""
120
+ return {"ok": True, "content": f"edited {rel}{note}"}
121
+
122
+ def _t_multi_edit(self, a: dict) -> dict:
123
+ edits = a.get("edits")
124
+ if not isinstance(edits, list) or not edits:
125
+ return {"ok": False, "content": "multi_edit requires a non-empty 'edits' list"}
126
+ # Validate EVERYTHING first -- all-or-nothing (a half-applied batch is
127
+ # worse than a failed one).
128
+ staged = []
129
+ problems = []
130
+ for i, e in enumerate(edits):
131
+ if not isinstance(e, dict):
132
+ problems.append(f"edit {i}: not an object")
133
+ continue
134
+ try:
135
+ rel = self._rel(e)
136
+ p = self.resolve_in_workspace(rel)
137
+ except (ValueError, OutsideWorkspaceError) as err:
138
+ problems.append(f"edit {i}: {err}")
139
+ continue
140
+ old = e.get("old", e.get("old_string", ""))
141
+ new = e.get("new", e.get("new_string", ""))
142
+ if not old:
143
+ problems.append(f"edit {i} ({rel}): 'old' is required")
144
+ continue
145
+ try:
146
+ with open(p, "r", encoding="utf-8") as f:
147
+ text = f.read()
148
+ except OSError as err:
149
+ problems.append(f"edit {i} ({rel}): {type(err).__name__}: {err}")
150
+ continue
151
+ n = text.count(old)
152
+ if n != 1:
153
+ problems.append(f"edit {i} ({rel}): 'old' occurs {n} times (must be exactly 1)")
154
+ continue
155
+ if not os.access(p, os.W_OK):
156
+ problems.append(f"edit {i} ({rel}): file is not writable")
157
+ continue
158
+ staged.append((p, rel, old, new))
159
+ if problems:
160
+ return {"ok": False, "content":
161
+ "multi_edit applied NOTHING -- fix these and retry:\n" + "\n".join(problems)}
162
+ # Apply sequentially, re-reading so multiple edits to the SAME file
163
+ # compose; if an earlier edit invalidated a later one, stop honestly.
164
+ applied = []
165
+ for p, rel, old, new in staged:
166
+ with open(p, "r", encoding="utf-8") as f:
167
+ text = f.read()
168
+ if text.count(old) != 1:
169
+ return {"ok": False, "content":
170
+ f"multi_edit stopped at {rel}: an earlier edit in this batch changed "
171
+ f"the text around 'old' (applied so far: {', '.join(applied) or 'none'}); "
172
+ f"re-read the file and retry the remaining edits"}
173
+ with open(p, "w", encoding="utf-8") as f:
174
+ f.write(text.replace(old, new, 1))
175
+ applied.append(rel)
176
+ return {"ok": True, "content": f"applied {len(applied)} edits: " + ", ".join(applied)}
177
+
178
+ def _t_checkpoint(self, a: dict) -> dict:
179
+ if self.shadow is None:
180
+ return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
181
+ cp = self.shadow.checkpoint(str(a.get("label", "") or "manual"))
182
+ if cp is None:
183
+ return {"ok": False, "content": "checkpoint failed (shadow git error)"}
184
+ note = "created" if cp.get("changed") else "no changes since the last checkpoint"
185
+ return {"ok": True, "content": f"checkpoint cp-{cp.get('n')} ({cp.get('id')}): {note}"}
186
+
187
+ def _t_diff(self, a: dict) -> dict:
188
+ if self.shadow is None:
189
+ return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
190
+ return {"ok": True, "content": self.shadow.diff(str(a.get("since", "") or ""))}
191
+
192
+ def _t_rollback(self, a: dict) -> dict:
193
+ if self.shadow is None:
194
+ return {"ok": False, "content": "reversibility is unavailable (no shadow git)"}
195
+ to = str(a.get("checkpoint", "") or a.get("to", "") or "")
196
+ if not to:
197
+ return {"ok": False, "content":
198
+ "rollback requires 'checkpoint' (an id, cp-N or N -- see diff/checkpoint output)"}
199
+ return self.shadow.rollback(to)
90
200
 
91
201
  def _t_bash(self, a: dict) -> dict:
92
202
  timeout = min(int(a.get("timeout", 120) or 120), 3600)