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/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()