python-codex 0.2.7__py3-none-any.whl → 0.3.1__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 (85) hide show
  1. pycodex/__init__.py +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +321 -883
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +7 -5
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +41 -72
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.1.dist-info/METADATA +743 -0
  56. python_codex-0.3.1.dist-info/RECORD +91 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +34 -33
  60. responses_server/config.py +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +209 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +465 -377
  71. workspace_server/owned_process.py +27 -0
  72. workspace_server/workspace.html +854 -228
  73. workspace_server/workspaces.html +94 -95
  74. workspace_server/workspaces.py +141 -84
  75. pycodex/collaboration.py +0 -20
  76. pycodex/interactive_session.py +0 -415
  77. pycodex/prompts/collaboration_default.md +0 -11
  78. pycodex/prompts/collaboration_plan.md +0 -128
  79. pycodex/utils/toolcall_visualize.py +0 -713
  80. pycodex/utils/visualize.py +0 -560
  81. python_codex-0.2.7.dist-info/METADATA +0 -455
  82. python_codex-0.2.7.dist-info/RECORD +0 -93
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/WHEEL +0 -0
  84. {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/entry_points.txt +0 -0
  85. {python_codex-0.2.7.dist-info → python_codex-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -9,7 +9,6 @@ from uuid import uuid4
9
9
 
10
10
  from pycodex.utils import uuid7_string
11
11
 
12
-
13
12
  SessionFactory = typing.Callable[[], object]
14
13
 
15
14
 
@@ -23,7 +22,7 @@ class WorkspaceStateStore:
23
22
  None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
24
23
  )
25
24
 
26
- def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
25
+ def load_tabs(self) -> "typing.List[typing.Dict[str, object]]":
27
26
  if self.path is None or not self.path.is_file():
28
27
  return []
29
28
 
@@ -44,11 +43,13 @@ class WorkspaceStateStore:
44
43
  continue
45
44
  title = str(tab.get("title") or "").strip()
46
45
  rollout_path = str(tab.get("rollout_path") or "").strip()
47
- if title or rollout_path:
48
- result.append({"title": title, "rollout_path": rollout_path})
46
+ restored = {"title": title, "rollout_path": rollout_path}
47
+ if tab.get("fork") is True:
48
+ restored["fork"] = True
49
+ result.append(restored)
49
50
  return result
50
51
 
51
- def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
52
+ def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, object]]") -> None:
52
53
  if self.path is None:
53
54
  return
54
55
 
@@ -56,28 +57,32 @@ class WorkspaceStateStore:
56
57
  {
57
58
  "title": str(tab.get("title") or ""),
58
59
  "rollout_path": str(tab.get("rollout_path") or ""),
60
+ **({"fork": True} if tab.get("fork") is True else {}),
59
61
  }
60
62
  for tab in tabs
61
63
  ]
62
- payload = json.dumps(
63
- {"version": 1, "tabs": state_tabs},
64
- ensure_ascii=False,
65
- indent=2,
66
- ) + "\n"
64
+ payload = (
65
+ json.dumps(
66
+ {"version": 1, "tabs": state_tabs},
67
+ ensure_ascii=False,
68
+ indent=2,
69
+ )
70
+ + "\n"
71
+ )
67
72
  self.path.parent.mkdir(parents=True, exist_ok=True)
68
73
  self.path.write_text(payload, encoding="utf-8")
69
74
 
70
75
 
71
76
  @dataclass(frozen=True)
72
77
  class WorkspaceDefinition:
73
- workspace_id: 'str'
74
- board_path: 'typing.Union[Path, None]'
75
- work_dir: 'Path'
78
+ workspace_id: "str"
79
+ board_path: "typing.Union[Path, None]"
80
+ work_dir: "Path"
76
81
 
77
82
 
78
83
  def load_workspace_definitions(
79
- config_path: 'typing.Union[str, Path]',
80
- ) -> 'typing.List[WorkspaceDefinition]':
84
+ config_path: "typing.Union[str, Path]",
85
+ ) -> "typing.List[WorkspaceDefinition]":
81
86
  path = Path(config_path).expanduser().resolve()
82
87
  if not path.exists():
83
88
  return []
@@ -112,7 +117,9 @@ def load_workspace_definitions(
112
117
  if work_dir_value is None:
113
118
  work_dir_value = item.get("cwd")
114
119
  if not str(work_dir_value or "").strip():
115
- raise ValueError("workspace `{0}` is missing `work_dir`".format(workspace_id))
120
+ raise ValueError(
121
+ "workspace `{0}` is missing `work_dir`".format(workspace_id)
122
+ )
116
123
  work_dir = _resolve_workspace_path(str(work_dir_value), path.parent)
117
124
  if not work_dir.is_dir():
118
125
  raise ValueError(
@@ -127,7 +134,9 @@ def load_workspace_definitions(
127
134
  if board_value is not False:
128
135
  if not isinstance(board_value, str) or not board_value.strip():
129
136
  raise ValueError(
130
- "workspace `{0}` requires a board path or false".format(workspace_id)
137
+ "workspace `{0}` requires a board path or false".format(
138
+ workspace_id
139
+ )
131
140
  )
132
141
  board_path = _resolve_workspace_path(board_value, path.parent)
133
142
  if board_path in seen_boards:
@@ -152,8 +161,8 @@ def load_workspace_definitions(
152
161
 
153
162
 
154
163
  def save_workspace_definitions(
155
- config_path: 'typing.Union[str, Path]',
156
- definitions: 'typing.Iterable[WorkspaceDefinition]',
164
+ config_path: "typing.Union[str, Path]",
165
+ definitions: "typing.Iterable[WorkspaceDefinition]",
157
166
  ) -> None:
158
167
  path = Path(config_path).expanduser().resolve()
159
168
  base_dir = path.parent
@@ -171,9 +180,9 @@ def save_workspace_definitions(
171
180
 
172
181
 
173
182
  def _workspace_definition_to_json(
174
- definition: 'WorkspaceDefinition',
175
- base_dir: 'Path',
176
- ) -> 'typing.Dict[str, object]':
183
+ definition: "WorkspaceDefinition",
184
+ base_dir: "Path",
185
+ ) -> "typing.Dict[str, object]":
177
186
  result = {
178
187
  "id": definition.workspace_id,
179
188
  "work_dir": _format_path_for_workspace_config(definition.work_dir, base_dir),
@@ -188,7 +197,7 @@ def _workspace_definition_to_json(
188
197
  return result
189
198
 
190
199
 
191
- def _format_path_for_workspace_config(path: 'Path', base_dir: 'Path') -> 'str':
200
+ def _format_path_for_workspace_config(path: "Path", base_dir: "Path") -> "str":
192
201
  resolved = path.resolve()
193
202
  try:
194
203
  relative = os.path.relpath(str(resolved), str(base_dir.resolve()))
@@ -201,14 +210,14 @@ def _format_path_for_workspace_config(path: 'Path', base_dir: 'Path') -> 'str':
201
210
  return relative
202
211
 
203
212
 
204
- def _resolve_workspace_path(value: str, base_dir: 'Path') -> 'Path':
213
+ def _resolve_workspace_path(value: str, base_dir: "Path") -> "Path":
205
214
  path = Path(str(value)).expanduser()
206
215
  if not path.is_absolute():
207
216
  path = base_dir / path
208
217
  return path.resolve()
209
218
 
210
219
 
211
- def _next_workspace_id(existing: 'typing.Container[str]') -> str:
220
+ def _next_workspace_id(existing: "typing.Container[str]") -> str:
212
221
  index = 1
213
222
  while True:
214
223
  candidate = "workspace-{0}".format(index)
@@ -242,11 +251,16 @@ class WorkspaceSessionManager:
242
251
  self._sessions: "typing.Dict[str, object]" = {}
243
252
  self._session_order: "typing.List[str]" = []
244
253
  self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
245
- self._persisted_titles: "typing.Dict[str, str]" = {}
246
- self._lock = asyncio.Lock()
254
+ self._tab_states: "typing.Dict[str, typing.Dict[str, object]]" = {}
255
+ self._lock = None
247
256
  self._state_store = WorkspaceStateStore(board_path)
248
257
  self._persist_callback = persist_callback
249
258
 
259
+ def _get_lock(self):
260
+ if self._lock is None:
261
+ self._lock = asyncio.Lock()
262
+ return self._lock
263
+
250
264
  def set_persist_callback(
251
265
  self,
252
266
  callback: "typing.Union[typing.Callable[[], None], None]",
@@ -259,57 +273,89 @@ class WorkspaceSessionManager:
259
273
  await self.create_session()
260
274
  return
261
275
  for tab in state_tabs:
262
- await self.create_session(
276
+ await self._create_session(
263
277
  title=str(tab.get("title") or ""),
264
278
  rollout_path=str(tab.get("rollout_path") or ""),
279
+ fork=tab.get("fork") is True,
265
280
  )
281
+ for session_id in self._session_order:
282
+ self._subscribe_session_state(session_id)
283
+ self.persist_workspace_state()
266
284
 
267
285
  async def close(self) -> None:
268
286
  sessions = list(self._sessions.values())
269
287
  watchers = list(self._state_watchers.values())
270
- self._sessions.clear()
271
- self._session_order = []
272
- self._state_watchers.clear()
273
- self._persisted_titles.clear()
274
- for watcher in watchers:
275
- watcher.cancel()
276
- if watchers:
277
- await asyncio.gather(*watchers, return_exceptions=True)
278
- for session in sessions:
279
- await session.close()
288
+ try:
289
+ for session in sessions:
290
+ await session.close()
291
+ if watchers:
292
+ await asyncio.gather(*watchers)
293
+ finally:
294
+ for watcher in watchers:
295
+ watcher.cancel()
296
+ if watchers:
297
+ await asyncio.gather(*watchers, return_exceptions=True)
298
+ self._sessions.clear()
299
+ self._session_order = []
300
+ self._state_watchers.clear()
301
+ self._tab_states.clear()
280
302
 
281
303
  async def create_session(
282
304
  self,
283
305
  title: str = "",
284
306
  rollout_path: str = "",
307
+ fork: bool = False,
308
+ ) -> str:
309
+ session_id = await self._create_session(title, rollout_path, fork)
310
+ self._subscribe_session_state(session_id)
311
+ self.persist_workspace_state()
312
+ return session_id
313
+
314
+ async def _create_session(
315
+ self,
316
+ title: str = "",
317
+ rollout_path: str = "",
318
+ fork: bool = False,
285
319
  ) -> str:
286
- async with self._lock:
320
+ async with self._get_lock():
287
321
  session_id = uuid7_string()
288
322
  session = self._session_factory()
289
323
  await session.start()
290
324
 
291
- if rollout_path:
292
- await session.restore_from_rollout(rollout_path, title=title)
325
+ try:
326
+ if rollout_path or title:
327
+ await session.restore_from_rollout(
328
+ rollout_path, title=title, fork=fork
329
+ )
330
+ except BaseException:
331
+ await session.close()
332
+ raise
293
333
 
294
334
  self._sessions[session_id] = session
295
335
  self._session_order.append(session_id)
296
- self._persisted_titles[session_id] = str(
297
- session_summary(session).get("title") or ""
298
- )
299
- self._state_watchers[session_id] = asyncio.create_task(
300
- self._watch_session_title(session_id, session)
301
- )
336
+ self._tab_states[session_id] = {
337
+ "title": str(session_summary(session).get("title") or ""),
338
+ "rollout_path": rollout_path,
339
+ **({"fork": True} if fork else {}),
340
+ }
302
341
  return session_id
303
342
 
343
+ def _subscribe_session_state(self, session_id: str) -> None:
344
+ session = self._sessions[session_id]
345
+ subscriber = session.subscribe()
346
+ self._state_watchers[session_id] = asyncio.create_task(
347
+ self._watch_session_state(session_id, session, subscriber)
348
+ )
349
+
304
350
  async def close_session(self, session_id: str) -> None:
305
- async with self._lock:
351
+ async with self._get_lock():
306
352
  if len(self._session_order) <= 1:
307
353
  raise ValueError("cannot close the last session")
308
354
  session = self._sessions.pop(session_id, None)
309
355
  if session is None:
310
356
  raise KeyError(session_id)
311
357
  watcher = self._state_watchers.pop(session_id, None)
312
- self._persisted_titles.pop(session_id, None)
358
+ self._tab_states.pop(session_id, None)
313
359
  self._session_order = [
314
360
  item for item in self._session_order if item != session_id
315
361
  ]
@@ -319,20 +365,33 @@ class WorkspaceSessionManager:
319
365
  await session.close()
320
366
  self.persist_workspace_state()
321
367
 
322
- async def _watch_session_title(self, session_id: str, session) -> None:
323
- subscriber = session.subscribe()
368
+ async def _watch_session_state(self, session_id: str, session, subscriber) -> None:
324
369
  try:
325
370
  while True:
326
371
  event = await subscriber.get()
327
372
  if event is None:
328
373
  return
329
- if not isinstance(event, dict) or event.get("type") != "title_changed":
374
+ if not isinstance(event, dict) or "snapshot" not in event:
330
375
  continue
331
- title = str(event.get("title") or "")
332
- if title == self._persisted_titles.get(session_id, ""):
333
- continue
334
- self._persisted_titles[session_id] = title
335
- self.persist_workspace_state()
376
+ reason = (
377
+ event["payload"]["reason"]
378
+ if event.get("kind") == "session_state"
379
+ else None
380
+ )
381
+ previous = self._tab_states[session_id]
382
+ state = dict(previous)
383
+ snapshot = event["snapshot"]
384
+ state["title"] = str(snapshot.get("title") or "")
385
+ if reason == "identity" and state["rollout_path"]:
386
+ # Recreate an unwritten fork from its recorded ancestor.
387
+ state["fork"] = True
388
+ path = str(snapshot["recorded_rollout_path"] or "")
389
+ if path or not snapshot["rollout_path"]:
390
+ state["rollout_path"] = path
391
+ state.pop("fork", None)
392
+ self._tab_states[session_id] = state
393
+ if state != previous:
394
+ self.persist_workspace_state()
336
395
  finally:
337
396
  session.unsubscribe(subscriber)
338
397
 
@@ -342,12 +401,8 @@ class WorkspaceSessionManager:
342
401
  session = self._sessions.get(session_id)
343
402
  if session is None:
344
403
  continue
345
- summary = session_summary(session)
346
- title = str(summary.get("title") or "").strip()
347
- rollout_path = str(session.rollout_path() or "")
348
- if not title and not rollout_path:
349
- continue
350
- tabs.append({"title": title, "rollout_path": rollout_path})
404
+ state = self._tab_states[session_id]
405
+ tabs.append(state)
351
406
  self._state_store.save_tabs(tabs)
352
407
  if self._persist_callback is not None:
353
408
  self._persist_callback()
@@ -389,14 +444,15 @@ class WorkspaceSessionManager:
389
444
 
390
445
  @dataclass(frozen=True)
391
446
  class WorkspaceEntry:
392
- definition: 'WorkspaceDefinition'
393
- manager: 'WorkspaceSessionManager'
447
+ definition: "WorkspaceDefinition"
448
+ manager: "WorkspaceSessionManager"
394
449
 
395
- def to_dict(self) -> 'typing.Dict[str, object]':
450
+ def to_dict(self) -> "typing.Dict[str, object]":
396
451
  return {
397
452
  "id": self.definition.workspace_id,
398
453
  "board_path": (
399
- "" if self.definition.board_path is None
454
+ ""
455
+ if self.definition.board_path is None
400
456
  else str(self.definition.board_path)
401
457
  ),
402
458
  "work_dir": str(self.definition.work_dir),
@@ -413,13 +469,13 @@ WorkspaceEntryFactory = typing.Callable[
413
469
  class WorkspaceRegistry:
414
470
  def __init__(
415
471
  self,
416
- entries: 'typing.Iterable[WorkspaceEntry]',
417
- config_path: 'typing.Union[str, Path, None]' = None,
472
+ entries: "typing.Iterable[WorkspaceEntry]",
473
+ config_path: "typing.Union[str, Path, None]" = None,
418
474
  entry_factory: "typing.Union[WorkspaceEntryFactory, None]" = None,
419
475
  ) -> None:
420
- self._entries: 'typing.Dict[str, WorkspaceEntry]' = {}
421
- self._name_to_key: 'typing.Dict[str, str]' = {}
422
- self._order: 'typing.List[str]' = []
476
+ self._entries: "typing.Dict[str, WorkspaceEntry]" = {}
477
+ self._name_to_key: "typing.Dict[str, str]" = {}
478
+ self._order: "typing.List[str]" = []
423
479
  self._started = False
424
480
  self._config_path = (
425
481
  None if config_path is None else Path(config_path).expanduser().resolve()
@@ -430,7 +486,7 @@ class WorkspaceRegistry:
430
486
  if not self._entries and self._entry_factory is None:
431
487
  raise ValueError("at least one workspace is required")
432
488
 
433
- def _register(self, entry: 'WorkspaceEntry') -> None:
489
+ def _register(self, entry: "WorkspaceEntry") -> None:
434
490
  workspace_id = entry.definition.workspace_id
435
491
  key = self._key_for_definition(entry.definition)
436
492
  if key in self._entries:
@@ -444,7 +500,7 @@ class WorkspaceRegistry:
444
500
  self._order.append(key)
445
501
  entry.manager.set_persist_callback(self.persist_definitions)
446
502
 
447
- def _key_for_definition(self, definition: 'WorkspaceDefinition') -> str:
503
+ def _key_for_definition(self, definition: "WorkspaceDefinition") -> str:
448
504
  if definition.board_path is None:
449
505
  return "workspace:{0}".format(definition.workspace_id)
450
506
  return str(definition.board_path.resolve())
@@ -459,7 +515,7 @@ class WorkspaceRegistry:
459
515
  await self._entries[key].manager.close()
460
516
  self._started = False
461
517
 
462
- def get(self, workspace_id: str) -> 'WorkspaceEntry':
518
+ def get(self, workspace_id: str) -> "WorkspaceEntry":
463
519
  workspace_id = normalize_workspace_id(workspace_id)
464
520
  if not workspace_id:
465
521
  raise KeyError(workspace_id)
@@ -473,11 +529,13 @@ class WorkspaceRegistry:
473
529
  name: str,
474
530
  work_dir: str = "./",
475
531
  board: "typing.Union[str, bool, None]" = None,
476
- ) -> 'WorkspaceEntry':
532
+ ) -> "WorkspaceEntry":
477
533
  if self._entry_factory is None:
478
534
  raise ValueError("workspace creation is unavailable")
479
535
 
480
- base_dir = self._config_path.parent if self._config_path is not None else Path.cwd()
536
+ base_dir = (
537
+ self._config_path.parent if self._config_path is not None else Path.cwd()
538
+ )
481
539
  resolved_work_dir = _resolve_workspace_path(str(work_dir or "./"), base_dir)
482
540
  resolved_work_dir.mkdir(parents=True, exist_ok=True)
483
541
 
@@ -493,7 +551,9 @@ class WorkspaceRegistry:
493
551
  board_path.parent.mkdir(parents=True, exist_ok=True)
494
552
  board_key = str(board_path.resolve())
495
553
  if board_key in self._entries:
496
- raise ValueError("workspace board already exists: {0}".format(board_path))
554
+ raise ValueError(
555
+ "workspace board already exists: {0}".format(board_path)
556
+ )
497
557
 
498
558
  workspace_id = normalize_workspace_id(name)
499
559
  if not workspace_id:
@@ -513,7 +573,7 @@ class WorkspaceRegistry:
513
573
  self.persist_definitions()
514
574
  return entry
515
575
 
516
- async def delete_workspace(self, name: str) -> 'WorkspaceDefinition':
576
+ async def delete_workspace(self, name: str) -> "WorkspaceDefinition":
517
577
  workspace_id = normalize_workspace_id(name)
518
578
  if not workspace_id:
519
579
  raise KeyError(workspace_id)
@@ -535,11 +595,8 @@ class WorkspaceRegistry:
535
595
  [self._entries[key].definition for key in self._order],
536
596
  )
537
597
 
538
- def list_workspaces(self) -> 'typing.List[typing.Dict[str, object]]':
539
- return [
540
- self._entries[key].to_dict()
541
- for key in self._order
542
- ]
598
+ def list_workspaces(self) -> "typing.List[typing.Dict[str, object]]":
599
+ return [self._entries[key].to_dict() for key in self._order]
543
600
 
544
601
 
545
602
  def session_snapshot(session) -> "typing.Dict[str, object]":
pycodex/collaboration.py DELETED
@@ -1,20 +0,0 @@
1
-
2
- from .compat import Literal
3
- import typing
4
-
5
- CollaborationMode = Literal["default", "plan", "execute", "pair_programming"]
6
-
7
- DEFAULT_COLLABORATION_MODE: 'CollaborationMode' = "default"
8
- PLAN_COLLABORATION_MODE: 'CollaborationMode' = "plan"
9
-
10
- _MODE_DISPLAY_NAMES: 'typing.Dict[str, str]' = {
11
- "default": "Default",
12
- "plan": "Plan",
13
- "execute": "Execute",
14
- "pair_programming": "Pair Programming",
15
- }
16
-
17
-
18
- def collaboration_mode_display_name(mode: 'typing.Union[str, None]') -> 'str':
19
- normalized = (mode or DEFAULT_COLLABORATION_MODE).strip().lower()
20
- return _MODE_DISPLAY_NAMES.get(normalized, normalized.replace("_", " ").title())