python-codex 0.1.13__py3-none-any.whl → 0.2.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 (50) hide show
  1. pycodex/agent.py +71 -11
  2. pycodex/cli.py +16 -356
  3. pycodex/context.py +12 -0
  4. pycodex/feishu_card.py +76 -30
  5. pycodex/feishu_link.py +131 -11
  6. pycodex/interactive_session.py +397 -0
  7. pycodex/model.py +11 -22
  8. pycodex/protocol.py +0 -5
  9. pycodex/runtime.py +23 -0
  10. pycodex/runtime_services.py +2 -2
  11. pycodex/tools/agent_tool_schemas.py +1 -1
  12. pycodex/tools/apply_patch_tool.py +1 -1
  13. pycodex/tools/base_tool.py +1 -27
  14. pycodex/tools/close_agent_tool.py +11 -4
  15. pycodex/tools/code_mode_manager.py +1 -1
  16. pycodex/tools/exec_command_tool.py +40 -16
  17. pycodex/tools/exec_tool.py +18 -2
  18. pycodex/tools/grep_files_tool.py +19 -6
  19. pycodex/tools/ipython_tool.py +3 -2
  20. pycodex/tools/list_dir_tool.py +19 -6
  21. pycodex/tools/read_file_tool.py +39 -9
  22. pycodex/tools/request_permissions_tool.py +12 -1
  23. pycodex/tools/request_user_input_tool.py +28 -1
  24. pycodex/tools/send_input_tool.py +4 -2
  25. pycodex/tools/shell_command_tool.py +23 -6
  26. pycodex/tools/shell_tool.py +13 -4
  27. pycodex/tools/spawn_agent_tool.py +31 -8
  28. pycodex/tools/unified_exec_manager.py +49 -93
  29. pycodex/tools/update_plan_tool.py +14 -6
  30. pycodex/tools/view_image_tool.py +17 -16
  31. pycodex/tools/wait_agent_tool.py +15 -3
  32. pycodex/tools/wait_tool.py +18 -4
  33. pycodex/tools/web_search_tool.py +2 -1
  34. pycodex/tools/write_stdin_tool.py +42 -10
  35. pycodex/utils/compactor.py +7 -1
  36. pycodex/utils/session_persist.py +42 -1
  37. pycodex/utils/truncation.py +206 -0
  38. pycodex/utils/visualize.py +34 -15
  39. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/METADATA +4 -1
  40. python_codex-0.2.0.dist-info/RECORD +88 -0
  41. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/entry_points.txt +1 -0
  42. workspace_server/__init__.py +23 -0
  43. workspace_server/__main__.py +5 -0
  44. workspace_server/app.py +1347 -0
  45. workspace_server/workspace.html +866 -0
  46. pycodex/prompts/exec_tools.json +0 -411
  47. pycodex/prompts/subagent_tools.json +0 -163
  48. python_codex-0.1.13.dist-info/RECORD +0 -84
  49. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/WHEEL +0 -0
  50. {python_codex-0.1.13.dist-info → python_codex-0.2.0.dist-info}/licenses/LICENSE +0 -0
pycodex/agent.py CHANGED
@@ -17,7 +17,8 @@ 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
+ from .utils.truncation import truncate_tool_results_for_history
21
22
  from .utils import uuid7_string
22
23
  import typing
23
24
 
@@ -46,6 +47,7 @@ _CONTEXT_LENGTH_ERROR_MARKERS = (
46
47
  "exceeds the context window",
47
48
  "exceeded the context window",
48
49
  )
50
+ TERMINAL_TURN_EVENTS = {"turn_completed", "turn_failed", "turn_interrupted"}
49
51
 
50
52
 
51
53
  class TurnInterrupted(RuntimeError):
@@ -85,6 +87,15 @@ class Agent:
85
87
  self._last_total_usage_tokens: 'typing.Union[int, None]' = None
86
88
  self.runtime_environment = runtime_environment
87
89
  self.interrupt_asap = False
90
+ self._turn_running = False
91
+ exec_command_tool = self._tool_registry.get_tool("exec_command")
92
+ self._exec_manager = (
93
+ exec_command_tool._manager
94
+ if isinstance(exec_command_tool, ExecCommandTool)
95
+ else None
96
+ )
97
+ if self._exec_manager is not None:
98
+ self._exec_manager.set_notify_hook(self.maybe_invoke)
88
99
 
89
100
  @property
90
101
  def history(self) -> 'typing.Tuple[ConversationItem, ...]':
@@ -129,6 +140,7 @@ class Agent:
129
140
  async def run_turn(
130
141
  self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
131
142
  ) -> 'TurnResult':
143
+ self._turn_running = True
132
144
  turn_id = turn_id or uuid7_string()
133
145
  self.interrupt_asap = False
134
146
  new_user_messages = [UserMessage(text=text) for text in texts]
@@ -168,16 +180,10 @@ class Agent:
168
180
  item_count=len(response.items),
169
181
  )
170
182
 
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)
183
+ recorded_items = self._record_model_response_items(response.items)
184
+ tool_calls = recorded_items[1]
185
+ if recorded_items[2] is not None:
186
+ last_assistant_message = recorded_items[2]
181
187
 
182
188
  if not tool_calls:
183
189
  self._raise_if_interrupt_requested(
@@ -191,6 +197,7 @@ class Agent:
191
197
  iteration=iteration,
192
198
  output_text=last_assistant_message,
193
199
  )
200
+ self._turn_running = False
194
201
  return TurnResult(
195
202
  turn_id=turn_id,
196
203
  output_text=last_assistant_message,
@@ -200,6 +207,7 @@ class Agent:
200
207
  )
201
208
 
202
209
  tool_results = await self._execute_tool_batch(turn_id, tool_calls)
210
+ tool_results = truncate_tool_results_for_history(tool_results)
203
211
  self._history.extend(tool_results)
204
212
  self._persist_history_items(tool_results)
205
213
  follow_up_messages = self._build_follow_up_messages(tool_results)
@@ -211,6 +219,10 @@ class Agent:
211
219
  output_text=last_assistant_message,
212
220
  )
213
221
  except TurnInterrupted:
222
+ self._turn_running = False
223
+ raise
224
+ except asyncio.CancelledError:
225
+ self._turn_running = False
214
226
  raise
215
227
  except Exception as exc:
216
228
  context_usage = _usage_from_context_length_error(str(exc))
@@ -224,8 +236,29 @@ class Agent:
224
236
  error=str(exc),
225
237
  error_type=type(exc).__name__,
226
238
  )
239
+ self._turn_running = False
227
240
  raise
228
241
 
242
+ async def maybe_invoke(self, event: 'typing.Dict[str, object]') -> 'bool':
243
+ if self._turn_running or event.get("type") != "exec_command_completed":
244
+ return False
245
+ payload = {
246
+ "session_id": event.get("session_id"),
247
+ "exit_code": event.get("exit_code"),
248
+ "command": event.get("command"),
249
+ }
250
+ text = (
251
+ "<exec_command_completed>\n"
252
+ f"{json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}\n"
253
+ "</exec_command_completed>"
254
+ )
255
+ self._turn_running = True
256
+ task = asyncio.create_task(self.run_turn([text]))
257
+ task.add_done_callback(
258
+ lambda task: None if task.cancelled() else task.exception()
259
+ )
260
+ return True
261
+
229
262
  async def _execute_tool_batch(
230
263
  self,
231
264
  turn_id: 'str',
@@ -294,10 +327,18 @@ class Agent:
294
327
  return result
295
328
 
296
329
  def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
330
+ if kind in TERMINAL_TURN_EVENTS:
331
+ payload["background_exec_count"] = self._background_exec_count()
297
332
  self._event_handler(
298
333
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
299
334
  )
300
335
 
336
+ def _background_exec_count(self) -> 'int':
337
+ manager: 'typing.Union[UnifiedExecManager, None]' = self._exec_manager
338
+ if manager is None:
339
+ return 0
340
+ return manager.running_session_count()
341
+
301
342
  def _persist_history_items(
302
343
  self,
303
344
  items: 'typing.Iterable[ConversationItem]',
@@ -310,6 +351,25 @@ class Agent:
310
351
  except Exception: # pragma: no cover - persistence should not break turns
311
352
  return
312
353
 
354
+ def _record_model_response_items(
355
+ self,
356
+ items: 'typing.Iterable[object]',
357
+ ) -> 'typing.Tuple[typing.Tuple[ConversationItem, ...], typing.List[ToolCall], typing.Union[str, None]]':
358
+ persisted_response_items: 'typing.List[ConversationItem]' = []
359
+ tool_calls: 'typing.List[ToolCall]' = []
360
+ last_assistant_message = None
361
+ for item in items:
362
+ if not isinstance(item, (AssistantMessage, ToolCall, ReasoningItem)):
363
+ continue
364
+ self._history.append(item)
365
+ persisted_response_items.append(item)
366
+ if isinstance(item, AssistantMessage):
367
+ last_assistant_message = item.text
368
+ elif isinstance(item, ToolCall):
369
+ tool_calls.append(item)
370
+ self._persist_history_items(persisted_response_items)
371
+ return tuple(persisted_response_items), tool_calls, last_assistant_message
372
+
313
373
  def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
314
374
  if event.kind == "token_count":
315
375
  self._remember_token_usage(event.payload.get("usage"))
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"
@@ -300,6 +289,7 @@ def build_agent(
300
289
  system_prompt: 'typing.Union[str, None]' = None,
301
290
  session_mode: 'CliSessionMode' = "exec",
302
291
  collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
292
+ extra_contextual_user_messages: 'typing.Iterable[str]' = (),
303
293
  ) -> 'Agent':
304
294
  config_path = str(config_path)
305
295
  context_manager = ContextManager.from_codex_config(
@@ -308,6 +298,7 @@ def build_agent(
308
298
  base_instructions_override=system_prompt,
309
299
  collaboration_mode=collaboration_mode,
310
300
  include_collaboration_instructions=session_mode == "tui",
301
+ extra_contextual_user_messages=extra_contextual_user_messages,
311
302
  )
312
303
  session_id = getattr(client, "_session_id", None) or uuid7_string()
313
304
  if hasattr(client, "_session_id"):
@@ -317,6 +308,7 @@ def build_agent(
317
308
  profile,
318
309
  base_instructions_override=system_prompt,
319
310
  include_collaboration_instructions=False,
311
+ extra_contextual_user_messages=extra_contextual_user_messages,
320
312
  )
321
313
  runtime_environment = create_agent_runtime_environment()
322
314
  runtime_environment.request_user_input_manager.set_handler(None)
@@ -372,12 +364,6 @@ def build_agent(
372
364
  )
373
365
 
374
366
 
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
367
  def build_model(
382
368
  config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
383
369
  profile: 'typing.Union[str, None]' = None,
@@ -443,343 +429,17 @@ def build_cli_queue(agent: 'Agent') -> 'CliSubmissionQueue':
443
429
  return CliSubmissionQueue(agent)
444
430
 
445
431
 
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
432
  async def run_interactive_session(
542
433
  queue: 'CliSubmissionQueue',
543
434
  json_mode: 'bool',
544
435
  config_path: 'typing.Union[str, None]' = None,
545
436
  ) -> '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)
437
+ return await _run_interactive_session(
438
+ queue,
439
+ json_mode,
440
+ config_path,
441
+ view_factory=CliSessionView,
563
442
  )
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
443
 
784
444
 
785
445
  async def run_cli(args: 'argparse.Namespace') -> 'int':
pycodex/context.py CHANGED
@@ -149,6 +149,7 @@ class ContextManager:
149
149
  include_permissions_instructions: 'bool' = True,
150
150
  include_skills_instructions: 'bool' = True,
151
151
  network_access: 'str' = "enabled",
152
+ extra_contextual_user_messages: 'typing.Iterable[str]' = (),
152
153
  ) -> 'None':
153
154
  self.cwd = Path.cwd().resolve()
154
155
  self._shell = get_shell_name()
@@ -166,6 +167,14 @@ class ContextManager:
166
167
  self._include_permissions_instructions = include_permissions_instructions
167
168
  self._include_skills_instructions = include_skills_instructions
168
169
  self._network_access = network_access
170
+ self._extra_contextual_user_messages = tuple(
171
+ text
172
+ for text in (
173
+ _normalize_text(message)
174
+ for message in extra_contextual_user_messages
175
+ )
176
+ if text is not None
177
+ )
169
178
  self._default_base_instructions = DEFAULT_BASE_INSTRUCTIONS_PATH.read_text(
170
179
  encoding="utf-8"
171
180
  )
@@ -183,6 +192,7 @@ class ContextManager:
183
192
  include_permissions_instructions: 'bool' = True,
184
193
  include_skills_instructions: 'bool' = True,
185
194
  network_access: 'str' = "enabled",
195
+ extra_contextual_user_messages: 'typing.Iterable[str]' = (),
186
196
  ) -> 'ContextManager':
187
197
  config = ContextConfig.from_codex_config(config_path, profile)
188
198
  return cls(
@@ -193,6 +203,7 @@ class ContextManager:
193
203
  include_permissions_instructions=include_permissions_instructions,
194
204
  include_skills_instructions=include_skills_instructions,
195
205
  network_access=network_access,
206
+ extra_contextual_user_messages=extra_contextual_user_messages,
196
207
  )
197
208
 
198
209
  @property
@@ -421,6 +432,7 @@ class ContextManager:
421
432
  )
422
433
  )
423
434
  sections.append(self._serialize_environment_context())
435
+ sections.extend(self._extra_contextual_user_messages)
424
436
  if not sections:
425
437
  return []
426
438
  return [