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.
- ai_code_engineer/__init__.py +2 -0
- ai_code_engineer/catalog.py +143 -0
- ai_code_engineer/chat.py +181 -0
- ai_code_engineer/cli.py +384 -0
- ai_code_engineer/config.py +405 -0
- ai_code_engineer/engine.py +1282 -0
- ai_code_engineer/errors.py +27 -0
- ai_code_engineer/git_integration.py +443 -0
- ai_code_engineer/gui.py +2646 -0
- ai_code_engineer/host.py +81 -0
- ai_code_engineer/ignore.py +269 -0
- ai_code_engineer/intent.py +222 -0
- ai_code_engineer/labels.py +871 -0
- ai_code_engineer/memory.py +91 -0
- ai_code_engineer/modes.py +156 -0
- ai_code_engineer/overrides.py +540 -0
- ai_code_engineer/planbook.py +192 -0
- ai_code_engineer/providers.py +404 -0
- ai_code_engineer/redaction.py +54 -0
- ai_code_engineer/repair.py +564 -0
- ai_code_engineer/report.py +352 -0
- ai_code_engineer/runner.py +854 -0
- ai_code_engineer/setup.py +386 -0
- ai_code_engineer/symbols.py +1286 -0
- ai_code_engineer/verification.py +218 -0
- ai_code_engineer/webapp/__init__.py +1 -0
- ai_code_engineer/webapp/__main__.py +45 -0
- ai_code_engineer/webapp/contract.py +36 -0
- ai_code_engineer/webapp/controller.py +3556 -0
- ai_code_engineer/webapp/fake.py +1141 -0
- ai_code_engineer/webapp/launch.py +108 -0
- ai_code_engineer/webapp/server.py +349 -0
- ai_code_engineer/webapp/static/app.css +780 -0
- ai_code_engineer/webapp/static/app.js +2118 -0
- ai_code_engineer/webapp/static/boot.js +19 -0
- ai_code_engineer/webapp/static/index.html +89 -0
- ai_code_engineer/webapp/static/tokens.css +173 -0
- ai_code_engineer/workspace.py +385 -0
- ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
- ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
- ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
- ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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=""))
|