noah-code 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.
- noah_code/__init__.py +3 -0
- noah_code/__main__.py +6 -0
- noah_code/agent.py +378 -0
- noah_code/approvals.py +105 -0
- noah_code/cli.py +422 -0
- noah_code/commands.py +70 -0
- noah_code/config.py +279 -0
- noah_code/custom_commands.py +103 -0
- noah_code/event_bridge.py +132 -0
- noah_code/events.py +27 -0
- noah_code/host.py +662 -0
- noah_code/macos_sandbox.py +142 -0
- noah_code/mcp_setup.py +91 -0
- noah_code/permissions.py +400 -0
- noah_code/sessions.py +157 -0
- noah_code/skills_setup.py +51 -0
- noah_code/snapshots.py +313 -0
- noah_code/tools/__init__.py +6 -0
- noah_code/tools/git_tools.py +44 -0
- noah_code/tools/workspace_tools.py +269 -0
- noah_code/ui/__init__.py +6 -0
- noah_code/ui/console.py +88 -0
- noah_code/ui/protocol.py +33 -0
- noah_code/ui/textual.css +9 -0
- noah_code/ui/textual_app.py +435 -0
- noah_code/updates.py +184 -0
- noah_code/workspace.py +49 -0
- noah_code-0.1.0.dist-info/METADATA +173 -0
- noah_code-0.1.0.dist-info/RECORD +31 -0
- noah_code-0.1.0.dist-info/WHEEL +4 -0
- noah_code-0.1.0.dist-info/entry_points.txt +4 -0
noah_code/host.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""Pure-Python host: dispatcher loop, persistence, approvals, slash commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from nooa.interactive import RespondReason, apply_model_limits
|
|
13
|
+
from nooa.tracing import enable_tracing, exporters, flush_traces, set_session
|
|
14
|
+
|
|
15
|
+
from noah_code.agent import CodingAgent
|
|
16
|
+
from noah_code.approvals import ApprovalChoice
|
|
17
|
+
from noah_code.commands import help_text, parse_slash
|
|
18
|
+
from noah_code.config import NoahCodeConfig
|
|
19
|
+
from noah_code.custom_commands import CustomCommand, discover_custom_commands
|
|
20
|
+
from noah_code.event_bridge import install_event_bridge
|
|
21
|
+
from noah_code.events import HostEvent, HostEventKind
|
|
22
|
+
from noah_code.sessions import SessionMeta, SessionStore
|
|
23
|
+
from noah_code.ui.console import ConsoleUI
|
|
24
|
+
from noah_code.ui.protocol import HostUI
|
|
25
|
+
from noah_code.workspace import Workspace
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _json_safe(value: Any) -> Any:
|
|
31
|
+
"""Best-effort conversion for session meta JSON."""
|
|
32
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
33
|
+
return value
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
return {str(k): _json_safe(v) for k, v in value.items()}
|
|
36
|
+
if isinstance(value, (list, tuple)):
|
|
37
|
+
return [_json_safe(v) for v in value]
|
|
38
|
+
if hasattr(value, "model_dump"):
|
|
39
|
+
try:
|
|
40
|
+
return _json_safe(value.model_dump())
|
|
41
|
+
except Exception: # noqa: BLE001
|
|
42
|
+
pass
|
|
43
|
+
if hasattr(value, "items") and not isinstance(value, type):
|
|
44
|
+
try:
|
|
45
|
+
return {str(k): _json_safe(v) for k, v in value.items()} # type: ignore[arg-type]
|
|
46
|
+
except Exception: # noqa: BLE001
|
|
47
|
+
pass
|
|
48
|
+
return str(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class HostResult:
|
|
53
|
+
exit_code: int
|
|
54
|
+
explanation: str = ""
|
|
55
|
+
session_id: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AgentHost:
|
|
59
|
+
"""Owns session lifecycle and the InteractiveAgent dispatcher loop."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
workspace: Workspace,
|
|
64
|
+
config: NoahCodeConfig,
|
|
65
|
+
*,
|
|
66
|
+
llm: Any = None,
|
|
67
|
+
ui: HostUI | None = None,
|
|
68
|
+
session_meta: SessionMeta | None = None,
|
|
69
|
+
store: SessionStore | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.workspace = workspace
|
|
72
|
+
self.config = config
|
|
73
|
+
self.ui: HostUI = ui or ConsoleUI(markdown=config.ui.markdown)
|
|
74
|
+
self.store = store or SessionStore(config.session_dir)
|
|
75
|
+
self._llm = llm
|
|
76
|
+
self._agent: CodingAgent | None = None
|
|
77
|
+
self._storage = None
|
|
78
|
+
self.meta = session_meta
|
|
79
|
+
self._cancel_turn = False
|
|
80
|
+
self._exit_requested = False
|
|
81
|
+
self._last_stop = ""
|
|
82
|
+
self._trace_info = "auto (viewer if reachable)"
|
|
83
|
+
self._active_turn: asyncio.Task[Any] | None = None
|
|
84
|
+
self._event_unsubs: list[Any] = []
|
|
85
|
+
self._custom_commands: dict[str, CustomCommand] = {}
|
|
86
|
+
self._last_turn_shell_bypass = False
|
|
87
|
+
self.on_session_changed: Any = None # optional UI callback
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def agent(self) -> CodingAgent:
|
|
91
|
+
if self._agent is None:
|
|
92
|
+
raise RuntimeError("host not started")
|
|
93
|
+
return self._agent
|
|
94
|
+
|
|
95
|
+
def _setup_tracing(self, session_id: str) -> None:
|
|
96
|
+
if not self.config.tracing.enabled:
|
|
97
|
+
return
|
|
98
|
+
set_session(session_id)
|
|
99
|
+
exps = []
|
|
100
|
+
if self.config.tracing.jsonl_dir:
|
|
101
|
+
path = Path(self.config.tracing.jsonl_dir).expanduser()
|
|
102
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
exps.append(exporters.jsonl(trace_dir=str(path)))
|
|
104
|
+
self._trace_info = str(path / f"{session_id}.jsonl")
|
|
105
|
+
if exps:
|
|
106
|
+
enable_tracing(exporters=exps)
|
|
107
|
+
|
|
108
|
+
async def start(self) -> SessionMeta:
|
|
109
|
+
if self.meta is None:
|
|
110
|
+
self.meta = self.store.create(
|
|
111
|
+
self.workspace, model=self.config.model, mode=self.config.mode
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
self.store.verify_workspace(self.meta, self.workspace)
|
|
115
|
+
|
|
116
|
+
self._setup_tracing(self.meta.session_id)
|
|
117
|
+
self._storage = self.store.open_storage(self.meta.session_id)
|
|
118
|
+
|
|
119
|
+
llm = self._llm
|
|
120
|
+
if llm is None:
|
|
121
|
+
from nooa.unifiedllm import get_llm_client
|
|
122
|
+
|
|
123
|
+
llm = get_llm_client(self.meta.model)
|
|
124
|
+
|
|
125
|
+
agent = CodingAgent(
|
|
126
|
+
self.workspace,
|
|
127
|
+
self.config,
|
|
128
|
+
llm=llm,
|
|
129
|
+
storage=self._storage,
|
|
130
|
+
)
|
|
131
|
+
# Restore snapshot if present.
|
|
132
|
+
restored = self._storage.restore_latest_snapshot(agent)
|
|
133
|
+
if restored:
|
|
134
|
+
# Re-bind host-owned nosnapshot infrastructure after restore.
|
|
135
|
+
agent._engine.mode = getattr(agent, "mode", self.meta.mode) # type: ignore[attr-defined]
|
|
136
|
+
agent._engine.load_session_rules(self.meta.permission_rules)
|
|
137
|
+
agent.journal.load_dict(self.meta.journal)
|
|
138
|
+
if self.meta.todos:
|
|
139
|
+
agent.todos.from_dict(self.meta.todos)
|
|
140
|
+
else:
|
|
141
|
+
agent.set_mode(self.meta.mode) # type: ignore[arg-type]
|
|
142
|
+
agent.v.mode = self.meta.mode
|
|
143
|
+
agent.v.model = self.meta.model
|
|
144
|
+
|
|
145
|
+
agent._approvals.set_handler(self.ui.ask_approval)
|
|
146
|
+
agent._render_message = self._on_agent_message
|
|
147
|
+
agent.ws.set_shell_chunk_handler(self._on_shell_chunk)
|
|
148
|
+
|
|
149
|
+
self._teardown_event_bridge()
|
|
150
|
+
self._event_unsubs = install_event_bridge(agent, self.ui.render)
|
|
151
|
+
|
|
152
|
+
self._custom_commands = discover_custom_commands(self.workspace.root)
|
|
153
|
+
|
|
154
|
+
self._agent = agent
|
|
155
|
+
|
|
156
|
+
# Optional MCP (best-effort; never blocks startup on missing extra).
|
|
157
|
+
try:
|
|
158
|
+
from noah_code.mcp_setup import install_mcp
|
|
159
|
+
|
|
160
|
+
mcp_status = await install_mcp(
|
|
161
|
+
agent,
|
|
162
|
+
self.workspace.root,
|
|
163
|
+
self.config,
|
|
164
|
+
engine=agent.engine,
|
|
165
|
+
approvals=agent.approvals,
|
|
166
|
+
)
|
|
167
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, mcp_status))
|
|
168
|
+
except Exception as exc: # noqa: BLE001
|
|
169
|
+
logger.debug("mcp setup skipped: %s", exc)
|
|
170
|
+
|
|
171
|
+
skills_status = getattr(agent, "_skills_status", "")
|
|
172
|
+
if skills_status:
|
|
173
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, skills_status))
|
|
174
|
+
|
|
175
|
+
self.ui.render(
|
|
176
|
+
HostEvent(
|
|
177
|
+
HostEventKind.STATUS,
|
|
178
|
+
f"session={self.meta.session_id} model={self.meta.model} "
|
|
179
|
+
f"mode={agent.mode} workspace={self.workspace.root}",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
title = self.meta.title if self.meta.title != "untitled" else ""
|
|
183
|
+
if title:
|
|
184
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, f"title={title}"))
|
|
185
|
+
self.ui.set_status(self.status_prompt())
|
|
186
|
+
if self.on_session_changed:
|
|
187
|
+
with contextlib.suppress(Exception):
|
|
188
|
+
self.on_session_changed(self.meta)
|
|
189
|
+
return self.meta
|
|
190
|
+
|
|
191
|
+
def _teardown_event_bridge(self) -> None:
|
|
192
|
+
for unsub in self._event_unsubs:
|
|
193
|
+
with contextlib.suppress(Exception):
|
|
194
|
+
unsub()
|
|
195
|
+
self._event_unsubs = []
|
|
196
|
+
|
|
197
|
+
def _on_shell_chunk(self, stream: str, text: str) -> None:
|
|
198
|
+
self.ui.render(HostEvent(HostEventKind.SHELL_CHUNK, text, meta={"stream": stream}))
|
|
199
|
+
|
|
200
|
+
def _on_agent_message(self, text: str, **_kwargs: Any) -> None:
|
|
201
|
+
self.ui.render(HostEvent(HostEventKind.MESSAGE, text))
|
|
202
|
+
|
|
203
|
+
def _persist(self) -> None:
|
|
204
|
+
if self._agent is None or self.meta is None or self._storage is None:
|
|
205
|
+
return
|
|
206
|
+
self.meta.mode = self._agent.mode
|
|
207
|
+
self.meta.model = getattr(self._agent.v, "model", self.meta.model) or self.meta.model
|
|
208
|
+
self.meta.permission_rules = self._agent.engine.snapshot_session_rules()
|
|
209
|
+
self.meta.journal = self._agent.journal.to_dict()
|
|
210
|
+
self.meta.todos = _json_safe(self._agent.todos.to_dict())
|
|
211
|
+
with contextlib.suppress(Exception):
|
|
212
|
+
title = getattr(self._agent.v, "title", None)
|
|
213
|
+
if title:
|
|
214
|
+
self.meta.title = str(title)
|
|
215
|
+
self.store.save_meta(self.meta)
|
|
216
|
+
self._storage.save_snapshot(self._agent)
|
|
217
|
+
|
|
218
|
+
async def close(self) -> None:
|
|
219
|
+
try:
|
|
220
|
+
self._persist()
|
|
221
|
+
finally:
|
|
222
|
+
self._teardown_event_bridge()
|
|
223
|
+
if self._agent is not None:
|
|
224
|
+
try:
|
|
225
|
+
await self._agent.ws.close()
|
|
226
|
+
except Exception: # noqa: BLE001
|
|
227
|
+
logger.debug("shell close failed", exc_info=True)
|
|
228
|
+
if self._storage is not None:
|
|
229
|
+
self._storage.close()
|
|
230
|
+
self._storage = None
|
|
231
|
+
try:
|
|
232
|
+
flush_traces()
|
|
233
|
+
except Exception: # noqa: BLE001
|
|
234
|
+
logger.debug("trace flush failed", exc_info=True)
|
|
235
|
+
|
|
236
|
+
def status_prompt(self) -> str:
|
|
237
|
+
sid = self.meta.session_id[:8] if self.meta else "?"
|
|
238
|
+
mode = self.agent.mode if self._agent else self.config.mode
|
|
239
|
+
model = self.meta.model if self.meta else self.config.model
|
|
240
|
+
title = ""
|
|
241
|
+
if self.meta and self.meta.title and self.meta.title != "untitled":
|
|
242
|
+
title = f"|{self.meta.title[:20]}"
|
|
243
|
+
return f"noah [{mode}|{model}|{sid}{title}]"
|
|
244
|
+
|
|
245
|
+
async def start_new_session(self) -> SessionMeta:
|
|
246
|
+
"""Persist current session and open a fresh one in-process."""
|
|
247
|
+
self._persist()
|
|
248
|
+
self._teardown_event_bridge()
|
|
249
|
+
if self._agent is not None:
|
|
250
|
+
with contextlib.suppress(Exception):
|
|
251
|
+
await self._agent.ws.close()
|
|
252
|
+
if self._storage is not None:
|
|
253
|
+
self._storage.close()
|
|
254
|
+
self._storage = None
|
|
255
|
+
self._agent = None
|
|
256
|
+
self.meta = None
|
|
257
|
+
self._exit_requested = False
|
|
258
|
+
return await self.start()
|
|
259
|
+
|
|
260
|
+
async def switch_session(self, session_id: str) -> SessionMeta:
|
|
261
|
+
"""Persist current session and resume another."""
|
|
262
|
+
self._persist()
|
|
263
|
+
self._teardown_event_bridge()
|
|
264
|
+
if self._agent is not None:
|
|
265
|
+
with contextlib.suppress(Exception):
|
|
266
|
+
await self._agent.ws.close()
|
|
267
|
+
if self._storage is not None:
|
|
268
|
+
self._storage.close()
|
|
269
|
+
self._storage = None
|
|
270
|
+
self._agent = None
|
|
271
|
+
meta = self.store.load_meta(session_id)
|
|
272
|
+
self.store.verify_workspace(meta, self.workspace)
|
|
273
|
+
self.meta = meta
|
|
274
|
+
self._exit_requested = False
|
|
275
|
+
return await self.start()
|
|
276
|
+
|
|
277
|
+
def list_session_metas(self) -> list[SessionMeta]:
|
|
278
|
+
return self.store.list_sessions(self.workspace)
|
|
279
|
+
|
|
280
|
+
def cancel_active_turn(self) -> None:
|
|
281
|
+
"""Cancel the in-flight turn and pending approvals (Ctrl-C)."""
|
|
282
|
+
self._cancel_turn = True
|
|
283
|
+
if self._agent is not None:
|
|
284
|
+
self._agent.approvals.cancel_all()
|
|
285
|
+
task = self._active_turn
|
|
286
|
+
if task is not None and not task.done():
|
|
287
|
+
task.cancel()
|
|
288
|
+
|
|
289
|
+
async def handle_line(self, line: str) -> Literal["continue", "exit", "handled"]:
|
|
290
|
+
slash = parse_slash(line)
|
|
291
|
+
if slash:
|
|
292
|
+
return await self._handle_slash(slash[0], slash[1])
|
|
293
|
+
self._cancel_turn = False
|
|
294
|
+
self.ui.set_busy(True)
|
|
295
|
+
self._active_turn = asyncio.current_task()
|
|
296
|
+
try:
|
|
297
|
+
await self._run_user_turn(line)
|
|
298
|
+
return "continue"
|
|
299
|
+
finally:
|
|
300
|
+
self._active_turn = None
|
|
301
|
+
self.ui.set_busy(False)
|
|
302
|
+
self.ui.set_status(self.status_prompt())
|
|
303
|
+
|
|
304
|
+
async def _handle_slash(self, name: str, args: str) -> Literal["continue", "exit", "handled"]:
|
|
305
|
+
agent = self.agent
|
|
306
|
+
if name == "help":
|
|
307
|
+
self.ui.render(HostEvent(HostEventKind.MESSAGE, help_text(self._custom_commands)))
|
|
308
|
+
return "handled"
|
|
309
|
+
if name in self._custom_commands:
|
|
310
|
+
return await self._run_custom_command(name, args)
|
|
311
|
+
if name == "exit" or name == "quit":
|
|
312
|
+
self._exit_requested = True
|
|
313
|
+
return "exit"
|
|
314
|
+
if name == "new":
|
|
315
|
+
meta = await self.start_new_session()
|
|
316
|
+
self.ui.render(
|
|
317
|
+
HostEvent(HostEventKind.STATUS, f"started new session {meta.session_id}")
|
|
318
|
+
)
|
|
319
|
+
return "handled"
|
|
320
|
+
if name == "skills":
|
|
321
|
+
skills = getattr(agent, "skills", None)
|
|
322
|
+
if skills is None:
|
|
323
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "no skills registry"))
|
|
324
|
+
else:
|
|
325
|
+
try:
|
|
326
|
+
text = skills.status()
|
|
327
|
+
except Exception as exc: # noqa: BLE001
|
|
328
|
+
text = f"skills error: {exc}"
|
|
329
|
+
self.ui.render(HostEvent(HostEventKind.MESSAGE, text or "(empty)"))
|
|
330
|
+
return "handled"
|
|
331
|
+
if name == "mode":
|
|
332
|
+
mode = args.strip().lower()
|
|
333
|
+
if mode not in {"build", "plan"}:
|
|
334
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, "usage: /mode build|plan"))
|
|
335
|
+
return "handled"
|
|
336
|
+
agent.set_mode(mode) # type: ignore[arg-type]
|
|
337
|
+
if self.meta:
|
|
338
|
+
self.meta.mode = mode
|
|
339
|
+
self.store.save_meta(self.meta)
|
|
340
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, f"mode set to {mode}"))
|
|
341
|
+
self.ui.set_status(self.status_prompt())
|
|
342
|
+
return "handled"
|
|
343
|
+
if name == "model":
|
|
344
|
+
if not args.strip():
|
|
345
|
+
self.ui.render(
|
|
346
|
+
HostEvent(
|
|
347
|
+
HostEventKind.STATUS,
|
|
348
|
+
f"model={self.meta.model if self.meta else self.config.model}",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
return "handled"
|
|
352
|
+
await self._switch_model(args.strip())
|
|
353
|
+
return "handled"
|
|
354
|
+
if name == "session":
|
|
355
|
+
self.ui.render(
|
|
356
|
+
HostEvent(
|
|
357
|
+
HostEventKind.MESSAGE,
|
|
358
|
+
f"id={self.meta.session_id}\ntitle={self.meta.title}\n"
|
|
359
|
+
f"mode={agent.mode}\nmodel={self.meta.model}\n"
|
|
360
|
+
f"workspace={self.meta.workspace_path}",
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
return "handled"
|
|
364
|
+
if name == "sessions":
|
|
365
|
+
if args.strip():
|
|
366
|
+
sid = args.strip().split()[0]
|
|
367
|
+
try:
|
|
368
|
+
await self.switch_session(sid)
|
|
369
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, f"switched to session {sid}"))
|
|
370
|
+
except Exception as exc: # noqa: BLE001
|
|
371
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, str(exc)))
|
|
372
|
+
return "handled"
|
|
373
|
+
rows = self.store.list_sessions(self.workspace)
|
|
374
|
+
text = (
|
|
375
|
+
"\n".join(
|
|
376
|
+
f"{s.session_id} {s.mode:5} {s.title}"
|
|
377
|
+
+ (" ← current" if self.meta and s.session_id == self.meta.session_id else "")
|
|
378
|
+
for s in rows
|
|
379
|
+
)
|
|
380
|
+
or "(none)"
|
|
381
|
+
)
|
|
382
|
+
text += "\n\nSwitch with: /sessions SESSION_ID"
|
|
383
|
+
self.ui.render(HostEvent(HostEventKind.MESSAGE, text))
|
|
384
|
+
return "handled"
|
|
385
|
+
if name == "todos":
|
|
386
|
+
self.ui.render(HostEvent(HostEventKind.MESSAGE, agent.todos.status() or "(no todos)"))
|
|
387
|
+
return "handled"
|
|
388
|
+
if name == "status":
|
|
389
|
+
reversible = agent.journal.last_turn_reversible() if agent.journal.can_undo() else True
|
|
390
|
+
warn = ""
|
|
391
|
+
if agent.journal.can_undo() and not reversible:
|
|
392
|
+
warn = " last_turn=NOT_FULLY_REVERSIBLE(shell)"
|
|
393
|
+
elif self._last_turn_shell_bypass:
|
|
394
|
+
warn = " last_turn=shell_bypass"
|
|
395
|
+
self.ui.render(
|
|
396
|
+
HostEvent(
|
|
397
|
+
HostEventKind.STATUS,
|
|
398
|
+
f"mode={agent.mode} model={self.meta.model if self.meta else '?'} "
|
|
399
|
+
f"session={self.meta.session_id if self.meta else '?'} "
|
|
400
|
+
f"title={self.meta.title if self.meta else '?'} "
|
|
401
|
+
f"undo={'yes' if agent.journal.can_undo() else 'no'} "
|
|
402
|
+
f"reversible={reversible}{warn}",
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
return "handled"
|
|
406
|
+
if name == "diff":
|
|
407
|
+
try:
|
|
408
|
+
text = await agent.git.diff()
|
|
409
|
+
except Exception as exc: # noqa: BLE001
|
|
410
|
+
text = f"diff failed: {exc}"
|
|
411
|
+
self.ui.render(HostEvent(HostEventKind.MESSAGE, f"```\n{text}\n```"))
|
|
412
|
+
return "handled"
|
|
413
|
+
if name == "undo":
|
|
414
|
+
try:
|
|
415
|
+
turn = agent.journal._turns[-1] if agent.journal.can_undo() else None
|
|
416
|
+
if turn:
|
|
417
|
+
agent.journal.capture_post_bytes_before_undo(turn)
|
|
418
|
+
undone = agent.journal.undo()
|
|
419
|
+
self.ui.render(
|
|
420
|
+
HostEvent(
|
|
421
|
+
HostEventKind.STATUS,
|
|
422
|
+
f"undid turn {undone.turn_id[:8]} ({len(undone.mutations)} files)",
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
except Exception as exc: # noqa: BLE001
|
|
426
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, str(exc)))
|
|
427
|
+
return "handled"
|
|
428
|
+
if name == "redo":
|
|
429
|
+
try:
|
|
430
|
+
redone = agent.journal.redo()
|
|
431
|
+
self.ui.render(
|
|
432
|
+
HostEvent(
|
|
433
|
+
HostEventKind.STATUS,
|
|
434
|
+
f"redid turn {redone.turn_id[:8]} ({len(redone.mutations)} files)",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
except Exception as exc: # noqa: BLE001
|
|
438
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, str(exc)))
|
|
439
|
+
return "handled"
|
|
440
|
+
if name == "trace":
|
|
441
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, f"trace: {self._trace_info}"))
|
|
442
|
+
return "handled"
|
|
443
|
+
if name == "compact":
|
|
444
|
+
for summarizer in getattr(agent, "_summarizers", []):
|
|
445
|
+
try:
|
|
446
|
+
await summarizer.summarize() # type: ignore[attr-defined]
|
|
447
|
+
except Exception as exc: # noqa: BLE001
|
|
448
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, f"compact failed: {exc}"))
|
|
449
|
+
return "handled"
|
|
450
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "compaction requested"))
|
|
451
|
+
return "handled"
|
|
452
|
+
if name in {"continue"}:
|
|
453
|
+
latest = self.store.latest_for_workspace(self.workspace)
|
|
454
|
+
if latest is None:
|
|
455
|
+
self.ui.render(
|
|
456
|
+
HostEvent(HostEventKind.ERROR, "no prior session for this workspace")
|
|
457
|
+
)
|
|
458
|
+
return "handled"
|
|
459
|
+
if self.meta and latest.session_id == self.meta.session_id:
|
|
460
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "already on the latest session"))
|
|
461
|
+
return "handled"
|
|
462
|
+
await self.switch_session(latest.session_id)
|
|
463
|
+
self.ui.render(
|
|
464
|
+
HostEvent(HostEventKind.STATUS, f"continued session {latest.session_id}")
|
|
465
|
+
)
|
|
466
|
+
return "handled"
|
|
467
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, f"unknown command /{name} - try /help"))
|
|
468
|
+
return "handled"
|
|
469
|
+
|
|
470
|
+
async def _run_custom_command(
|
|
471
|
+
self, name: str, args: str
|
|
472
|
+
) -> Literal["continue", "exit", "handled"]:
|
|
473
|
+
cmd = self._custom_commands[name]
|
|
474
|
+
if cmd.mode in {"build", "plan"}:
|
|
475
|
+
self.agent.set_mode(cmd.mode) # type: ignore[arg-type]
|
|
476
|
+
if self.meta:
|
|
477
|
+
self.meta.mode = cmd.mode
|
|
478
|
+
self.store.save_meta(self.meta)
|
|
479
|
+
if cmd.model:
|
|
480
|
+
try:
|
|
481
|
+
await self._switch_model(cmd.model)
|
|
482
|
+
except Exception as exc: # noqa: BLE001
|
|
483
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, f"model switch failed: {exc}"))
|
|
484
|
+
rendered = cmd.render(args)
|
|
485
|
+
if not rendered:
|
|
486
|
+
self.ui.render(
|
|
487
|
+
HostEvent(HostEventKind.ERROR, f"custom command /{name} produced empty body")
|
|
488
|
+
)
|
|
489
|
+
return "handled"
|
|
490
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, f"running /{name} ({cmd.source})"))
|
|
491
|
+
self.ui.set_busy(True)
|
|
492
|
+
self._active_turn = asyncio.current_task()
|
|
493
|
+
try:
|
|
494
|
+
await self._run_user_turn(rendered)
|
|
495
|
+
finally:
|
|
496
|
+
self._active_turn = None
|
|
497
|
+
self.ui.set_busy(False)
|
|
498
|
+
self.ui.set_status(self.status_prompt())
|
|
499
|
+
return "continue"
|
|
500
|
+
|
|
501
|
+
async def _switch_model(self, model: str) -> None:
|
|
502
|
+
from nooa.unifiedllm import get_llm_client
|
|
503
|
+
|
|
504
|
+
llm = get_llm_client(model)
|
|
505
|
+
self.agent._llm = llm
|
|
506
|
+
apply_model_limits(self.agent)
|
|
507
|
+
if self.meta:
|
|
508
|
+
self.meta.model = model
|
|
509
|
+
self.agent.v.model = model
|
|
510
|
+
self.store.save_meta(self.meta)
|
|
511
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, f"model set to {model}"))
|
|
512
|
+
self.ui.set_status(self.status_prompt())
|
|
513
|
+
|
|
514
|
+
async def _run_user_turn(self, text: str) -> HostResult:
|
|
515
|
+
agent = self.agent
|
|
516
|
+
agent.journal.begin_turn()
|
|
517
|
+
agent._user_messages_in.put(text)
|
|
518
|
+
|
|
519
|
+
if self.meta and self.meta.title == "untitled":
|
|
520
|
+
asyncio.create_task(self._maybe_title(text))
|
|
521
|
+
|
|
522
|
+
exit_code = 0
|
|
523
|
+
explanation = ""
|
|
524
|
+
try:
|
|
525
|
+
wins = await agent.queue_manager.race()
|
|
526
|
+
notification: dict[str, list] = {}
|
|
527
|
+
for name, item in wins:
|
|
528
|
+
notification.setdefault(name, []).append(item)
|
|
529
|
+
result = await agent.handle(notification)
|
|
530
|
+
explanation = getattr(result, "explanation", "") or ""
|
|
531
|
+
kind = getattr(result, "kind", None)
|
|
532
|
+
self._last_stop = explanation
|
|
533
|
+
self.ui.render(HostEvent(HostEventKind.STOP, f"{kind}: {explanation}"))
|
|
534
|
+
if kind == RespondReason.NEED_INPUT:
|
|
535
|
+
exit_code = 0
|
|
536
|
+
except PermissionError as exc:
|
|
537
|
+
exit_code = 3
|
|
538
|
+
explanation = str(exc)
|
|
539
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, explanation))
|
|
540
|
+
except asyncio.CancelledError:
|
|
541
|
+
exit_code = 130
|
|
542
|
+
explanation = "cancelled"
|
|
543
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "turn cancelled"))
|
|
544
|
+
raise
|
|
545
|
+
except Exception as exc:
|
|
546
|
+
exit_code = 1
|
|
547
|
+
explanation = f"agent failure: {exc}"
|
|
548
|
+
self.ui.render(HostEvent(HostEventKind.ERROR, explanation))
|
|
549
|
+
logger.exception("handle() failed")
|
|
550
|
+
finally:
|
|
551
|
+
agent.journal.end_turn()
|
|
552
|
+
self._last_turn_shell_bypass = bool(
|
|
553
|
+
agent.journal._turns and agent.journal._turns[-1].shell_may_bypass
|
|
554
|
+
)
|
|
555
|
+
if self._last_turn_shell_bypass:
|
|
556
|
+
self.ui.render(
|
|
557
|
+
HostEvent(
|
|
558
|
+
HostEventKind.STATUS,
|
|
559
|
+
"warning: this turn ran mutating shell commands; "
|
|
560
|
+
"file-journal undo may be incomplete",
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
self._persist()
|
|
564
|
+
return HostResult(
|
|
565
|
+
exit_code=exit_code,
|
|
566
|
+
explanation=explanation,
|
|
567
|
+
session_id=self.meta.session_id if self.meta else None,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
async def _maybe_title(self, text: str) -> None:
|
|
571
|
+
try:
|
|
572
|
+
title = await self.agent.name_session(text)
|
|
573
|
+
title = (title or "").strip().strip('"')[:60]
|
|
574
|
+
if title and self.meta:
|
|
575
|
+
self.meta.title = title
|
|
576
|
+
self.agent.v.title = title
|
|
577
|
+
self.store.save_meta(self.meta)
|
|
578
|
+
except Exception: # noqa: BLE001 - never fail the main task
|
|
579
|
+
logger.debug("title generation failed", exc_info=True)
|
|
580
|
+
|
|
581
|
+
async def run_interactive(self) -> int:
|
|
582
|
+
"""Line-oriented console loop."""
|
|
583
|
+
await self.start()
|
|
584
|
+
interrupt_count = 0
|
|
585
|
+
try:
|
|
586
|
+
while not self._exit_requested:
|
|
587
|
+
try:
|
|
588
|
+
line = await self.ui.prompt(self.status_prompt())
|
|
589
|
+
except KeyboardInterrupt:
|
|
590
|
+
interrupt_count += 1
|
|
591
|
+
if interrupt_count >= 2:
|
|
592
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "exiting"))
|
|
593
|
+
return 130
|
|
594
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "Ctrl-C again to exit"))
|
|
595
|
+
continue
|
|
596
|
+
if line is None:
|
|
597
|
+
break
|
|
598
|
+
interrupt_count = 0
|
|
599
|
+
line = line.strip()
|
|
600
|
+
if not line:
|
|
601
|
+
continue
|
|
602
|
+
try:
|
|
603
|
+
action = await self.handle_line(line)
|
|
604
|
+
except KeyboardInterrupt:
|
|
605
|
+
interrupt_count += 1
|
|
606
|
+
self.cancel_active_turn()
|
|
607
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "turn cancelled"))
|
|
608
|
+
self._persist()
|
|
609
|
+
if interrupt_count >= 2:
|
|
610
|
+
return 130
|
|
611
|
+
continue
|
|
612
|
+
except asyncio.CancelledError:
|
|
613
|
+
self.ui.render(HostEvent(HostEventKind.STATUS, "turn cancelled"))
|
|
614
|
+
self._persist()
|
|
615
|
+
continue
|
|
616
|
+
if action == "exit":
|
|
617
|
+
break
|
|
618
|
+
return 0
|
|
619
|
+
finally:
|
|
620
|
+
await self.close()
|
|
621
|
+
|
|
622
|
+
async def run_tui(self) -> int:
|
|
623
|
+
"""Full-screen Textual UI. App owns input; host owns turns."""
|
|
624
|
+
try:
|
|
625
|
+
from noah_code.ui.textual_app import NoahCodeApp, TextualUI
|
|
626
|
+
except ImportError as exc: # pragma: no cover
|
|
627
|
+
raise RuntimeError(
|
|
628
|
+
"Textual is required for the TUI. Install with: uv sync / pip install textual"
|
|
629
|
+
) from exc
|
|
630
|
+
|
|
631
|
+
ui = TextualUI()
|
|
632
|
+
self.ui = ui
|
|
633
|
+
await self.start()
|
|
634
|
+
app = NoahCodeApp(self, ui)
|
|
635
|
+
try:
|
|
636
|
+
await app.run_async()
|
|
637
|
+
return 0
|
|
638
|
+
finally:
|
|
639
|
+
await self.close()
|
|
640
|
+
|
|
641
|
+
async def run_once(self, prompt: str) -> HostResult:
|
|
642
|
+
await self.start()
|
|
643
|
+
try:
|
|
644
|
+
if self.config.auto_approve:
|
|
645
|
+
|
|
646
|
+
async def _auto(req): # noqa: ANN001
|
|
647
|
+
if req.decision.action == "deny":
|
|
648
|
+
return ApprovalChoice.REJECT
|
|
649
|
+
return ApprovalChoice.ONCE
|
|
650
|
+
|
|
651
|
+
self.agent.approvals.set_handler(_auto)
|
|
652
|
+
else:
|
|
653
|
+
|
|
654
|
+
async def _reject(req): # noqa: ANN001
|
|
655
|
+
return ApprovalChoice.REJECT
|
|
656
|
+
|
|
657
|
+
self.agent.approvals.set_handler(_reject)
|
|
658
|
+
|
|
659
|
+
result = await self._run_user_turn(prompt)
|
|
660
|
+
return result
|
|
661
|
+
finally:
|
|
662
|
+
await self.close()
|