pulse-coding-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/cli.py ADDED
@@ -0,0 +1,1075 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import getpass
6
+ import io
7
+ import json
8
+ import shutil
9
+ import sys
10
+ import warnings
11
+ from pathlib import Path
12
+
13
+ from rich.table import Table
14
+
15
+ from pulse import __version__
16
+ from pulse.auth import (
17
+ AuthenticationManager,
18
+ AuthError,
19
+ AuthTimeoutError,
20
+ StateMismatchError,
21
+ UserCancelledError,
22
+ get_current_user,
23
+ is_authenticated,
24
+ login,
25
+ )
26
+ from pulse.ci import github_client
27
+ from pulse.ci.runner import CIRunner
28
+ from pulse.config import load_agent_config
29
+ from pulse.conversations import ConversationManager
30
+ from pulse.edits import EditProposal
31
+ from pulse.interactive import InteractivePrompt, parse_slash_command
32
+ from pulse.provider_keys import ProviderKeyError, ProviderKeyStore
33
+ from pulse.providers.manager import ProviderManager
34
+ from pulse.runtime import build_runtime
35
+ from pulse.telemetry import set_correlation_id
36
+ from pulse.tool_registry import ToolInvocation
37
+
38
+ from .cli_ui import (
39
+ print_all_models_list,
40
+ print_auth_prompt,
41
+ print_banner,
42
+ print_chat_card,
43
+ print_chat_created,
44
+ print_chat_exported,
45
+ print_chat_list,
46
+ print_chat_search_results,
47
+ print_chat_switched,
48
+ print_cli_output,
49
+ print_current_model_card,
50
+ print_error,
51
+ print_help_screen,
52
+ print_info,
53
+ print_model_selection,
54
+ print_provider_changed_card,
55
+ print_provider_selection,
56
+ print_session_footer,
57
+ print_signed_in,
58
+ print_status_cards,
59
+ print_success,
60
+ print_verification,
61
+ print_warning,
62
+ task_spinner,
63
+ thinking_spinner,
64
+ )
65
+
66
+
67
+ def _build_parser() -> argparse.ArgumentParser:
68
+ """Build the public parser shared by batch and interactive modes."""
69
+ parser = argparse.ArgumentParser(prog="pulse", description="Permissioned single-model project agent.")
70
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
71
+ subparsers = parser.add_subparsers(dest="command")
72
+
73
+ subparsers.add_parser("version", help="Show the installed Pulse version.")
74
+
75
+ ask_parser = subparsers.add_parser("ask", help="Ask about the current project.")
76
+ ask_parser.add_argument("question", nargs="+")
77
+
78
+ model_parser = subparsers.add_parser("model", help="Interactive AI provider & model manager.")
79
+ model_parser.add_argument("provider", nargs="?", default=None, help="Provider name or subcommand ('list', 'current', gemini, openrouter, openai, anthropic, groq, deepseek)")
80
+ model_parser.add_argument("model", nargs="?", default=None, help="Model identifier")
81
+
82
+ keys_parser = subparsers.add_parser(
83
+ "keys", help="Securely manage provider API keys in the OS credential vault."
84
+ )
85
+ keys_subparsers = keys_parser.add_subparsers(dest="keys_command")
86
+ keys_subparsers.add_parser("list", help="Show provider key configuration without values.")
87
+ keys_set = keys_subparsers.add_parser("set", help="Securely prompt for a provider API key.")
88
+ keys_set.add_argument("provider", help="Provider name")
89
+ keys_rotate = keys_subparsers.add_parser(
90
+ "rotate", help="Securely replace a configured provider API key."
91
+ )
92
+ keys_rotate.add_argument("provider", help="Provider name")
93
+ keys_remove = keys_subparsers.add_parser(
94
+ "remove", help="Remove a provider key from secure local storage."
95
+ )
96
+ keys_remove.add_argument("provider", help="Provider name")
97
+
98
+ # ── Conversation management ──────────────────────────────────────────────
99
+ chat_parser = subparsers.add_parser("chat", help="Manage conversations.")
100
+ chat_subs = chat_parser.add_subparsers(dest="chat_cmd")
101
+
102
+ chat_subs.add_parser("new", help="Start a new conversation.").add_argument(
103
+ "--title", default=None, help="Optional title for the new conversation."
104
+ )
105
+ chat_subs.add_parser("list", help="List all conversations.")
106
+
107
+ chat_switch = chat_subs.add_parser("switch", help="Switch to a conversation by ID.")
108
+ chat_switch.add_argument("id", help="Conversation ID (or unique prefix)")
109
+
110
+ chat_delete = chat_subs.add_parser("delete", help="Delete a conversation.")
111
+ chat_delete.add_argument("id", help="Conversation ID (or unique prefix)")
112
+
113
+ chat_rename = chat_subs.add_parser("rename", help="Rename a conversation.")
114
+ chat_rename.add_argument("id", help="Conversation ID (or unique prefix)")
115
+ chat_rename.add_argument("title", help="New title")
116
+
117
+ chat_export = chat_subs.add_parser("export", help="Export a conversation to Markdown or JSON.")
118
+ chat_export.add_argument("id", help="Conversation ID (or unique prefix)")
119
+ chat_export.add_argument("--output", default=None, help="Output file path")
120
+ chat_export.add_argument("--format", dest="fmt", choices=["md", "json"], default="md", help="Export format (md or json)")
121
+
122
+ chat_search = chat_subs.add_parser("search", help="Search conversations by title or content.")
123
+ chat_search.add_argument("query", help="Search query")
124
+ # ────────────────────────────────────────────────────────────────────────
125
+
126
+ subparsers.add_parser("status", help="Show agent configuration.")
127
+ doctor_parser = subparsers.add_parser(
128
+ "doctor", help="Check CLI, provider, and production deployment readiness."
129
+ )
130
+ doctor_parser.add_argument(
131
+ "--production", action="store_true", help="Run release-blocking production checks."
132
+ )
133
+ doctor_parser.add_argument(
134
+ "--target", choices=("local", "remote"), default="local"
135
+ )
136
+ doctor_parser.add_argument(
137
+ "--json", action="store_true", dest="as_json", help="Emit machine-readable JSON."
138
+ )
139
+ mutations_parser = subparsers.add_parser("mutations", help="Show tracked repository mutations.")
140
+ mutations_parser.add_argument("--last", action="store_true", help="Show only the latest transaction.")
141
+ edit_parser = subparsers.add_parser("edit", help="Propose a file replacement and request approval.")
142
+ edit_parser.add_argument("file")
143
+ edit_parser.add_argument("content")
144
+ patch_parser = subparsers.add_parser("patch", help="Patch a specific function or class in a file.")
145
+ patch_parser.add_argument("file", help="Path to the target file")
146
+ patch_parser.add_argument("target", help="Name of the function or class to patch")
147
+ patch_parser.add_argument("operation", choices=["insert", "replace", "delete", "rename"], help="Patch operation")
148
+ patch_parser.add_argument("content", nargs="?", default=None, help="Patch content string or path to a file containing the content")
149
+ subparsers.add_parser("rollback", help="Rollback the latest approved edit.")
150
+ subparsers.add_parser("index", help="Incrementally index the repository.")
151
+ search_parser = subparsers.add_parser("search", help="Search indexed repository files.")
152
+ search_parser.add_argument("query")
153
+ symbols_parser = subparsers.add_parser("symbols", help="Show indexed symbols from a file.")
154
+ symbols_parser.add_argument("file")
155
+ subparsers.add_parser("verify", help="Run the detected project test suite.")
156
+ subparsers.add_parser("git", help="Show Git status, diff analysis, and a commit suggestion.")
157
+ memory_parser = subparsers.add_parser("memory", help="Inspect long-term memory or save a preference.")
158
+ memory_parser.add_argument("--query", default="", help="Search remembered context.")
159
+ memory_parser.add_argument("--set", nargs=2, metavar=("KEY", "VALUE"), help="Save a user preference.")
160
+ ci_parser = subparsers.add_parser("ci", help="Run CI for a pull request.")
161
+ ci_parser.add_argument("--pr", type=int, required=True, help="Pull request number to process.")
162
+ tasks_parser = subparsers.add_parser("tasks", help="List workspace tasks.")
163
+ tasks_parser.add_argument("--status", help="Filter tasks by status (PENDING, RUNNING, COMPLETED, FAILED, PAUSED, CANCELLED)")
164
+ task_detail_parser = subparsers.add_parser("task", help="Display task details.")
165
+ task_detail_parser.add_argument("id", help="Task ID")
166
+ resume_parser = subparsers.add_parser("resume", help="Resume a paused or failed task.")
167
+ resume_parser.add_argument("id", help="Task ID to resume")
168
+ cancel_parser = subparsers.add_parser("cancel", help="Cancel a pending, queued, or running task.")
169
+ cancel_parser.add_argument("id", help="Task ID to cancel")
170
+ cancel_parser.add_argument("--reason", default="Cancelled via CLI", help="Cancellation reason")
171
+ subparsers.add_parser("sessions", help="List all sessions.")
172
+ session_parser = subparsers.add_parser("session", help="Display session details.")
173
+ session_parser.add_argument("id", help="Session ID")
174
+ resume_session_parser = subparsers.add_parser("resume-session", help="Resume an archived or inactive session.")
175
+ resume_session_parser.add_argument("id", help="Session ID to resume")
176
+
177
+ # Authentication commands
178
+ subparsers.add_parser(
179
+ "login",
180
+ help="Sign in with Google OAuth 2.0 PKCE flow.",
181
+ )
182
+
183
+ subparsers.add_parser("logout", help="Sign out and clear stored tokens.")
184
+ subparsers.add_parser("whoami", help="Show the currently signed-in user.")
185
+ subparsers.add_parser("auth-status", help="Show current authentication status.")
186
+
187
+ serve_parser = subparsers.add_parser("serve", help="Start the local JSON-RPC WebSocket server for IDE clients.")
188
+ serve_parser.add_argument("--host", default="127.0.0.1")
189
+ serve_parser.add_argument("--port", type=int, default=8765)
190
+
191
+ return parser
192
+
193
+
194
+ def _run_main(argv: list[str] | None = None) -> None:
195
+ set_correlation_id()
196
+ parser = _build_parser()
197
+ args = parser.parse_args(argv)
198
+ workspace = Path.cwd()
199
+
200
+ if args.command == "version":
201
+ print(f"pulse {__version__}")
202
+ return
203
+
204
+ if args.command == "keys":
205
+ _handle_keys_command(workspace, args)
206
+ return
207
+
208
+ if args.command in {"login", "logout", "whoami", "auth-status"}:
209
+ auth = AuthenticationManager(workspace)
210
+ if args.command == "login":
211
+ if _handle_login_command():
212
+ _run_provider_onboarding(workspace)
213
+ elif args.command == "logout":
214
+ auth.logout()
215
+ print_success("Signed out and cleared the stored session.")
216
+ elif args.command == "whoami":
217
+ _show_whoami()
218
+ elif auth.is_authenticated():
219
+ user = auth.get_current_user()
220
+ identity = (user.name or user.email) if user else "Authenticated user"
221
+ print_success(f"Signed in as {identity}.")
222
+ else:
223
+ print_warning("Not signed in. Run `pulse login` to authenticate.")
224
+ raise SystemExit(1)
225
+ return
226
+
227
+ if args.command == "model":
228
+ _handle_model_command(workspace, args.provider, args.model)
229
+ return
230
+
231
+ if args.command == "chat":
232
+ _handle_chat_command(workspace, args)
233
+ return
234
+
235
+ config = load_agent_config(workspace)
236
+ if args.command == "serve":
237
+ from pulse.rpc import serve
238
+
239
+ asyncio.run(serve(str(workspace), args.host, args.port))
240
+ return
241
+
242
+ runtime = build_runtime(workspace, config)
243
+
244
+ try:
245
+ if args.command == "ask":
246
+ with task_spinner("Generating response"):
247
+ runtime.agent.ask(" ".join(args.question), auto_approve_reads=True)
248
+ elif args.command == "status":
249
+ print_status_cards(config, runtime.provider, runtime=runtime)
250
+ elif args.command == "doctor":
251
+ passed = print_doctor(
252
+ config,
253
+ runtime.provider,
254
+ workspace,
255
+ production=args.production,
256
+ target=args.target,
257
+ as_json=args.as_json,
258
+ )
259
+ if not passed:
260
+ raise SystemExit(2)
261
+
262
+ elif args.command == "mutations":
263
+ if not is_authenticated():
264
+ print_info("Authentication required. Please login first.")
265
+ return
266
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="mutations", arguments={"last": args.last}))).content, title="Mutations")
267
+ elif args.command == "edit":
268
+ if not is_authenticated():
269
+ print_info("Authentication required. Please login first.")
270
+ return
271
+ result = asyncio.run(runtime.tools.execute(ToolInvocation(name="edit", arguments={"file": args.file, "content": args.content, "reason": "CLI requested edit", "approve": approve_in_cli})))
272
+ print_cli_output(result.content, title="Edit Result")
273
+ elif args.command == "patch":
274
+ if not is_authenticated():
275
+ print_info("Authentication required. Please login first.")
276
+ return
277
+ from pulse.patch import PatchEngine
278
+ content = args.content
279
+ if content and Path(content).is_file():
280
+ content = Path(content).read_text(encoding="utf-8")
281
+
282
+ patch_engine = PatchEngine(
283
+ edits=runtime.edits,
284
+ safety_manager=runtime.reasoning_engine.safety_manager,
285
+ mutations=runtime.mutations,
286
+ context_manager=runtime.context_manager,
287
+ reasoning_engine=runtime.reasoning_engine,
288
+ task_manager=runtime.task_manager
289
+ )
290
+
291
+ async def run_patch():
292
+ try:
293
+ success = await patch_engine.apply_patch(
294
+ file_path=args.file,
295
+ target_name=args.target,
296
+ operation=args.operation,
297
+ content=content,
298
+ approve=approve_in_cli
299
+ )
300
+ if success:
301
+ print_info("Patch applied successfully.")
302
+ else:
303
+ print_error("Patch rejected or failed safety check.")
304
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
305
+ except Exception as e: # noqa: BLE001
306
+ # Intentionally broad at CLI boundary to gracefully report user errors.
307
+ print_error(f"Patch error: {e}")
308
+
309
+ asyncio.run(run_patch())
310
+ elif args.command == "rollback":
311
+ if not is_authenticated():
312
+ print_info("Authentication required. Please login first.")
313
+ return
314
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="rollback"))).content, title="Rollback")
315
+ elif args.command == "index":
316
+ with task_spinner("Indexing repository"):
317
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="index"))).content, title="Index")
318
+ elif args.command == "search":
319
+ with task_spinner("Searching repository"):
320
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="search", arguments={"query": args.query}))).content, title="Search")
321
+ elif args.command == "symbols":
322
+ with task_spinner("Parsing symbols"):
323
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="symbols", arguments={"file": args.file}))).content, title="Symbols")
324
+ elif args.command == "verify":
325
+ with task_spinner("Running verification test suite"):
326
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="verify"))).content, title="Verify")
327
+ elif args.command == "git":
328
+ with task_spinner("Analyzing Git repository state"):
329
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="git"))).content, title="Git")
330
+ elif args.command == "memory":
331
+ arguments = {"query": args.query}
332
+ if args.set:
333
+ arguments.update({"preference_key": args.set[0], "preference_value": args.set[1]})
334
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="memory", arguments=arguments))).content, title="Memory")
335
+ elif args.command == "tasks":
336
+ arguments = {"status": args.status} if hasattr(args, "status") else {}
337
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="tasks", arguments=arguments))).content, title="Tasks")
338
+ elif args.command == "task":
339
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="task", arguments={"id": args.id, "action": "show"}))).content, title=f"Task {args.id}")
340
+ elif args.command == "resume":
341
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="task", arguments={"id": args.id, "action": "resume"}))).content, title=f"Resume Task {args.id}")
342
+ elif args.command == "cancel":
343
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="cancel", arguments={"id": args.id, "reason": getattr(args, "reason", "CLI cancellation")}))) .content, title=f"Cancel Task {args.id}")
344
+ elif args.command == "sessions":
345
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="sessions", arguments={}))).content, title="Sessions")
346
+ elif args.command == "session":
347
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="session", arguments={"id": args.id}))).content, title=f"Session {args.id}")
348
+ elif args.command == "resume-session":
349
+ print_cli_output(asyncio.run(runtime.tools.execute(ToolInvocation(name="resume-session", arguments={"id": args.id}))).content, title=f"Resume Session {args.id}")
350
+ elif args.command == "ci":
351
+ if not is_authenticated():
352
+ print_info("Authentication required. Please login first.")
353
+ return
354
+ client = github_client.GitHubClient()
355
+ runner = CIRunner(client, workspace=Path.cwd())
356
+ comment = asyncio.run(runner.run_pr(args.pr))
357
+ print_cli_output(comment, title="CI Comment")
358
+ else:
359
+ _handle_interactive_mode(runtime.auth, runtime.agent, runtime=runtime)
360
+ finally:
361
+ runtime.audit.print_summary()
362
+
363
+
364
+ def _handle_model_command(
365
+ workspace: Path, provider_arg: str | None = None, model_arg: str | None = None
366
+ ) -> None:
367
+ """Handle interactive model manager, subcommands ('list', 'current'), and direct CLI args."""
368
+ pm = ProviderManager(workspace)
369
+
370
+ # Subcommand: pulse model current
371
+ if provider_arg and provider_arg.lower() == "current":
372
+ active_prov, active_mod, warning = pm.validate_active_selection()
373
+ if warning:
374
+ print_warning(warning)
375
+ spec = pm.get_provider_spec(active_prov)
376
+ meta = pm.get_model_metadata(active_prov, active_mod)
377
+ providers_status = {p["key"]: p["configured"] for p in pm.list_providers()}
378
+ print_current_model_card(
379
+ spec.display_name,
380
+ active_mod,
381
+ meta,
382
+ spec.env_var,
383
+ providers_status.get(active_prov, False),
384
+ )
385
+ return
386
+
387
+ # Subcommand: pulse model list
388
+ if provider_arg and provider_arg.lower() == "list":
389
+ active_prov, active_mod, warning = pm.validate_active_selection()
390
+ if warning:
391
+ print_warning(warning)
392
+ if model_arg:
393
+ try:
394
+ spec = pm.get_provider_spec(model_arg)
395
+ print_model_selection(
396
+ spec.display_name,
397
+ spec.available_models,
398
+ spec.default_model,
399
+ active_mod if active_prov == spec.key else "",
400
+ )
401
+ except ValueError as error:
402
+ print_error(str(error))
403
+ sys.exit(1)
404
+ else:
405
+ providers = pm.list_providers()
406
+ print_all_models_list(providers, active_prov, active_mod)
407
+ return
408
+
409
+ # Direct provider setting: pulse model openrouter [qwen/qwen3-coder]
410
+ if provider_arg:
411
+ try:
412
+ spec = pm.get_provider_spec(provider_arg)
413
+ except ValueError as error:
414
+ print_error(str(error))
415
+ sys.exit(1)
416
+
417
+ target_model = model_arg or spec.default_model
418
+ saved_prov, saved_mod = pm.save_selection(spec.key, target_model)
419
+ providers_status = {p["key"]: p["configured"] for p in pm.list_providers()}
420
+ print_provider_changed_card(
421
+ spec.display_name,
422
+ saved_mod,
423
+ spec.env_var,
424
+ providers_status.get(saved_prov, False),
425
+ )
426
+ return
427
+
428
+ selection = _prompt_provider_and_model(pm)
429
+ if selection is None:
430
+ return
431
+ chosen_key, chosen_model = selection
432
+ spec = pm.get_provider_spec(chosen_key)
433
+ saved_prov, saved_mod = pm.save_selection(chosen_key, chosen_model)
434
+ providers_status = {p["key"]: p["configured"] for p in pm.list_providers()}
435
+ print_provider_changed_card(
436
+ spec.display_name,
437
+ saved_mod,
438
+ spec.env_var,
439
+ providers_status.get(saved_prov, False),
440
+ )
441
+
442
+
443
+ def _prompt_provider_and_model(pm: ProviderManager) -> tuple[str, str] | None:
444
+ """Prompt for one provider/model pair without saving partial selection."""
445
+ active_prov, active_mod, warning = pm.validate_active_selection()
446
+ if warning:
447
+ print_warning(warning)
448
+
449
+ providers = pm.list_providers()
450
+ print_provider_selection(providers, active_prov)
451
+
452
+ try:
453
+ selection = (
454
+ input("Select provider number (1-6) or key [Enter keeps active]: ")
455
+ .strip()
456
+ .lower()
457
+ )
458
+ except (KeyboardInterrupt, EOFError):
459
+ print("\nCancelled model selection.")
460
+ return
461
+
462
+ if not selection:
463
+ chosen_key = active_prov
464
+ elif selection.isdigit():
465
+ idx = int(selection)
466
+ if 1 <= idx <= len(providers):
467
+ chosen_key = providers[idx - 1]["key"]
468
+ else:
469
+ print_error("Invalid provider selection index.")
470
+ return None
471
+ else:
472
+ try:
473
+ chosen_key = pm.get_provider_spec(selection).key
474
+ except ValueError as error:
475
+ print_error(str(error))
476
+ return None
477
+
478
+ spec = pm.get_provider_spec(chosen_key)
479
+ print_model_selection(
480
+ spec.display_name,
481
+ spec.available_models,
482
+ spec.default_model,
483
+ active_mod if active_prov == spec.key else "",
484
+ )
485
+
486
+ try:
487
+ model_choice = input(
488
+ f"Select model number (1-{len(spec.available_models)}) or enter custom model [Enter keeps default]: "
489
+ ).strip()
490
+ except (KeyboardInterrupt, EOFError):
491
+ print("\nCancelled model selection.")
492
+ return
493
+
494
+ if not model_choice:
495
+ chosen_model = spec.default_model
496
+ elif model_choice.isdigit():
497
+ m_idx = int(model_choice)
498
+ if 1 <= m_idx <= len(spec.available_models):
499
+ chosen_model = spec.available_models[m_idx - 1].name
500
+ else:
501
+ print_error("Invalid model selection index.")
502
+ return None
503
+ elif model_choice.lower() == "c":
504
+ try:
505
+ chosen_model = input("Enter custom model identifier: ").strip()
506
+ except (KeyboardInterrupt, EOFError):
507
+ print("\nCancelled model selection.")
508
+ return
509
+ if not chosen_model:
510
+ chosen_model = spec.default_model
511
+ else:
512
+ chosen_model = model_choice
513
+
514
+ return chosen_key, chosen_model
515
+
516
+
517
+ def _handle_login_command() -> bool:
518
+ """Handle `pulse login` experience."""
519
+ if is_authenticated():
520
+ user = get_current_user()
521
+ email = user.email if user else "user"
522
+ print_success(f"Already signed in as {email}.")
523
+ return False
524
+
525
+ try:
526
+ user = login()
527
+ if user:
528
+ print_signed_in(user.name, user.email)
529
+ return True
530
+ return False
531
+ except UserCancelledError:
532
+ print_error("Authentication was cancelled in the browser.")
533
+ sys.exit(1)
534
+ except AuthTimeoutError:
535
+ print_error("Authentication timed out waiting for browser callback.")
536
+ sys.exit(1)
537
+ except StateMismatchError:
538
+ print_error("Authentication state mismatch. Possible security issue.")
539
+ sys.exit(1)
540
+ except AuthError as e:
541
+ print_error(f"Authentication failed: {e}")
542
+ sys.exit(1)
543
+ except ValueError as e:
544
+ print_error(f"Configuration error: {e}")
545
+ print_info(
546
+ "Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your .env file.\n"
547
+ "See .env.example and the README for setup instructions."
548
+ )
549
+ sys.exit(1)
550
+
551
+
552
+ def _run_provider_onboarding(workspace: Path) -> bool:
553
+ """Connect a provider/model/key immediately after successful login."""
554
+ print_info("Authentication complete. Connect your BYOK model provider.")
555
+ manager = ProviderManager(workspace)
556
+ selection = _prompt_provider_and_model(manager)
557
+ if selection is None:
558
+ print_warning("Provider setup skipped. Run /keys or /model at any time.")
559
+ return False
560
+
561
+ provider, model = selection
562
+ store = ProviderKeyStore(workspace)
563
+ status = next(item for item in store.statuses() if item.provider == provider)
564
+ if status.configured:
565
+ print_info(
566
+ f"Using the existing {provider} credential from {status.source}."
567
+ )
568
+ elif not _prompt_and_store_provider_key(store, provider, rotating=False):
569
+ print_warning("Provider setup was not saved because no key was stored.")
570
+ return False
571
+
572
+ saved_provider, saved_model = manager.save_selection(provider, model)
573
+ spec = manager.get_provider_spec(saved_provider)
574
+ print_provider_changed_card(
575
+ spec.display_name,
576
+ saved_model,
577
+ spec.env_var,
578
+ True,
579
+ )
580
+ print_success("BYOK setup complete. Your next message will use this provider.")
581
+ return True
582
+
583
+
584
+ def _active_provider_has_key(workspace: Path) -> bool:
585
+ manager = ProviderManager(workspace)
586
+ active_provider, _ = manager.get_active_selection()
587
+ return any(
588
+ status.provider == active_provider and status.configured
589
+ for status in ProviderKeyStore(workspace).statuses()
590
+ )
591
+
592
+
593
+ def _read_hidden_provider_key(provider: str) -> str | None:
594
+ """Read a secret only when the terminal can suppress input echo."""
595
+ try:
596
+ with warnings.catch_warnings():
597
+ warnings.simplefilter("error", getpass.GetPassWarning)
598
+ return getpass.getpass(f"Enter the {provider} API key (input hidden): ")
599
+ except getpass.GetPassWarning:
600
+ print_error(
601
+ "Secure hidden input is unavailable in this terminal; the key was not read."
602
+ )
603
+ except (KeyboardInterrupt, EOFError):
604
+ print_warning("API key entry cancelled.")
605
+ return None
606
+
607
+
608
+ def _prompt_and_store_provider_key(
609
+ store: ProviderKeyStore, provider: str, *, rotating: bool
610
+ ) -> bool:
611
+ value = _read_hidden_provider_key(provider)
612
+ if value is None:
613
+ return False
614
+ try:
615
+ variable = store.rotate(provider, value) if rotating else store.set(provider, value)
616
+ except ProviderKeyError as error:
617
+ print_error(str(error))
618
+ return False
619
+ action = "Rotated" if rotating else "Stored"
620
+ print_success(f"{action} {variable} in the OS credential vault.")
621
+ return True
622
+
623
+
624
+ def _print_provider_key_statuses(store: ProviderKeyStore) -> None:
625
+ table = Table(title="Provider API keys (secret values are never displayed)")
626
+ table.add_column("#", justify="right", style="dim")
627
+ table.add_column("Provider", style="cyan")
628
+ table.add_column("Environment variable")
629
+ table.add_column("State")
630
+ table.add_column("Source")
631
+ for index, status in enumerate(store.statuses(), 1):
632
+ table.add_row(
633
+ str(index),
634
+ status.provider,
635
+ status.environment_variable,
636
+ "Configured" if status.configured else "Missing",
637
+ status.source,
638
+ )
639
+ print_cli_output(table, title="Provider keys")
640
+
641
+
642
+ def _run_keys_manager(workspace: Path) -> None:
643
+ """Interactive provider-key status, rotation, and removal manager."""
644
+ store = ProviderKeyStore(workspace)
645
+ while True:
646
+ _print_provider_key_statuses(store)
647
+ statuses = store.statuses()
648
+ try:
649
+ choice = input(
650
+ "Select provider number or key to manage [Enter exits]: "
651
+ ).strip().lower()
652
+ except (KeyboardInterrupt, EOFError):
653
+ print("\nKey manager closed.")
654
+ return
655
+ if not choice:
656
+ return
657
+ if choice.isdigit() and 1 <= int(choice) <= len(statuses):
658
+ status = statuses[int(choice) - 1]
659
+ else:
660
+ status = next((item for item in statuses if item.provider == choice), None)
661
+ if status is None:
662
+ print_error("Unknown provider selection.")
663
+ continue
664
+
665
+ actions = "[R]otate [D]elete [Enter] back" if status.configured else "[S]et [Enter] back"
666
+ try:
667
+ action = input(f"{status.provider}: {actions}: ").strip().lower()
668
+ except (KeyboardInterrupt, EOFError):
669
+ print("\nKey manager closed.")
670
+ return
671
+ if action in {"s", "set"} and not status.configured:
672
+ _prompt_and_store_provider_key(store, status.provider, rotating=False)
673
+ elif action in {"r", "rotate"} and status.configured:
674
+ _prompt_and_store_provider_key(store, status.provider, rotating=True)
675
+ elif action in {"d", "delete", "remove"} and status.configured:
676
+ confirm = input(
677
+ f"Remove the stored {status.provider} key? [y/N]: "
678
+ ).strip().lower()
679
+ if confirm in {"y", "yes"}:
680
+ variable, removed, environment_still_set = store.remove(status.provider)
681
+ if removed:
682
+ print_success(f"Removed {variable} from secure local storage.")
683
+ if environment_still_set:
684
+ print_warning(
685
+ f"{variable} remains set by the process environment."
686
+ )
687
+
688
+
689
+ def _handle_keys_command(workspace: Path, args: object) -> None:
690
+ """Handle provider keys without ever echoing secret values."""
691
+ store = ProviderKeyStore(workspace)
692
+ command = getattr(args, "keys_command", None)
693
+ try:
694
+ if command is None:
695
+ _run_keys_manager(workspace)
696
+ return
697
+
698
+ if command == "list":
699
+ _print_provider_key_statuses(store)
700
+ return
701
+
702
+ provider = str(getattr(args, "provider", ""))
703
+ if command in {"set", "rotate"}:
704
+ stored = _prompt_and_store_provider_key(
705
+ store,
706
+ provider,
707
+ rotating=command == "rotate",
708
+ )
709
+ if not stored:
710
+ raise SystemExit(2)
711
+ return
712
+
713
+ if command == "remove":
714
+ variable, removed, environment_still_set = store.remove(provider)
715
+ if removed:
716
+ print_success(f"Removed {variable} from secure local storage.")
717
+ else:
718
+ print_warning(f"{variable} was not present in managed storage.")
719
+ if environment_still_set:
720
+ print_warning(
721
+ f"{variable} is still configured in the process environment; "
722
+ "remove it from your shell or secret manager separately."
723
+ )
724
+ return
725
+ except ProviderKeyError as error:
726
+ print_error(str(error))
727
+ raise SystemExit(2) from error
728
+
729
+
730
+ def _show_whoami() -> None:
731
+ """Print current user profile info."""
732
+ user = get_current_user()
733
+ if user:
734
+ if user.name and user.name != user.email:
735
+ print_info(f"{user.name} ({user.email})")
736
+ else:
737
+ print_info(user.email)
738
+ else:
739
+ print_info("Not signed in. Run `pulse login` to sign in.")
740
+
741
+
742
+ def approve_in_cli(proposal: EditProposal) -> bool:
743
+ verification_msg = f"Proposed edit: {proposal.file_path}\n{proposal.unified_diff or '(no changes)'}"
744
+ print_verification(verification_msg)
745
+ answer = input("Apply this edit? [y/N] ").strip().lower()
746
+ return answer in {"y", "yes"}
747
+
748
+
749
+ def print_doctor(
750
+ config,
751
+ provider,
752
+ workspace: Path,
753
+ *,
754
+ production: bool = False,
755
+ target: str = "local",
756
+ as_json: bool = False,
757
+ ) -> bool:
758
+ if production:
759
+ from pulse.production import run_production_checks
760
+
761
+ report = run_production_checks(
762
+ workspace,
763
+ config,
764
+ provider_configured=provider.is_configured,
765
+ target=target,
766
+ )
767
+ if as_json:
768
+ print(json.dumps(report.to_dict(), separators=(",", ":")))
769
+ return report.passed
770
+
771
+ table = Table(title=f"Pulse production doctor ({target})")
772
+ table.add_column("Check", style="cyan")
773
+ table.add_column("State")
774
+ table.add_column("Detail")
775
+ table.add_column("Remediation")
776
+ for check in report.checks:
777
+ state = "OK" if check.ok else "Warning" if not check.blocking else "BLOCKED"
778
+ table.add_row(check.name, state, check.detail, "" if check.ok else check.remediation)
779
+ print_cli_output(table, title="Production doctor")
780
+ return report.passed
781
+
782
+ api_key_env_var = getattr(provider, "api_key_env_var", "Provider API key")
783
+ checks = [
784
+ ("Workspace", str(workspace), workspace.exists()),
785
+ ("agent.config.json", str(workspace / "agent.config.json"), (workspace / "agent.config.json").exists()),
786
+ ("Provider key", api_key_env_var, provider.is_configured),
787
+ ("uv command", shutil.which("uv") or "not on PATH", shutil.which("uv") is not None),
788
+ ("pulse command", shutil.which("pulse") or "not on PATH", shutil.which("pulse") is not None),
789
+ ("Single model mode", config.mode, config.mode == "single-model"),
790
+ ("Configured provider", config.model.provider, bool(config.model.provider)),
791
+ ("Configured model", config.model.name, bool(config.model.name)),
792
+ ("Maximum output tokens", str(config.model.max_tokens), config.model.max_tokens > 0),
793
+ ]
794
+
795
+ table = Table(title="Pulse doctor")
796
+ table.add_column("Check", style="cyan")
797
+ table.add_column("Value")
798
+ table.add_column("State")
799
+
800
+ for name, value, ok in checks:
801
+ table.add_row(name, value, "OK" if ok else "Needs attention")
802
+
803
+ passed = all(ok for _, _, ok in checks)
804
+ if as_json:
805
+ print(
806
+ json.dumps(
807
+ {
808
+ "target": "development",
809
+ "passed": passed,
810
+ "checks": [
811
+ {"name": name, "value": value, "ok": ok}
812
+ for name, value, ok in checks
813
+ ],
814
+ },
815
+ separators=(",", ":"),
816
+ )
817
+ )
818
+ else:
819
+ print_cli_output(table, title="Doctor")
820
+ return passed
821
+
822
+
823
+ def print_mutations(events: list[dict[str, object]]) -> None:
824
+ if not events:
825
+ print_info("No tracked mutations found.")
826
+ return
827
+
828
+ table = Table(title="Pulse mutations")
829
+ table.add_column("Transaction", style="cyan")
830
+ table.add_column("Time")
831
+ table.add_column("Action")
832
+ table.add_column("File")
833
+ table.add_column("Command")
834
+ for event in events:
835
+ table.add_row(
836
+ str(event.get("transaction_id", ""))[:8],
837
+ str(event.get("timestamp", "")),
838
+ str(event.get("action", "")),
839
+ str(event.get("file_path", "")),
840
+ str(event.get("command") or ""),
841
+ )
842
+ print_cli_output(table, title="Mutations")
843
+
844
+
845
+ def _handle_chat_command(workspace: Path, args: object) -> None:
846
+ """Dispatch pulse chat subcommands."""
847
+ cm = ConversationManager(workspace)
848
+ chat_cmd = getattr(args, "chat_cmd", None)
849
+
850
+ if chat_cmd == "new" or chat_cmd is None:
851
+ title = getattr(args, "title", None)
852
+ conv = cm.create(title=title)
853
+ print_chat_created(conv)
854
+ return
855
+
856
+ if chat_cmd == "list":
857
+ conversations = cm.list_all()
858
+ active = cm.get_active()
859
+ active_id = active.id if active else ""
860
+ print_chat_list(conversations, active_id)
861
+ return
862
+
863
+ if chat_cmd == "switch":
864
+ conv = _resolve_conversation(cm, args.id)
865
+ if conv is None:
866
+ return
867
+ cm.switch(conv.id)
868
+ print_chat_switched(conv)
869
+ return
870
+
871
+ if chat_cmd == "delete":
872
+ conv = _resolve_conversation(cm, args.id)
873
+ if conv is None:
874
+ return
875
+ confirm = input(f'Delete conversation "{conv.title}"? [y/N] ').strip().lower()
876
+ if confirm in {"y", "yes"}:
877
+ cm.delete(conv.id)
878
+ print_info(f'Conversation "{conv.title}" deleted.')
879
+ else:
880
+ print_info("Deletion cancelled.")
881
+ return
882
+
883
+ if chat_cmd == "rename":
884
+ conv = _resolve_conversation(cm, args.id)
885
+ if conv is None:
886
+ return
887
+ updated = cm.rename(conv.id, args.title)
888
+ print_info(f'Conversation renamed to "{updated.title}".')
889
+ return
890
+
891
+ if chat_cmd == "export":
892
+ conv = _resolve_conversation(cm, args.id)
893
+ if conv is None:
894
+ return
895
+ output_path = Path(args.output) if getattr(args, "output", None) else None
896
+ fmt = getattr(args, "fmt", "md")
897
+ try:
898
+ saved_path = cm.export(conv.id, output_path=output_path, fmt=fmt)
899
+ print_chat_exported(saved_path)
900
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
901
+ except Exception as exc: # noqa: BLE001
902
+ # Intentionally broad at CLI boundary to gracefully report user errors.
903
+ print_error(f"Export failed: {exc}")
904
+ return
905
+
906
+ if chat_cmd == "search":
907
+ results = cm.search(args.query)
908
+ active = cm.get_active()
909
+ active_id = active.id if active else ""
910
+ print_chat_search_results(results, args.query, active_id)
911
+ return
912
+
913
+ # Unknown subcommand — show list
914
+ conversations = cm.list_all()
915
+ active = cm.get_active()
916
+ active_id = active.id if active else ""
917
+ print_chat_list(conversations, active_id)
918
+
919
+
920
+ def _resolve_conversation(cm: ConversationManager, id_prefix: str):
921
+ """Resolve a conversation by full ID or unique prefix. Returns None on failure."""
922
+ all_convs = cm.list_all()
923
+ matches = [c for c in all_convs if c.id == id_prefix or c.id.startswith(id_prefix)]
924
+ if not matches:
925
+ print_error(f"No conversation found matching ID prefix: {id_prefix!r}")
926
+ return None
927
+ if len(matches) > 1:
928
+ print_error(
929
+ f"Ambiguous prefix {id_prefix!r} matches {len(matches)} conversations. "
930
+ "Please use a longer prefix."
931
+ )
932
+ return None
933
+ return matches[0]
934
+
935
+
936
+ def _handle_interactive_mode(auth, agent, runtime=None) -> None:
937
+ """Handle the responsive interactive shell with conversation tracking."""
938
+ del auth # Authentication state is read from the shared workspace store.
939
+ workspace = Path.cwd()
940
+ cm = ConversationManager(workspace)
941
+ prompt = InteractivePrompt(workspace, _build_parser())
942
+
943
+ # Restore last active conversation or create a fresh one
944
+ active_conv = cm.get_active()
945
+ if active_conv is None:
946
+ active_conv = cm.create()
947
+ is_first_message = active_conv.turn_count == 0
948
+
949
+ print_banner()
950
+ if not is_authenticated():
951
+ print_auth_prompt()
952
+ answer = input("Sign in now with Google? [Y/n] ").strip().lower()
953
+ if answer not in {"n", "no"}:
954
+ if _handle_login_command():
955
+ _run_provider_onboarding(workspace)
956
+ runtime = build_runtime(workspace, load_agent_config(workspace))
957
+ agent = runtime.agent
958
+ else:
959
+ print_info("Continuing unauthenticated.")
960
+ else:
961
+ user = get_current_user()
962
+ if user:
963
+ print_signed_in(user.name, user.email)
964
+ if not _active_provider_has_key(workspace):
965
+ print_warning("The active model provider has no API key configured.")
966
+ answer = input("Configure BYOK now? [Y/n] ").strip().lower()
967
+ if answer not in {"n", "no"} and _run_provider_onboarding(workspace):
968
+ runtime = build_runtime(workspace, load_agent_config(workspace))
969
+ agent = runtime.agent
970
+
971
+ # Show active conversation info
972
+ print_chat_card(active_conv)
973
+ print_info("Type / to open the command menu, or /help to see every command.")
974
+
975
+ while True:
976
+ try:
977
+ user_input = prompt.read(active_conv.title)
978
+ if not user_input:
979
+ continue
980
+ normalized = user_input.lower()
981
+ if normalized in {"exit", "quit", "/exit", "/quit"}:
982
+ break
983
+ if normalized in {"help", "?", "/help", "/?"}:
984
+ print_help_screen(interactive=True)
985
+ continue
986
+ if normalized == "/clear":
987
+ print("\033[2J\033[H", end="")
988
+ continue
989
+ if user_input.startswith("/"):
990
+ try:
991
+ command_args = parse_slash_command(user_input)
992
+ except ValueError as error:
993
+ print_error(str(error))
994
+ continue
995
+ if not command_args:
996
+ continue
997
+ try:
998
+ main(command_args)
999
+ except KeyboardInterrupt:
1000
+ print_warning("Command cancelled.")
1001
+ except SystemExit as error:
1002
+ # argparse and command handlers use SystemExit for ordinary
1003
+ # user errors; a REPL command must never terminate the shell.
1004
+ if error.code not in {None, 0}:
1005
+ print_warning(f"Command finished with exit code {error.code}.")
1006
+
1007
+ refreshed = cm.get_active()
1008
+ if refreshed is not None:
1009
+ active_conv = refreshed
1010
+ is_first_message = active_conv.turn_count == 0
1011
+
1012
+ # Model and key changes must take effect on the very next prompt.
1013
+ if command_args[0] in {"model", "keys"}:
1014
+ runtime = build_runtime(workspace, load_agent_config(workspace))
1015
+ agent = runtime.agent
1016
+ continue
1017
+
1018
+ # Capture stdout to record the assistant response
1019
+ captured = io.StringIO()
1020
+ real_stdout = sys.stdout
1021
+ sys.stdout = captured
1022
+ interrupted = False
1023
+ try:
1024
+ with thinking_spinner():
1025
+ agent.ask(user_input, auto_approve_reads=True)
1026
+ except KeyboardInterrupt:
1027
+ interrupted = True
1028
+ finally:
1029
+ sys.stdout = real_stdout
1030
+ output = captured.getvalue()
1031
+ # Print to real stdout so user sees the answer
1032
+ print(output, end="")
1033
+
1034
+ if interrupted:
1035
+ print_warning("Request cancelled. Your session is still active.")
1036
+ continue
1037
+
1038
+ # Auto-title on first message in a fresh conversation
1039
+ if is_first_message:
1040
+ active_conv = cm.auto_title(active_conv.id, user_input)
1041
+ is_first_message = False
1042
+
1043
+ # Record turns
1044
+ cm.add_turn(active_conv.id, "user", user_input)
1045
+ if output.strip():
1046
+ cm.add_turn(active_conv.id, "assistant", output.strip())
1047
+
1048
+ print_session_footer(
1049
+ provider=runtime.config.model.provider if runtime else "pulse",
1050
+ model=runtime.config.model.name if runtime else "default",
1051
+ conversation=active_conv.title,
1052
+ )
1053
+ except (KeyboardInterrupt, EOFError):
1054
+ print("\nExiting Pulse REPL.")
1055
+ break
1056
+
1057
+
1058
+ def main(argv: list[str] | None = None) -> None:
1059
+ try:
1060
+ _run_main(argv)
1061
+ except SystemExit:
1062
+ raise
1063
+ except KeyboardInterrupt:
1064
+ print_warning("Command cancelled.")
1065
+ raise SystemExit(130) from None
1066
+ except (EOFError, OSError, RuntimeError, ValueError, json.JSONDecodeError):
1067
+ print_error("Pulse could not complete the request. Check configuration and inputs.")
1068
+ raise SystemExit(1) from None
1069
+ except Exception: # noqa: BLE001
1070
+ print_error("Pulse could not complete the request. Check configuration and inputs.")
1071
+ raise SystemExit(1) from None
1072
+
1073
+
1074
+ if __name__ == "__main__":
1075
+ main()