python-codex 0.1.13__py3-none-any.whl → 0.1.14__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 (45) hide show
  1. pycodex/agent.py +77 -12
  2. pycodex/cli.py +13 -356
  3. pycodex/feishu_card.py +76 -30
  4. pycodex/feishu_link.py +131 -11
  5. pycodex/interactive_session.py +397 -0
  6. pycodex/model.py +1 -19
  7. pycodex/protocol.py +0 -5
  8. pycodex/runtime.py +13 -0
  9. pycodex/runtime_services.py +2 -2
  10. pycodex/tools/agent_tool_schemas.py +1 -1
  11. pycodex/tools/apply_patch_tool.py +1 -1
  12. pycodex/tools/base_tool.py +1 -27
  13. pycodex/tools/close_agent_tool.py +11 -4
  14. pycodex/tools/exec_command_tool.py +40 -16
  15. pycodex/tools/exec_tool.py +18 -2
  16. pycodex/tools/grep_files_tool.py +19 -6
  17. pycodex/tools/ipython_tool.py +3 -2
  18. pycodex/tools/list_dir_tool.py +19 -6
  19. pycodex/tools/read_file_tool.py +39 -9
  20. pycodex/tools/request_permissions_tool.py +12 -1
  21. pycodex/tools/request_user_input_tool.py +28 -1
  22. pycodex/tools/send_input_tool.py +4 -2
  23. pycodex/tools/shell_command_tool.py +23 -6
  24. pycodex/tools/shell_tool.py +13 -4
  25. pycodex/tools/spawn_agent_tool.py +31 -8
  26. pycodex/tools/unified_exec_manager.py +42 -1
  27. pycodex/tools/update_plan_tool.py +14 -6
  28. pycodex/tools/view_image_tool.py +17 -16
  29. pycodex/tools/wait_agent_tool.py +15 -3
  30. pycodex/tools/wait_tool.py +18 -4
  31. pycodex/tools/web_search_tool.py +2 -1
  32. pycodex/tools/write_stdin_tool.py +42 -10
  33. pycodex/utils/compactor.py +7 -1
  34. pycodex/utils/visualize.py +34 -15
  35. {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/METADATA +4 -1
  36. {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/RECORD +43 -40
  37. {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/entry_points.txt +1 -0
  38. workspace_server/__init__.py +21 -0
  39. workspace_server/__main__.py +5 -0
  40. workspace_server/app.py +983 -0
  41. workspace_server/workspace.html +790 -0
  42. pycodex/prompts/exec_tools.json +0 -411
  43. pycodex/prompts/subagent_tools.json +0 -163
  44. {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/WHEEL +0 -0
  45. {python_codex-0.1.13.dist-info → python_codex-0.1.14.dist-info}/licenses/LICENSE +0 -0
pycodex/agent.py CHANGED
@@ -5,7 +5,7 @@ import re
5
5
  from typing import Callable
6
6
 
7
7
  from .context import ContextManager
8
- from .model import ModelClient
8
+ from .model import ModelClient, ResponsesIncompleteError
9
9
  from .protocol import (
10
10
  AgentEvent,
11
11
  AssistantMessage,
@@ -17,7 +17,7 @@ from .protocol import (
17
17
  TurnResult,
18
18
  UserMessage,
19
19
  )
20
- from .tools import ToolContext, ToolRegistry
20
+ from .tools import ExecCommandTool, ToolContext, ToolRegistry, UnifiedExecManager
21
21
  from .utils import uuid7_string
22
22
  import typing
23
23
 
@@ -46,6 +46,7 @@ _CONTEXT_LENGTH_ERROR_MARKERS = (
46
46
  "exceeds the context window",
47
47
  "exceeded the context window",
48
48
  )
49
+ TERMINAL_TURN_EVENTS = {"turn_completed", "turn_failed", "turn_interrupted"}
49
50
 
50
51
 
51
52
  class TurnInterrupted(RuntimeError):
@@ -85,6 +86,15 @@ class Agent:
85
86
  self._last_total_usage_tokens: 'typing.Union[int, None]' = None
86
87
  self.runtime_environment = runtime_environment
87
88
  self.interrupt_asap = False
89
+ self._turn_running = False
90
+ exec_command_tool = self._tool_registry.get_tool("exec_command")
91
+ self._exec_manager = (
92
+ exec_command_tool._manager
93
+ if isinstance(exec_command_tool, ExecCommandTool)
94
+ else None
95
+ )
96
+ if self._exec_manager is not None:
97
+ self._exec_manager.set_notify_hook(self.maybe_invoke)
88
98
 
89
99
  @property
90
100
  def history(self) -> 'typing.Tuple[ConversationItem, ...]':
@@ -129,6 +139,7 @@ class Agent:
129
139
  async def run_turn(
130
140
  self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
131
141
  ) -> 'TurnResult':
142
+ self._turn_running = True
132
143
  turn_id = turn_id or uuid7_string()
133
144
  self.interrupt_asap = False
134
145
  new_user_messages = [UserMessage(text=text) for text in texts]
@@ -168,16 +179,10 @@ class Agent:
168
179
  item_count=len(response.items),
169
180
  )
170
181
 
171
- tool_calls: 'typing.List[ToolCall]' = []
172
- persisted_response_items: 'typing.List[ConversationItem]' = []
173
- for item in response.items:
174
- self._history.append(item)
175
- persisted_response_items.append(item)
176
- if isinstance(item, AssistantMessage):
177
- last_assistant_message = item.text
178
- elif isinstance(item, ToolCall):
179
- tool_calls.append(item)
180
- self._persist_history_items(persisted_response_items)
182
+ recorded_items = self._record_model_response_items(response.items)
183
+ tool_calls = recorded_items[1]
184
+ if recorded_items[2] is not None:
185
+ last_assistant_message = recorded_items[2]
181
186
 
182
187
  if not tool_calls:
183
188
  self._raise_if_interrupt_requested(
@@ -191,6 +196,7 @@ class Agent:
191
196
  iteration=iteration,
192
197
  output_text=last_assistant_message,
193
198
  )
199
+ self._turn_running = False
194
200
  return TurnResult(
195
201
  turn_id=turn_id,
196
202
  output_text=last_assistant_message,
@@ -211,6 +217,7 @@ class Agent:
211
217
  output_text=last_assistant_message,
212
218
  )
213
219
  except TurnInterrupted:
220
+ self._turn_running = False
214
221
  raise
215
222
  except Exception as exc:
216
223
  context_usage = _usage_from_context_length_error(str(exc))
@@ -224,8 +231,29 @@ class Agent:
224
231
  error=str(exc),
225
232
  error_type=type(exc).__name__,
226
233
  )
234
+ self._turn_running = False
227
235
  raise
228
236
 
237
+ async def maybe_invoke(self, event: 'typing.Dict[str, object]') -> 'bool':
238
+ if self._turn_running or event.get("type") != "exec_command_completed":
239
+ return False
240
+ payload = {
241
+ "session_id": event.get("session_id"),
242
+ "exit_code": event.get("exit_code"),
243
+ "command": event.get("command"),
244
+ }
245
+ text = (
246
+ "<exec_command_completed>\n"
247
+ f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
248
+ "</exec_command_completed>"
249
+ )
250
+ self._turn_running = True
251
+ task = asyncio.create_task(self.run_turn([text]))
252
+ task.add_done_callback(
253
+ lambda task: None if task.cancelled() else task.exception()
254
+ )
255
+ return True
256
+
229
257
  async def _execute_tool_batch(
230
258
  self,
231
259
  turn_id: 'str',
@@ -294,10 +322,18 @@ class Agent:
294
322
  return result
295
323
 
296
324
  def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
325
+ if kind in TERMINAL_TURN_EVENTS:
326
+ payload["background_exec_count"] = self._background_exec_count()
297
327
  self._event_handler(
298
328
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
299
329
  )
300
330
 
331
+ def _background_exec_count(self) -> 'int':
332
+ manager: 'typing.Union[UnifiedExecManager, None]' = self._exec_manager
333
+ if manager is None:
334
+ return 0
335
+ return manager.running_session_count()
336
+
301
337
  def _persist_history_items(
302
338
  self,
303
339
  items: 'typing.Iterable[ConversationItem]',
@@ -310,6 +346,28 @@ class Agent:
310
346
  except Exception: # pragma: no cover - persistence should not break turns
311
347
  return
312
348
 
349
+ def _record_model_response_items(
350
+ self,
351
+ items: 'typing.Iterable[object]',
352
+ include_tool_calls: 'bool' = True,
353
+ ) -> 'typing.Tuple[typing.Tuple[ConversationItem, ...], typing.List[ToolCall], typing.Union[str, None]]':
354
+ persisted_response_items: 'typing.List[ConversationItem]' = []
355
+ tool_calls: 'typing.List[ToolCall]' = []
356
+ last_assistant_message = None
357
+ for item in items:
358
+ if isinstance(item, ToolCall) and not include_tool_calls:
359
+ continue
360
+ if not isinstance(item, (AssistantMessage, ToolCall, ReasoningItem)):
361
+ continue
362
+ self._history.append(item)
363
+ persisted_response_items.append(item)
364
+ if isinstance(item, AssistantMessage):
365
+ last_assistant_message = item.text
366
+ elif isinstance(item, ToolCall):
367
+ tool_calls.append(item)
368
+ self._persist_history_items(persisted_response_items)
369
+ return tuple(persisted_response_items), tool_calls, last_assistant_message
370
+
313
371
  def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
314
372
  if event.kind == "token_count":
315
373
  self._remember_token_usage(event.payload.get("usage"))
@@ -355,6 +413,13 @@ class Agent:
355
413
  prompt,
356
414
  lambda event: self._handle_model_stream_event(turn_id, event),
357
415
  )
416
+ except ResponsesIncompleteError as exc:
417
+ if exc.reason == "max_output_tokens":
418
+ self._record_model_response_items(
419
+ exc.partial_items,
420
+ include_tool_calls=False,
421
+ )
422
+ raise
358
423
  except Exception as exc:
359
424
  error_message = str(exc)
360
425
  if (
pycodex/cli.py CHANGED
@@ -2,13 +2,12 @@
2
2
  import atexit
3
3
  import argparse
4
4
  import asyncio
5
- import json
6
5
  import os
7
6
  import shlex
8
7
  import sys
9
8
  import tempfile
10
9
  import traceback
11
- from dataclasses import asdict, replace
10
+ from dataclasses import replace
12
11
  from pathlib import Path
13
12
  from typing import Sequence
14
13
 
@@ -18,32 +17,22 @@ from .compat import Literal
18
17
  from .context import ContextManager
19
18
  from .model import DEFAULT_CODEX_CONFIG_PATH, ResponsesModelClient, ResponsesProviderConfig
20
19
  from .portable import bootstrap_called_home, upload_codex_home
21
- from .protocol import AgentEvent
22
20
  from .runtime import CliSubmissionQueue
23
21
  from .runtime_services import AgentRuntimeEnvironment, create_agent_runtime_environment
24
22
  from .utils import CliSessionView, get_debug_dir, load_codex_dotenv, uuid7_string
25
- from .utils.compactor import compact_agent
23
+ from .interactive_session import (
24
+ EXTRA_COMMANDS_LINE,
25
+ format_turn_output,
26
+ run_interactive_session as _run_interactive_session,
27
+ prompt_request_permissions,
28
+ prompt_request_user_input,
29
+ )
26
30
  from .utils.session_persist import (
27
31
  SessionRolloutRecorder,
28
- conversation_history_to_turns,
29
- list_resumable_sessions,
30
- load_resumed_session,
31
32
  resolve_codex_home,
32
33
  )
33
34
  import typing
34
35
 
35
- EXIT_COMMANDS = {"/exit", "/quit"}
36
- HISTORY_COMMAND = "/history"
37
- TITLE_COMMAND = "/title"
38
- MODEL_COMMAND = "/model"
39
- QUEUE_COMMAND = "/queue"
40
- RESUME_COMMAND = "/resume"
41
- COMPACT_COMMAND = "/compact"
42
- LINK_COMMAND = "/link"
43
- UNLINK_COMMAND = "/unlink"
44
- EXTRA_COMMANDS_LINE = (
45
- "Extra commands: /history, /title, /model, /resume, /compact, /link, /unlink"
46
- )
47
36
  CliSessionMode = Literal["exec", "tui"]
48
37
  LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
49
38
  CLI_ORIGINATOR = "codex-tui"
@@ -372,12 +361,6 @@ def build_agent(
372
361
  )
373
362
 
374
363
 
375
- def format_turn_output(result, json_mode: 'bool') -> 'str':
376
- if json_mode:
377
- return json.dumps(asdict(result), ensure_ascii=False, indent=2)
378
- return result.output_text or ""
379
-
380
-
381
364
  def build_model(
382
365
  config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
383
366
  profile: 'typing.Union[str, None]' = None,
@@ -443,343 +426,17 @@ def build_cli_queue(agent: 'Agent') -> 'CliSubmissionQueue':
443
426
  return CliSubmissionQueue(agent)
444
427
 
445
428
 
446
- async def prompt_request_user_input(
447
- view: 'CliSessionView',
448
- payload: 'typing.Dict[str, object]',
449
- ) -> 'typing.Union[typing.Dict[str, object], None]':
450
- view.finish_stream()
451
- view.write_line("[request_user_input] waiting for user response")
452
- answers: 'typing.Dict[str, typing.Dict[str, typing.List[str]]]' = {}
453
- for question in payload.get("questions", []):
454
- if not isinstance(question, dict):
455
- continue
456
- header = str(question.get("header", "")).strip()
457
- question_text = str(question.get("question", "")).strip()
458
- question_id = str(question.get("id", "")).strip()
459
- if header:
460
- view.write_line(f"[{header}] {question_text}")
461
- else:
462
- view.write_line(question_text)
463
-
464
- options = question.get("options") or []
465
- if isinstance(options, list):
466
- for index, option in enumerate(options, start=1):
467
- if not isinstance(option, dict):
468
- continue
469
- label = str(option.get("label", "")).strip()
470
- description = str(option.get("description", "")).strip()
471
- view.write_line(f" {index}. {label} - {description}")
472
- view.write_line(" 0. Other")
473
-
474
- try:
475
- raw_answer = await view.get_prompt("answer> ")
476
- except EOFError:
477
- return None
478
- answer_text = raw_answer.strip()
479
- if not answer_text:
480
- return None
481
-
482
- selected_answer = answer_text
483
- if answer_text.isdigit() and isinstance(options, list):
484
- choice = int(answer_text)
485
- if 1 <= choice <= len(options):
486
- option = options[choice - 1]
487
- if isinstance(option, dict):
488
- selected_answer = (
489
- str(option.get("label", "")).strip() or answer_text
490
- )
491
- elif choice == 0:
492
- try:
493
- raw_answer = await view.get_prompt("other> ")
494
- except EOFError:
495
- return None
496
- selected_answer = raw_answer.strip()
497
- if not selected_answer:
498
- return None
499
-
500
- answers[question_id] = {"answers": [selected_answer]}
501
-
502
- return {"answers": answers}
503
-
504
-
505
- async def prompt_request_permissions(
506
- view: 'CliSessionView',
507
- payload: 'typing.Dict[str, object]',
508
- ) -> 'typing.Union[typing.Dict[str, object], None]':
509
- view.finish_stream()
510
- view.write_line("[request_permissions] user approval required")
511
- reason = payload.get("reason")
512
- if reason:
513
- view.write_line(f"Reason: {reason}")
514
- view.write_line("Requested permissions:")
515
- view.write_line(
516
- json.dumps(payload.get("permissions", {}), ensure_ascii=False, indent=2)
517
- )
518
- view.write_line("Choose: [n] deny / [t] grant for turn / [s] grant for session")
519
- try:
520
- raw_answer = await view.get_prompt("permissions> ")
521
- except EOFError:
522
- return None
523
-
524
- answer = raw_answer.strip().lower()
525
- if answer in {"t", "turn", "y", "yes"}:
526
- return {
527
- "permissions": payload.get("permissions", {}),
528
- "scope": "turn",
529
- }
530
- if answer in {"s", "session"}:
531
- return {
532
- "permissions": payload.get("permissions", {}),
533
- "scope": "session",
534
- }
535
- return {
536
- "permissions": {},
537
- "scope": "turn",
538
- }
539
-
540
-
541
429
  async def run_interactive_session(
542
430
  queue: 'CliSubmissionQueue',
543
431
  json_mode: 'bool',
544
432
  config_path: 'typing.Union[str, None]' = None,
545
433
  ) -> 'int':
546
- worker = asyncio.create_task(queue.run_forever())
547
- context_window_tokens = queue._agent._context_manager.resolve_model_context_window()
548
- view = CliSessionView()
549
- view.set_context_window_tokens(context_window_tokens)
550
- model_client = queue._agent._model_client
551
- codex_home = resolve_codex_home(config_path)
552
- queue.set_event_handler(view.handle_event)
553
- pending_turn_tasks: 'typing.Set[asyncio.Task[None]]' = set()
554
- runtime_environment = queue._agent.runtime_environment
555
- if runtime_environment is None:
556
- runtime_environment = create_agent_runtime_environment()
557
- queue._agent.runtime_environment = runtime_environment
558
- runtime_environment.request_user_input_manager.set_handler(
559
- lambda payload: prompt_request_user_input(view, payload)
560
- )
561
- runtime_environment.request_permissions_manager.set_handler(
562
- lambda payload: prompt_request_permissions(view, payload)
434
+ return await _run_interactive_session(
435
+ queue,
436
+ json_mode,
437
+ config_path,
438
+ view_factory=CliSessionView,
563
439
  )
564
- view.write_line("pycodex interactive mode. Type /exit to quit.")
565
- view.write_line(EXTRA_COMMANDS_LINE)
566
- feishu_link = None
567
- try:
568
-
569
- def has_pending_turn_tasks() -> 'bool':
570
- pending_turn_tasks.difference_update(
571
- task for task in tuple(pending_turn_tasks) if task.done()
572
- )
573
- return bool(pending_turn_tasks)
574
-
575
- async def run_manual_compact() -> 'None':
576
- current_agent = queue._agent
577
- if not current_agent.history:
578
- view.write_line("Nothing to compact.")
579
- return
580
-
581
- compact_turn_id = uuid7_string()
582
-
583
- def handle_compact_stream_event(event) -> 'None':
584
- if event.kind not in {"token_count", "stream_error"}:
585
- return
586
- view.handle_event(
587
- AgentEvent(
588
- kind=event.kind,
589
- turn_id=compact_turn_id,
590
- payload=dict(event.payload),
591
- )
592
- )
593
-
594
- view.write_line("Compacting conversation history...")
595
- compact_result = await compact_agent(
596
- current_agent,
597
- handle_compact_stream_event,
598
- True,
599
- )
600
- if compact_result is None:
601
- view.write_line("Nothing to compact.")
602
- return
603
- view.load_session_history(
604
- getattr(view, "_title", None),
605
- conversation_history_to_turns(compact_result.history),
606
- )
607
- view.write_line(compact_result.display_text())
608
-
609
- async def wait_for_turn_result(future) -> 'None':
610
- try:
611
- result = await future
612
- except Exception as exc: # pragma: no cover - defensive surface
613
- if str(exc) == "submission interrupted":
614
- return
615
- view.show_error(str(exc))
616
- return
617
-
618
- if json_mode:
619
- view.write_line(format_turn_output(result, True))
620
-
621
- while True:
622
- try:
623
- raw_line = await view.poll_prompt()
624
- except EOFError:
625
- break
626
- if raw_line is None:
627
- await asyncio.sleep(0.05)
628
- continue
629
-
630
- prompt_text = raw_line.strip()
631
- if not prompt_text:
632
- continue
633
- if prompt_text in EXIT_COMMANDS:
634
- break
635
- if prompt_text == HISTORY_COMMAND:
636
- view.show_history()
637
- continue
638
- if prompt_text == TITLE_COMMAND:
639
- view.show_title()
640
- continue
641
- if prompt_text == RESUME_COMMAND:
642
- sessions = list_resumable_sessions(codex_home)
643
- if not sessions:
644
- view.write_line("No resumable sessions found.")
645
- continue
646
- view.write_line("Available sessions:")
647
- for index, session in enumerate(sessions, start=1):
648
- view.write_line(f"[{index}] {session['preview']}")
649
- continue
650
- if prompt_text.startswith(f"{RESUME_COMMAND} "):
651
- if has_pending_turn_tasks():
652
- view.write_line(
653
- "Cannot resume while work is running or queued."
654
- )
655
- continue
656
- resume_target = prompt_text[len(RESUME_COMMAND) :].strip()
657
- try:
658
- resumed = load_resumed_session(codex_home, resume_target)
659
- queue._agent.replace_history(resumed["history"])
660
- if hasattr(model_client, "_session_id"):
661
- model_client._session_id = str(resumed["session_id"])
662
- queue._agent.set_rollout_recorder(
663
- SessionRolloutRecorder.resume(resumed["rollout_path"])
664
- )
665
- view.load_session_history(
666
- str(resumed["title"]),
667
- tuple(resumed["turns"]),
668
- )
669
- view.write_line(f"Resumed session: {resumed['title']}")
670
- view.show_history()
671
- except Exception as exc: # pragma: no cover - defensive surface
672
- view.show_error(str(exc))
673
- continue
674
- if prompt_text == COMPACT_COMMAND:
675
- if has_pending_turn_tasks():
676
- view.write_line(
677
- "Cannot compact while work is running or queued."
678
- )
679
- continue
680
- try:
681
- await run_manual_compact()
682
- except Exception as exc: # pragma: no cover - defensive surface
683
- view.show_error(str(exc))
684
- continue
685
- if prompt_text.startswith(f"{LINK_COMMAND} "):
686
- link_target = prompt_text[len(LINK_COMMAND) :].strip()
687
- if not link_target:
688
- view.write_line("Usage: /link <feishu-email|open_id|chat_id>")
689
- continue
690
- if feishu_link:
691
- view.write_line("A Feishu card is already linked. Use /unlink first.")
692
- continue
693
- try:
694
- from .feishu_link import PycodexRuntimeLink
695
-
696
- view.write_line(f"Linking Feishu card to current session: {link_target}")
697
- link = await PycodexRuntimeLink(
698
- queue,
699
- link_target,
700
- ).start_async()
701
- feishu_link = link
702
- view.write_line(
703
- "Linked Feishu card: session_key={0} message_id={1}".format(
704
- link.session_key,
705
- link.message_id or "-",
706
- )
707
- )
708
- except Exception as exc: # pragma: no cover - defensive surface
709
- view.show_error(str(exc))
710
- continue
711
- if prompt_text == UNLINK_COMMAND:
712
- if not feishu_link:
713
- view.write_line("No Feishu card is linked.")
714
- continue
715
- feishu_link.detach()
716
- feishu_link = None
717
- view.write_line("Unlinked Feishu card.")
718
- continue
719
- if prompt_text.startswith(f"{QUEUE_COMMAND} "):
720
- queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
721
- if not queued_text:
722
- view.write_line("Usage: /queue <message>")
723
- continue
724
- try:
725
- submission_id, future = await queue.enqueue_user_turn(
726
- queued_text, queue="enqueue"
727
- )
728
- view.show_steer_queued(submission_id, queued_text)
729
- turn_task = asyncio.create_task(wait_for_turn_result(future))
730
- pending_turn_tasks.add(turn_task)
731
- except Exception as exc: # pragma: no cover - defensive surface
732
- view.show_error(str(exc))
733
- continue
734
- if prompt_text == MODEL_COMMAND:
735
- view.write_line(
736
- f"Current model: {getattr(model_client, 'model', None) or 'unavailable'}"
737
- )
738
- models = await model_client.list_models()
739
- view.write_line(f"Available models: {', '.join(models)}")
740
- continue
741
- if prompt_text.startswith(f"{MODEL_COMMAND} "):
742
- if has_pending_turn_tasks():
743
- view.write_line(
744
- "Cannot change model while work is running or queued in steer mode."
745
- )
746
- continue
747
- model_name = prompt_text[len(MODEL_COMMAND) :].strip()
748
- if not model_name:
749
- view.write_line("Usage: /model <model>")
750
- continue
751
-
752
- model_client.model = model_name
753
- view.write_line(f"Switched model to {model_name}.")
754
- continue
755
-
756
- try:
757
- steered = has_pending_turn_tasks()
758
- submission_id, future = await queue.enqueue_user_turn(
759
- prompt_text,
760
- queue="steer",
761
- )
762
- if steered:
763
- view.schedule_steer_inserted(submission_id, prompt_text)
764
- turn_task = asyncio.create_task(wait_for_turn_result(future))
765
- pending_turn_tasks.add(turn_task)
766
- continue
767
- except Exception as exc: # pragma: no cover - defensive surface
768
- view.show_error(str(exc))
769
- continue
770
- finally:
771
- if feishu_link:
772
- feishu_link.detach()
773
- feishu_link.stop()
774
- runtime_environment.request_user_input_manager.set_handler(None)
775
- runtime_environment.request_permissions_manager.set_handler(None)
776
- await queue.shutdown()
777
- await worker
778
- if pending_turn_tasks:
779
- await asyncio.gather(*pending_turn_tasks, return_exceptions=True)
780
- view.close()
781
-
782
- return 0
783
440
 
784
441
 
785
442
  async def run_cli(args: 'argparse.Namespace') -> 'int':