ai-code-engineer 0.1.0__py3-none-any.whl

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 (44) hide show
  1. ai_code_engineer/__init__.py +2 -0
  2. ai_code_engineer/catalog.py +143 -0
  3. ai_code_engineer/chat.py +181 -0
  4. ai_code_engineer/cli.py +384 -0
  5. ai_code_engineer/config.py +405 -0
  6. ai_code_engineer/engine.py +1282 -0
  7. ai_code_engineer/errors.py +27 -0
  8. ai_code_engineer/git_integration.py +443 -0
  9. ai_code_engineer/gui.py +2646 -0
  10. ai_code_engineer/host.py +81 -0
  11. ai_code_engineer/ignore.py +269 -0
  12. ai_code_engineer/intent.py +222 -0
  13. ai_code_engineer/labels.py +871 -0
  14. ai_code_engineer/memory.py +91 -0
  15. ai_code_engineer/modes.py +156 -0
  16. ai_code_engineer/overrides.py +540 -0
  17. ai_code_engineer/planbook.py +192 -0
  18. ai_code_engineer/providers.py +404 -0
  19. ai_code_engineer/redaction.py +54 -0
  20. ai_code_engineer/repair.py +564 -0
  21. ai_code_engineer/report.py +352 -0
  22. ai_code_engineer/runner.py +854 -0
  23. ai_code_engineer/setup.py +386 -0
  24. ai_code_engineer/symbols.py +1286 -0
  25. ai_code_engineer/verification.py +218 -0
  26. ai_code_engineer/webapp/__init__.py +1 -0
  27. ai_code_engineer/webapp/__main__.py +45 -0
  28. ai_code_engineer/webapp/contract.py +36 -0
  29. ai_code_engineer/webapp/controller.py +3556 -0
  30. ai_code_engineer/webapp/fake.py +1141 -0
  31. ai_code_engineer/webapp/launch.py +108 -0
  32. ai_code_engineer/webapp/server.py +349 -0
  33. ai_code_engineer/webapp/static/app.css +780 -0
  34. ai_code_engineer/webapp/static/app.js +2118 -0
  35. ai_code_engineer/webapp/static/boot.js +19 -0
  36. ai_code_engineer/webapp/static/index.html +89 -0
  37. ai_code_engineer/webapp/static/tokens.css +173 -0
  38. ai_code_engineer/workspace.py +385 -0
  39. ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
  40. ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
  41. ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
  42. ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
  43. ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3556 @@
1
+ """Headless orchestration: every decision the desktop window made, with no Tk in sight.
2
+
3
+ The shape of a turn is the same one the Tk window established: validate what the user
4
+ asked for, run the engine in a worker thread, and only then let a proposal be applied
5
+ after an explicit yes. The difference is that output is a stream of events instead of
6
+ widget updates, and the two places the window reached for a native dialog now ask the
7
+ front-end and block until it answers.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ import copy
13
+ import difflib
14
+ import json
15
+ import os
16
+ import re
17
+ import secrets
18
+ import shlex
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import threading
23
+ import uuid
24
+ from dataclasses import replace
25
+ from datetime import datetime
26
+ from pathlib import Path, PurePosixPath
27
+
28
+ from ..catalog import LIVE, models_for
29
+ from ..chat import (context_block, context_use, create_chat, load_chat, project_of, respond,
30
+ title_for)
31
+ from .. import config
32
+ from ..config import Settings
33
+ from ..engine import (MAX_TASK_CHARS, apply_proposal, atomic_json, chat_sessions, diff_size,
34
+ load_session, plan, project_key, propose_block, read_plan_reference, rollback)
35
+ from ..errors import AgentError, PolicyError
36
+ from ..labels import (INTERRUPTED_STATES, MUTABLE_STATES, STEP_FIELDS, TONE, UNVERIFIED_STATES,
37
+ applied_line, applied_note, artifact_card, batch_summary_line,
38
+ branch_started, branch_switched,
39
+ catalog_status_line, checkpoint_note, detail_section, executed_line,
40
+ executing_line, friendly_error, fix_offers_off_line, is_arabic,
41
+ log_dropped_line,
42
+ no_branch_note,
43
+ no_checkpoint_note, queue_notes,
44
+ restore_done, restore_offer, run_unrecorded_line, run_verdict, run_warning,
45
+ say, state_label,
46
+ status_text, step_has_detail, step_line, step_missing_line,
47
+ graph_caption, graph_empty_line,
48
+ write_notice)
49
+ from ..labels import note as shared_note # `note` is a local variable in three methods here
50
+ from ..providers import make_provider
51
+ from ..redaction import redact
52
+ from .. import memory as memory_store
53
+ from .. import (git_integration, host, ignore, intent, modes, overrides, planbook, repair,
54
+ runner, setup, symbols)
55
+ from ..verification import verify
56
+ from ..workspace import Workspace, ensure_project_dir
57
+
58
+ DEFAULT_MODEL = "qwen2.5-coder:1.5b"
59
+ RECOMMENDED = {
60
+ "qwen2.5-coder:1.5b": "recommended here — valid proposals in ~25s, good default for iterating",
61
+ "qwen3:4b": "more careful answers, roughly 2× slower on this machine",
62
+ }
63
+
64
+
65
+ def _mode_rows() -> tuple[tuple[str, config.Kind], ...]:
66
+ """One row per provider the windows offer, as ``(label, kind)``.
67
+
68
+ OpenRouter is two rows on purpose: its free list and its paid list differ in what they cost,
69
+ which is a decision the user makes per task, not a setting. Every other row is one provider.
70
+ """
71
+ rows = []
72
+ for kind in config.KINDS:
73
+ if kind.free_only:
74
+ rows.append((f"{kind.label} \u00b7 Free", kind))
75
+ rows.append((f"{kind.label} \u00b7 Paid", kind))
76
+ else:
77
+ rows.append((kind.label, kind))
78
+ return tuple(rows)
79
+
80
+
81
+ MODE_ROWS = _mode_rows()
82
+ MODES = tuple(label for label, _ in MODE_ROWS)
83
+ MODE_KIND = dict(MODE_ROWS)
84
+
85
+
86
+ def free_mode(kind: config.Kind) -> str:
87
+ return f"{kind.label} \u00b7 Free"
88
+
89
+
90
+ def paid_mode(kind: config.Kind) -> str:
91
+ return f"{kind.label} \u00b7 Paid"
92
+ PLAN_SUFFIXES = (".md", ".txt")
93
+ # A sidebar entry is a branch, and a branch is the only thing that owns a folder. "chat" never
94
+ # has one unless a project was bound to it by name, so the program cannot start pointed at a
95
+ # directory the user did not choose in this session.
96
+ BRANCH_CHAT, BRANCH_PROJECT = "chat", "project"
97
+ CHAT_COMPOSER, CHANGE_COMPOSER, READ_COMPOSER = "chat", "change", "read"
98
+ CHAT_ID = re.compile(r"[a-f0-9]{32}")
99
+ # A closed palette, not free text: the value is painted into the sidebar, so anything a
100
+ # crafted registry could inject there would read to the user as their own label. Escapes
101
+ # keep the source plain ASCII, which a cp1252 console can at least print.
102
+ PROJECT_ICONS = ("📁", "🚀", "🐍", "☕", "⚛️",
103
+ "🦀", "🌐", "📦", "🧪", "🛠️")
104
+ DEFAULT_ICON = "📁" # folder
105
+
106
+ # A project branch opens in Chat, and that is the right default: a greeting must not become a
107
+ # rejected diff. It is also the wrong answer to "add a logout button to the login page", so the
108
+ # opening of a message is checked before the mode is honoured. A request rarely starts on its
109
+ # verb — "عايز اعمل مشروع" opens on "عايز" and "I want to create" opens on "I" — so the first
110
+ # five folded tokens are scanned for a build verb, and an Arabic request word at the front counts
111
+ # on its own. Two shapes still veto the whole thing: a message that opens with a question word,
112
+ # and a message that ends with a question mark, because "how do I add a logout button?" is a
113
+ # question about code, not permission to write it.
114
+ # A match routes that one message down the reviewed-change path; it does not change the branch's
115
+ # mode, so the next message is prose again.
116
+ CHANGE_WINDOW = 5
117
+ CHANGE_VERBS = frozenset(("create", "add", "fix", "implement", "refactor", "build", "make",
118
+ "delete", "scaffold", "write", "setup", "generate"))
119
+ # Spelling is folded to one form first, so أنشئ and طوّر match what a person types rather
120
+ # than what a keyboard layout calls it.
121
+ ARABIC_CHANGE_VERBS = frozenset(("انشي", "عدل", "صلح", "اكتب", "ضيف", "اضف",
122
+ "اعمل", "احذف", "غير", "ابني", "طور", "نفذ",
123
+ "سو", "سوي", "ركب"))
124
+ # Egyptian and Levantine ways of opening "I want you to …". These count anywhere in the window,
125
+ # because the wanting word is rarely first ("يا صاحبي عايز تعمل login", "برضو عايز اضيف زر").
126
+ # What makes that safe is order: a message that opens with a question word, or ends with a question
127
+ # mark, has already been refused before these are consulted — "هل ممكن…" stays a question.
128
+ ARABIC_INTENT_WORDS = frozenset(("عايز", "محتاج", "ياريت", "ممكن", "اريد", "بدي", "حابب"))
129
+ # "لو سمحت" is two tokens, so it is matched as a prefix of the folded head rather than as a word.
130
+ # "من فضلك" is deliberately absent: its first token is already the question word "من", and a
131
+ # phrase that has to be tested before the veto is a phrase the veto will keep breaking.
132
+ ARABIC_INTENT_PHRASES = ("لو سمحت",)
133
+
134
+
135
+ ASKING_FIRST = frozenset(("what", "whats", "why", "how", "when", "where", "which", "who",
136
+ "explain", "describe", "tell", "is", "are", "does", "do", "can",
137
+ "could", "should", "would", "show", "difference", "meaning"))
138
+ ARABIC_ASKING_FIRST = frozenset(("ما", "متي", "اين", "كيف", "ليه", "لماذا", "هل", "اشرح",
139
+ "عرف", "احكيلي", "ايه", "شو", "من", "كم"))
140
+ ARABIC_FOLDS = str.maketrans({"أ": "ا", "إ": "ا", "آ": "ا", "ٱ": "ا", "ؤ": "و", "ئ": "ي",
141
+ "ى": "ي", "ة": "ه", "ـ": ""})
142
+ ARABIC_MARKS = re.compile("[\\u064b-\\u0652\\u0670]")
143
+ QUESTION_ENDINGS = ("?", "\u061f") # the Latin and the Arabic question mark
144
+ # "can you fix this" opens like a question and means a request; "can you add a column?" means a
145
+ # question. Only these four openers carry that ambiguity, and only a following "you" plus a build
146
+ # verb resolves it — so they are vetted by the mark instead of by the word.
147
+ REQUEST_MODAL = ("can", "could", "will", "would")
148
+
149
+
150
+ def _words(text: str, limit: int = CHANGE_WINDOW) -> list[str]:
151
+ """The opening tokens, folded for spelling and stripped of their punctuation."""
152
+ parts = (text or "").split()[:limit]
153
+ return [re.sub("[^\\w'-]+", "", ARABIC_MARKS.sub("", part).translate(ARABIC_FOLDS)).casefold()
154
+ for part in parts]
155
+
156
+
157
+ def _wants_a_change(stripped: str) -> bool:
158
+ words = _words(stripped)
159
+ if not words:
160
+ return False
161
+ asked = stripped.endswith(QUESTION_ENDINGS)
162
+ first = words[0]
163
+ if first in ARABIC_ASKING_FIRST:
164
+ return False
165
+ if first in ASKING_FIRST:
166
+ if not (first in REQUEST_MODAL and "you" in words[1:3] and not asked):
167
+ return False
168
+ elif asked:
169
+ return False
170
+ if any(word in CHANGE_VERBS or word in ARABIC_CHANGE_VERBS for word in words):
171
+ return True
172
+ if any(word in ARABIC_INTENT_WORDS for word in words):
173
+ return True
174
+ return " ".join(words).startswith(ARABIC_INTENT_PHRASES)
175
+
176
+
177
+ def asks_for_a_change(text: str) -> bool:
178
+ """True when the message opens by asking for files to be changed."""
179
+ return _wants_a_change((text or "").strip())
180
+
181
+
182
+ def _clock() -> str:
183
+ return datetime.now().strftime("%H:%M")
184
+
185
+
186
+ class LineFeed:
187
+ """Whole lines out of a token stream.
188
+
189
+ A model answers in fragments of a few characters, and redaction reads a line: a key that arrives
190
+ as ``sk-`` then ``abcdef`` is one the pattern cannot see in either piece. So the fragments are
191
+ reassembled here and handed on a line at a time — which is also the grain a reader watches, and
192
+ the reason a build output has always streamed by the line rather than by the character.
193
+ """
194
+
195
+ def __init__(self, emit, cap: int = 500):
196
+ self.emit = emit
197
+ self.cap = cap
198
+ self.held = ""
199
+
200
+ def feed(self, piece: str) -> None:
201
+ self.held += piece
202
+ while "\n" in self.held:
203
+ line, self.held = self.held.split("\n", 1)
204
+ self.emit(line)
205
+ if len(self.held) > self.cap:
206
+ # A model that never breaks a line must not make the reader wait for the whole answer, and
207
+ # must not grow this buffer without bound. Cut at the cap, on a line the redactor has seen.
208
+ self.emit(self.held)
209
+ self.held = ""
210
+
211
+ def close(self) -> None:
212
+ if self.held:
213
+ self.emit(self.held)
214
+ self.held = ""
215
+
216
+
217
+ # How long a question may wait for the browser before the worker gives up on it. The window has to
218
+ # be long enough for a person to read a diff, and the ask is withdrawn when it expires (see _ask).
219
+ ASK_TIMEOUT = 1800
220
+
221
+ # The Activity list is shipped whole in every snapshot, so an uncapped one turns a 5 000-line build
222
+ # into a 5 000-line payload on every later push — the same reason streamed output never reaches the
223
+ # server at all (`_build_line`). What falls off the front is counted and said, not dropped quietly.
224
+ MAX_LOG_ENTRIES = 400
225
+
226
+ # The only file count a batch row trusts: the one the task spelled out in the scaffold's own words
227
+ # ("Create exactly three new files"). D36 was a small model answering that with two files, and this
228
+ # is the check that says so. An implied count in other prose is not counted, because reading it would
229
+ # refuse good work as often as it caught this.
230
+ BATCH_FILE_COUNT = re.compile(r"create exactly (one|two|three|four|five|six|seven|eight|[1-8]) "
231
+ r"(?:new )?files?", re.I)
232
+ COUNT_WORDS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8}
233
+
234
+
235
+ def _asked_file_count(text: str) -> int | None:
236
+ match = BATCH_FILE_COUNT.search(text or "")
237
+ if not match:
238
+ return None
239
+ word = match.group(1)
240
+ return int(word) if word.isdigit() else COUNT_WORDS.get(word.lower())
241
+
242
+
243
+ class AgentController:
244
+ """One user, one window, one task at a time. ``busy`` is the lock that keeps it that way."""
245
+
246
+ def __init__(self, app_dir: Path) -> None:
247
+ self.app_dir = Path(app_dir).resolve()
248
+ # First run writes the override file and its signing key beside the other records; an existing
249
+ # pair is left alone, because rewriting a signed file on every start is how a row gets lost.
250
+ overrides.ensure(self.app_dir)
251
+ self.runs = self.app_dir / ".agent-runs"
252
+ self.chats = self.app_dir / ".agent-chats"
253
+ # Plan step ledgers live outside the approved project folder: a step that gates
254
+ # the next step must not be writable by the model working on that step.
255
+ self.plans = self.app_dir / ".agent-plans"
256
+ # Same reason for the user's standing notes: they are instructions the model must
257
+ # not be able to rewrite into something it will obey on the next task.
258
+ self.memory_dir = self.app_dir / ".agent-memory"
259
+ self.cancel_event = threading.Event()
260
+ self._emit = lambda _event: None
261
+ self._replies: dict[str, threading.Event] = {}
262
+ self._answers: dict[str, dict] = {}
263
+ # Every question still waiting for an answer, with the id the answer has to carry. The ids are
264
+ # minted here and delivered once over SSE, so before this a restart — or a page that missed the
265
+ # event — had an unanswerable question holding a worker: measured as a 30-minute stall on
266
+ # `busy=False`. A list, not one slot, because two offers were live at the same time.
267
+ self._active_asks: list[dict] = []
268
+ self._session_cache: dict[Path, tuple[tuple, dict | None]] = {}
269
+ self._chat_cache: dict[Path, tuple[tuple, dict | None]] = {}
270
+ self._jobs: list[threading.Thread] = []
271
+ # One re-entrant lock over the state the two windows read. `ThreadingHTTPServer` runs a
272
+ # thread per request and `run_job` adds a worker per task, so an action handler, a worker
273
+ # announcing progress and a browser asking for a snapshot are all in this object at once —
274
+ # the log and message lists were being appended and serialized against each other, and two
275
+ # clicks in one tick could both claim the busy flag before either saw the other.
276
+ #
277
+ # It guards short transitions only: the busy claim and release, the message and log
278
+ # mutators, the catalog and connection fields, and the whole of `snapshot`. It is **never
279
+ # held across a wait** — `confirm_choice` blocks on an event whose answer arrives on another
280
+ # HTTP thread, and holding the lock there would be a deadlock with the user holding the key.
281
+ self._state = threading.RLock()
282
+
283
+ self.status = "Ready — a new chat answers in prose. Choose a project for reviewed changes."
284
+ self.log: list[dict] = []
285
+ self.log_dropped = 0
286
+ # The one step row the window has open. Fetched, never shipped with every snapshot: a single
287
+ # run's stored tail is 2 500 characters, and a build per turn would ride every later push.
288
+ self.step_detail: dict | None = None
289
+ # Index of the "Executing …" row that is waiting for its command to answer.
290
+ self._run_step = -1
291
+ self.messages: list[dict] = []
292
+ self.title = "New chat"
293
+ self.subtitle = "Standalone chat — no folder attached, nothing to change"
294
+ self.repo = ""
295
+ self.plan_file = ""
296
+ self.chained = False
297
+ self.mode = "Ollama"
298
+ self.model = ""
299
+ self.model_filter = ""
300
+ self.key = ""
301
+ self.cloud_ok = False
302
+ # One endpoint per provider row, because a machine can have LM Studio *and* Ollama running
303
+ # and switching between them must not lose where the other one lives. Empty means the row's
304
+ # own default; nothing here is a secret, so all of it is safe to persist.
305
+ self.endpoints: dict[str, str] = {}
306
+ self.profile = ""
307
+ # Which provider row each catalog came from: a live answer and the built-in fallback read
308
+ # differently, and the window has to say which one the user is looking at.
309
+ self.catalog_source: dict[str, str] = {}
310
+ self.request_timeout = config.REQUEST_TIMEOUT_DEFAULT
311
+ self.recipe = ""
312
+ self.run_info = "No command has run yet."
313
+ self.catalogs: dict[str, list[dict]] = {mode: [] for mode in MODES}
314
+ self.selections: dict[str, str] = {}
315
+ self.active_mode = "Ollama"
316
+ self.busy = False
317
+ self.cancellable = False
318
+ self.pending: str | None = None
319
+ # The newest line the running job said, so the strip can tell a progress line from an outcome
320
+ # worth keeping when the job ends.
321
+ self._last_progress = ""
322
+ # The last step sentence drawn into the chat, so a model that reads the same file twice
323
+ # does not say so twice.
324
+ self._last_step = ""
325
+ self.session: dict | None = None
326
+ self.session_path: Path | None = None
327
+ self.chat: dict | None = None
328
+ self.chat_id = uuid.uuid4().hex
329
+ self.current_project = ""
330
+ self.branch: dict = {"kind": BRANCH_CHAT, "key": "", "id": self.chat_id}
331
+ self.composer = CHAT_COMPOSER
332
+ self._composer_pref: dict[str, str] = {}
333
+ # Auto-Apply belongs to a folder and starts off. `wrote_without_asking` is set by the
334
+ # one apply that skipped the confirmation, so the message afterwards can say so.
335
+ self._auto_pref: dict[str, bool] = {}
336
+ self.auto_apply = False
337
+ self.wrote_without_asking = False
338
+ # How many files the last automatic write touched, so the review card can keep saying
339
+ # "this appeared here without a click" after the status line has moved on.
340
+ self.auto_banner = 0
341
+ self.ledger_path: Path | None = None
342
+ self.ledger: dict | None = None
343
+ self.recipes: list[str] = []
344
+ # Which module of a multi-project folder the command runs in, and every module that has one.
345
+ # `_targets_root` records the folder the list was scanned from, so a task whose folder is not
346
+ # the window's folder cannot be handed a module path that only made sense next to the other.
347
+ self.targets: list[dict] = []
348
+ self.target = ""
349
+ self._targets_root = ""
350
+ # The first-run card. Rows are computed once and stored, never per snapshot: building them asks
351
+ # the provider, and a snapshot goes out on every streamed log line.
352
+ self._setup_rows: list[dict] = []
353
+ self._setup_demo: dict | None = None
354
+ self._setup_open = False
355
+ self.review_file = 0
356
+ self.diff_tab = "diff"
357
+ self._fix_round = 0
358
+ self._auto_fix = False
359
+ self._loading_session = False
360
+ self._reverting = False
361
+ self._pending_model = ""
362
+ self._draft = ""
363
+ # The branch a task branch was started from, so the chip can offer the way back. In memory
364
+ # on purpose: after a restart the folder's git history is the record, not this window.
365
+ self._git_base = ""
366
+ # The commit made before this task wrote its files, and the restore offer that grows out of
367
+ # it when the session's own rollback refuses. Both belong to one task, so a new task clears
368
+ # them: a hash from the previous job would restore the wrong version of the same path.
369
+ self._task_commit = ""
370
+ self._git_restore: dict = {}
371
+ # Messages sent while a task is running. In memory on purpose: a queued change request that
372
+ # outlives a reboot can be applied to files that have moved on since it was typed.
373
+ self.queue: list[dict] = []
374
+ # Stop stops the queue as well as the task, or pressing it would be followed immediately by
375
+ # the next queued message starting. Any queue action, or a new task, clears it.
376
+ self._queue_held = False
377
+ # The fix offer's third answer, and it lives exactly as long as the batch does: an unanswered
378
+ # offer blocks the worker that asked it, so every red build in a queue cost the whole ask
379
+ # timeout. Turning the offers off is the operator's call, never the tool's.
380
+ self._batch_fixes_off = False
381
+ # What the queue has run so far, so the end of a batch can be said in one row instead of
382
+ # being counted by the user scrolling. `_batch_row` is the task currently in flight.
383
+ self._batch: list[dict] = []
384
+ self._batch_row: dict | None = None
385
+ # A detached item opens a new chat, which selects a branch, which would otherwise try to
386
+ # drain the queue again from inside the drain that started it.
387
+ self._draining = False
388
+ # Set by `run_job` on the side that knows, and read by the drain to decide whether a queued
389
+ # row has been taken. `start_plan` has seven ways to refuse, and a row consumed by a refusal
390
+ # is a typed message that no longer exists anywhere.
391
+ self._job_started = False
392
+ self.projects: dict[str, str] = {}
393
+ self.icons: dict[str, str] = {}
394
+ self._saved_ui: dict = {}
395
+ try:
396
+ registry = json.loads((self.app_dir / ".agent-projects.json").read_text(encoding="utf-8"))
397
+ for entry in registry["projects"]:
398
+ # An entry is a bare path or a {"path", "icon"} pair: the file on disk predates
399
+ # icons, and a granted folder must not vanish from the sidebar over a format bump.
400
+ path = entry if isinstance(entry, str) else \
401
+ entry.get("path") if isinstance(entry, dict) else None
402
+ if not isinstance(path, str) or not path.strip():
403
+ continue
404
+ key = project_key(path)
405
+ self.projects[key] = str(Path(path).resolve())
406
+ icon = entry.get("icon") if isinstance(entry, dict) else None
407
+ if isinstance(icon, str) and icon in PROJECT_ICONS:
408
+ self.icons[key] = icon
409
+ if isinstance(registry.get("ui"), dict):
410
+ self._saved_ui = registry["ui"]
411
+ except (OSError, ValueError, KeyError, TypeError):
412
+ self.projects, self.icons = {}, {}
413
+ self.chained = bool(self._saved_ui.get("plan_chained"))
414
+ # The container choice belongs to the machine, never to the project: a repository must not get
415
+ # to name the image the tool builds that repository inside.
416
+ self.sandbox_on = bool(self._saved_ui.get("sandbox_on"))
417
+ self.sandbox_image = str(self._saved_ui.get("sandbox_image") or "")
418
+ saved_auto = self._saved_ui.get("auto_apply")
419
+ if isinstance(saved_auto, dict):
420
+ self._auto_pref = {str(row): bool(flag) for row, flag in saved_auto.items()}
421
+ self.request_timeout = config.clamp_request_timeout(self._saved_ui.get("request_timeout"))
422
+ saved_endpoints = self._saved_ui.get("endpoints")
423
+ if isinstance(saved_endpoints, dict):
424
+ self.endpoints = {str(row): str(value) for row, value in saved_endpoints.items()
425
+ if row in config.BY_KEY and isinstance(value, str)}
426
+ saved_profile = self._saved_ui.get("profile")
427
+ self.profile = saved_profile if isinstance(saved_profile, str) else ""
428
+ saved_mode = self._saved_ui.get("mode")
429
+ if isinstance(saved_mode, str) and saved_mode in self.catalogs:
430
+ self.mode = self.active_mode = saved_mode
431
+ if isinstance(self._saved_ui.get("model"), str) and self._saved_ui["model"]:
432
+ self._pending_model = self._saved_ui["model"]
433
+ # A batch outlives the window that typed it, but it does not resume itself: a queued change
434
+ # request can be pointed at files that moved on since it was written, so every restored row
435
+ # is held until the operator presses ▶, and carries the sentence that says why.
436
+ saved_queue = self._saved_ui.get("queue")
437
+ if isinstance(saved_queue, list):
438
+ for item in saved_queue[:20]:
439
+ if (isinstance(item, dict) and isinstance(item.get("text"), str)
440
+ and item["text"].strip() and len(item["text"]) <= MAX_TASK_CHARS):
441
+ self.queue.append({**item, "restored": True})
442
+ self._queue_held = bool(self.queue)
443
+ last = self._saved_ui.get("last_project")
444
+ if isinstance(last, str) and last and Path(last).is_dir():
445
+ self.projects.setdefault(project_key(last), str(Path(last).resolve()))
446
+ # Which folder the window opens on belongs to the branch the user left selected, not to
447
+ # one remembered path applied to everything. `last_project` is read into the project list
448
+ # above so a granted folder keeps showing in the sidebar, and never auto-attaches: that is
449
+ # how a greeting turned into a rejected diff.
450
+ saved = self._saved_ui.get("composer")
451
+ self._composer_pref = {str(k): v for k, v in saved.items() if isinstance(v, str)} \
452
+ if isinstance(saved, dict) else {}
453
+ # A position chosen before the declaration file existed is honoured, not lost. Adopted once per
454
+ # folder, and only where nothing has been declared yet, so this never overrules a choice made in
455
+ # the terminal after the window closed.
456
+ for saved_key, saved_mode in self._composer_pref.items():
457
+ folder = self.projects.get(saved_key, "")
458
+ if folder and not modes.mode_for(self.app_dir, folder):
459
+ modes.declare(self.app_dir, folder, saved_mode, by=modes.SAVED)
460
+ branch = self._saved_ui.get("last_branch")
461
+ kind = branch.get("kind") if isinstance(branch, dict) else None
462
+ key = branch.get("key") if isinstance(branch, dict) else ""
463
+ saved_chat = self._saved_ui.get("last_chat")
464
+ resumed = (self.chats / saved_chat / "chat.json"
465
+ if isinstance(saved_chat, str) and CHAT_ID.fullmatch(saved_chat) else None)
466
+ if kind == BRANCH_PROJECT and self.projects.get(str(key)):
467
+ self._select_branch(BRANCH_PROJECT, str(key))
468
+ if self.composer == CHANGE_COMPOSER:
469
+ self.auto_apply = bool(self._auto_pref.get(str(key), False))
470
+ elif resumed is not None and resumed.exists():
471
+ try:
472
+ self.open_chat(resumed)
473
+ except (AgentError, OSError):
474
+ self._select_branch(BRANCH_CHAT)
475
+ else:
476
+ self._select_branch(BRANCH_CHAT)
477
+ # A machine that has never granted a folder gets the card, once. "Never" is the operative word:
478
+ # the dismiss writes `setup_seen`, and a first-run helper that came back every launch would be
479
+ # the thing the operator learns to click through without reading.
480
+ self._setup_open = (setup.first_run(self.app_dir)
481
+ and not bool(self._saved_ui.get("setup_seen")))
482
+
483
+ # ------------------------------- prompts -------------------------------
484
+ def _ask(self, kind: str, payload: dict) -> dict:
485
+ """Push a modal to the front-end and block this thread until it answers.
486
+
487
+ A question the browser never answers is withdrawn before this thread stops waiting. The
488
+ wait ending on its own is invisible to the window otherwise, and the modal it leaves behind
489
+ covers the app: every later question then stacks another one behind it, unanswered.
490
+ """
491
+ request_id = secrets.token_hex(8)
492
+ waiter = threading.Event()
493
+ with self._state:
494
+ self._replies[request_id] = waiter
495
+ # A list, not one slot: two questions were simultaneously live during the run — the previous
496
+ # task's offer and the current one — and a single field would have dropped the first along
497
+ # with the only id that could unblock it.
498
+ self._active_asks.append({"kind": kind, "id": request_id, **payload})
499
+ self._emit({"kind": kind, "id": request_id, **payload})
500
+ # The wait is outside the lock, and that is the whole shape of this rule: the answer arrives
501
+ # on an HTTP thread that has to take the lock to deliver it. A thread that waits for the user
502
+ # while holding it would stop every other request in the window behind one open dialog.
503
+ answered = waiter.wait(timeout=ASK_TIMEOUT)
504
+ with self._state:
505
+ self._replies.pop(request_id, None)
506
+ self._active_asks = [a for a in self._active_asks if a.get("id") != request_id]
507
+ if not answered:
508
+ self._answers.pop(request_id, None)
509
+ self.status = status_text("ask_expired")
510
+ if not answered:
511
+ self._emit({"kind": "retract", "id": request_id})
512
+ return {}
513
+ return self._answers.pop(request_id, {}) or {}
514
+
515
+ def confirm_choice(self, title: str, message: str, warning: str = "", ok_label: str = "Continue",
516
+ alt_label: str = "") -> dict:
517
+ """The same question as `confirm`, answered with everything the reply carried.
518
+
519
+ `set_reply` already stores the whole body, so a third button needs no transport work — only a
520
+ caller that can read it. `confirm` throws the rest away because for it yes/no is the question.
521
+ """
522
+ payload = {"title": title, "message": message, "warning": warning, "confirm": ok_label}
523
+ if alt_label:
524
+ payload["alt"] = alt_label
525
+ return self._ask("confirm", payload)
526
+
527
+ def confirm(self, title: str, message: str, warning: str = "", ok_label: str = "Continue") -> bool:
528
+ answer = self.confirm_choice(title, message, warning, ok_label)
529
+ return bool(answer.get("ok"))
530
+
531
+ # ---------------- the four Host verbs ----------------
532
+ # The web half of `host.Host`, and the reason the contract is not Tk-only vocabulary. Every one of
533
+ # these already existed under another name; the verb is the name the *other* window can be checked
534
+ # against, which is what `tests/test_host.py` does — including a ratchet on the raw primitives
535
+ # below, so a new un-routed sink has to be a deliberate act.
536
+ def say(self, text: str) -> None:
537
+ self.status = text
538
+
539
+ def line(self, role: str, author: str, text: str) -> None:
540
+ self._add(role, author, text)
541
+
542
+ def ask(self, title: str, message: str, warning: str = "", ok_label: str = "Continue",
543
+ alt_label: str = "") -> bool:
544
+ return self.confirm(title, message, warning, alt_label or ok_label)
545
+
546
+ def stream(self, event: dict) -> None:
547
+ self._emit(event)
548
+
549
+ def ask_directory(self, title: str, hint: str = "", mustexist: bool = True) -> Path | None:
550
+ start = self.repo or str(Path.home())
551
+ answer = self._ask("folder", {"title": title, "hint": hint, "path": start, "mustexist": mustexist})
552
+ chosen = answer.get("path") if answer.get("ok") else None
553
+ return Path(chosen) if chosen else None
554
+
555
+ def ask_plan_file(self) -> Path | None:
556
+ start = self.repo or str(Path.home())
557
+ answer = self._ask("folder", {"title": "Attach a project plan", "path": start,
558
+ "hint": "Choose a .md or .txt plan inside the project folder.",
559
+ "files": list(PLAN_SUFFIXES)})
560
+ chosen = answer.get("path") if answer.get("ok") else None
561
+ return Path(chosen) if chosen else None
562
+
563
+ def set_reply(self, request_id: str, reply: dict) -> None:
564
+ with self._state:
565
+ waiter = self._replies.pop(request_id, None)
566
+ if waiter is None:
567
+ return # a reply for a question that already closed or expired
568
+ # Retire on the answering side too: the thread that asked has to be scheduled before it
569
+ # removes itself, and a snapshot built in that gap would redraw a question already answered.
570
+ self._active_asks = [a for a in self._active_asks if a.get("id") != request_id]
571
+ self._answers[request_id] = reply or {}
572
+ # Woken outside the lock: the worker this releases goes on to take the lock itself, and a
573
+ # thread that has been handed the state it is waiting for must never be made to queue twice.
574
+ waiter.set()
575
+
576
+ # ------------------------------ jobs ------------------------------
577
+ def run_job(self, operation, on_done, status: str, *, cancellable: bool = False,
578
+ on_busy=None) -> None:
579
+ with self._state:
580
+ self._job_started = False
581
+ if self.busy:
582
+ # Two clicks in one tick used to both pass this check, because each HTTP handler read
583
+ # the flag before either job had claimed it. The claim is inside the lock now, so
584
+ # exactly one of them gets here — and a message that is dropped is work the user
585
+ # already typed, so the caller still gets to say what it becomes.
586
+ if on_busy is not None:
587
+ on_busy()
588
+ return
589
+ self._job_started = True
590
+ self.busy, self.cancellable = True, cancellable
591
+ self.cancel_event.clear()
592
+ self.status = status
593
+ self._last_progress = ""
594
+ self.pending = status
595
+ self._emit({"kind": "busy", "value": True, "cancellable": cancellable})
596
+ self._emit({"kind": "status", "text": status})
597
+ self._note("job", status)
598
+
599
+ def worker():
600
+ result, failure, raw_error = None, None, None
601
+ try:
602
+ result = operation()
603
+ except Exception as exc: # noqa: BLE001 - reported, never raised
604
+ raw_error = str(exc)
605
+ failure = friendly_error(exc)
606
+ with self._state:
607
+ self.busy = self.cancellable = False
608
+ self.pending = None
609
+ self._emit({"kind": "busy", "value": False, "cancellable": False})
610
+ if failure is not None:
611
+ # The detail is the exception verbatim, which is the one field that can carry a
612
+ # provider's own text — including a bearer token it echoed back. Cap after redacting.
613
+ log_msg = (f"{failure} [Detail: {redact(raw_error)[:300]}]"
614
+ if (raw_error and raw_error != failure) else failure)
615
+ with self._state:
616
+ self.status = failure
617
+ self._note("error", log_msg)
618
+ self._add("tool", "Tool", failure)
619
+ else:
620
+ try:
621
+ on_done(result)
622
+ except (AgentError, OSError, ValueError) as exc:
623
+ self.status = friendly_error(exc)
624
+ self._note("error", f"{self.status} [Detail: {redact(str(exc))[:300]}]")
625
+ # The strip keeps the last sentence it was told. If nothing but the running line and
626
+ # this job's own progress said anything, they are dropped — "Connecting to the model…"
627
+ # sitting there after the answer arrived is the phantom this window keeps having to kill.
628
+ if self.status in (status, self._last_progress):
629
+ self.status = ""
630
+ # The queue runs from here rather than from a timer: this is the only moment that is
631
+ # known to be "the task has finished", and a chained job (propose → apply → run) keeps
632
+ # busy set until the chain is truly done, so the drain waits for the last step.
633
+ self._drain_queue()
634
+ self._emit({"kind": "state", "data": self.snapshot()})
635
+
636
+ job = threading.Thread(target=worker, name="agent-job", daemon=True)
637
+ self._jobs.append(job)
638
+ job.start()
639
+
640
+ # ------------------------------ queue ------------------------------
641
+ def queue_add(self, text: str) -> None:
642
+ """Hold a message typed during a running task, and start it when that task ends.
643
+
644
+ The click that queues is the approval: the user chose both the text and the moment, so
645
+ this is the one path in the app that starts work without a second click. It is still the
646
+ folder's own rules that run — Change mode proposes, Auto-Apply writes, Chat answers in
647
+ prose — and **Stop** holds the queue, because a stop that is followed instantly by the
648
+ next message is not a stop.
649
+ """
650
+ task = (text or "").strip()
651
+ if not task:
652
+ return
653
+ if len(task) > MAX_TASK_CHARS:
654
+ self._add("tool", "Tool", say(self.arabic,
655
+ en=f"That message is longer than {MAX_TASK_CHARS:,} characters, so it was "
656
+ "not queued.",
657
+ ar="هذه الرسالة أطول من ٤٠٠٠ حرف، لذلك لم تُضَف إلى قائمة الانتظار."))
658
+ return
659
+ if any(item.get("text") == task and item.get("chat") == self.chat_id for item in self.queue):
660
+ self._queue_held = False
661
+ return # the same message twice in a row is one queue line
662
+ self.queue.append({"id": uuid.uuid4().hex[:8], "text": task, "chat": self.chat_id,
663
+ "branch": str(self.branch.get("key") or ""),
664
+ "project": self.repo, "at": _clock()})
665
+ self._queue_held = False
666
+ # The message is on its way either way, so the confirmation is the server's — and it goes
667
+ # out as the toast event the client already handles but nothing had ever sent.
668
+ self._emit({"kind": "toast", "text": say(self.arabic,
669
+ en="Queued — it runs when the current task ends",
670
+ ar="أُضيفت إلى قائمة الانتظار — تُنفَّذ عند انتهاء المهمة الجارية")})
671
+ # The click can land after the task it was queued behind has already finished, and a
672
+ # message that says "Queued" and then never runs is worse than one that just sends.
673
+ self._drain_queue()
674
+ self._emit({"kind": "state", "data": self.snapshot()})
675
+
676
+ def queue_edit(self, item_id: str, text: str) -> None:
677
+ for item in self.queue:
678
+ if item.get("id") == item_id:
679
+ item["text"] = (text or "").strip()[:MAX_TASK_CHARS]
680
+ self._queue_held = False
681
+ self._emit({"kind": "state", "data": self.snapshot()})
682
+
683
+ def queue_drop(self, item_id: str) -> None:
684
+ self.queue = [item for item in self.queue if item.get("id") != item_id]
685
+ self._save_state()
686
+ self._emit({"kind": "state", "data": self.snapshot()})
687
+
688
+ def queue_now(self, item_id: str) -> None:
689
+ """Move one item to the front. With the queue already running this is the whole of
690
+ "run this one next", and dropping a held queue with it is what ▶ resumes."""
691
+ ids = [item.get("id") for item in self.queue]
692
+ if item_id in ids:
693
+ item = self.queue.pop(ids.index(item_id))
694
+ self.queue.insert(0, item)
695
+ self._release_restored()
696
+ self._queue_held = False
697
+ self._drain_queue()
698
+ self._emit({"kind": "state", "data": self.snapshot()})
699
+
700
+ def queue_detached(self, item_id: str) -> None:
701
+ """Take an item out of this conversation and ask it in a chat of its own.
702
+
703
+ The branch cannot move while a job runs — `_select_branch` refuses so a chained apply
704
+ cannot find itself pointed at another folder — so an item detached mid-task waits for the
705
+ moment the switch is allowed, and then opens there. It never runs in the chat it left.
706
+ """
707
+ for index, item in enumerate(self.queue):
708
+ if item.get("id") == item_id:
709
+ self.queue.pop(index)
710
+ self.queue.insert(0, {**item, "detached": True, "chat": ""})
711
+ break
712
+ self._queue_held = False
713
+ self._drain_queue()
714
+ self._emit({"kind": "state", "data": self.snapshot()})
715
+
716
+ def queue_resume(self) -> None:
717
+ self._release_restored()
718
+ self._queue_held = False
719
+ self._drain_queue()
720
+ self._emit({"kind": "state", "data": self.snapshot()})
721
+
722
+ def _release_restored(self) -> None:
723
+ """A press on the strip is the approval a restored row was waiting for.
724
+
725
+ It covers every row rather than the one clicked, because the batch is the unit the operator
726
+ is resuming: pressing ▶ beside "2 waiting" and having one of them run is the surprise.
727
+ """
728
+ for item in self.queue:
729
+ item.pop("restored", None)
730
+
731
+ def _drain_queue(self) -> None:
732
+ """Start the next queued message whose conversation is the one in front.
733
+
734
+ A row leaves the queue only once a job has actually taken it. `start_plan` refuses for reasons
735
+ the queue cannot fix — no model selected, the folder is gone, the message too long — and a
736
+ drain that popped first lost the typed message with nothing but a status line to show for it;
737
+ two create tasks disappeared that way in the ecommerce run.
738
+ """
739
+ # The queue is stored in the same file as the rest of the window's state, and this is the one
740
+ # place every mutation path passes through -- except an edit, which saves for itself.
741
+ self._save_state()
742
+ # A row that came back with the restart does not run on its own accord: the operator pressing
743
+ # ▶ (or queueing something new beside it) is the approval, and until then the strip shows it
744
+ # with the sentence that says it was typed in an earlier session.
745
+ runnable = next((item for item in self.queue if not item.get("restored")), None)
746
+ if self.busy or self._loading_session or self._queue_held or not self.queue:
747
+ if not self.queue and not self.busy and self._batch:
748
+ # Cleared when the batch it belongs to actually ends. A task with an empty queue is
749
+ # not a batch of one that just closed: the flag has to outlast it, or "don't ask
750
+ # again for this batch" would mean "not for the next two minutes".
751
+ self._batch_fixes_off = False
752
+ self._close_batch()
753
+ return
754
+ if runnable is None:
755
+ return
756
+ item = runnable
757
+ self._draining = True
758
+ try:
759
+ if item.get("detached"):
760
+ key = item.get("branch") or ""
761
+ if key:
762
+ self.new_chat_in(key)
763
+ else:
764
+ self.new_chat()
765
+ if self.busy:
766
+ return # the switch starts nothing on its own; send below
767
+ self.start_plan(item.get("text", ""))
768
+ if self._job_started:
769
+ self.queue.remove(item)
770
+ self._open_batch_row(item)
771
+ return
772
+ if item.get("chat") == self.chat_id:
773
+ self.start_plan(item.get("text", ""))
774
+ if self._job_started:
775
+ self.queue.remove(item)
776
+ self._open_batch_row(item)
777
+ finally:
778
+ self._draining = False
779
+
780
+ def _open_batch_row(self, item: dict) -> None:
781
+ """Start recording the queued task that was just taken out of the queue."""
782
+ first = ((item.get("text") or "").strip().splitlines() or [""])
783
+ self._batch_row = {"task": (first[0] if first else "")[:60], "paths": []}
784
+ self._batch.append(self._batch_row)
785
+
786
+ def _close_batch(self) -> None:
787
+ """Say what the batch did, once, when its queue ran out.
788
+
789
+ The discrepancy flag is deliberately narrow: it fires only when the task text literally asked
790
+ for a count of new files — the shape D36 actually hit, where "Create exactly two new files"
791
+ delivered one — because parsing arbitrary prose for an implied file count would refuse good
792
+ work as often as it caught this.
793
+ """
794
+ rows, self._batch = self._batch, []
795
+ self._batch_row = None
796
+ if not rows:
797
+ return
798
+ paths = list(dict.fromkeys(path for row in rows for path in row["paths"]))
799
+ short = [row["task"] for row in rows
800
+ if (asked := _asked_file_count(row.get("task", ""))) is not None
801
+ and len(row["paths"]) < asked]
802
+ self._add("tool", "Batch", batch_summary_line(arabic=self.arabic, tasks=len(rows),
803
+ files=len(paths), paths=paths, short=short))
804
+
805
+ def _queue_view(self) -> dict:
806
+ """What the strip shows: the messages, and whether they are held.
807
+
808
+ The sentences are built here for the same reason the banner's are: the client cannot tell
809
+ what language the task was asked in, and a strip that mixed English status lines into an
810
+ Arabic conversation would be the drift this project keeps having to undo.
811
+
812
+ Items queued for another conversation stay in the list — they belong to that chat and will
813
+ run when the user goes back to it — but they are not drawn here, because an invisible line
814
+ that fires later is exactly the surprise this strip exists to avoid.
815
+ """
816
+ here = [{"id": item.get("id", ""), "text": item.get("text", ""),
817
+ "detached": bool(item.get("detached")), "at": item.get("at", ""),
818
+ "restored": bool(item.get("restored"))}
819
+ for item in self.queue if item.get("chat") == self.chat_id or item.get("detached")]
820
+ elsewhere = len([item for item in self.queue
821
+ if item.get("chat") not in {self.chat_id, ""} and not item.get("detached")])
822
+ return {"items": here, "held": bool(self._queue_held), "elsewhere": elsewhere,
823
+ **queue_notes(self.arabic, elsewhere, bool(self._replies))}
824
+
825
+ def join(self, timeout: float = 60.0) -> None:
826
+ """Wait for every started job — including one a completion started behind it.
827
+
828
+ A job's `on_done` can begin another job, which is exactly what Auto-Apply does: the
829
+ proposal's completion calls apply, whose completion runs the project's command. One
830
+ pass over the list would return with that chain still writing, so each job is waited
831
+ on once and any that appeared while waiting is waited on too.
832
+ """
833
+ deadline = time.monotonic() + timeout
834
+ seen = set()
835
+ while True:
836
+ pending = [job for job in self._jobs if id(job) not in seen]
837
+ if not pending:
838
+ break
839
+ for job in pending:
840
+ seen.add(id(job))
841
+ job.join(max(0.05, deadline - time.monotonic()))
842
+ self._jobs.clear()
843
+
844
+ def _note(self, kind: str, text: str) -> None:
845
+ entry = {"ts": _clock(), "kind": kind, "text": text}
846
+ with self._state:
847
+ self.log.append(entry)
848
+ if len(self.log) > MAX_LOG_ENTRIES:
849
+ # Counted rather than trimmed: the dropped number is drawn at the top of Activity, because
850
+ # a log that quietly got shorter reads as a task that did less than it did.
851
+ self.log.pop(0)
852
+ self.log_dropped += 1
853
+ # `kind` names the SSE channel, so the entry travels inside its own field instead of
854
+ # being flattened into the event — flattened, the two kinds collided.
855
+ self._emit({"kind": "log", "entry": entry})
856
+
857
+ def _add(self, role: str, author: str, text: str, step: dict | None = None) -> None:
858
+ message = {"role": role, "author": author, "text": text, "time": _clock()}
859
+ if step:
860
+ # A step row is a message with a handle on its own record: the sentence is what the row
861
+ # says, and `step` is what lets it be opened again after a reload or a reopen.
862
+ message["step"] = step
863
+ with self._state:
864
+ self.messages.append(message)
865
+ self._emit({"kind": "message", "message": message})
866
+
867
+ def note_failure(self, reference: str, detail: str) -> None:
868
+ """The one place a raw exception may be written down: the Activity log, redacted and capped.
869
+
870
+ `server._fail` answers the browser with a fixed sentence and this id, so the reason is still
871
+ findable by a person who needs it and is never handed to whoever asked.
872
+ """
873
+ self._note("error", f"Request {reference}: {detail}")
874
+
875
+ # ------------------------------ contract ------------------------------
876
+ def _git_info(self) -> dict:
877
+ """The git fields the branch chip draws.
878
+
879
+ Only names and a count cross the boundary: the chip says "main, 3 changed", and the
880
+ path list behind that count belongs to the server, which is also the only place that
881
+ ever acts on it. An empty dict means there is no folder to ask about, so the front
882
+ end has one less thing to special-case.
883
+
884
+ `base` is the exception to "ask git": it is what *this window* moved HEAD away from, so
885
+ git has no way to answer it and the chip needs it to offer the return trip.
886
+ """
887
+ if not self.repo:
888
+ return {}
889
+ info = git_integration.status(self.repo)
890
+ shown = {key: info[key] for key in ("repo", "branch", "detached", "head", "dirty")}
891
+ if self._git_base:
892
+ shown["base"] = self._git_base
893
+ if self._git_restore.get("paths"):
894
+ shown["restore"] = {"commit": self._git_restore.get("commit", ""),
895
+ "paths": len(self._git_restore["paths"])}
896
+ return shown
897
+
898
+ @property
899
+ def arabic(self) -> bool:
900
+ """Was the task in front of the window asked in Arabic?
901
+
902
+ The model is already told to answer in the language it was asked in; this is the same rule
903
+ applied to the sentences we write. It reads the session's task first, then the last thing
904
+ the user typed, because a status line set before any session exists still answers to that
905
+ person. Anything with no text of theirs to look at stays English.
906
+ """
907
+ task = (self.session or {}).get("task") or ""
908
+ if not task:
909
+ task = next((m.get("text", "") for m in reversed(self.messages)
910
+ if m.get("role") == "user"), "")
911
+ return is_arabic(task)
912
+
913
+ def _banner(self) -> dict:
914
+ """The auto-write note as a finished sentence.
915
+
916
+ The server writes the text rather than the card, because the card would have to guess the
917
+ language: only this side knows what the task was asked in. `count` stays for anything that
918
+ wants the number instead of the prose.
919
+ """
920
+ if not self.auto_banner:
921
+ return {"count": 0, "text": ""}
922
+ return {"count": self.auto_banner,
923
+ "text": applied_note(arabic=self.arabic, count=self.auto_banner)}
924
+
925
+ def snapshot(self) -> dict:
926
+ """The whole UI contract, as a structure that cannot change under whoever reads it.
927
+
928
+ The caller is a different thread from the one writing: the HTTP handler serializes this while
929
+ a worker is appending its next line, and `self.messages` used to be handed out as the live
930
+ list. Building it and freezing it happen inside the same lock, so no line can arrive between
931
+ the two. Measured on the worst state this window holds — 200 messages, a 400-line Activity
932
+ log and a 300-entry model list, 117 KB of snapshot — the copy costs 3.4 ms against a 1.0 ms
933
+ serialization, and a state push happens per action, not per token.
934
+ """
935
+ with self._state:
936
+ return copy.deepcopy(self._snapshot())
937
+
938
+ def _snapshot(self) -> dict:
939
+ project = Path(self.repo) if self.repo else None
940
+ plan_info = self._plan_info()
941
+ return {
942
+ "prefs": {"style": self._saved_ui.get("style", "claude"),
943
+ "theme": self._saved_ui.get("theme", "light"),
944
+ "collapsed": bool(self._saved_ui.get("collapsed", False))},
945
+ "busy": self.busy, "cancellable": self.cancellable, "pending": self.pending,
946
+ # The strip under the header draws from here. It used to be write-only — about sixty
947
+ # sentences were assigned to this field and none of them reached a surface, which is
948
+ # why the ones this window does show had to be rebuilt in the front end.
949
+ "status": self.status,
950
+ "current": self.session_path.parent.name if self.session_path else self.chat_id,
951
+ "header": {"title": self.title, "subtitle": self.subtitle},
952
+ "project": None if not project else {"name": project.name, "path": str(project)},
953
+ "branch": {**self.branch, "projectName": project.name if project else ""},
954
+ "git": self._git_info(),
955
+ "icons": list(PROJECT_ICONS),
956
+ "composer": self.composer,
957
+ # What the folder itself was told, by whichever surface told it. The badge is the window's
958
+ # own choice; this is the fact the gates obey, and a seal that arrived from a terminal has
959
+ # to be visible or every refusal below looks like a bug.
960
+ "declared": self._declared_info(),
961
+ "plan": plan_info,
962
+ "provider": {"mode": self.mode, "modes": list(MODES), "model": self.model,
963
+ "models": self.visible_models()},
964
+ # The whole connection row: a drawer that offered a provider without saying where it
965
+ # points, or whether it wants a key, could only be filled by trial and error.
966
+ "connection": self.connection_info(),
967
+ # The signed rows a task is actually running on, beside the row that produced them:
968
+ # a number the field and the run disagree about has to be visible in the same drawer.
969
+ "overrides": self.overrides_info(),
970
+ "recipes": [runner.RECIPES[name]["label"] for name in self.recipes],
971
+ "recipe": self.recipe, "canRun": self._can_run(), "runInfo": self.run_info,
972
+ # Which folder of a multi-project folder the command runs in. One entry means there is
973
+ # nothing to choose, and the window says so rather than drawing a picker of one.
974
+ "targets": [{"path": row["path"], "label": row["label"]} for row in self.targets],
975
+ "target": self.target, "targetLabel": self.target_label(),
976
+ # Rounds used to exist only as a number in one window's memory and a status line that had
977
+ # already scrolled away by the third one. The count is in-memory and belongs in the
978
+ # snapshot; the attempts themselves are read from the sessions when the offer or the stop
979
+ # line needs them, never on every poll.
980
+ "fixRounds": {"of": repair.MAX_FIX_ROUNDS, "spent": self._fix_round},
981
+ # Said where the button is, not only in the docstring of the module that runs it.
982
+ "runWarning": run_warning(arabic=self.arabic),
983
+ # And said where the command it warns about will actually run.
984
+ "sandbox": self.sandbox_info(),
985
+ "memory": {"info": self._memory_info()},
986
+ "settings": {"project": self.repo, "plan": self.plan_file, "chained": self.chained,
987
+ "auto_apply": self.auto_apply, "bound": bool(self.branch.get("bound")),
988
+ "timeout": self.request_timeout, "model_info": self._model_info(),
989
+ "memory": self._project_notes(), "memory_info": self._memory_info(),
990
+ "consent": self.cloud_ok},
991
+ "artifact": self._artifact(), "review": self._review(), "banner": self._banner(),
992
+ # The first-run card: rows computed once and stored, never probed per snapshot.
993
+ "setup": self.setup_view(),
994
+ "queue": self._queue_view(),
995
+ # The questions a worker is blocked on, with their ids: a page that connects after the
996
+ # event was sent can still answer one, which is the whole of D33.
997
+ "asks": [dict(a) for a in self._active_asks],
998
+ "draft": self._draft,
999
+ "messages": self.messages, "log": self.log, "log_dropped": self.log_dropped,
1000
+ # The same fact as a sentence: `log_dropped` is the number, this is what Activity prints.
1001
+ "log_note": (log_dropped_line(arabic=self.arabic, count=self.log_dropped)
1002
+ if self.log_dropped else ""),
1003
+ # The one step row the window has open, with the records behind it. Fetched on a click
1004
+ # rather than shipped every push — see `open_step`.
1005
+ "step_detail": self.step_detail,
1006
+ "projects": self._nav_projects(), "chats": self._nav_chats(),
1007
+ }
1008
+
1009
+ def action(self, type: str, payload: dict, emit) -> dict | None:
1010
+ self._emit = emit
1011
+ handlers = {
1012
+ "send": lambda: self.start_plan(payload.get("text", "")),
1013
+ "queue_add": lambda: self.queue_add(payload.get("text", "")),
1014
+ "queue_edit": lambda: self.queue_edit(str(payload.get("id", "")),
1015
+ payload.get("text", "")),
1016
+ "queue_drop": lambda: self.queue_drop(str(payload.get("id", ""))),
1017
+ "queue_now": lambda: self.queue_now(str(payload.get("id", ""))),
1018
+ "queue_chat": lambda: self.queue_detached(str(payload.get("id", ""))),
1019
+ "queue_resume": self.queue_resume,
1020
+ "stop": self.stop, "apply": self.apply, "rollback": self.undo,
1021
+ "git_branch": lambda: self.git_branch(str(payload.get("back", ""))),
1022
+ "git_restore": self.git_restore,
1023
+ "apply_block": lambda: self.offer_block(payload),
1024
+ "verify": self.check_changes, "run": lambda: self.run_tests(bool(payload.get("fix"))),
1025
+ # The container switch and its image field: one action, because a tick without the digest
1026
+ # it belongs to is half an answer.
1027
+ "sandbox": lambda: self.set_sandbox(payload),
1028
+ # Opening a step row: the id is the handle on the records this session already keeps, and
1029
+ # an empty one closes the row. Nothing here reads or writes the project.
1030
+ "step_detail": lambda: self.open_step(str(payload.get("id", ""))),
1031
+ # The module graph. No id, no payload: it describes the folder this window is on, and the
1032
+ # answer is the data itself, fetched on the click.
1033
+ "show_graph": self.open_graph,
1034
+ "new_chat": self.new_chat, "new_project": self.new_project, "example": self.example,
1035
+ # The first-run card: the two buttons that do work, and the one that dismisses it for good.
1036
+ # There is deliberately no bare "show it again": the only way back is the Settings entry
1037
+ # that says it will ask the machine, so re-opening and re-checking are the same click.
1038
+ "setup_check": self.run_setup_check, "setup_demo": self.run_setup_demo,
1039
+ "setup_hide": lambda: self.set_setup_open(False),
1040
+ "open": lambda: self.open_item(payload.get("kind", "session"), payload.get("id", "")),
1041
+ "pick_project": self.browse,
1042
+ "set_composer": lambda: self.set_composer(str(payload.get("value", ""))),
1043
+ "set_auto_apply": lambda: self.set_auto_apply(bool(payload.get("value"))),
1044
+ # Both are validated inside: an endpoint that cannot be a base is refused and reported,
1045
+ # and a profile label that is not a bare name reads no file at all.
1046
+ "set_endpoint": lambda: self.set_endpoint(str(payload.get("value", ""))),
1047
+ "set_profile": lambda: self.set_profile(str(payload.get("value", ""))),
1048
+ "bind_chat": lambda: self.bind_chat(payload.get("chat", ""), payload.get("project")),
1049
+ "set_icon": lambda: self.set_icon(payload.get("project", ""), payload.get("value", "")),
1050
+ "new_chat_in": lambda: self.new_chat_in(payload.get("project", "")),
1051
+ "reveal": lambda: self.reveal(payload.get("project", "")),
1052
+ "pick_plan": self.browse_plan, "clear_plan": self.clear_plan,
1053
+ "set_mode": lambda: self.set_mode(payload.get("value", "")),
1054
+ "set_model": lambda: self.set_model(payload.get("value", "")),
1055
+ "set_filter": lambda: self.set_filter(payload.get("value", "")),
1056
+ "set_recipe": lambda: self.set_recipe(payload.get("value", "")),
1057
+ "set_target": lambda: self.set_target(str(payload.get("value", ""))),
1058
+ "set_chained": lambda: self.set_chained(bool(payload.get("value"))),
1059
+ "set_timeout": lambda: self.set_timeout(payload.get("value")),
1060
+ "set_key": lambda: self.set_key(payload.get("value", "")),
1061
+ "set_override": lambda: self.set_override(payload),
1062
+ "unset_override": lambda: self.unset_override(payload),
1063
+ "set_consent": lambda: self.set_consent(bool(payload.get("value"))),
1064
+ "set_style": lambda: self.set_pref("style", str(payload.get("style", "claude"))),
1065
+ "set_collapsed": lambda: self.set_pref("collapsed", bool(payload.get("value"))),
1066
+ "set_theme": lambda: self.set_pref("theme", str(payload.get("theme", "light"))),
1067
+ "set_draft": lambda: setattr(self, "_draft", payload.get("text", "")),
1068
+ "save_memory": lambda: self.save_memory(payload.get("text", "")),
1069
+ "select_file": lambda: setattr(self, "review_file", int(payload.get("index", 0))),
1070
+ "select_tab": lambda: setattr(self, "diff_tab", str(payload.get("tab", "diff"))),
1071
+ "refresh_models": self.check_setup,
1072
+ }
1073
+ handler = handlers.get(type)
1074
+ if handler is None:
1075
+ raise PolicyError("Unknown action: " + str(type)[:40])
1076
+ return handler()
1077
+
1078
+ def list_dir(self, path: str, want_files=None) -> dict:
1079
+ root = Path(path or Path.home())
1080
+ if not root.is_dir():
1081
+ root = Path.home()
1082
+ dirs, files = [], []
1083
+ for entry in sorted(root.iterdir(), key=lambda p: p.name.casefold()):
1084
+ try:
1085
+ if entry.is_dir():
1086
+ if not _hidden(entry) and not ignore.picker_dir(entry.name):
1087
+ dirs.append(entry)
1088
+ elif want_files and entry.suffix.lower() in {str(s) for s in want_files}:
1089
+ files.append(entry)
1090
+ except OSError:
1091
+ continue
1092
+ # A drive root has no parent, so without the drive list the picker is a dead end on C: and
1093
+ # a project on another disk cannot be reached at all.
1094
+ return {"path": str(root), "parent": str(root.parent) if root.parent != root else None,
1095
+ "roots": drive_roots(),
1096
+ "dirs": [{"name": p.name, "path": str(p)} for p in dirs[:400]],
1097
+ "files": [{"name": p.name, "path": str(p)} for p in files[:400]]}
1098
+
1099
+ # --------------------------- selection setters ---------------------------
1100
+ def _select_branch(self, kind: str, key: str = "", *, chat_id: str | None = None,
1101
+ composer: str | None = None, bound: bool = False) -> None:
1102
+ """The only writer of ``self.repo``: a folder belongs to the selected branch or to nothing.
1103
+
1104
+ ``chat_id`` means "keep this conversation" — moving a chat between branches must not
1105
+ reset the thread the user is reading, so only a branch with no chat of its own calls
1106
+ ``new_task``.
1107
+
1108
+ ``bound`` is a chat that reads a project. It keeps the folder for context and nothing
1109
+ else: neither the folder's remembered mode nor its Auto-Apply switch is taken, because
1110
+ the promise to a bound chat is that it cannot write, and the folder it was dropped on may
1111
+ have been left in Change with the switch on.
1112
+ """
1113
+ if self.busy:
1114
+ return
1115
+ repo = ""
1116
+ if kind == BRANCH_PROJECT:
1117
+ repo = self.projects.get(key, "")
1118
+ if not repo or not Path(repo).is_dir():
1119
+ self.status = "That project folder is not available. Open it again from the sidebar."
1120
+ return
1121
+ if repo != self.repo.strip():
1122
+ self._loading_session = True
1123
+ try:
1124
+ self.project_changed(repo) # keeps the unsent-draft confirmation
1125
+ finally:
1126
+ self._loading_session = False
1127
+ if repo != self.repo.strip():
1128
+ return # the user chose to stay on the current project
1129
+ self.branch = {"kind": kind, "key": key if repo else "", "id": chat_id or self.chat_id,
1130
+ "bound": bool(repo and bound)}
1131
+ self.composer = (CHAT_COMPOSER if self.branch["bound"]
1132
+ else composer or self._branch_mode(self.branch["key"]))
1133
+ # A folder the window just put into Change mode has to still be in Change mode after a
1134
+ # restart. `set_composer` writes this map when a *person* flips the badge; the paths that
1135
+ # select a branch with an explicit composer wrote nothing, so a folder granted in one session
1136
+ # came back as Chat in the next and its first message was answered in prose. Only the Change
1137
+ # grant is recorded: `new_chat_in` names Chat on purpose for one conversation, and a bound
1138
+ # chat must never be able to write anything at all.
1139
+ if composer == CHANGE_COMPOSER and not self.branch["bound"] and self.branch["key"]:
1140
+ self._composer_pref[self.branch["key"]] = CHANGE_COMPOSER
1141
+ modes.declare(self.app_dir, repo, CHANGE_COMPOSER, by=modes.WEB)
1142
+ self.auto_apply = (bool(self._auto_pref.get(str(self.branch.get("key") or ""), False))
1143
+ if self.composer == CHANGE_COMPOSER and not self.branch["bound"] else False)
1144
+ if chat_id:
1145
+ self.chat_id = chat_id
1146
+ else:
1147
+ self.new_task()
1148
+ # project_changed saved the state while the branch was still the previous one, so the
1149
+ # branch that was actually selected is written once more.
1150
+ self._save_state()
1151
+ self.subtitle = self._subtitle()
1152
+ # Arriving at a conversation that has something waiting starts it here; without this the
1153
+ # queue would only move when a job finished, and a stopped task never finishes again.
1154
+ if not self._draining:
1155
+ self._drain_queue()
1156
+
1157
+ def reading_only(self) -> bool:
1158
+ """The one question every write gate and every run gate asks.
1159
+
1160
+ Deliberately not "a bound chat": a chat moved into a project reads its folder as context and
1161
+ still plans a proposal when a message asks for one, and that behaviour is pinned by tests and
1162
+ by the tooltip on its own badge. Read-only is the position that refuses even the proposal.
1163
+
1164
+ Two things can put a folder here, and both are honoured: the badge this window is holding, and
1165
+ the declaration the folder itself carries — written by any surface, the other window or the
1166
+ terminal, and read from disk on every ask. A badge alone would mean a folder sealed in a
1167
+ terminal could be written from a window that never looked at the terminal.
1168
+ """
1169
+ return intent.read_only(self.composer) or modes.sealed(self.app_dir, self.repo)
1170
+
1171
+ def _declared_info(self) -> dict:
1172
+ """The folder's own declaration, spoken, plus the line the badge owes it.
1173
+
1174
+ `note` is only filled when the two disagree: a window showing Change over a folder a terminal
1175
+ sealed is the one case where a refusal needs an explanation before it needs a retry.
1176
+ """
1177
+ row = modes.row_for(self.app_dir, self.repo)
1178
+ sealed = row.get("mode") == intent.READ
1179
+ return {"sealed": sealed, "mode": row.get("mode", ""),
1180
+ "by": intent.source(row.get("by", ""), arabic=self.arabic), "at": row.get("at", ""),
1181
+ "note": (intent.followed(row.get("by", ""), row.get("at", ""), arabic=self.arabic)
1182
+ if sealed and not intent.read_only(self.composer) else "")}
1183
+
1184
+ def write_refusal(self, what: str) -> str:
1185
+ """The sentence every write gate prints, and it names where the promise came from.
1186
+
1187
+ "Switch to Change mode" is useless advice when the seal was set by a terminal the window has
1188
+ never seen, so the declaration gets the sentence that says who set it and how to lift it.
1189
+ """
1190
+ return modes.refusal(self.app_dir, self.repo, what, arabic=self.arabic,
1191
+ badge=self.composer)
1192
+
1193
+ def _branch_mode(self, key: str) -> str:
1194
+ """A chat with no folder can only answer in prose; a project keeps the position it was told to
1195
+ hold, wherever it was told.
1196
+
1197
+ A seal outranks everything, because it is the only row that promises *less*: a folder somebody
1198
+ told to stay read-only opens read-only here too. A stored Change does not outrank the window's
1199
+ own memory of a folder the person left on Chat — that row is not a promise about files, and
1200
+ letting it win would turn "the tool remembers your choice" into "the tool overrules it".
1201
+ Everything else goes through `intent.normalise`, because a pref written by an older version of
1202
+ this file has to land on the promise that writes nothing.
1203
+ """
1204
+ if not key:
1205
+ return CHAT_COMPOSER
1206
+ if modes.sealed(self.app_dir, self.projects.get(key, "")):
1207
+ return intent.READ
1208
+ return intent.normalise(self._composer_pref.get(key, CHAT_COMPOSER))
1209
+
1210
+ def _grant_folder(self, key: str, resolved: str) -> None:
1211
+ """Register a folder and open it — the one way a project branch is entered by granting.
1212
+
1213
+ A folder never seen before lands on Change mode, because naming a folder and asking for work
1214
+ on it is what the user asked the window to do. A folder the person has since switched by hand —
1215
+ here, in the other window, or in a terminal — comes back exactly the way they left it, so the
1216
+ grant never overrules a choice.
1217
+ """
1218
+ self.projects.setdefault(key, resolved)
1219
+ told = bool(key in self._composer_pref or modes.mode_for(self.app_dir, resolved))
1220
+ self._select_branch(BRANCH_PROJECT, key,
1221
+ composer=None if told else CHANGE_COMPOSER)
1222
+
1223
+ def set_composer(self, value: str) -> None:
1224
+ """What the next Send is allowed to become: prose, a read-only analysis, or a reviewed diff."""
1225
+ wanted = intent.normalise(value)
1226
+ if wanted != CHAT_COMPOSER and not self.repo:
1227
+ self.status = intent.needs_folder(wanted, arabic=self.arabic)
1228
+ return
1229
+ if wanted == CHANGE_COMPOSER and self.branch.get("bound"):
1230
+ # The folder this chat reads is not a folder it may write. Leaving the chat is the way.
1231
+ self.status = ("A chat moved into a project reads that folder only. Open the project "
1232
+ "itself from the sidebar to ask for reviewed changes to its files.")
1233
+ return
1234
+ self.composer = wanted
1235
+ lifted = modes.sealed(self.app_dir, self.repo)
1236
+ if self.branch.get("key") and not self.branch.get("bound"):
1237
+ self._composer_pref[self.branch["key"]] = wanted
1238
+ # Read and Change are the two positions that say what happens to this folder's files, so
1239
+ # either of them is written down for every surface to obey. Chat is not: it writes nothing
1240
+ # on its own, and choosing it over a sealed folder leaves the seal standing rather than
1241
+ # quietly lifting a protection somebody else asked for.
1242
+ if wanted in (intent.READ, intent.CHANGE):
1243
+ modes.declare(self.app_dir, self.repo, wanted, by=modes.WEB)
1244
+ # The switch is a property of Change mode. Reading masks it for this conversation and leaves
1245
+ # the folder's stored preference alone, so switching back is not a silent re-arm of anything.
1246
+ self.auto_apply = (bool(self._auto_pref.get(str(self.branch.get("key") or ""), False))
1247
+ if wanted == CHANGE_COMPOSER and not self.branch.get("bound") else False)
1248
+ self.subtitle = self._subtitle()
1249
+ self._save_state()
1250
+ if lifted and wanted == CHANGE_COMPOSER:
1251
+ # The window is about to say a sentence that ends a promise made elsewhere, so it says
1252
+ # that instead of the ordinary one.
1253
+ self.say(intent.unchecked(arabic=self.arabic))
1254
+ return
1255
+ self.status = intent.switched(wanted, arabic=self.arabic,
1256
+ project=Path(self.repo).name if self.repo else "")
1257
+
1258
+ def set_auto_apply(self, value) -> None:
1259
+ """Turn on the switch that removes the click between reviewing a diff and writing it.
1260
+
1261
+ It is per folder and it is off until asked, because this is the one setting in the
1262
+ program that lets a program change a disk without being told to each time. Turning
1263
+ it on for one repo says nothing about the next folder opened in the same window.
1264
+ The review itself is untouched: the proposal is still built, hashed and shown, and
1265
+ two cases still stop for an answer — see `apply`.
1266
+ """
1267
+ if self.reading_only():
1268
+ if not bool(value):
1269
+ return # switching a switch that is already off asks nothing of anyone
1270
+ # A conversation that promises to write nothing cannot be handed the switch that writes
1271
+ # without a click. The folder's stored preference is left alone; only this conversation
1272
+ # refuses, which is the promise a mode is allowed to keep.
1273
+ self.status = intent.no_auto_apply(arabic=self.arabic)
1274
+ return
1275
+ if not self.repo:
1276
+ self.status = "Choose a project before turning Auto-Apply on."
1277
+ return
1278
+ if self.branch.get("bound"):
1279
+ # Writing the folder's pref from here would arm the project branch that owns it.
1280
+ self.status = ("A chat moved into a project cannot switch it to writing itself. Turn "
1281
+ "the switch on while the project itself is selected.")
1282
+ return
1283
+ wanted = bool(value)
1284
+ self.auto_apply = wanted
1285
+ key = str(self.branch.get("key") or project_key(self.repo))
1286
+ if wanted:
1287
+ self._auto_pref[key] = True
1288
+ else:
1289
+ self._auto_pref.pop(key, None)
1290
+ self._save_state()
1291
+ self.status = ("\u26a1 Auto-Apply is ON for " + Path(self.repo).name +
1292
+ ": a proposal that comes back from the model writes itself, then runs the"
1293
+ " project's own command. It still asks before emptying a file you wrote, and"
1294
+ " a code block you click still waits for Apply." if wanted else
1295
+ "Auto-Apply is OFF: every proposal waits for you to click Apply.")
1296
+
1297
+ def bind_chat(self, chat_id, project) -> None:
1298
+ """Link a chat to a granted project so its answers read that folder's context.
1299
+
1300
+ The browser names a project by its registry key, never by a path, so a dropped leaf
1301
+ cannot aim a conversation at a folder the user has not granted. Binding adds read
1302
+ context only: a chat still has no tools, no proposal, and no way to write.
1303
+ """
1304
+ if self.busy or not isinstance(chat_id, str) or not CHAT_ID.fullmatch(chat_id):
1305
+ self.status = "Only a chat listed in the sidebar can be moved."
1306
+ return
1307
+ key = "" if project in (None, "") else str(project)
1308
+ folder = self.projects.get(key, "") if key else ""
1309
+ if key and not folder:
1310
+ self.status = "That project has not been granted access, so a chat cannot be moved into it."
1311
+ return
1312
+ path = self.chats / chat_id / "chat.json"
1313
+ if not path.exists():
1314
+ self.status = "That chat no longer exists."
1315
+ return
1316
+ try:
1317
+ chat = load_chat(path)
1318
+ except (AgentError, OSError) as exc:
1319
+ self.status = friendly_error(exc)
1320
+ return
1321
+ chat["project"] = {"key": key, "path": folder} if key else None
1322
+ try:
1323
+ atomic_json(path, chat)
1324
+ except OSError:
1325
+ self.status = "Could not save the chat's project link."
1326
+ return
1327
+ if key:
1328
+ self.status = "Chat moved into " + Path(folder).name + "."
1329
+ else:
1330
+ self.status = "Chat detached from its project."
1331
+ if chat_id == self.chat_id:
1332
+ self.chat = chat
1333
+ self._select_branch(BRANCH_PROJECT if key else BRANCH_CHAT, key, chat_id=chat_id,
1334
+ bound=bool(key))
1335
+ # The notice belongs to the conversation it describes, not to whichever one is open.
1336
+ notice = ("Now reading " + Path(folder).name + " as context. This is still chat mode: "
1337
+ "your next message is answered in prose and nothing is proposed." if key else
1338
+ "This chat is on its own again — no project context is read.")
1339
+ self._add("tool", "Tool", notice)
1340
+ self._note("chat", "Moved a chat " + ("into " + Path(folder).name if key else "out of its project") + ".")
1341
+
1342
+ def set_icon(self, key, icon) -> None:
1343
+ """Label a project with one of the palette marks; anything else is refused."""
1344
+ key = str(key or "")
1345
+ if key not in self.projects:
1346
+ self.status = "That project is not one you have granted access to."
1347
+ return
1348
+ if icon not in PROJECT_ICONS:
1349
+ self.status = "Choose one of the offered project marks."
1350
+ return
1351
+ self.icons[key] = "" if icon == DEFAULT_ICON else icon
1352
+ self._save_state()
1353
+ self.status = "Project mark updated."
1354
+
1355
+ def new_chat_in(self, key) -> None:
1356
+ """The + on a project row: a fresh chat that reads that folder, nothing else changes."""
1357
+ key = str(key or "")
1358
+ if key not in self.projects:
1359
+ self.status = "That project is not one you have granted access to."
1360
+ return
1361
+ self._select_branch(BRANCH_PROJECT, key, composer=CHAT_COMPOSER)
1362
+ self.status = "New chat in " + Path(self.repo).name + " — it reads that project and writes nothing."
1363
+
1364
+ def set_repo(self, value: str) -> None:
1365
+ """Programmatic entry point for "work on this folder" — it selects a project branch too.
1366
+
1367
+ ``browse`` and ``new_project`` deliberately land in chat mode; a caller that names a
1368
+ folder and asks for work on it gets the reviewed-change path.
1369
+ """
1370
+ raw = (value or "").strip()
1371
+ if not raw:
1372
+ self._select_branch(BRANCH_CHAT)
1373
+ return
1374
+ key = project_key(raw)
1375
+ try:
1376
+ resolved = str(Path(raw).resolve())
1377
+ except OSError:
1378
+ resolved = raw
1379
+ self._grant_folder(key, resolved)
1380
+
1381
+ def project_changed(self, raw: str) -> None:
1382
+ if self._reverting:
1383
+ return
1384
+ identity = project_key(raw) if raw.strip() else ""
1385
+ if identity == self.current_project:
1386
+ return
1387
+ if not self._loading_session and self._draft.strip():
1388
+ previous = self.projects.get(self.current_project, "") if self.current_project else ""
1389
+ if not self.confirm("Switch project",
1390
+ "Switching projects starts a new chat and clears your draft. Continue?"):
1391
+ self._reverting = True
1392
+ try:
1393
+ self.repo = previous
1394
+ self._sync_project()
1395
+ finally:
1396
+ self._reverting = False
1397
+ return
1398
+ self.repo = raw.strip()
1399
+ self.current_project = identity
1400
+ self.cloud_ok = False
1401
+ if identity:
1402
+ self.projects[identity] = str(Path(self.repo).resolve())
1403
+ self._save_state()
1404
+ self._sync_project()
1405
+ self.refresh_recipes()
1406
+ if not self._loading_session:
1407
+ self.new_task()
1408
+
1409
+ def browse(self) -> None:
1410
+ chosen = self.ask_directory("Select project folder",
1411
+ "The agent reads and proposes inside this folder only.")
1412
+ if not chosen:
1413
+ return
1414
+ key = project_key(str(chosen))
1415
+ self._grant_folder(key, str(Path(chosen).resolve()))
1416
+ self._adopted(Path(chosen).name)
1417
+
1418
+ def new_project(self) -> None:
1419
+ if self.busy:
1420
+ return
1421
+ chosen = self.ask_directory("Choose a folder for the new project",
1422
+ "An empty folder, or one that does not exist yet.", mustexist=False)
1423
+ if not chosen:
1424
+ return
1425
+ try:
1426
+ folder = ensure_project_dir(chosen)
1427
+ except (AgentError, OSError) as exc:
1428
+ self.status = friendly_error(exc)
1429
+ return
1430
+ key = project_key(str(folder))
1431
+ self._grant_folder(key, str(Path(folder).resolve()))
1432
+ self.status = status_text("granted", arabic=self.arabic) + str(folder)
1433
+ self._adopted(Path(folder).name)
1434
+
1435
+ def _adopted(self, name: str) -> None:
1436
+ """Granting a folder is a decision to build in it, so Send proposes from then on."""
1437
+ self._add("tool", "Tool", "Working in " + name + ". Describe what you want built or fixed: "
1438
+ "Send proposes a diff, nothing is written until you click Apply. The badge by Send "
1439
+ "switches back to Chat when you only want to ask.")
1440
+
1441
+ def browse_plan(self) -> None:
1442
+ path = self.ask_plan_file()
1443
+ if not path:
1444
+ return
1445
+ if not self.repo:
1446
+ key = project_key(str(path.parent))
1447
+ self.projects.setdefault(key, str(Path(path.parent).resolve()))
1448
+ self._select_branch(BRANCH_PROJECT, key, chat_id=self.chat_id)
1449
+ try:
1450
+ reference = read_plan_reference(Workspace(Path(self.repo)), str(path), Settings())
1451
+ except (AgentError, OSError) as exc:
1452
+ self.status = friendly_error(exc)
1453
+ return
1454
+ self.plan_file = str(Path(self.repo) / reference["path"])
1455
+ # Attaching a plan is a decision to implement it, so this is the one path that selects
1456
+ # Change mode on the user's behalf instead of leaving prose as the default.
1457
+ self.set_composer(CHANGE_COMPOSER)
1458
+ self._save_state()
1459
+ self.status = shared_note("plan_attached_chained" if self.chained
1460
+ else "plan_attached_plain", arabic=self.arabic)
1461
+ self.refresh_plan_status()
1462
+
1463
+ def clear_plan(self) -> None:
1464
+ self.plan_file = ""
1465
+ self.ledger_path = self.ledger = None
1466
+ self.refresh_plan_status()
1467
+
1468
+ def set_mode(self, value: str) -> None:
1469
+ if value not in MODES:
1470
+ return
1471
+ with self._state:
1472
+ self.selections[self.active_mode] = self.model
1473
+ self.active_mode = self.mode = value
1474
+ self.model = self.selections.get(value, "")
1475
+ self.cloud_ok = False
1476
+ self.model_filter = ""
1477
+ self.subtitle = self._subtitle()
1478
+ refresh = not self.catalogs.get(value)
1479
+ if refresh:
1480
+ self.check_setup()
1481
+
1482
+ # ------------------------------- connection -------------------------------
1483
+ def active_kind(self) -> config.Kind:
1484
+ return MODE_KIND.get(self.mode, config.DEFAULT_KIND)
1485
+
1486
+ def endpoint_for(self, mode: str = "") -> str:
1487
+ """Where this provider row actually is — typed value, saved value, or its own default."""
1488
+ kind = MODE_KIND.get(mode or self.mode, config.DEFAULT_KIND)
1489
+ return self.endpoints.get(kind.key, "") or config.default_endpoint(kind, self.app_dir)
1490
+
1491
+ def set_endpoint(self, value: str) -> None:
1492
+ """Point the active row somewhere else. Refused loudly, never half-applied.
1493
+
1494
+ An endpoint says where the code and the key go, so a typo must not be stored and discovered
1495
+ mid-task: the URL is checked on the way in, and a value that cannot be a valid base leaves
1496
+ the field exactly as it was. A change also drops that row's catalog — a list of models from
1497
+ the old address is not a list of models at the new one.
1498
+ """
1499
+ kind = self.active_kind()
1500
+ text = str(value or "").strip()
1501
+ with self._state:
1502
+ if not text:
1503
+ self.endpoints.pop(kind.key, None)
1504
+ else:
1505
+ try:
1506
+ self.endpoints[kind.key] = config.check_endpoint(kind, text)
1507
+ except AgentError as exc:
1508
+ self.status = friendly_error(exc)
1509
+ return
1510
+ for label, row in MODE_ROWS:
1511
+ if row is kind:
1512
+ self.catalogs[label] = []
1513
+ self.catalog_source.pop(label, None)
1514
+ self.subtitle = self._subtitle()
1515
+ self._save_state()
1516
+ self.check_setup()
1517
+
1518
+ def set_profile(self, label: str) -> None:
1519
+ """Adopt one ``profiles/*.toml``: provider row, model, endpoint and limits.
1520
+
1521
+ The file names an environment variable, never a key, so choosing a profile cannot put a
1522
+ credential on disk — and the key field is cleared rather than filled, because the value it
1523
+ would need is not in the file.
1524
+ """
1525
+ self.profile = label
1526
+ try:
1527
+ settings = config.load_profile(label, app_dir=self.app_dir) if label else None
1528
+ except AgentError as exc:
1529
+ self.status = friendly_error(exc)
1530
+ self.profile = ""
1531
+ self._save_state()
1532
+ return
1533
+ if settings is not None:
1534
+ self.apply_connection(settings.provider, settings.endpoint, settings.model)
1535
+ self._save_state()
1536
+
1537
+ def available_profiles(self) -> list[str]:
1538
+ return config.profile_names()
1539
+
1540
+ def apply_connection(self, provider: str, endpoint: str, model: str = "") -> None:
1541
+ """Move the window onto another provider row without losing the model choice by accident."""
1542
+ mode = config.mode_for(provider, model)
1543
+ if not mode:
1544
+ self.status = friendly_error(AgentError(f"Unknown provider: {provider}"))
1545
+ return
1546
+ self.set_mode(mode)
1547
+ if endpoint:
1548
+ self.set_endpoint(endpoint)
1549
+ if model:
1550
+ self.selections[self.mode] = model
1551
+ self.model = model
1552
+ self._pending_model = ""
1553
+ self.model_changed()
1554
+ self.check_setup()
1555
+
1556
+ def connection_info(self) -> dict:
1557
+ kind = self.active_kind()
1558
+ endpoint = self.endpoint_for()
1559
+ needs_consent = config.needs_consent(kind, endpoint)
1560
+ return {"kind": kind.key, "label": kind.label, "endpoint": endpoint,
1561
+ "default_endpoint": config.default_endpoint(kind, self.app_dir), "cloud": kind.cloud,
1562
+ "shape": kind.shape,
1563
+ "needs_key": kind.needs_key, "key_env": kind.key_env or "",
1564
+ "consent": needs_consent, "paid": self.mode.endswith(" \u00b7 Paid"),
1565
+ "profile": self.profile, "profiles": self.available_profiles(),
1566
+ "source": self.catalog_source.get(self.mode, ""),
1567
+ "key_present": bool(self.key.strip() or os.environ.get(kind.key_env or ""))}
1568
+
1569
+ def cloud_choice(self) -> tuple[bool, bool]:
1570
+ """``(cloud, paid)`` for the row on screen — one answer, used by all three send paths.
1571
+
1572
+ Two things can make a task leave the device: the provider row itself, and a model entry the
1573
+ catalog marked cloud (an Ollama "cloud" tag answers over the internet from a local URL).
1574
+ """
1575
+ kind = self.active_kind()
1576
+ entry = self.selected_entry()
1577
+ paid = kind.free_only and self.mode == paid_mode(kind)
1578
+ cloud = config.needs_consent(kind, self.endpoint_for()) or bool(entry and entry.get("cloud"))
1579
+ return cloud, paid
1580
+
1581
+ def task_settings(self, cloud: bool) -> Settings | None:
1582
+ """The Settings for a task on the current row, or None with the reason on the status line.
1583
+
1584
+ An endpoint is checked when it is typed, but a row can still be unusable — Custom with
1585
+ nothing in the field — and refusing here is what keeps a half-built Settings away from
1586
+ ``make_provider``, which would otherwise fail inside a worker thread.
1587
+ """
1588
+ kind = self.active_kind()
1589
+ try:
1590
+ return config.settings_for(kind, self.endpoint_for(), app_dir=self.app_dir, model=self.model,
1591
+ api_key_env=kind.key_env,
1592
+ max_turns=8 if cloud else 12,
1593
+ timeout_seconds=self.timeout_seconds())
1594
+ except AgentError as exc:
1595
+ self.status = friendly_error(exc)
1596
+ return None
1597
+
1598
+ def set_model(self, value: str) -> None:
1599
+ self.model = value
1600
+ self.model_changed()
1601
+
1602
+ def set_filter(self, value: str) -> None:
1603
+ # A view change, not a state change: nothing here is worth a disk write per keystroke,
1604
+ # and the filter is not the kind of thing you want restored next launch.
1605
+ self.model_filter = str(value or "")
1606
+
1607
+ def set_recipe(self, label: str) -> None:
1608
+ self.recipe = label
1609
+ self._save_state()
1610
+
1611
+ def set_chained(self, value: bool) -> None:
1612
+ self.chained = value
1613
+ self.refresh_plan_status()
1614
+ self._save_state()
1615
+
1616
+ def set_timeout(self, value) -> None:
1617
+ # Unreadable input falls back to what is already on screen, so the field never jumps.
1618
+ self.request_timeout = config.clamp_request_timeout(value, self.request_timeout)
1619
+ self._save_state()
1620
+
1621
+ def set_key(self, value: str) -> None:
1622
+ self.key = str(value or "") # memory only; never written to disk
1623
+
1624
+ # ------------------------------- configuration overrides -------------------------------
1625
+ def overrides_info(self) -> dict:
1626
+ """The signed rows, the ones this file refused, and what may be added.
1627
+
1628
+ Shipped whole rather than fetched: it is a handful of rows, the drawer cannot draw the
1629
+ "what is live now" line without it, and a refusal a person hand-edited has to be visible the
1630
+ moment the section opens — that is the entire promise the file makes about its own signature.
1631
+ """
1632
+ return {"rows": overrides.reported(self.app_dir, arabic=self.arabic),
1633
+ "keys": overrides.fields(),
1634
+ "targets": [overrides.EVERY] + [kind.key for kind in config.KINDS],
1635
+ "path": str(overrides.path(self.app_dir)), "kind": self.active_kind().key,
1636
+ "note": overrides.scope(arabic=self.arabic)}
1637
+
1638
+ def set_override(self, payload: dict) -> None:
1639
+ """Sign one row. Refused loudly and stored nowhere if it is not a row this tool would obey.
1640
+
1641
+ The value goes through ``overrides.put``, which validates it with the same ``validate`` a
1642
+ profile is judged by — so a number out of range, a credential-shaped string, a wildcard address
1643
+ and a key the schema does not know are all refused here rather than dropped silently later.
1644
+ """
1645
+ try:
1646
+ row = overrides.put(self.app_dir, str(payload.get("target", "")),
1647
+ str(payload.get("key", "")), payload.get("value", ""),
1648
+ by=modes.WEB, arabic=self.arabic)
1649
+ except AgentError as exc:
1650
+ self.status = friendly_error(exc)
1651
+ return
1652
+ self.status = overrides.written(row, arabic=self.arabic)
1653
+
1654
+ def unset_override(self, payload: dict) -> None:
1655
+ """Remove one row, or say there was nothing to remove — a silent no-op reads as a save."""
1656
+ gone = overrides.delete(self.app_dir, str(payload.get("target", "")),
1657
+ str(payload.get("key", "")))
1658
+ self.status = overrides.removed(str(payload.get("key", "")), arabic=self.arabic) if gone \
1659
+ else overrides.absent(str(payload.get("key", "")), arabic=self.arabic)
1660
+
1661
+ def set_consent(self, value: bool) -> None:
1662
+ self.cloud_ok = bool(value)
1663
+
1664
+ def timeout_seconds(self) -> int:
1665
+ return config.clamp_request_timeout(self.request_timeout)
1666
+
1667
+ # ------------------------------ model catalog ------------------------------
1668
+ def selected_entry(self) -> dict | None:
1669
+ return next((entry for entry in self.catalogs.get(self.mode, []) if entry["id"] == self.model), None)
1670
+
1671
+ def visible_models(self, mode: str | None = None) -> list[dict]:
1672
+ """The catalog as filtered — a view, never a mutation of what was loaded.
1673
+
1674
+ ``filter_models`` used to write the subset back into ``self.catalogs``, so searching the
1675
+ list cost you every dropped entry until the next Refresh, and then cleared ``self.model``
1676
+ when the query stopped matching it: typing three letters could deselect the model running
1677
+ your task.
1678
+ """
1679
+ query = self.model_filter.strip().casefold()
1680
+ entries = self.catalogs.get(mode if mode is not None else self.mode, [])
1681
+ if not query:
1682
+ return list(entries)
1683
+ kept = [entry for entry in entries
1684
+ if query in " ".join([entry.get("id", ""), entry.get("name", ""),
1685
+ entry.get("description", "")]).casefold()]
1686
+ kept.sort(key=lambda entry: entry.get("id") != DEFAULT_MODEL)
1687
+ return kept
1688
+
1689
+ def model_changed(self) -> None:
1690
+ self.selections[self.mode] = self.model
1691
+ self.subtitle = self._subtitle()
1692
+ self._save_state()
1693
+
1694
+ def _model_info(self) -> str:
1695
+ entry = self.selected_entry()
1696
+ if entry:
1697
+ text = entry["name"] + " — " + entry["description"]
1698
+ if entry["id"] in RECOMMENDED:
1699
+ text += " · ★ " + RECOMMENDED[entry["id"]]
1700
+ return text
1701
+ loaded = len(self.catalogs.get(self.mode, []))
1702
+ shown = len(self.visible_models())
1703
+ if shown != loaded:
1704
+ return (f"{shown} of {loaded} models match \"{self.model_filter.strip()}\". "
1705
+ "Clear the filter to see the rest.")
1706
+ return f"{loaded} models available at {self.endpoint_for()}. Select one from the list."
1707
+
1708
+ def check_setup(self) -> None:
1709
+ """Ask the selected provider what it has. Read-only: no code leaves, no token is generated."""
1710
+ if self.busy:
1711
+ return
1712
+ selected_mode, api_key = self.mode, (self.key.strip() or None)
1713
+ kind = self.active_kind()
1714
+ endpoint = self.endpoint_for(selected_mode)
1715
+
1716
+ def done(result):
1717
+ entries, source = result
1718
+ self.catalog_source[selected_mode] = source
1719
+ if kind.free_only:
1720
+ free, paid = config.free_mode(kind), config.paid_mode(kind)
1721
+ self.catalogs[free] = [entry for entry in entries if entry.get("free")]
1722
+ self.catalogs[paid] = [entry for entry in entries if not entry.get("free")]
1723
+ else:
1724
+ self.catalogs[selected_mode] = entries
1725
+ pending, self._pending_model = self._pending_model, ""
1726
+ catalog = self.catalogs.get(self.mode, [])
1727
+ if pending and not self.model and any(entry["id"] == pending for entry in catalog):
1728
+ self.model = pending
1729
+ elif not self.model and any(entry["id"] == DEFAULT_MODEL for entry in catalog):
1730
+ self.model = DEFAULT_MODEL
1731
+ self.model_changed()
1732
+ self.status = catalog_status_line(arabic=self.arabic, count=len(catalog),
1733
+ model=self.model, label=selected_mode,
1734
+ live=source == LIVE)
1735
+ self._note("catalog", f"{selected_mode}: {len(catalog)} models ({source}).")
1736
+
1737
+ operation = lambda: models_for(kind, endpoint, api_key)
1738
+ self.run_job(operation, done, "Refreshing available models…")
1739
+
1740
+ # ------------------------------ first run ------------------------------
1741
+ def setup_view(self) -> dict:
1742
+ """The card, from rows that were computed once. Asking the provider is never a side effect.
1743
+
1744
+ A snapshot goes out on every streamed log line, and building the audit asks a service over the
1745
+ network and walks a folder — so the rows are stored, and only the operator's click refreshes
1746
+ them.
1747
+ """
1748
+ if self._setup_open and not self._setup_rows:
1749
+ self._setup_rows = setup.audit(repo=self.repo.strip(), provider=self.active_kind().key,
1750
+ endpoint=self.endpoint_for(self.mode),
1751
+ api_key=self.key.strip() or None, model=self.model,
1752
+ arabic=self.arabic, demo=self._setup_demo, probe=False)
1753
+ counts = setup.counts(self._setup_rows)
1754
+ # The tally travels as a sentence: the card's numbers are the server's verdict in the window's
1755
+ # language, not a string the front end assembles from bare counts.
1756
+ return {"show": self._setup_open, "rows": self._setup_rows, "counts": counts,
1757
+ "tally": setup.tally(counts, arabic=self.arabic),
1758
+ "demo": self._setup_demo, "busy": self.busy}
1759
+
1760
+ def run_setup_check(self) -> None:
1761
+ """Ask what this machine can reach. The one click that sends a request for the audit."""
1762
+ if self.busy:
1763
+ return
1764
+ kind, endpoint = self.active_kind(), self.endpoint_for(self.mode)
1765
+ repo, model, arabic = self.repo.strip(), self.model, self.arabic
1766
+ api_key = self.key.strip() or None
1767
+ previous = self._setup_demo
1768
+
1769
+ def work():
1770
+ return setup.audit(repo=repo, provider=kind.key, endpoint=endpoint, api_key=api_key,
1771
+ model=model, arabic=arabic, demo=previous)
1772
+
1773
+ def done(rows):
1774
+ self._setup_rows = rows
1775
+ self._setup_open = True
1776
+ self.say(setup.checks_done(setup.counts(rows), arabic=arabic))
1777
+
1778
+ self.run_job(work, done, "Checking this machine…")
1779
+
1780
+ def run_setup_demo(self) -> None:
1781
+ """The offline proof: a temporary folder, a proposal, an apply, a rollback. Never this project."""
1782
+ if self.busy:
1783
+ return
1784
+ arabic = self.arabic
1785
+
1786
+ def done(result):
1787
+ self._setup_demo = result
1788
+ if self._setup_rows:
1789
+ self._setup_rows = [setup.demo_row(result, arabic=arabic) if item["id"] == "demo" else item
1790
+ for item in self._setup_rows]
1791
+ self._setup_open = True
1792
+ self.say(setup.proof_done(result.get("proposal_apply_rollback") == "passed",
1793
+ result.get("note", ""), arabic=arabic))
1794
+
1795
+ self.run_job(setup.run_demo, done, "Running the offline proof…")
1796
+
1797
+ def set_setup_open(self, value: bool) -> None:
1798
+ """The card's own two buttons: show it again, or say this machine has already read it."""
1799
+ self._setup_open = bool(value)
1800
+ if not value:
1801
+ self._saved_ui["setup_seen"] = True
1802
+ self._save_state()
1803
+
1804
+ # ------------------------------- memory -------------------------------
1805
+ def _project_notes(self) -> str:
1806
+ if not self.repo:
1807
+ return ""
1808
+ try:
1809
+ return memory_store.read(self.memory_dir, self.repo)
1810
+ except AgentError:
1811
+ return ""
1812
+
1813
+ def _memory_info(self) -> str:
1814
+ if not self.repo:
1815
+ return "Choose a project folder to edit its notes."
1816
+ saved = self._project_notes()
1817
+ return f"{len(saved)} chars saved · {memory_store.key_for(self.repo)}.md · sent with every task here"
1818
+
1819
+ def project_info(self, key: str) -> dict:
1820
+ """Everything the per-project drawer shows, for any folder the user has granted.
1821
+
1822
+ Walking the folder to measure its map is the slow part; it happens here, on the
1823
+ request thread, and §7's index cache makes the second opening instant.
1824
+ """
1825
+ root = self.projects.get(str(key))
1826
+ if not root:
1827
+ # A PolicyError, not a ValueError: friendly_error keeps the reason for an AgentError and
1828
+ # replaces anything else with "Could not complete the operation."
1829
+ raise PolicyError("Unknown project.")
1830
+ path = Path(root)
1831
+ exists = path.is_dir()
1832
+ notes = ""
1833
+ if exists:
1834
+ try:
1835
+ notes = memory_store.read(self.memory_dir, root)
1836
+ except AgentError:
1837
+ notes = ""
1838
+ return {"key": str(key), "name": path.name, "path": str(path), "exists": exists,
1839
+ "icon": self.icons.get(str(key), ""), "notes": notes,
1840
+ "notes_limit": memory_store.MAX_MEMORY,
1841
+ "notes_file": f"{memory_store.key_for(root)}.md",
1842
+ "notes_dir": str(self.memory_dir),
1843
+ "context": self._context_use(path, key, notes),
1844
+ "toolchain": self._toolchain_info(path, key, exists)}
1845
+
1846
+ def _context_use(self, path: Path, key: str, notes: str) -> dict:
1847
+ """Map, conversation and what is left, against the configured context budget."""
1848
+ settings = Settings()
1849
+ blank = {"system": 0, "context": 0, "turns": 0, "kept": 0, "used": len(notes),
1850
+ "budget": settings.context_chars, "remaining": settings.context_chars,
1851
+ "est_tokens": 0, "map": 0, "notes": len(notes), "files": 0, "bound": False}
1852
+ if not path.is_dir():
1853
+ return blank
1854
+ try:
1855
+ repo = Workspace(path)
1856
+ text = repo.repo_map()
1857
+ files = len(repo.files(limit=symbols.MAX_FILES))
1858
+ except (PolicyError, OSError):
1859
+ return blank
1860
+ # Only a conversation that reads this folder can spend its budget here.
1861
+ chat = self.chat if self.branch.get("key") == key and self.chat else None
1862
+ context = context_block(text, notes) if text or notes else ""
1863
+ use = context_use(chat, settings, context)
1864
+ return {**use, "map": len(text), "notes": len(notes), "files": files,
1865
+ "bound": bool(chat)}
1866
+
1867
+ def _toolchain_info(self, path: Path, key: str, exists: bool) -> dict:
1868
+ if not exists:
1869
+ return {"detected": [], "selected": "", "timeout": 0, "proof": "",
1870
+ "request_timeout": self.request_timeout}
1871
+ detected = runner.detect(path)
1872
+ # ``self.recipe`` is a label chosen for the branch in front of the user; another
1873
+ # project's drawer can only report what would be picked by default.
1874
+ selected = self.selected_recipe() if self.branch.get("key") == key else None
1875
+ selected = selected or (detected[0] if detected else "")
1876
+ entry = runner.RECIPES.get(selected) or {}
1877
+ writes_report = bool(entry.get("reports") or entry.get("junit_arg"))
1878
+ return {"detected": [{"name": name, "label": runner.RECIPES[name]["label"],
1879
+ "command": shlex.join(runner.RECIPES[name]["command"])}
1880
+ for name in detected],
1881
+ "selected": selected,
1882
+ "timeout": runner.timeout_for(selected) if selected else 0,
1883
+ "proof": (entry.get("proof_source", "JUnit XML report") if writes_report
1884
+ else "the command's own summary line") if selected else "",
1885
+ "request_timeout": self.request_timeout}
1886
+
1887
+ def reveal(self, key: str) -> None:
1888
+ """Show a granted project folder in the file manager.
1889
+
1890
+ The one deliberate exception to "nothing runs but the recipe allowlist": it starts a
1891
+ program with a fixed argv and no shell, and the path comes from the folder the user
1892
+ already granted — never from the browser, which only sends the key.
1893
+ """
1894
+ root = self.projects.get(str(key))
1895
+ if not root:
1896
+ self.status = "That folder is not one of the projects you opened."
1897
+ return
1898
+ path = Path(root)
1899
+ if not path.is_dir():
1900
+ self.status = "That project folder is not on this disk right now."
1901
+ return
1902
+ if os.name == "nt":
1903
+ command = ["explorer.exe", str(path)]
1904
+ elif sys.platform == "darwin":
1905
+ command = ["open", str(path)]
1906
+ else:
1907
+ command = ["xdg-open", str(path)]
1908
+ try:
1909
+ subprocess.Popen(command, stdin=subprocess.DEVNULL,
1910
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
1911
+ except OSError as exc:
1912
+ # The one place this window wrote an exception's own text to a surface the browser
1913
+ # reads. A Windows `OSError` names a path, and a path in the status line is a piece of
1914
+ # the file system nobody granted. The reason still gets recorded, redacted, in Activity.
1915
+ self.status = "Could not open the folder."
1916
+ self._note("error", "Could not open the folder: " + redact(str(exc))[:120])
1917
+ return
1918
+ self.status = "Opened " + path.name + " in the file manager."
1919
+
1920
+ def save_memory(self, text: str) -> None:
1921
+ if not self.repo:
1922
+ self.status = status_text("need_folder_notes", arabic=self.arabic)
1923
+ return
1924
+ try:
1925
+ memory_store.write(self.memory_dir, self.repo, str(text).rstrip("\n"))
1926
+ except (AgentError, OSError) as exc:
1927
+ self.status = friendly_error(exc)
1928
+ return
1929
+ self.status = shared_note("notes_saved", arabic=self.arabic)
1930
+ self._note("memory", "Project notes updated.")
1931
+
1932
+ # ------------------------------- planning -------------------------------
1933
+ def start_plan(self, text: str) -> None:
1934
+ if self.busy:
1935
+ # Not a mistake to swallow. The window's own busy flag is a race when two clicks land in
1936
+ # one tick — a second Send then reaches the server while the first is still starting — and
1937
+ # dropping it loses a written prompt with no message and no trace. The queue is the
1938
+ # mechanism that already means "said while a task was running".
1939
+ self.queue_add(text)
1940
+ return
1941
+ repo, task = self.repo.strip(), (text or "").strip()
1942
+ plan_file = self.plan_file.strip() or None
1943
+ entry = self.selected_entry()
1944
+ cloud, paid = self.cloud_choice()
1945
+ if repo and not Path(repo).is_dir():
1946
+ self.status = status_text("need_folder_exists", arabic=self.arabic)
1947
+ return
1948
+ # A new task means a new "before": an offer left over from the last one would restore the
1949
+ # wrong bytes for a path both tasks touched.
1950
+ self._task_commit, self._git_restore = "", {}
1951
+ if not self._draining:
1952
+ # A Send by hand is a new batch, even with an empty queue: "don't ask again" was answered
1953
+ # against work the operator was watching, and it does not travel to the next one.
1954
+ self._batch_fixes_off = False
1955
+ chained_plan = bool(repo and plan_file and self.chained)
1956
+ if (not task and not chained_plan) or len(task) > MAX_TASK_CHARS or not self.model:
1957
+ self.status = status_text("too_long", arabic=self.arabic)
1958
+ return
1959
+ if entry is None:
1960
+ self.status = status_text("pick_model", arabic=self.arabic)
1961
+ return
1962
+ if cloud and not self.cloud_ok:
1963
+ self.status = status_text("consent_message", arabic=self.arabic)
1964
+ return
1965
+ settings = self.task_settings(cloud)
1966
+ if settings is None:
1967
+ return
1968
+ key = self.key.strip() or None
1969
+ self._draft = ""
1970
+ # The position is decided before the message is read. `asks_for_a_change` exists to promote a
1971
+ # chat that turned out to name files, and promoting is exactly what Read-only refuses — so an
1972
+ # imperative here gets the analysis it is allowed, and the row above the answer says why no
1973
+ # diff follows it.
1974
+ if self.reading_only():
1975
+ self.start_chat(task, settings, cloud, paid, key, intent.no_proposal(arabic=self.arabic))
1976
+ return
1977
+ # A bound project answers in prose by default: bound means it may *read* that project,
1978
+ # never that a greeting became a change request. A message that opens with "add" or
1979
+ # "صلح" is a different thing, and it is planned as a change — for this message only,
1980
+ # because remembering the route would turn the next "thanks" into a rejected diff.
1981
+ as_change = bool(repo) and self.composer == CHAT_COMPOSER and asks_for_a_change(task)
1982
+ if not repo or (self.composer == CHAT_COMPOSER and not as_change):
1983
+ self.start_chat(task, settings, cloud, paid, key)
1984
+ return
1985
+ if plan_file:
1986
+ try:
1987
+ read_plan_reference(Workspace(Path(repo)), plan_file, settings)
1988
+ except (AgentError, OSError) as exc:
1989
+ self.status = friendly_error(exc)
1990
+ return
1991
+ step_id = None
1992
+ if chained_plan:
1993
+ try:
1994
+ ledger_path, book = planbook.open_book(self.plans, Workspace(Path(repo)), plan_file)
1995
+ row = planbook.current(book)
1996
+ if row is None:
1997
+ raise PolicyError("Every step of this plan is already verified by a passing run.")
1998
+ step_id = row["id"]
1999
+ task = planbook.task_for(book, row, task)
2000
+ except (AgentError, OSError) as exc:
2001
+ self.status = friendly_error(exc)
2002
+ return
2003
+ self.ledger_path, self.ledger = ledger_path, book
2004
+ prior = self._unverified_prior(self.chat_id, repo)
2005
+ if prior:
2006
+ note = shared_note("prior_write", arabic=self.arabic,
2007
+ task=str(prior.get("task", ""))[:60], state=prior["state"])
2008
+ if prior["state"] in INTERRUPTED_STATES:
2009
+ if not self.confirm("Unfinished task",
2010
+ note + shared_note("prior_continue", arabic=self.arabic),
2011
+ ok_label="Continue anyway"):
2012
+ self.status = note + shared_note("prior_blocked", arabic=self.arabic)
2013
+ return
2014
+ self._add("tool", "Tool", note + shared_note("prior_continued", arabic=self.arabic))
2015
+ else:
2016
+ self._add("tool", "Tool", note + shared_note("prior_stacked", arabic=self.arabic))
2017
+ self.status = status_text("prior_unverified", arabic=self.arabic)
2018
+ self.title = task.replace("\n", " ")[:45]
2019
+ self._add("user", "You", task)
2020
+ if as_change:
2021
+ self._add("tool", "Tool", "This branch is in Chat mode, so the answer would have been "
2022
+ "prose. The message asks for a change, so it is planned as a "
2023
+ "proposal instead: review the diff, then Apply to write it. "
2024
+ "The next message is Chat again.")
2025
+ self.session = self.session_path = None
2026
+ notes = self._project_notes()
2027
+ if notes:
2028
+ self._add("tool", "Tool", shared_note("notes_in_request", arabic=self.arabic,
2029
+ count=len(notes)))
2030
+ chat_id = self.chat_id
2031
+
2032
+ def work():
2033
+ provider = make_provider(settings, allow_cloud=cloud,
2034
+ data_class="public" if cloud else "restricted",
2035
+ api_key=key, allow_paid=paid)
2036
+ # A model turn has no row of its own, so its writing streams to the one place built for
2037
+ # text that is still arriving: the Activity panel. A JSON envelope is not prose, and the
2038
+ # reader's question during a turn is "is it still working", which is what this answers.
2039
+ feed = LineFeed(self._build_line) if getattr(provider, "supports_stream", False) else None
2040
+ try:
2041
+ return plan(Workspace(Path(repo)), task, provider, settings, self.runs,
2042
+ progress=lambda line: self._progress(line),
2043
+ step=self._step, on_token=(feed.feed if feed else None),
2044
+ cancelled=self.cancel_event.is_set, plan_file=plan_file, chat_id=chat_id,
2045
+ plan_step=step_id, memory=notes)
2046
+ finally:
2047
+ if feed:
2048
+ feed.close()
2049
+
2050
+ def done(path):
2051
+ if step_id is not None and self.ledger_path:
2052
+ try:
2053
+ planbook.record_session(self.ledger_path, self.ledger, step_id, path.parent.name)
2054
+ except (AgentError, OSError) as exc:
2055
+ self._add("tool", "Tool", "The plan ledger was not updated: " + friendly_error(exc))
2056
+ self.display_session(path)
2057
+ if self.session and self.session.get("changes"):
2058
+ self.status = status_text("proposal_ready", arabic=self.arabic)
2059
+ self._emit({"kind": "view", "value": "preview"})
2060
+ self.auto_apply_ready()
2061
+ else:
2062
+ self.status = status_text("no_proposal", arabic=self.arabic)
2063
+
2064
+ # A status set here is overwritten the moment the job starts, so the routing notice
2065
+ # rides along as the running line — which is also the entry the log keeps.
2066
+ running = ("⚡ Switched to Change mode to propose and write file edits…" if as_change
2067
+ else "Connecting to the model and preparing changes…")
2068
+ self.run_job(work, done, running, cancellable=True, on_busy=lambda: self.queue_add(task))
2069
+
2070
+ def _progress(self, line: str, *, record: bool = True) -> None:
2071
+ self.pending = line
2072
+ self._last_progress = line
2073
+ # `status` is the field the strip draws from the snapshot, so a state push mid-run cannot
2074
+ # erase what the window just said; the event is only there to make it arrive instantly.
2075
+ self.status = line
2076
+ if record:
2077
+ self._note("progress", line)
2078
+ self._emit({"kind": "status", "text": line})
2079
+
2080
+ def _step(self, line: str, step_id: str = "", action: str = "",
2081
+ fields: dict | None = None) -> int:
2082
+ """One tool action, announced in the conversation as a row that can be opened.
2083
+
2084
+ `engine.plan` announces an action through `progress` and `step` with the same line, so the
2085
+ strip and the stored log already have their row when this runs: this adds the conversation row
2086
+ and moves the status, and records no log row of its own. Two log rows for one action used to
2087
+ double the history of every tool call.
2088
+
2089
+ Consecutive repeats collapse because a small model reads the same file twice in a row often
2090
+ enough to fill the thread with one sentence, and a row that says nothing new costs the reader
2091
+ the same attention as one that does.
2092
+
2093
+ `action` and `fields` are what the row opens to. A row whose action has no stored detail of its
2094
+ own gets no chevron — an expander that opens onto nothing is worse than no expander. Returns
2095
+ the index of the row it added, or -1 when the line collapsed into the one before it.
2096
+ """
2097
+ index = -1
2098
+ with self._state:
2099
+ # The compare and the append are one decision: two workers announcing the same read in the
2100
+ # same tick must collapse to one row, and checking outside the lock would let both add one.
2101
+ if line != self._last_step:
2102
+ self._last_step = line
2103
+ self._add("tool", "Steps", line, step={
2104
+ "id": step_id or uuid.uuid4().hex[:8], "action": action,
2105
+ "fields": dict(fields or {}),
2106
+ "detail": step_has_detail(action, fields)})
2107
+ index = len(self.messages) - 1
2108
+ self._progress(line, record=False)
2109
+ return index
2110
+
2111
+ def _settle_run_step(self, result: dict) -> None:
2112
+ """Turn the "Executing …" row into what actually came back.
2113
+
2114
+ That row is the only place the command was named before it ran, and it is the row the user
2115
+ will look for afterwards. Left in the present tense it reads as a build still going, which is
2116
+ how a finished red run gets waited on.
2117
+ """
2118
+ index, self._run_step = self._run_step, -1
2119
+ with self._state:
2120
+ if not 0 <= index < len(self.messages):
2121
+ return
2122
+ message = self.messages[index]
2123
+ step = message.get("step") or {}
2124
+ if step.get("action") != "executing":
2125
+ return
2126
+ step["action"] = "executed"
2127
+ step["detail"] = step_has_detail("executed", step.get("fields"))
2128
+ message["text"] = executed_line(
2129
+ arabic=self.arabic, command=str(step.get("fields", {}).get("command", "")),
2130
+ verdict=run_verdict(self.arabic, result))
2131
+
2132
+ def open_step(self, step_id: str) -> None:
2133
+ """Open one step row, or close every one of them when the id is empty.
2134
+
2135
+ The detail is fetched rather than shipped. A stored run tail alone is 2 500 characters, and a
2136
+ task with a build per turn would carry all of them on every later snapshot — the same reason
2137
+ streamed output never reaches the server at all.
2138
+ """
2139
+ self.step_detail = None
2140
+ if not step_id:
2141
+ return
2142
+ missing = {"id": step_id, "sections": [], "files": [],
2143
+ "note": step_missing_line(arabic=self.arabic)}
2144
+ # A copy taken under the lock, then searched outside it: `_detail_for` reads the session on
2145
+ # disk, and no reason exists to hold the window's state while a file is being opened.
2146
+ with self._state:
2147
+ messages = list(self.messages)
2148
+ for message in reversed(messages):
2149
+ step = message.get("step") or {}
2150
+ if step.get("id") == step_id:
2151
+ block = self._detail_for(step)
2152
+ # A row that cannot answer still answers in words: a click that does nothing is the
2153
+ # dead-button failure this window already had once.
2154
+ self.step_detail = block or missing
2155
+ return
2156
+ # A row the server has never heard of means the page is behind the task.
2157
+ self.step_detail = missing
2158
+
2159
+ def open_graph(self) -> dict:
2160
+ """The module graph behind the Sources card, walked when the button is pressed.
2161
+
2162
+ It answers the click rather than riding the snapshot: building it walks the tree and parses every
2163
+ source file, and a snapshot goes out on every streamed log line. The reply travels as `result`,
2164
+ so nothing is stored and there is no stale copy to invalidate when the folder changes.
2165
+ """
2166
+ blank = {"nodes": [], "edges": [], "columns": 0, "cyclic": False, "hidden": 0,
2167
+ "caption": "", "note": graph_empty_line(arabic=self.arabic)}
2168
+ root = Path(self.repo) if self.repo else None
2169
+ if root is None or not root.is_dir():
2170
+ return blank
2171
+ try:
2172
+ _files, rows = Workspace(root).index()
2173
+ except (PolicyError, OSError):
2174
+ return blank
2175
+ data = symbols.graph(rows)
2176
+ if not data["nodes"]:
2177
+ return blank
2178
+ return {**data, "caption": graph_caption(
2179
+ self.arabic, nodes=len(data["nodes"]), edges=len(data["edges"]),
2180
+ cyclic=data["cyclic"], hidden=data["hidden"])}
2181
+
2182
+ def _detail_for(self, step: dict) -> dict | None:
2183
+ """The inside of one row, from the records this session already keeps."""
2184
+ action = str(step.get("action", ""))
2185
+ fields = step.get("fields") or {}
2186
+ session = self.session or {}
2187
+ sections: list[list] = []
2188
+ files: list[str] = []
2189
+ if action in {"executing", "executed"} or fields.get("command"):
2190
+ command = str(fields.get("command", ""))
2191
+ runs = session.get("runs") or []
2192
+ run = next((item for item in reversed(runs) if item.get("command") == command),
2193
+ runs[-1] if runs else None)
2194
+ if run is None:
2195
+ # The command ran and no session could hold its record — the D28 case, where the task
2196
+ # before it blocked or rolled back. The row still answers, with the sentence that says
2197
+ # why there is nothing else inside.
2198
+ return {"id": step.get("id", ""), "sections": [], "files": [],
2199
+ "note": run_unrecorded_line(arabic=self.arabic,
2200
+ project=Path(self.repo or ".").name)}
2201
+ sections = [[detail_section(self.arabic, "command"), [str(run.get("command", ""))]],
2202
+ [detail_section(self.arabic, "result"),
2203
+ [run_verdict(self.arabic, run),
2204
+ str(run.get("label") or run.get("recipe") or "")]],
2205
+ [detail_section(self.arabic, "problems"),
2206
+ [str(row)[:300] for row in run.get("failures") or []]],
2207
+ [detail_section(self.arabic, "output"),
2208
+ (str(run.get("tail") or "")).splitlines()]]
2209
+ elif action in {"propose", "applied"}:
2210
+ files = [str(name) for name in fields.get("names") or []]
2211
+ if not files:
2212
+ return None
2213
+ sections = []
2214
+ elif action == "search_code":
2215
+ sections = [[detail_section(self.arabic, "search"),
2216
+ [str(fields.get("query", "")),
2217
+ f"{fields.get('count', 0)} match(es)"]]]
2218
+ elif action == "list_files":
2219
+ sections = [[detail_section(self.arabic, "files"),
2220
+ [f"{fields.get('count', 0)} file(s) in the folder"]]]
2221
+ elif action == "read_file":
2222
+ # What a read row can add over its own sentence is *which version* the model saw — the
2223
+ # digest is the difference between "it read the file" and "it read the file as it stood
2224
+ # before the last write". Without it there is nothing to open, so no chevron.
2225
+ path = str(fields.get("path", ""))
2226
+ digest = next((str(item.get("sha256", ""))[:8]
2227
+ for item in reversed(session.get("events") or [])
2228
+ if item.get("kind") in {"tool", "auto_read"}
2229
+ and item.get("path") == path), "")
2230
+ if not digest:
2231
+ return None
2232
+ sections = [[detail_section(self.arabic, "read"), [path, "sha256 " + digest]]]
2233
+ elif action == "model_reasoning":
2234
+ words = str(fields.get("detail") or "")
2235
+ if not words:
2236
+ return None
2237
+ sections = [[detail_section(self.arabic, "reasoning"), words.splitlines()]]
2238
+ else:
2239
+ return None
2240
+ sections = [[title, [row for row in rows if str(row).strip()]]
2241
+ for title, rows in sections]
2242
+ sections = [[title, rows] for title, rows in sections if rows]
2243
+ if not sections and not files:
2244
+ return None
2245
+ return {"id": step.get("id", ""), "sections": sections, "files": files}
2246
+
2247
+ def _build_line(self, line: str) -> None:
2248
+ """One line of build output, streamed while the command is still running.
2249
+
2250
+ Redacted here rather than at display time: a test that prints its own connection
2251
+ string would otherwise reach the browser intact. These lines go on the SSE channel
2252
+ only — the stored log keeps the run's summary, so a 5 000-line build cannot bloat
2253
+ every later snapshot.
2254
+ """
2255
+ text = redact(line).strip()[:500]
2256
+ if not text:
2257
+ return
2258
+ self.pending = text
2259
+ self._stream(text, "log_chunk")
2260
+
2261
+ def _stream(self, text: str, kind: str) -> None:
2262
+ """One piece of untrusted output, already redacted, said on the SSE channel and nowhere else.
2263
+
2264
+ One owner because both consumers are the same promise with a different destination: build
2265
+ output goes to the live row, a model's answer goes to the bubble, and neither may reach the
2266
+ browser unscrubbed or unbounded. A snapshot would be wrong for both — the stored log keeps the
2267
+ run's summary, and a streamed answer is not finished yet.
2268
+ """
2269
+ self._emit({"kind": kind, "ts": _clock(), "text": text})
2270
+
2271
+ def _token(self, text: str) -> None:
2272
+ """One line of a model's answer, arriving while the rest of it is still being generated."""
2273
+ text = redact(text).strip()[:500]
2274
+ if text:
2275
+ self._stream(text, "token")
2276
+
2277
+ def start_chat(self, task: str, settings, cloud: bool, paid: bool, key: str | None,
2278
+ note: str = "") -> None:
2279
+ """Prose answer. With a bound project it may read that folder's map; never write to it.
2280
+
2281
+ `note` is a line the *mode* puts above its own answer. Read-only uses it for the refusal, which
2282
+ has to be part of the transcript — a status line is replaced by the next click, and "this
2283
+ conversation builds no proposal" is something the operator reads back afterwards.
2284
+ """
2285
+ if self.chat is None or self.chat.get("id") != self.chat_id:
2286
+ self.chat = create_chat(self.model or Settings().model, self.chat_id,
2287
+ project=self._chat_project())
2288
+ chat = self.chat
2289
+ self.session = self.session_path = None
2290
+ self.title = chat.get("title") or task.replace("\n", " ")[:45]
2291
+ self._add("user", "You", task)
2292
+ if note:
2293
+ self.line("tool", "Tool", note)
2294
+ repo = self.repo
2295
+
2296
+ def work():
2297
+ provider = make_provider(settings, allow_cloud=cloud,
2298
+ data_class="public" if cloud else "restricted",
2299
+ api_key=key, allow_paid=paid)
2300
+ # A stream is offered only to a model that says it can hold one, and the answer it produces
2301
+ # is the same one the buffered call returns: what arrives here is for the reader's sake.
2302
+ feed = LineFeed(self._token) if getattr(provider, "supports_stream", False) else None
2303
+ try:
2304
+ return respond(chat, provider, task, settings, self.chats,
2305
+ context=self._chat_context(repo),
2306
+ on_token=(feed.feed if feed else None))
2307
+ finally:
2308
+ if feed:
2309
+ feed.close()
2310
+
2311
+ def done(reply):
2312
+ self._add("assistant", "AI Code Engineer", reply)
2313
+ self.title = title_for(chat)
2314
+ self.status = (intent.answered(arabic=self.arabic) if self.reading_only() else
2315
+ "Answered. Switch to Change mode when you want reviewed changes to these files."
2316
+ if repo else
2317
+ "Answered. Choose a project when you want reviewed changes to real files.")
2318
+
2319
+ self.run_job(work, done, "Thinking…", on_busy=lambda: self.queue_add(task))
2320
+
2321
+ def _chat_project(self) -> dict | None:
2322
+ """What a new chat records as its home: the selected project, or nothing at all."""
2323
+ key = self.branch.get("key") or ""
2324
+ if self.branch.get("kind") != BRANCH_PROJECT or not key or not self.repo:
2325
+ return None
2326
+ return {"key": key, "path": str(Path(self.repo).resolve())}
2327
+
2328
+ def _chat_context(self, repo: str) -> str:
2329
+ """The repository map and standing notes, gathered here so a chat can read without tools.
2330
+
2331
+ Building the map means walking the folder, which is why the caller does it on the worker
2332
+ thread. A project that cannot be read answers with no context rather than an error: a
2333
+ question about a folder is not a reason to fail the question.
2334
+ """
2335
+ if not repo or not Path(repo).is_dir():
2336
+ return ""
2337
+ try:
2338
+ return context_block(Workspace(Path(repo)).repo_map(), self._project_notes())
2339
+ except (AgentError, OSError):
2340
+ return ""
2341
+
2342
+ def stop(self) -> None:
2343
+ with self._state:
2344
+ if self.busy and self.cancellable:
2345
+ self.cancel_event.set()
2346
+ self.status = shared_note("stop_requested", arabic=self.arabic)
2347
+ # A queue that carried on the instant this task ended would make the button mean
2348
+ # "skip to the next one". It holds; ▶ in the strip resumes it.
2349
+ self._queue_held = True
2350
+
2351
+ def _unverified_prior(self, chat_id: str, repo: str) -> dict | None:
2352
+ try:
2353
+ turns = chat_sessions(self.runs, repo, chat_id)
2354
+ except (AgentError, OSError):
2355
+ return None
2356
+ for _, item in reversed(turns):
2357
+ if item.get("state") in UNVERIFIED_STATES:
2358
+ return item
2359
+ return None
2360
+
2361
+ # --------------------------- apply / check / undo ---------------------------
2362
+ def approval_notice(self, session: dict | None = None) -> str:
2363
+ """The dialog text both windows show, from one owner: the removals and the unexpected files."""
2364
+ return repair.approval_advisories(session if session is not None else self.session)
2365
+
2366
+ def offer_block(self, payload: dict) -> None:
2367
+ """Turn one code block from an answer into a proposal, exactly like a model would.
2368
+
2369
+ The browser sends the file name it saw in the block's header and the text it is
2370
+ already showing; neither is trusted. The path goes through the same workspace
2371
+ policy as a model's proposal, the content is re-checked for syntax and size, and
2372
+ what comes back is a WAITING_APPROVAL session — so the Apply dialog, the hash
2373
+ guard and rollback all mean what they usually mean.
2374
+ """
2375
+ if self.busy or not self.repo:
2376
+ self.status = "Choose a project before applying a block to a file."
2377
+ return
2378
+ if self.reading_only():
2379
+ # The answer above may show a block in full, and this is the click that would turn it into
2380
+ # a proposal — so it is refused by name, not by the block being hidden.
2381
+ self.say(intent.no_proposal(arabic=self.arabic))
2382
+ return
2383
+ name = str(payload.get("path", ""))[:240]
2384
+ content = str(payload.get("content", ""))
2385
+ task = ("Write " + name + " from a code block in the chat answer")[:MAX_TASK_CHARS]
2386
+ repo, chat_id, model = self.repo, self.chat_id, self.model
2387
+
2388
+ def work():
2389
+ try:
2390
+ return propose_block(Workspace(Path(repo)), task, name, content, self.runs,
2391
+ chat_id=chat_id, model=model or "user")
2392
+ except PolicyError as exc:
2393
+ # friendly_error turns a bare "unchanged file" into advice about the Chat badge.
2394
+ # That is right for a message and nonsense for a block the user just clicked, so
2395
+ # the refusal is re-said in block terms before it reaches the status line.
2396
+ reason = str(exc)
2397
+ reason = ("it holds the same text the file already holds"
2398
+ if "unchanged file" in reason else reason)
2399
+ raise AgentError("That block could not become a proposal: " + reason) from None
2400
+
2401
+ def done(path):
2402
+ self.display_session(path)
2403
+ self.status = ("That block is a proposal now. Review the diff, then Apply to write it.")
2404
+ self._emit({"kind": "view", "value": "preview"})
2405
+
2406
+ self.run_job(work, done, "Turning that block into a reviewed proposal...")
2407
+
2408
+ def apply(self) -> None:
2409
+ if self.busy or not self.session or self.session.get("state") != "WAITING_APPROVAL":
2410
+ return
2411
+ if self.reading_only():
2412
+ # A proposal built before the badge moved is still on the screen, and the mode is a
2413
+ # position about this folder — not about whether a diff happens to be rendered right now.
2414
+ self.say(self.write_refusal("Apply"))
2415
+ return
2416
+ changes = self.session.get("changes", [])
2417
+ again = ""
2418
+ if self._auto_fix and self.selected_recipe():
2419
+ again = shared_note("apply_rerun_warning", arabic=self.arabic,
2420
+ label=runner.RECIPES[self.selected_recipe()]["label"])
2421
+ notice = self.approval_notice()
2422
+ prior = self._unverified_prior(self.chat_id, self.repo or "")
2423
+ # `must_ask` is the whole refusal rule, and it is the same one the switch's own tooltip
2424
+ # describes: an emptying proposal, or a folder a previous task left half-written.
2425
+ reason = repair.must_ask(self.session, prior)
2426
+ self.wrote_without_asking = bool(self.auto_apply and not reason)
2427
+ self.auto_banner = len(changes) if self.wrote_without_asking else 0
2428
+ if not self.wrote_without_asking:
2429
+ prompt = host.apply_prompt(self.session, notice=notice, reason=reason, again=again)
2430
+ if not self.confirm(prompt["title"], prompt["message"], prompt["warning"],
2431
+ prompt["ok_label"]):
2432
+ return
2433
+ path, approved = self.session_path, self.session["proposal_hash"]
2434
+ self.run_job(lambda: apply_proposal(path, approved), self._applied, "Applying the changes you reviewed…")
2435
+
2436
+ def auto_apply_ready(self) -> None:
2437
+ """Write a proposal the moment it lands, for a folder whose switch is on.
2438
+
2439
+ Called by the jobs that can produce a proposal. It is deliberately not wired to the
2440
+ Apply-to-File button: that click already is the request, and what Auto-Apply removes
2441
+ is the click that follows a model's work. `apply` still asks when a proposal empties
2442
+ an existing file, so the switch never writes something it would have to explain.
2443
+ """
2444
+ if self.auto_apply and self.session and self.session.get("changes"):
2445
+ self.apply()
2446
+
2447
+ def _checkpoint(self) -> None:
2448
+ """Commit the files this task just wrote, when that folder is under git anyway.
2449
+
2450
+ The commit is the last step of a write that already happened, so a failure here is
2451
+ reported and forgotten: the change is on disk, and the in-session rollback is
2452
+ unaffected. Only the proposal's own paths are staged — an unrelated edit in the
2453
+ same working tree is nobody's business to commit.
2454
+ """
2455
+ if not self.repo or not self.session:
2456
+ return
2457
+ if not git_integration.status(self.repo)["repo"]:
2458
+ return # not a git folder: nothing to say about a checkpoint
2459
+ paths = [change.get("path", "") for change in self.session.get("changes", [])]
2460
+ outcome = git_integration.checkpoint(self.repo, self.session.get("task", ""),
2461
+ self.session.get("id", ""), paths)
2462
+ if outcome["ok"]:
2463
+ # The parent of this commit is the only hash that means "before this task" for these
2464
+ # paths, and it is what a later refused rollback can offer to restore from.
2465
+ self._task_commit = outcome["before"]
2466
+ self.status = ("Applied, and committed " + str(len(paths)) +
2467
+ " file(s) to git as " + outcome["hash"] +
2468
+ ". Roll back from this card, or with git.")
2469
+ self._add("tool", "Tool",
2470
+ checkpoint_note(arabic=self.arabic, count=len(paths), sha=outcome["hash"]))
2471
+ else:
2472
+ self._add("tool", "Tool",
2473
+ no_checkpoint_note(arabic=self.arabic, reason=outcome["reason"][:200]))
2474
+
2475
+ def _applied(self, _result) -> None:
2476
+ git_integration.forget()
2477
+ self.display_session(self.session_path)
2478
+ self._checkpoint()
2479
+ automatic, self.wrote_without_asking = self.wrote_without_asking, False
2480
+ count = len(self.session.get("changes", [])) if self.session else 0
2481
+ removed = sum(1 for change in ((self.session or {}).get("changes") or [])
2482
+ if change.get("delete"))
2483
+ if self._batch_row is not None:
2484
+ # The batch ledger counts what landed, not what was asked: a fix round that writes the
2485
+ # same path twice is one file in the user's folder, and the summary dedupes on read.
2486
+ self._batch_row["paths"].extend(change["path"] for change in
2487
+ ((self.session or {}).get("changes") or []))
2488
+ if not automatic:
2489
+ # The click happened, and this is the record of what it did. Before this the only trace
2490
+ # of a manual apply in the conversation was the git row, so a folder outside git looked
2491
+ # like nothing had happened at all. It carries a step handle so the row opens onto the
2492
+ # files it wrote — the paths are the one thing the sentence itself leaves out.
2493
+ written = [change["path"] for change in ((self.session or {}).get("changes") or [])]
2494
+ self._add("tool", "Tool", applied_line(arabic=self.arabic, count=count, removed=removed),
2495
+ step={"id": uuid.uuid4().hex[:8], "action": "applied",
2496
+ "fields": {"count": count, "names": written},
2497
+ "detail": step_has_detail("applied", {"names": written})})
2498
+ if automatic:
2499
+ self.status = ("\u2705 Applied changes to " + str(count) +
2500
+ " file(s) automatically. Roll back from this card.")
2501
+ self.auto_banner = count
2502
+ # The notice carries the model's own summary: for a write nobody clicked for, that is
2503
+ # the first place the user reads what actually happened to their files.
2504
+ lines, total, rewrote = diff_size((self.session or {}).get("changes") or [])
2505
+ self._add("tool", "Tool", write_notice(arabic=self.arabic, count=count,
2506
+ summary=(self.session or {}).get("summary") or "",
2507
+ lines=lines, total=total, rewrote=rewrote))
2508
+ if not self._auto_fix:
2509
+ # A write nobody clicked for still has to be followed by the run that a clicked
2510
+ # apply would have had. The branch's selection can be empty while the folder does
2511
+ # have a command the tool detected — clearing it is a real state, and every card
2512
+ # after it said "tests have not run". Fall back to the detected command instead of
2513
+ # skipping the check: runner.detect() listed it, so nothing here is invented.
2514
+ if self.recipes:
2515
+ if self.selected_recipe() is None:
2516
+ self.recipe = runner.RECIPES[self.recipes[0]]["label"]
2517
+ self.run_tests(False)
2518
+ return
2519
+ self.status = status_text("applied_no_command", arabic=self.arabic)
2520
+ return
2521
+ if self._auto_fix:
2522
+ self.status = status_text("applied_rerun", arabic=self.arabic)
2523
+ self.run_tests(False)
2524
+ return
2525
+ self.status = status_text("applied_idle", arabic=self.arabic)
2526
+
2527
+ def check_changes(self) -> None:
2528
+ if self.busy or not self.session or self.session.get("state") not in MUTABLE_STATES:
2529
+ return
2530
+ path = self.session_path
2531
+
2532
+ def done(result):
2533
+ self.display_session(path)
2534
+ self.status = shared_note("syntax_failed" if result["status"] == "failed"
2535
+ else "syntax_clean", arabic=self.arabic)
2536
+
2537
+ self.run_job(lambda: verify(path), done, "Checking syntax in changed files…")
2538
+
2539
+ def undo(self) -> None:
2540
+ if self.busy:
2541
+ return
2542
+ if self.reading_only():
2543
+ # Rolling back is a write with a friendly name: it puts different bytes on the same paths.
2544
+ self.say(self.write_refusal("Roll back"))
2545
+ return
2546
+ if not self.session or self.session.get("state") not in MUTABLE_STATES | {"PARTIAL_APPLY", "APPLYING"}:
2547
+ return
2548
+ if not self.confirm("Roll back changes",
2549
+ "Restore this task's files to their previous contents?\nLater edits will block "
2550
+ "rollback to protect your work.", ok_label="Roll back"):
2551
+ return
2552
+ path, approved = self.session_path, self.session["proposal_hash"]
2553
+ # The refusal this can answer is the one about a later edit, and only where this program made
2554
+ # a commit that means "before this task". With nothing to escalate to, the old path is kept
2555
+ # exactly as it was: the failure row is the right answer, not a dead end with a button on it.
2556
+ escalatable = bool(self._task_commit and self.repo and self.session.get("changes"))
2557
+
2558
+ def operation():
2559
+ if not escalatable:
2560
+ return rollback(path, approved)
2561
+ try:
2562
+ return rollback(path, approved)
2563
+ except PolicyError as exc:
2564
+ if "Rollback would overwrite a later edit" not in str(exc):
2565
+ raise
2566
+ return {"later_edit": friendly_error(exc)}
2567
+
2568
+ def done(result):
2569
+ if isinstance(result, dict) and result.get("later_edit"):
2570
+ self.status = "Rollback refused: the files changed after this task wrote them."
2571
+ self._git_restore = {"commit": self._task_commit, "paths": [
2572
+ change.get("path", "") for change in self.session.get("changes", [])]}
2573
+ self._add("tool", "Tool", restore_offer(
2574
+ arabic=self.arabic, commit=self._task_commit,
2575
+ count=len(self._git_restore["paths"])))
2576
+ return
2577
+ git_integration.forget()
2578
+ self.auto_banner = 0
2579
+ self.display_session(path)
2580
+ self.status = status_text("rolled_back", arabic=self.arabic)
2581
+ self._git_restore = {}
2582
+ step_id = self.session.get("plan_step") if self.session else None
2583
+ pair = self.ledger_for(self.session) if step_id is not None and self.session else None
2584
+ if pair:
2585
+ try:
2586
+ planbook.reopen(pair[0], pair[1], step_id)
2587
+ except (AgentError, OSError) as exc:
2588
+ self.status = status_text("ledger_needs_look", arabic=self.arabic) + friendly_error(exc)
2589
+ return
2590
+ self.refresh_plan_status()
2591
+ self._add("tool", "Tool", shared_note("step_reopened", arabic=self.arabic, step=step_id))
2592
+
2593
+ self.run_job(operation, done, "Rolling back changes…")
2594
+
2595
+ def git_restore(self) -> None:
2596
+ """Put this task's own files back to the commit made before it wrote them.
2597
+
2598
+ This is the escalation, not a second undo, and the two cannot disagree about anything: it only
2599
+ appears after the session rollback refused, it can only name the files that proposal wrote, and
2600
+ it only knows a hash this program committed itself. `git reset` is not used at any point — it
2601
+ would take the rest of the working tree with it, including work no proposal ever proposed.
2602
+ """
2603
+ offer = dict(self._git_restore or {})
2604
+ paths = [name for name in offer.get("paths", []) if name]
2605
+ if self.busy or not paths or not self.repo:
2606
+ return
2607
+ if self.reading_only():
2608
+ # The escalation is the larger write, so it cannot be the door left open: this replaces
2609
+ # bytes on disk from a commit, which is exactly what the position in front of it refuses.
2610
+ self.say(self.write_refusal("Restoring files from git"))
2611
+ return
2612
+ commit = offer.get("commit", "")
2613
+ if not self.confirm("Restore these files from git",
2614
+ "Replace what is in " + str(len(paths)) + " file(s) now with the copy in"
2615
+ " commit " + commit + "?\nThat overwrites any edit made after the task"
2616
+ " wrote them. Nothing outside this list is touched.",
2617
+ ok_label="Restore " + str(len(paths)) + " file(s)"):
2618
+ return
2619
+
2620
+ def done(outcome):
2621
+ self._git_restore = {}
2622
+ restored, skipped = outcome.get("restored", []), outcome.get("skipped", [])
2623
+ git_integration.forget(self.repo)
2624
+ self.display_session(self.session_path)
2625
+ if not outcome.get("ok"):
2626
+ self.status = "git restored nothing."
2627
+ self._add("tool", "Tool",
2628
+ no_branch_note(arabic=self.arabic,
2629
+ reason=str(outcome.get("reason") or "git said nothing")[:200]))
2630
+ return
2631
+ self.status = ("Restored " + str(len(restored)) + " file(s) from " + commit + ".")
2632
+ self._add("tool", "Tool", restore_done(arabic=self.arabic, commit=commit,
2633
+ restored=restored, skipped=skipped))
2634
+
2635
+ self.run_job(lambda: git_integration.restore_paths(self.repo, commit, paths), done,
2636
+ "Asking git for the earlier copies…")
2637
+
2638
+ def git_branch(self, back: str = "") -> None:
2639
+ """Start a git branch for this task, or return to the branch this window came from.
2640
+
2641
+ Moving HEAD is the largest claim this window can make on a working tree, so it happens only
2642
+ on this click, only after a confirmation that names the branch, and only through
2643
+ `git_integration`'s two non-destructive forms: `checkout -b`, which carries the uncommitted
2644
+ work with it, and an unforced switch, which git refuses rather than overwrite. The reason to
2645
+ offer it at all is a folder where every milestone is committed — without a branch of its own,
2646
+ that history lands on whatever branch the developer happened to be standing on.
2647
+ """
2648
+ if self.busy or not self.repo:
2649
+ return
2650
+ if self.reading_only():
2651
+ # A branch switch is not a read: git rewrites the tracked files to match the commit you
2652
+ # move to, which is a larger change than any proposal this window would have asked about.
2653
+ self.say(self.write_refusal("Switching branches"))
2654
+ return
2655
+ states = git_integration.status(self.repo)
2656
+ if not states["repo"]:
2657
+ self.status = "This folder is not a git repository, so there is no branch to change."
2658
+ return
2659
+ target = str(back or "").strip()
2660
+ task = (self.session or {}).get("task") or self._draft
2661
+ tail = (self.session or {}).get("id") if self.session else self.chat_id
2662
+ name = git_integration.task_branch_name(task, tail or self.chat_id)
2663
+ if target:
2664
+ ask, body, ok = ("Return to a git branch",
2665
+ "Move HEAD back to " + target + "?\nNothing is forced: git refuses if a"
2666
+ " file here would be overwritten, and that refusal is reported.",
2667
+ "Go to " + target)
2668
+ else:
2669
+ ask, body, ok = ("Start a git branch for this task",
2670
+ "Create and switch to " + name + "?\nThe uncommitted work moves with it,"
2671
+ " and this task's commits land there instead of on " +
2672
+ (states["branch"] or "a detached HEAD") + ".",
2673
+ "Create branch")
2674
+ if not self.confirm(ask, body, ok_label=ok):
2675
+ return
2676
+
2677
+ def done(outcome):
2678
+ reason = str(outcome.get("reason") or "")[:200]
2679
+ if not outcome.get("ok"):
2680
+ self.status = "git did not change the branch."
2681
+ self._add("tool", "Tool",
2682
+ no_branch_note(arabic=self.arabic, reason=reason or "git said nothing"))
2683
+ return
2684
+ moved = outcome.get("branch") or ""
2685
+ if outcome.get("created"):
2686
+ self._git_base = outcome.get("from") or ""
2687
+ if moved == self._git_base:
2688
+ self._git_base = "" # back where we started, so there is nothing to return to
2689
+ self.status = ("On " + moved + " — this task's commits land here."
2690
+ if outcome.get("created") else "On " + moved + ".")
2691
+ if outcome.get("created"):
2692
+ text = branch_started(arabic=self.arabic, branch=moved,
2693
+ back=outcome.get("from") or "")
2694
+ elif reason.startswith("already on"):
2695
+ text = no_branch_note(arabic=self.arabic, reason=reason)
2696
+ else:
2697
+ text = branch_switched(arabic=self.arabic, branch=moved)
2698
+ self._add("tool", "Tool", text)
2699
+
2700
+ self.run_job((lambda: git_integration.switch_branch(self.repo, target)) if target
2701
+ else (lambda: git_integration.start_task_branch(self.repo, task, tail or self.chat_id)),
2702
+ done, "Asking git about branches…")
2703
+
2704
+ def refresh_recipes(self) -> None:
2705
+ """Which folders in this project have a command, and what each one answers to.
2706
+
2707
+ A single-project folder produces one target and the picker is not shown; a reactor or a
2708
+ monorepo produces one per module, because `backend/pom.xml` was always invisible to a scan
2709
+ that only looked at the opened folder's own children.
2710
+ """
2711
+ try:
2712
+ self.targets = (runner.targets(Path(self.repo))
2713
+ if self.repo and Path(self.repo).is_dir() else [])
2714
+ self._targets_root = str(Path(self.repo).resolve()) if self.targets else ""
2715
+ except OSError:
2716
+ self.targets, self._targets_root = [], ""
2717
+ if not any(row["path"] == self.target for row in self.targets):
2718
+ self.target = self.targets[0]["path"] if self.targets else ""
2719
+ self.recipes = next((row["recipes"] for row in self.targets if row["path"] == self.target), [])
2720
+ labels = [runner.RECIPES[name]["label"] for name in self.recipes]
2721
+ if self.recipe not in labels:
2722
+ self.recipe = labels[0] if labels else ""
2723
+
2724
+ def set_target(self, label: str) -> None:
2725
+ """Choose which module of this project the command runs in — by the label it was shown as."""
2726
+ chosen = next((row for row in self.targets if row["label"] == label), None)
2727
+ if chosen and chosen["path"] != self.target:
2728
+ self.target = chosen["path"]
2729
+ self.refresh_recipes()
2730
+
2731
+ def target_label(self) -> str:
2732
+ return next((row["label"] for row in self.targets if row["path"] == self.target), "")
2733
+
2734
+ def selected_recipe(self) -> str | None:
2735
+ labels = [runner.RECIPES[name]["label"] for name in self.recipes]
2736
+ try:
2737
+ return self.recipes[labels.index(self.recipe)]
2738
+ except ValueError:
2739
+ return None
2740
+
2741
+ # The only states that lock the Run control: work in front of the user that has not been written
2742
+ # yet. Every other state -- including a task that blocked, cancelled or rolled back, and a folder
2743
+ # with no task at all -- has files on disk that a project command can still be pointed at, which
2744
+ # is what D28 was about: the build used to be unreachable exactly when the last task went wrong.
2745
+ RUN_LOCKED_STATES = {"DISCOVERING", "WAITING_APPROVAL"}
2746
+
2747
+ def _can_run(self) -> bool:
2748
+ state = self.session.get("state") if self.session else ""
2749
+ return (not self.busy and bool(self.recipes) and bool(self.repo)
2750
+ and Path(self.repo).is_dir() and state not in self.RUN_LOCKED_STATES)
2751
+
2752
+ def sandbox_info(self) -> dict:
2753
+ """The container choice as the card draws it: the two values, whether this machine can honour
2754
+ them, and the one sentence saying what pressing Run will do now.
2755
+
2756
+ The sentence is the same four answers the desktop window gives, out of `labels`, so a user who
2757
+ switches windows is not answering a different question.
2758
+ """
2759
+ image = self.sandbox_image.strip()
2760
+ available = runner.sandbox_available()
2761
+ return {"on": bool(self.sandbox_on and available), "image": image, "available": available,
2762
+ "note": shared_note(runner.sandbox_state(self.sandbox_on, image, available),
2763
+ arabic=self.arabic)}
2764
+
2765
+ def set_sandbox(self, payload: dict) -> None:
2766
+ """A tick, a typed digest, or both — the card sends what it holds and the answer comes back."""
2767
+ if "on" in payload:
2768
+ self.sandbox_on = bool(payload.get("on"))
2769
+ if "image" in payload:
2770
+ self.sandbox_image = str(payload.get("image") or "")
2771
+ self._save_state()
2772
+
2773
+ def run_tests(self, auto_fix: bool = False) -> None:
2774
+ if self.busy:
2775
+ return
2776
+ state = self.session.get("state") if self.session else ""
2777
+ if state in self.RUN_LOCKED_STATES or not (self.session or self.repo.strip()):
2778
+ self.status = status_text("apply_first", arabic=self.arabic)
2779
+ return
2780
+ recipe = self.selected_recipe()
2781
+ if recipe is None:
2782
+ self.status = status_text("no_recipe", arabic=self.arabic)
2783
+ return
2784
+ # The folder comes from the task when there is one and from the window when the last task
2785
+ # blocked or rolled back. A run is recorded on the session only when that session can hold
2786
+ # it -- `repair.record_run` refuses any state outside its own set, and writing into one of
2787
+ # those would rewrite a finished task's verdict.
2788
+ repo = self.session["root"] if self.session else self.repo.strip()
2789
+ # Read at the click, not kept on the window: unticking the box has to stop the container run,
2790
+ # and an image typed while it was unticked is not an answer to anything.
2791
+ sandbox = self.sandbox_image.strip() if self.sandbox_on else ""
2792
+ command = runner.display_command(recipe) + (" in Docker" if sandbox else "")
2793
+ if self.reading_only():
2794
+ # Reading a folder is not running inside it, and this is the only action in the mode that
2795
+ # can execute anything: a build runs whatever its own scripts do. So it asks once per
2796
+ # command, and names the command the answer is about.
2797
+ if not self.ask("Run this command?", intent.run_ask(
2798
+ command, arabic=self.arabic, project=Path(repo).name), ok_label="Run it"):
2799
+ self.say(intent.run_declined(arabic=self.arabic))
2800
+ return
2801
+ # What it never runs is a fix round, because the output of a round is a proposal. That is
2802
+ # said where the loop would have started — `_offer_fix`, on a real failure — rather than as
2803
+ # a warning about something that may not happen.
2804
+ auto_fix = False
2805
+ self._auto_fix = bool(auto_fix)
2806
+ if auto_fix:
2807
+ self._fix_round = 0
2808
+ # The module list was scanned from the window's folder. A task pointed somewhere else gets
2809
+ # its own root and no module, rather than a path resolved against the wrong tree.
2810
+ target = self.target if str(Path(repo).resolve()) == self._targets_root else "."
2811
+ path = self.session_path
2812
+ recordable = bool(path) and state in MUTABLE_STATES
2813
+ label = runner.RECIPES[recipe]["label"]
2814
+ where = Path(repo).name if target in ("", ".") else PurePosixPath(target).name
2815
+ # Say the command before running it, not only after it fails: a project's own build
2816
+ # executes code the repository defines, and this is the last line worth reading first.
2817
+ self._run_step = self._step(executing_line(arabic=self.arabic, command=command),
2818
+ action="executing", fields={"command": command})
2819
+
2820
+ def work():
2821
+ result = runner.run(Path(repo), recipe, timeout=runner.timeout_for(recipe),
2822
+ progress=self._build_line, target=target, sandbox=sandbox)
2823
+ return (repair.record_run(path, result) if recordable else None), result
2824
+
2825
+ def done(pair):
2826
+ if pair[0] is not None:
2827
+ self.display_session(path)
2828
+ self.report_run(pair[1])
2829
+ if pair[0] is None:
2830
+ # A run that no session can hold is still a run the operator asked for and paid
2831
+ # for; saying nothing would leave the folder's state to a scrollback line.
2832
+ self._add("tool", "Checks",
2833
+ run_unrecorded_line(arabic=self.arabic, project=Path(repo).name))
2834
+
2835
+ self.run_job(work, done, "Running %s in %s%s…" % (label, where, " Docker" if sandbox else ""))
2836
+
2837
+ def report_run(self, result) -> None:
2838
+ summary = runner.summarize(result)
2839
+ self.run_info = summary
2840
+ self._settle_run_step(result)
2841
+ if result["status"] == "passed":
2842
+ self._add("tool", "Checks", "✅ " + summary)
2843
+ self._auto_fix = False
2844
+ self.status = status_text("command_passed", arabic=self.arabic) + summary
2845
+ else:
2846
+ # A tool row is plain text — the thread escapes it and never runs markdown — and the
2847
+ # pill collapses newlines, so this line has to read as one sentence with no markup.
2848
+ # And this row is built from the raw runner result rather than the slimmed record the
2849
+ # session stores, and `runner` does not scrub: the two other paths that show command
2850
+ # output redact it (`repair.record_run` for storage and the model, `_build_line` for the
2851
+ # stream). A test that prints its own connection string must not find the chat to do it
2852
+ # in — every row here can be copied out with one click.
2853
+ msg = "❌ " + summary + " — command: " + redact(str(result.get("command", "")))
2854
+ failures = result.get("failures") or []
2855
+ if failures:
2856
+ msg += (" · Errors: " + " | ".join(redact(str(f).strip())
2857
+ for f in failures[:repair.FAILURES_KEPT]))
2858
+ elif result.get("reason"):
2859
+ msg += " · Reason: " + redact(str(result["reason"]))
2860
+ elif result.get("tail"):
2861
+ err_lines = [l.strip() for l in result["tail"].strip().splitlines()
2862
+ if "[ERROR]" in l or "Error" in l or "Exception" in l]
2863
+ sample = err_lines[-6:] or [l.strip() for l in result["tail"].strip().splitlines()][-6:]
2864
+ if sample:
2865
+ msg += " · Output: " + " | ".join(redact(line) for line in sample)
2866
+ msg += " · The full output is in Activity."
2867
+ self._add("tool", "Checks", msg)
2868
+ if self._auto_fix and result["status"] in {"failed", "timeout"}:
2869
+ self.ask_for_fix(result)
2870
+ return
2871
+ self._auto_fix = False
2872
+ self.status = summary + " — the captured output is in the Checks tab."
2873
+ if result["status"] in {"failed", "timeout"} and self._offer_fix(result):
2874
+ return
2875
+ self.advance_plan(result)
2876
+
2877
+ def round_history(self) -> list:
2878
+ """Every attempt in this chat and folder, oldest first. `repair` reads the record; this window
2879
+ only knows where its own chat lives."""
2880
+ if not self.session:
2881
+ return []
2882
+ return repair.round_history(self.runs, self.session["root"], self.chat_id)
2883
+
2884
+ def fix_rounds(self) -> list:
2885
+ """The attempt rows the offer, the stop line and the report all read: what ran, what it cost."""
2886
+ if not self.session:
2887
+ return []
2888
+ return repair.attempts_of(self.runs, self.session["root"], self.chat_id)
2889
+
2890
+ def _show_rounds(self) -> None:
2891
+ """Put the attempts on record where they stay readable after the round counter has moved on."""
2892
+ block = repair.timeline(self.fix_rounds())
2893
+ if block:
2894
+ self.line("tool", "Tool", block)
2895
+
2896
+ def stop_fix_loop(self, reason: str) -> None:
2897
+ """The one answer both windows give when the loop has no reason to spend another turn.
2898
+
2899
+ The two sentences are `repair`'s, because the Tk window reaches the same decision from a
2900
+ different place and used to word it differently on the way.
2901
+ """
2902
+ self._auto_fix = False
2903
+ self.line("tool", "Tool", repair.stop_line(reason))
2904
+ self.say(repair.stop_status(reason))
2905
+ self._show_rounds()
2906
+
2907
+ def _offer_fix(self, run) -> bool:
2908
+ """Ask before spending a turn. Continuing used to be a status line to interpret.
2909
+
2910
+ Nothing is written here either: the round produces a proposal, and Apply stays a
2911
+ separate, deliberate click.
2912
+ """
2913
+ if self.reading_only():
2914
+ # Reached from `report_run` after a command the user agreed to run. The round this offers
2915
+ # ends in a proposal, so the offer is not made — the failure stands in the transcript on
2916
+ # its own, which is what this mode was asked for.
2917
+ self.line("tool", "Tool", intent.no_fix_round(arabic=self.arabic))
2918
+ return False
2919
+ if not self.model:
2920
+ return False
2921
+ if not self.session or self.session.get("state") not in MUTABLE_STATES:
2922
+ # A fix round repairs *a task's* proposal, so it needs one to sit on. The run itself no
2923
+ # longer does — that is D28 -- and offering to fix what cannot be recorded would send the
2924
+ # model a task with no folder to change.
2925
+ return False
2926
+ if self._batch_fixes_off:
2927
+ # Suppressed first: the operator said once that no further round is wanted in this batch,
2928
+ # and a batch of red builds is not the place to argue about why each one is hopeless too.
2929
+ self._add("tool", "Tool", fix_offers_off_line(arabic=self.arabic))
2930
+ return False
2931
+ stop, reason = repair.should_stop(self.round_history(), self._fix_round)
2932
+ if stop:
2933
+ # D42's other half: three rounds that leave the same seven failures standing are not
2934
+ # progress, and asking the user to spend another model turn to find that out again is.
2935
+ self.stop_fix_loop(reason)
2936
+ return False
2937
+ answer = self.confirm_choice(repair.FIX_OFFER_TITLE,
2938
+ repair.fix_offer(self.model, self._fix_round + 1, self.auto_apply,
2939
+ history=self.fix_rounds()),
2940
+ ok_label="Run the fix round", alt_label=repair.FIX_OFFER_ALT)
2941
+ if answer.get("alt"):
2942
+ self._batch_fixes_off = True
2943
+ self._add("tool", "Tool", fix_offers_off_line(arabic=self.arabic))
2944
+ return False
2945
+ if not answer.get("ok"):
2946
+ return False
2947
+ self._auto_fix = True
2948
+ self.ask_for_fix(run)
2949
+ return True
2950
+
2951
+ def ask_for_fix(self, run) -> None:
2952
+ stop, reason = repair.should_stop(self.round_history(), self._fix_round)
2953
+ if stop:
2954
+ self.stop_fix_loop(reason)
2955
+ return
2956
+ cloud, paid = self.cloud_choice()
2957
+ if cloud and not self.cloud_ok:
2958
+ self._auto_fix = False
2959
+ self.status = status_text("consent_project", arabic=self.arabic)
2960
+ return
2961
+ settings = self.task_settings(cloud)
2962
+ if settings is None:
2963
+ self._auto_fix = False
2964
+ return
2965
+ self._fix_round += 1
2966
+ repo, chat_id = self.session["root"], self.chat_id
2967
+ reference, step_id = self.session.get("plan_reference"), self.session.get("plan_step")
2968
+ plan_file = str(Path(repo) / reference["path"]) if reference and step_id is not None else None
2969
+ task, evidence = repair.fix_task(run), repair.evidence(run)
2970
+ notes = self._project_notes()
2971
+ # The category belongs in the line the user reads, because "asking for a fix" and "asking for
2972
+ # a fix to a dependency the machine cannot resolve" are different odds of working.
2973
+ status = repair.round_status(settings.model, self._fix_round, repair.classify(run))
2974
+
2975
+ def work():
2976
+ provider = make_provider(settings, allow_cloud=cloud,
2977
+ data_class="public" if cloud else "restricted",
2978
+ api_key=self.key.strip() or None, allow_paid=paid)
2979
+ return plan(Workspace(Path(repo)), task, provider, settings, self.runs,
2980
+ progress=lambda line: self._progress(line),
2981
+ step=self._step, cancelled=self.cancel_event.is_set,
2982
+ chat_id=chat_id, extra_context=evidence, plan_file=plan_file,
2983
+ plan_step=step_id if plan_file else None, memory=notes)
2984
+
2985
+ def done(path):
2986
+ self.display_session(path)
2987
+ # The proposal exists now, so this is the first moment its *contents* can be compared
2988
+ # with the previous rounds'. A second attempt that writes the same files with the same
2989
+ # text is not a second attempt, and counting it as one is how a loop reads as progress.
2990
+ earlier = [item for item in self.round_history() if item.get("id") != path.parent.name]
2991
+ if repair.already_tried(earlier, (self.session or {}).get("changes") or []):
2992
+ self._auto_fix = False
2993
+ self.line("tool", "Tool", "🔁 " + repair.REPEATED_PROPOSAL)
2994
+ self.say(repair.REPEATED_ADVICE)
2995
+ self._show_rounds()
2996
+ return
2997
+ if plan_file:
2998
+ try:
2999
+ pair = self.ledger_for(self.session)
3000
+ if pair:
3001
+ planbook.record_session(pair[0], pair[1], step_id, path.parent.name)
3002
+ except (AgentError, OSError) as exc:
3003
+ self._add("tool", "Tool", "The plan ledger was not updated: " + friendly_error(exc))
3004
+ self._emit({"kind": "view", "value": "preview"})
3005
+ self.status = status_text("fix_ready", arabic=self.arabic)
3006
+ self.auto_apply_ready()
3007
+
3008
+ self.run_job(work, done, status, cancellable=True)
3009
+
3010
+ # ------------------------------ plan ledger ------------------------------
3011
+ def ledger_for(self, session: dict) -> tuple[Path, dict] | None:
3012
+ reference, step_id = session.get("plan_reference"), session.get("plan_step")
3013
+ if not reference or step_id is None:
3014
+ return None
3015
+ try:
3016
+ path, book = planbook.open_book(self.plans, Workspace(Path(session["root"])), reference["path"])
3017
+ except (AgentError, OSError):
3018
+ return None
3019
+ if book.get("plan_sha256") != reference["sha256"]:
3020
+ return None
3021
+ self.ledger_path, self.ledger = path, book
3022
+ return path, book
3023
+
3024
+ def advance_plan(self, result) -> None:
3025
+ session = self.session
3026
+ if session is None:
3027
+ return
3028
+ pair = self.ledger_for(session)
3029
+ if pair is None:
3030
+ return
3031
+ path, book = pair
3032
+ step_id = session["plan_step"]
3033
+ row = planbook.step(book, step_id)
3034
+ if row is None or row["status"] == "verified":
3035
+ return
3036
+ if result["status"] != "passed":
3037
+ self.status = (planbook.progress_line(book) + " — step " + str(step_id) +
3038
+ " stays open until a command run proves it.")
3039
+ return
3040
+ try:
3041
+ planbook.complete(path, book, step_id, session)
3042
+ except (AgentError, OSError) as exc:
3043
+ self._add("tool", "Tool", shared_note("step_open_detail", arabic=self.arabic, step=step_id,
3044
+ reason=friendly_error(exc)))
3045
+ self.status = shared_note("step_still_open", arabic=self.arabic, step=step_id)
3046
+ return
3047
+ self.refresh_plan_status()
3048
+ nxt = planbook.current(book)
3049
+ self._add("tool", "Tool", f"Plan step {step_id}/{len(book['steps'])} verified. " + runner.summarize(result))
3050
+ if nxt is None:
3051
+ self.status = f"Plan complete — {len(book['steps'])} steps verified."
3052
+ return
3053
+ if self.chained and self.composer == CHANGE_COMPOSER:
3054
+ self.start_plan("")
3055
+ elif self.chained:
3056
+ self._draft = planbook.task_for(book, nxt)
3057
+ self.status = (f"Step {step_id} verified. Switch the badge to Change mode to let the "
3058
+ "next step propose its work.")
3059
+ elif self._offer_next_step(book, nxt, step_id):
3060
+ return
3061
+ else:
3062
+ self._draft = planbook.task_for(book, nxt)
3063
+ self.status = (f"Step {step_id} verified. The next step is ready in the message box — press Send.")
3064
+
3065
+ def _offer_next_step(self, book: dict, nxt: dict, done_id: int) -> bool:
3066
+ """Chained mode is off, so the next step starts on an answer rather than on a guess."""
3067
+ if not self.model:
3068
+ return False
3069
+ question = repair.step_offer(self.model, done_id, nxt, len(book["steps"]), self.auto_apply)
3070
+ if not self.confirm("Continue with the plan?", question,
3071
+ warning="Auto-continue can be switched on in Settings to skip this "
3072
+ "question for every later step.",
3073
+ ok_label=f"Start step {nxt['id']}"):
3074
+ return False
3075
+ self.start_plan(planbook.task_for(book, nxt))
3076
+ return True
3077
+
3078
+ def refresh_plan_status(self) -> None:
3079
+ repo, plan_file = self.repo.strip(), self.plan_file.strip()
3080
+ if not repo or not plan_file or not Path(repo).is_dir():
3081
+ self.ledger_path = self.ledger = None
3082
+ return
3083
+ try:
3084
+ self.ledger_path, self.ledger = planbook.open_book(self.plans, Workspace(Path(repo)), plan_file)
3085
+ except (AgentError, OSError):
3086
+ self.ledger_path = self.ledger = None
3087
+
3088
+ def _plan_info(self) -> dict | None:
3089
+ if not self.plan_file or self.ledger is None:
3090
+ if not self.plan_file:
3091
+ return None
3092
+ self.refresh_plan_status()
3093
+ if self.ledger is None:
3094
+ return None
3095
+ book = self.ledger
3096
+ row = planbook.current(book)
3097
+ verified = len(planbook.done_titles(book))
3098
+ return {"name": Path(book["plan_path"]).name, "step": row["id"] if row else len(book["steps"]),
3099
+ "total": len(book["steps"]), "verified": verified,
3100
+ "note": planbook.progress_line(book),
3101
+ "steps": [{"id": step["id"], "title": step["title"], "status": step["status"],
3102
+ "current": bool(row) and step["id"] == row["id"]} for step in book["steps"]]}
3103
+
3104
+ # ------------------------------ sessions ------------------------------
3105
+ def _load_session_cached(self, path: Path) -> dict | None:
3106
+ try:
3107
+ info = path.stat()
3108
+ except OSError:
3109
+ return None
3110
+ key = (info.st_mtime_ns, info.st_size)
3111
+ cached = self._session_cache.get(path)
3112
+ if cached and cached[0] == key:
3113
+ return cached[1]
3114
+ try:
3115
+ session = load_session(path)
3116
+ except (AgentError, OSError):
3117
+ session = None
3118
+ self._session_cache[path] = (key, session)
3119
+ return session
3120
+
3121
+ def _load_chat_cached(self, path: Path) -> dict | None:
3122
+ try:
3123
+ info = path.stat()
3124
+ except OSError:
3125
+ return None
3126
+ key = (info.st_mtime_ns, info.st_size)
3127
+ cached = self._chat_cache.get(path)
3128
+ if cached and cached[0] == key:
3129
+ return cached[1]
3130
+ try:
3131
+ chat = load_chat(path)
3132
+ except (AgentError, OSError):
3133
+ chat = None
3134
+ self._chat_cache[path] = (key, chat)
3135
+ return chat
3136
+
3137
+ def _nav_projects(self) -> list[dict]:
3138
+ """The sidebar's project nodes: one per granted folder, holding its tasks and bound chats.
3139
+
3140
+ Filtering is the client's own — it happens on every keystroke, and a round trip per
3141
+ keystroke would make the box lag behind the typing.
3142
+ """
3143
+ groups: dict[str, dict[str, list]] = {key: {} for key in self.projects}
3144
+ # A chat that was dropped onto a project belongs to that project's node, so it is listed
3145
+ # beside the tasks instead of in the standalone column.
3146
+ bound: dict[str, list] = {}
3147
+ for path in self.chats.glob("*/chat.json"):
3148
+ chat = self._load_chat_cached(path)
3149
+ project = project_of(chat) if chat else None
3150
+ if not project:
3151
+ continue
3152
+ root = str(project.get("key", ""))
3153
+ folder = str(project.get("path", ""))
3154
+ if not root or not folder:
3155
+ continue
3156
+ self.projects.setdefault(root, folder)
3157
+ groups.setdefault(root, {})
3158
+ title = title_for(chat)
3159
+ bound.setdefault(root, []).append(
3160
+ {"id": path.parent.name, "kind": "chat", "title": title, "state": "",
3161
+ "updated": chat.get("created", ""), "busy": False})
3162
+ for path in self.runs.glob("*/session.json"):
3163
+ session = self._load_session_cached(path)
3164
+ if session is None:
3165
+ continue
3166
+ try:
3167
+ root = project_key(session["root"])
3168
+ self.projects.setdefault(root, str(Path(session["root"]).resolve()))
3169
+ groups.setdefault(root, {}).setdefault(session.get("chat_id", session["id"]), []).append((path, session))
3170
+ except (KeyError, TypeError, ValueError, OSError):
3171
+ continue
3172
+ out = []
3173
+ for root, chats in groups.items():
3174
+ folder = Path(self.projects[root])
3175
+ ordered = sorted(chats.values(),
3176
+ key=lambda turns: max(t[1].get("created", "") for t in turns), reverse=True)
3177
+ items = []
3178
+ for turns in ordered:
3179
+ turns.sort(key=lambda pair: (pair[1].get("created", ""), pair[1]["id"]))
3180
+ latest = turns[-1][1]
3181
+ title = latest.get("task", "Chat").replace("\n", " ")[:60]
3182
+ items.append({"id": turns[-1][0].parent.name, "kind": "session", "title": title,
3183
+ "state": latest.get("state", ""),
3184
+ "updated": latest.get("created", ""), "busy": False})
3185
+ items.extend(bound.get(root, []))
3186
+ items.sort(key=lambda item: item["updated"], reverse=True)
3187
+ out.append({"key": root, "name": folder.name, "path": str(folder),
3188
+ "initials": initials_for(folder.name), "icon": self.icons.get(root, ""),
3189
+ "chats": items})
3190
+ return sorted(out, key=lambda group: group["name"].casefold())
3191
+
3192
+ def _nav_chats(self) -> list[dict]:
3193
+ loose = []
3194
+ for path in self.chats.glob("*/chat.json"):
3195
+ chat = self._load_chat_cached(path)
3196
+ if chat and not project_of(chat):
3197
+ loose.append((path, chat))
3198
+ loose.sort(key=lambda pair: pair[1].get("created", ""), reverse=True)
3199
+ return [{"id": path.parent.name, "title": title_for(chat), "updated": chat.get("created", "")}
3200
+ for path, chat in loose]
3201
+
3202
+ def open_item(self, kind: str, item_id: str) -> None:
3203
+ if self.busy or not isinstance(item_id, str) or not CHAT_ID.fullmatch(item_id):
3204
+ return
3205
+ if kind == "chat":
3206
+ path = self.chats / item_id / "chat.json"
3207
+ if path.exists():
3208
+ self.open_chat(path)
3209
+ return
3210
+ path = self.runs / item_id / "session.json"
3211
+ if not path.exists():
3212
+ return
3213
+ try:
3214
+ self.display_session(path, select=True)
3215
+ self.status = status_text("task_opened", arabic=self.arabic)
3216
+ except (AgentError, OSError, ValueError) as exc:
3217
+ self.status = friendly_error(exc)
3218
+
3219
+ def open_chat(self, path: Path) -> None:
3220
+ chat = load_chat(path)
3221
+ project = project_of(chat)
3222
+ if project:
3223
+ key = str(project.get("key", ""))
3224
+ self.projects.setdefault(key, str(project.get("path", "")))
3225
+ self._select_branch(BRANCH_PROJECT if project else BRANCH_CHAT,
3226
+ str(project["key"]) if project else "", chat_id=chat["id"],
3227
+ composer=CHAT_COMPOSER, bound=bool(project)) # a saved chat is a conversation
3228
+ self.chat = chat
3229
+ self.chat_id = chat["id"]
3230
+ self.session = self.session_path = None
3231
+ self.title = title_for(chat)
3232
+ self.messages = []
3233
+ for turn in chat["turns"]:
3234
+ if turn.get("role") == "user":
3235
+ self.messages.append({"role": "user", "author": "You", "text": turn.get("content", ""), "time": ""})
3236
+ elif turn.get("role") == "assistant":
3237
+ self.messages.append({"role": "assistant", "author": "AI Code Engineer",
3238
+ "text": turn.get("content", ""), "time": ""})
3239
+ self.status = (shared_note("chat_reopened_project", arabic=self.arabic,
3240
+ project=Path(project["path"]).name) if project
3241
+ else shared_note("chat_reopened_plain", arabic=self.arabic))
3242
+
3243
+ def _history_steps(self, turn: dict) -> list[dict]:
3244
+ """The step rows a saved task used to have on screen while it ran.
3245
+
3246
+ They are rebuilt from the session's own records, which is what makes them survive a restart:
3247
+ the sentence comes back through the same `labels.step_line` the live loop used, in the language
3248
+ the task was asked in, and the row keeps the stored id so opening it still finds its detail.
3249
+ A `run` event has no step row of its own — the command was announced by the window, not the
3250
+ loop — so it becomes one here, in the past tense, matched to its stored record in order.
3251
+ """
3252
+ arabic = is_arabic(turn.get("task", ""))
3253
+ records = turn.get("runs") or []
3254
+ rows: list[dict] = []
3255
+ seen_runs = 0
3256
+ for item in turn.get("events") or []:
3257
+ kind = item.get("kind")
3258
+ if kind == "step":
3259
+ action = str(item.get("action", ""))
3260
+ fields = {key: item[key] for key in STEP_FIELDS if key in item}
3261
+ line = step_line(arabic, action, **fields)
3262
+ # The loop records every action it takes and the thread shows a repeated one once, so
3263
+ # the rebuild has to collapse the same way or history would show a step nobody saw.
3264
+ if rows and rows[-1]["text"] == line:
3265
+ continue
3266
+ rows.append({"role": "tool", "author": "Steps", "time": "",
3267
+ "text": line,
3268
+ "step": {"id": str(item.get("id", "")), "action": action,
3269
+ "fields": fields, "detail": step_has_detail(action, fields)}})
3270
+ elif kind == "run":
3271
+ if seen_runs < len(records):
3272
+ record = records[seen_runs]
3273
+ else:
3274
+ # A run no session could hold is still a run the operator paid for (D28), and the
3275
+ # event carries its verdict. The recipe name stands in for the command there.
3276
+ record = {key: item.get(key) for key in ("status", "exit_code", "seconds")}
3277
+ record["command"] = str(item.get("recipe", ""))
3278
+ seen_runs += 1
3279
+ command = str(record.get("command", ""))
3280
+ rows.append({"role": "tool", "author": "Steps", "time": "",
3281
+ "text": executed_line(arabic=arabic, command=command,
3282
+ verdict=run_verdict(arabic, record)),
3283
+ "step": {"id": f"{str(turn.get('id', ''))[:8]}-run-{seen_runs}",
3284
+ "action": "executed", "fields": {"command": command},
3285
+ "detail": step_has_detail("executed", {"command": command})}})
3286
+ return rows
3287
+
3288
+ def display_session(self, path: Path, *, select: bool = False) -> None:
3289
+ session = load_session(path)
3290
+ self.session, self.session_path = session, path
3291
+ self.review_file = 0
3292
+ if select:
3293
+ self.title = session.get("task", "Saved task").replace("\n", " ")[:45]
3294
+ self.chat_id = session.get("chat_id", session["id"])
3295
+ self._loading_session = True
3296
+ try:
3297
+ key = project_key(session["root"])
3298
+ if key != self.current_project:
3299
+ self.projects.setdefault(key, str(Path(session["root"]).resolve()))
3300
+ self._select_branch(BRANCH_PROJECT, key, chat_id=self.chat_id,
3301
+ composer=CHANGE_COMPOSER)
3302
+ finally:
3303
+ self._loading_session = False
3304
+ self.cloud_ok = False
3305
+ events = session.get("events", [])
3306
+ self.log = []
3307
+ self.log_dropped = 0
3308
+ # The same ceiling the live list carries, taken off the front so the most recent work is
3309
+ # what survives. The count is stated on the list rather than left to be inferred from a
3310
+ # log that got shorter on its own.
3311
+ if len(events) > MAX_LOG_ENTRIES:
3312
+ self.log_dropped = len(events) - MAX_LOG_ENTRIES
3313
+ events = events[self.log_dropped:]
3314
+ for entry in events:
3315
+ self.log.append({"ts": str(entry.get("at", ""))[-8:], "kind": entry.get("kind", ""),
3316
+ "text": " ".join(f"{k}={v}" for k, v in entry.items() if k not in {"at", "kind"})})
3317
+ reference = session.get("plan_reference")
3318
+ self.plan_file = str(Path(session["root"]) / reference["path"]) if reference else ""
3319
+ self.messages = []
3320
+ turns = chat_sessions(self.runs, session["root"], self.chat_id)
3321
+ if not any(turn["id"] == session["id"] for _, turn in turns):
3322
+ turns.append((path, session))
3323
+ for _, turn in turns:
3324
+ self.messages.append({"role": "user", "author": "You",
3325
+ "text": turn.get("task", "Saved task"), "time": ""})
3326
+ # Only the task this window was opened on gets its steps back: the earlier turns in
3327
+ # the chat are there for their answers, and rebuilding every one of their tool calls
3328
+ # would bury the task the user came to look at.
3329
+ if turn.get("id") == session.get("id"):
3330
+ self.messages.extend(self._history_steps(turn))
3331
+ self.messages.append({"role": "assistant", "author": "AI Code Engineer",
3332
+ "text": state_label(turn.get("state")) + "\n\n" +
3333
+ (turn.get("summary") or turn.get("error") or "No applicable proposal was created."),
3334
+ "time": ""})
3335
+ if session.get("changes"):
3336
+ self.messages.append({"role": "tool", "author": "Changes",
3337
+ "text": self.approval_notice(session) +
3338
+ ", ".join(change["path"] for change in session["changes"])})
3339
+ self.refresh_recipes()
3340
+ self.refresh_plan_status()
3341
+
3342
+ def new_task(self) -> None:
3343
+ """Start a fresh conversation. The branch it belongs to is left alone."""
3344
+ if self.busy:
3345
+ return
3346
+ self.chat_id = uuid.uuid4().hex
3347
+ self.plan_file = ""
3348
+ self.cloud_ok = False
3349
+ self._auto_fix = False
3350
+ self._fix_round = 0
3351
+ self.log = []
3352
+ self.log_dropped = 0
3353
+ self.step_detail = None
3354
+ self._run_step = -1
3355
+ self.session = self.session_path = None
3356
+ self.chat = None
3357
+ self.ledger_path = self.ledger = None
3358
+ self.title = "New chat"
3359
+ self.subtitle = self._subtitle()
3360
+ self.run_info = "No command has run yet."
3361
+ self._draft = ""
3362
+ self.branch = {**self.branch, "id": self.chat_id}
3363
+ self.reset_conversation()
3364
+ self.status = (shared_note("new_chat_plain", arabic=self.arabic)
3365
+ if self.branch.get("kind") != BRANCH_PROJECT else
3366
+ shared_note("new_chat_project", arabic=self.arabic, project=Path(self.repo).name))
3367
+ self.refresh_recipes()
3368
+
3369
+ def new_chat(self) -> None:
3370
+ """The sidebar's New chat: a branch of its own, with no folder attached to it."""
3371
+ self._select_branch(BRANCH_CHAT)
3372
+
3373
+ def example(self) -> None:
3374
+ self.new_task()
3375
+ key = project_key(str(self.app_dir / "examples" / "demo_repo"))
3376
+ self.projects.setdefault(key, str((self.app_dir / "examples" / "demo_repo").resolve()))
3377
+ self._select_branch(BRANCH_PROJECT, key, chat_id=self.chat_id, composer=CHANGE_COMPOSER)
3378
+ self._draft = ("Fix the add function in calculator.py so it adds the two numbers "
3379
+ "instead of subtracting them.")
3380
+ self.set_mode("Ollama")
3381
+ self.status = shared_note("sample_ready", arabic=self.arabic)
3382
+
3383
+ def reset_conversation(self) -> None:
3384
+ self.messages = [{
3385
+ "role": "assistant", "author": "AI Code Engineer", "time": _clock(),
3386
+ "text": "What would you like to work on?\n\nAsk me anything as it is, or choose a project "
3387
+ "in the sidebar and I will propose reviewed changes to its files."}]
3388
+
3389
+ # ------------------------------ projection ------------------------------
3390
+ def _artifact(self) -> dict:
3391
+ """The right-hand card. `written` is what keeps it honest: the same count reads as a
3392
+ proposal before the click and as files on disk after it."""
3393
+ state = self.session.get("state") if self.session else None
3394
+ changes = (self.session or {}).get("changes", [])
3395
+ return artifact_card(state,
3396
+ arabic=self.arabic,
3397
+ count=len(changes),
3398
+ project=Path(self.session["root"]).name if self.session else "",
3399
+ summary=(self.session or {}).get("summary") or "",
3400
+ written=bool(changes) and state in MUTABLE_STATES | INTERRUPTED_STATES,
3401
+ has_project=bool(self.repo))
3402
+
3403
+ def _review(self) -> dict:
3404
+ session = self.session or {}
3405
+ changes = session.get("changes", [])
3406
+ state = session.get("state")
3407
+ files = []
3408
+ for change in changes:
3409
+ lines = _diff(change["before"] or "", change["after"] or "", change["path"])
3410
+ files.append({"path": change["path"],
3411
+ "kind": ("D" if change.get("delete")
3412
+ else "A" if change["before"] is None else "M"),
3413
+ "add": sum(1 for line in lines if line.startswith("+") and not line.startswith("+++")),
3414
+ "del": sum(1 for line in lines if line.startswith("-") and not line.startswith("---"))})
3415
+ chosen = changes[min(self.review_file, len(changes) - 1)] if changes else None
3416
+ return {
3417
+ "state": state_label(state), "tone": TONE.get(state or "", ""),
3418
+ "title": session.get("task", "No proposal yet")[:120],
3419
+ "detail": (f"{len(changes)} file(s)"
3420
+ + (" · attached plan " + session["plan_reference"]["path"] if session.get("plan_reference") else "")
3421
+ + (" · proposal " + str(session.get("proposal_hash", ""))[:4] + "…"
3422
+ + str(session.get("proposal_hash", ""))[-4:] if session.get("proposal_hash") else "")),
3423
+ "canApply": state == "WAITING_APPROVAL" and not self.busy and not self.reading_only(),
3424
+ "canMutate": state in MUTABLE_STATES and not self.busy,
3425
+ "canRollback": (state in MUTABLE_STATES | {"PARTIAL_APPLY", "APPLYING"})
3426
+ and not self.busy and not self.reading_only(),
3427
+ "files": files, "selected": min(self.review_file, max(0, len(changes) - 1)),
3428
+ "tab": self.diff_tab,
3429
+ "view": {
3430
+ # A delete has no `after` at all, and the viewer shows the removal as the whole
3431
+ # file going out with minus signs -- which is only readable if the empty side is
3432
+ # built here rather than assumed by the caller.
3433
+ "diff": _diff(chosen["before"] or "", chosen["after"] or "", chosen["path"]) if chosen else [],
3434
+ "before": (chosen["before"] or "").splitlines() if chosen else [],
3435
+ "after": (chosen["after"] or "").splitlines() if chosen else [],
3436
+ "checks": self._checks_text(session),
3437
+ } if chosen else {"diff": [], "before": [], "after": [], "checks": self._checks_text(session)},
3438
+ }
3439
+
3440
+ def _checks_text(self, session: dict) -> list[str]:
3441
+ out = ["Proposed checks (not execution results):"] + ["• " + check for check in session.get("checks", [])]
3442
+ result = session.get("verification")
3443
+ if result:
3444
+ out.append("Latest check: " + result.get("status", "unknown"))
3445
+ out += [item["path"] + ": " + item["status"] for item in result.get("static", [])]
3446
+ if result.get("reason"):
3447
+ out.append(result["reason"])
3448
+ runs = session.get("runs") or []
3449
+ if runs:
3450
+ last = runs[-1]
3451
+ out.append(f"Command runs ({len(runs)}): last was {last.get('command', '')}")
3452
+ out.append(f"→ {last.get('status')} · exit {last.get('exit_code')} · {last.get('seconds')}s")
3453
+ out += [" " + str(row)[:200] for row in (last.get("failures") or [])[:8]]
3454
+ return out
3455
+
3456
+ # ------------------------------ persistence ------------------------------
3457
+ def _sync_project(self) -> None:
3458
+ self.refresh_plan_status() # the subtitle reads the ledger, so it goes first
3459
+ self.subtitle = self._subtitle()
3460
+
3461
+ def _subtitle(self) -> str:
3462
+ """The header line: what the next Send would actually do, and with what."""
3463
+ if not self.repo:
3464
+ return "Standalone chat — no folder attached, nothing to change"
3465
+ parts = [Path(self.repo).name, intent.subtitle(self.composer)]
3466
+ plan_info = self._plan_info()
3467
+ if plan_info:
3468
+ parts.append(f"step {plan_info['step']} of {plan_info['total']}"
3469
+ + (" · step-by-step" if self.chained else ""))
3470
+ parts.append(self.model or "no model selected")
3471
+ return " · ".join(parts)
3472
+
3473
+ def _save_state(self) -> None:
3474
+ # No `last_project` any more: which folder the window opens on is a property of the branch
3475
+ # the user left selected, so a launch cannot point the whole program at one directory.
3476
+ ui = {"mode": self.mode, "last_chat": self.chat_id,
3477
+ "last_branch": {"kind": self.branch.get("kind", BRANCH_CHAT),
3478
+ "key": self.branch.get("key", "")},
3479
+ # The window's own memory of where it left each folder, Chat included — which is the one
3480
+ # position the durable file does not carry, because Chat is not a promise about files.
3481
+ # Losing this map costs a folder its Chat label and reopens it on Change; it cannot put a
3482
+ # sealed folder back on the writable path, and that half is in `modes.FILE`.
3483
+ "composer": dict(self._composer_pref),
3484
+ "auto_apply": dict(self._auto_pref),
3485
+ "endpoints": dict(self.endpoints), "profile": self.profile,
3486
+ "request_timeout": self.timeout_seconds(), "plan_chained": bool(self.chained),
3487
+ "style": self._saved_ui.get("style", "claude"), "theme": self._saved_ui.get("theme", "light"),
3488
+ # Written by "Don't show this again". The dict below is rebuilt from named keys, so a
3489
+ # preference nobody lists here is erased by the next save of anything else.
3490
+ "setup_seen": bool(self._saved_ui.get("setup_seen")),
3491
+ "sandbox_on": bool(self.sandbox_on), "sandbox_image": self.sandbox_image.strip(),
3492
+ "collapsed": bool(self._saved_ui.get("collapsed", False))}
3493
+ if self.queue:
3494
+ # Stored so a batch survives the restart that would otherwise eat it, and restored held
3495
+ # (see the load path): nothing in here starts on its own accord.
3496
+ ui["queue"] = [dict(item) for item in self.queue[:20]]
3497
+ if self.model or self._pending_model:
3498
+ ui["model"] = self.model or self._pending_model
3499
+ self._saved_ui = ui
3500
+ try:
3501
+ atomic_json(self.app_dir / ".agent-projects.json", {
3502
+ "projects": [{"path": path, "key": key, "icon": self.icons.get(key, "")}
3503
+ for key, path in self.projects.items()], "ui": ui})
3504
+ except OSError:
3505
+ self.status = status_text("registry_unsaved", arabic=self.arabic)
3506
+
3507
+ def set_pref(self, name: str, value: str) -> None:
3508
+ """Style and theme belong to the window, but they survive a restart with the rest."""
3509
+ self._saved_ui[name] = value
3510
+ self._save_state()
3511
+
3512
+ def close(self) -> None:
3513
+ with self._state:
3514
+ self.key = ""
3515
+
3516
+
3517
+ def _hidden(path: Path) -> bool:
3518
+ """The picker's own rule, older than the shared one: a dot folder is not where the project is.
3519
+
3520
+ `ignore.picker_dir` decides the rest beside it. This stays separate because it is a *navigation*
3521
+ choice — the file-system gate that protects `.git` and the credentials lives in `ignore`, and a
3522
+ folder being tedious to browse is not the same reason to refuse it.
3523
+ """
3524
+ return path.name.startswith(".")
3525
+
3526
+
3527
+ def drive_roots() -> list[str]:
3528
+ """Every mounted root, so the folder picker can leave the drive it started on."""
3529
+ if os.name != "nt":
3530
+ return ["/"]
3531
+ try:
3532
+ mask = ctypes.windll.kernel32.GetLogicalDrives()
3533
+ except (OSError, AttributeError):
3534
+ mask = 1
3535
+ return [letter for letter in
3536
+ (chr(ord("A") + index) + ":\\" for index in range(26) if mask & (1 << index))
3537
+ if Path(letter).is_dir()]
3538
+
3539
+
3540
+ def initials_for(name: str) -> str:
3541
+ """A two-letter mark that survives near-identical folder names.
3542
+
3543
+ ``demo2`` and ``demo_repo`` both start with "de", which made the sidebar avatars
3544
+ indistinguishable; segment boundaries and a trailing digit separate them.
3545
+ """
3546
+ parts = [part for part in re.split(r"[\s._\-]+", name) if part]
3547
+ if len(parts) >= 2:
3548
+ return (parts[0][0] + parts[1][0]).casefold()
3549
+ word = parts[0] if parts else "?"
3550
+ digit = next((char for char in word[1:] if char.isdigit()), "")
3551
+ return (word[0] + (digit or (word[1] if len(word) > 1 else ""))).casefold()
3552
+
3553
+
3554
+ def _diff(before: str, after: str, name: str) -> list[str]:
3555
+ return list(difflib.unified_diff(before.splitlines(), after.splitlines(),
3556
+ fromfile="a/" + name, tofile="b/" + name, lineterm=""))