mycode-cli 0.14.2__py3-none-any.whl → 0.15.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.
mycode_cli/config.py CHANGED
@@ -187,7 +187,7 @@ def parse_compact_threshold(value: Any) -> float | None:
187
187
  return None
188
188
  threshold = float(value)
189
189
 
190
- if threshold < 0 or threshold > 1:
190
+ if not 0 <= threshold <= 1:
191
191
  return None
192
192
  return threshold
193
193
 
@@ -504,45 +504,37 @@ def get_settings(cwd: str | None = None) -> Settings:
504
504
  if resolved_path not in config_paths:
505
505
  config_paths.append(resolved_path)
506
506
 
507
- for name, raw in (data.get("providers") or {}).items():
508
- if not isinstance(name, str) or not isinstance(raw, dict):
509
- continue
510
-
507
+ for name, raw in data.get("providers", {}).items():
511
508
  merged = dict(raw_providers.get(name, {}))
512
509
 
513
510
  if "type" in raw:
514
- merged["type"] = raw.get("type") or "anthropic"
511
+ merged["type"] = raw["type"]
515
512
  if "models" in raw:
516
- merged["models"] = raw.get("models")
513
+ merged["models"] = raw["models"]
517
514
  if "api_key" in raw:
518
- api_key, api_key_env_var = _parse_config_api_key(raw.get("api_key"))
515
+ api_key, api_key_env_var = _parse_config_api_key(raw["api_key"])
519
516
  merged["api_key"] = api_key
520
517
  merged["api_key_env_var"] = api_key_env_var
521
518
  if "base_url" in raw:
522
- merged["base_url"] = raw.get("base_url") or None
519
+ merged["base_url"] = raw["base_url"]
523
520
  if "supports_reasoning_effort" in raw:
524
- merged["supports_reasoning_effort"] = raw.get("supports_reasoning_effort")
521
+ merged["supports_reasoning_effort"] = raw["supports_reasoning_effort"]
525
522
 
526
523
  raw_providers[name] = merged
527
524
 
528
- default = data.get("default")
529
- if isinstance(default, dict):
530
- if "provider" in default:
531
- v = default.get("provider")
532
- default_provider = v if isinstance(v, str) else None
533
- if "model" in default:
534
- v = default.get("model")
535
- default_model = v if isinstance(v, str) else None
536
- if "compact_threshold" in default:
537
- parsed_threshold = parse_compact_threshold(default.get("compact_threshold"))
538
- if parsed_threshold is not None:
539
- compact_threshold = parsed_threshold
525
+ default = data.get("default", {})
526
+ if "provider" in default:
527
+ default_provider = default["provider"]
528
+ if "model" in default:
529
+ default_model = default["model"]
530
+ if "compact_threshold" in default:
531
+ compact_threshold = float(default["compact_threshold"])
540
532
 
541
533
  if "permission" in data:
542
534
  permission = parse_permission(data.get("permission"), permission)
543
535
 
544
- if data.get("web") is not None:
545
- web = _validate_web_config(data["web"])
536
+ if "web" in data:
537
+ web = data["web"]
546
538
  for key in ("fetch", "search"):
547
539
  if key in web:
548
540
  raw_web[key] = web[key]
@@ -768,14 +760,12 @@ def _available_provider_references(settings: Settings) -> list[str]:
768
760
 
769
761
  add(settings.default_provider)
770
762
 
771
- for name, provider in settings.providers.items():
772
- if provider_is_available(provider):
773
- add(name)
763
+ for name in settings.providers:
764
+ add(name)
774
765
 
775
766
  for provider_id in list_env_discoverable_providers():
776
- if provider_id in configured_types_with_credentials or not provider_api_key_from_env(provider_id):
777
- continue
778
- add(provider_id)
767
+ if provider_id not in configured_types_with_credentials:
768
+ add(provider_id)
779
769
 
780
770
  return available
781
771
 
mycode_cli/main.py CHANGED
@@ -7,7 +7,7 @@ import os
7
7
  import sys
8
8
  from contextlib import suppress
9
9
  from dataclasses import dataclass, replace
10
- from typing import Annotated, Any, Literal
10
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
11
11
  from uuid import uuid4
12
12
 
13
13
  import typer
@@ -28,8 +28,9 @@ from mycode_cli.permissions import PERMISSION_DENIED_BY_USER_OUTPUT, PERMISSION_
28
28
  from mycode_cli.sessions import SessionStore
29
29
 
30
30
  from .runtime import build_agent
31
- from .tui.chat import TerminalChat
32
- from .tui.render import TerminalView
31
+
32
+ if TYPE_CHECKING:
33
+ from .tui.render import TerminalView
33
34
 
34
35
  app = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
35
36
  session_app = typer.Typer(help="Session management")
@@ -157,6 +158,8 @@ def _bootstrap(
157
158
  ) -> _BootstrapContext:
158
159
  """Shared setup for the chat and run commands."""
159
160
 
161
+ from .tui.render import TerminalView
162
+
160
163
  if session and continue_last:
161
164
  raise typer.BadParameter("--session and --continue are mutually exclusive")
162
165
 
@@ -244,6 +247,8 @@ def chat(
244
247
  if ctx.invoked_subcommand is not None:
245
248
  return
246
249
 
250
+ from .tui.chat import TerminalChat
251
+
247
252
  setup = _bootstrap(
248
253
  provider=provider,
249
254
  model=model,
@@ -323,6 +328,7 @@ def web(
323
328
  resolved_port = port or settings.port
324
329
 
325
330
  import uvicorn
331
+ from uvicorn.config import LOGGING_CONFIG
326
332
 
327
333
  app_factory = "mycode_cli.server.app:create_web_app"
328
334
  if dev:
@@ -334,6 +340,11 @@ def web(
334
340
  port=resolved_port,
335
341
  reload=dev,
336
342
  factory=True,
343
+ timeout_graceful_shutdown=5,
344
+ log_config={
345
+ **LOGGING_CONFIG,
346
+ "root": {"handlers": ["default"], "level": "INFO"},
347
+ },
337
348
  )
338
349
 
339
350
 
@@ -343,6 +354,8 @@ def session_list(
343
354
  ) -> None:
344
355
  """List saved sessions."""
345
356
 
357
+ from .tui.render import TerminalView
358
+
346
359
  cwd = os.path.abspath(os.getcwd())
347
360
  store = SessionStore(data_dir=resolve_sessions_dir())
348
361
  view = TerminalView()
mycode_cli/permissions.py CHANGED
@@ -13,7 +13,6 @@ from mycode_cli.config import PermissionConfig, PermissionLevel, Settings
13
13
  from mycode_cli.system_prompt import discover_skills
14
14
  from mycode_cli.workspace import CliDeps, resolve_path
15
15
 
16
- PermissionTier = Literal["readonly", "safe", "standard", "yolo"]
17
16
  PermissionDecision = Literal["allow", "ask", "deny"]
18
17
  ToolReviewDecision = Literal["allow", "deny"]
19
18
 
@@ -54,7 +53,7 @@ _FIND_DANGEROUS_FLAGS = {
54
53
 
55
54
 
56
55
  class PermissionCheck(NamedTuple):
57
- tier: PermissionTier
56
+ tier: PermissionLevel
58
57
  preview: str
59
58
 
60
59
 
@@ -102,10 +101,8 @@ def build_permission_hooks(
102
101
  return hooks
103
102
 
104
103
 
105
- def permission_decision(permission: PermissionConfig, tier: PermissionTier) -> PermissionDecision:
106
- if permission.level == "yolo":
107
- return "allow"
108
- if tier != "yolo" and _LEVEL_RANK[tier] <= _LEVEL_RANK[permission.level]:
104
+ def permission_decision(permission: PermissionConfig, tier: PermissionLevel) -> PermissionDecision:
105
+ if _LEVEL_RANK[tier] <= _LEVEL_RANK[permission.level]:
109
106
  return "allow"
110
107
  return permission.mode
111
108
 
@@ -147,7 +144,7 @@ def classify_tool(
147
144
  return PermissionCheck("yolo", ctx.tool_name)
148
145
 
149
146
 
150
- def _classify_bash(command: str) -> PermissionTier:
147
+ def _classify_bash(command: str) -> PermissionLevel:
151
148
  if not command:
152
149
  return "yolo"
153
150
  # Newlines and shell expansion bypass static analysis; treat as compound.
mycode_cli/runtime.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from mycode.agent import Agent
6
6
  from mycode_cli.config import ResolvedProvider, Settings
7
7
  from mycode_cli.permissions import ToolReviewCallback, build_permission_hooks
8
- from mycode_cli.sessions import SessionStore
8
+ from mycode_cli.sessions import SessionStore, sum_session_cost
9
9
  from mycode_cli.system_prompt import build_system_prompt
10
10
  from mycode_cli.tools import DEFAULT_TOOLS
11
11
  from mycode_cli.web_tools import build_web_tools
@@ -24,18 +24,7 @@ async def load_session_cost(store: SessionStore, session_id: str) -> float | Non
24
24
  discarded by rewind. Records without a cost are skipped.
25
25
  """
26
26
 
27
- total: float | None = None
28
- for message in await store.load_raw_messages(session_id):
29
- if message.get("role") not in {"assistant", "compact"}:
30
- continue
31
- meta = message.get("meta") or {}
32
- cost = meta.get("cost")
33
- if not isinstance(cost, dict):
34
- continue
35
- request_total = cost.get("total")
36
- if isinstance(request_total, int | float):
37
- total = sum_known_costs(total, float(request_total))
38
- return total
27
+ return sum_session_cost(await store.load_raw_messages(session_id))
39
28
 
40
29
 
41
30
  def build_agent(
mycode_cli/server/app.py CHANGED
@@ -1,20 +1,24 @@
1
1
  """FastAPI application entry point."""
2
2
 
3
+ import asyncio
3
4
  import logging
4
- from collections.abc import Sequence
5
+ from collections.abc import AsyncGenerator, Sequence
6
+ from contextlib import asynccontextmanager
5
7
  from pathlib import Path
6
8
 
7
9
  from fastapi import FastAPI
8
10
  from fastapi.middleware.cors import CORSMiddleware
9
11
  from fastapi.staticfiles import StaticFiles
10
12
 
13
+ from mycode_cli.config import resolve_sessions_dir
11
14
  from mycode_cli.server.routers.chat import router as chat_router
12
15
  from mycode_cli.server.routers.sessions import router as sessions_router
13
16
  from mycode_cli.server.routers.settings import router as settings_router
14
17
  from mycode_cli.server.routers.workspaces import router as workspaces_router
18
+ from mycode_cli.server.run_manager import RunManager
19
+ from mycode_cli.sessions import SessionStore
15
20
 
16
21
  logger = logging.getLogger(__name__)
17
- logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
18
22
  DEV_CORS_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173")
19
23
 
20
24
 
@@ -24,9 +28,20 @@ def web_static_path() -> Path:
24
28
  return Path(__file__).resolve().parent / "static"
25
29
 
26
30
 
31
+ @asynccontextmanager
32
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
33
+ app.state.store = await asyncio.to_thread(SessionStore, data_dir=resolve_sessions_dir())
34
+ runs = RunManager()
35
+ app.state.runs = runs
36
+ try:
37
+ yield
38
+ finally:
39
+ await runs.aclose()
40
+
41
+
27
42
  def create_app(*, serve_web: bool = True, cors_origins: Sequence[str] = ()) -> FastAPI:
28
43
  """Create the FastAPI app."""
29
- application = FastAPI(title="mycode")
44
+ application = FastAPI(title="mycode", lifespan=lifespan)
30
45
 
31
46
  if cors_origins:
32
47
  application.add_middleware(
mycode_cli/server/deps.py CHANGED
@@ -3,12 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from functools import cache
7
- from typing import Annotated
6
+ from typing import Annotated, cast
8
7
 
9
- from fastapi import Depends, HTTPException
8
+ from fastapi import Depends, HTTPException, Request
10
9
 
11
- from mycode_cli.config import resolve_sessions_dir
12
10
  from mycode_cli.server.run_manager import RunManager
13
11
  from mycode_cli.sessions import SessionStore
14
12
 
@@ -22,18 +20,16 @@ def resolve_workspace_cwd(raw: str | None) -> str:
22
20
  return cwd
23
21
 
24
22
 
25
- @cache
26
- def get_store() -> SessionStore:
27
- """Return the shared session store for server requests."""
23
+ async def get_store(request: Request) -> SessionStore:
24
+ """Return this application's session store."""
28
25
 
29
- return SessionStore(data_dir=resolve_sessions_dir())
26
+ return cast(SessionStore, request.app.state.store)
30
27
 
31
28
 
32
- @cache
33
- def get_run_manager() -> RunManager:
34
- """Return the shared in-process run manager."""
29
+ async def get_run_manager(request: Request) -> RunManager:
30
+ """Return this application's run manager."""
35
31
 
36
- return RunManager()
32
+ return cast(RunManager, request.app.state.runs)
37
33
 
38
34
 
39
35
  StoreDep = Annotated[SessionStore, Depends(get_store)]
@@ -63,7 +63,7 @@ router = APIRouter()
63
63
  def _resolve_workspace_attachment_path(rel_path: str, *, cwd: str) -> Path:
64
64
  base = resolve_path(".", cwd=cwd)
65
65
  path = resolve_path(rel_path, cwd=cwd)
66
- if base != path and not path.is_relative_to(base):
66
+ if not path.is_relative_to(base):
67
67
  raise HTTPException(status_code=400, detail=f"path outside workspace: {rel_path}")
68
68
  return path
69
69
 
@@ -97,7 +97,9 @@ async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessag
97
97
  for block in chat.input:
98
98
  if block.type == "text":
99
99
  if block.path:
100
- blocks.extend(_read_workspace_text_attachment(block.path, name=block.name, cwd=cwd))
100
+ blocks.extend(
101
+ await asyncio.to_thread(_read_workspace_text_attachment, block.path, name=block.name, cwd=cwd)
102
+ )
101
103
  continue
102
104
  text = block.text or ""
103
105
  if block.is_attachment:
@@ -145,11 +147,8 @@ def _validate_rewind_request(
145
147
  *,
146
148
  session: dict[str, Any] | None,
147
149
  messages: list[ConversationMessage],
148
- rewind_to: int | None,
150
+ rewind_to: int,
149
151
  ) -> None:
150
- if rewind_to is None:
151
- return
152
-
153
152
  if session is None:
154
153
  raise HTTPException(status_code=400, detail="rewind_to requires an existing session")
155
154
 
@@ -236,10 +235,11 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
236
235
  detail={"message": "session already has a running task", "run": active},
237
236
  )
238
237
 
239
- data = await store.load_session(session_id)
240
- session = cast(dict[str, Any] | None, data["session"] if data else None)
241
- existing_messages = data["messages"] if data else []
242
- _validate_rewind_request(session=session, messages=existing_messages, rewind_to=chat.rewind_to)
238
+ if chat.rewind_to is not None:
239
+ data = await store.load_session(session_id)
240
+ session = cast(dict[str, Any] | None, data["session"] if data else None)
241
+ existing_messages = data["messages"] if data else []
242
+ _validate_rewind_request(session=session, messages=existing_messages, rewind_to=chat.rewind_to)
243
243
 
244
244
  # All validation passed. Land the rewind marker (if any) and register
245
245
  # the user turn in the catalog: first turn creates the entry and sets
@@ -374,6 +374,7 @@ async def compact_session(
374
374
  session_id=session_id,
375
375
  base_messages=agent.messages,
376
376
  agent=agent,
377
+ session_cost_base=data["session_cost"],
377
378
  on_complete=store.touch,
378
379
  )
379
380
  except ActiveRunError as exc:
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException, Query
9
9
  from fastapi import Path as PathParam
10
10
 
11
11
  from mycode.messages import ConversationMessage
12
- from mycode_cli.runtime import load_session_cost
13
12
  from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
14
13
  from mycode_cli.server.schemas import SessionCreateRequest, StatusResponse
15
14
 
@@ -57,19 +56,18 @@ async def load_session(
57
56
  ) -> dict[str, Any]:
58
57
  """Load a session, overlaying any active in-memory run state."""
59
58
 
60
- data = await store.load_session(session_id)
61
- # Folded from the raw JSONL: counts everything persisted so far, including
62
- # rewound-away turns. During an active run the SSE usage events supersede it.
63
- session_cost = await load_session_cost(store, session_id)
64
- active = await runs.snapshot_session(session_id)
65
- if active:
66
- return {
67
- "session": data["session"] if data else None,
68
- "messages": _redact_document_data(active["messages"]),
69
- "session_cost": session_cost,
70
- "active_run": active["run"],
71
- "pending_events": active["pending_events"],
72
- }
59
+ async with runs.session_operation(session_id):
60
+ active = await runs.snapshot_session(session_id)
61
+ if active:
62
+ return {
63
+ "session": await store.load_metadata(session_id),
64
+ "messages": _redact_document_data(active["messages"]),
65
+ "session_cost": active["session_cost"],
66
+ "active_run": active["run"],
67
+ "pending_events": active["pending_events"],
68
+ }
69
+
70
+ data = await store.load_session(session_id)
73
71
 
74
72
  if data is None:
75
73
  return {"session": None, "messages": [], "session_cost": None, "active_run": None, "pending_events": []}
@@ -77,7 +75,7 @@ async def load_session(
77
75
  return {
78
76
  "session": data["session"],
79
77
  "messages": _redact_document_data(data["messages"]),
80
- "session_cost": session_cost,
78
+ "session_cost": data["session_cost"],
81
79
  "active_run": None,
82
80
  "pending_events": [],
83
81
  }
@@ -168,14 +168,16 @@ def put_settings_endpoint(payload: SettingsRequest) -> dict[str, Any]:
168
168
  # Three-state api_key merge: when the UI sends null / omits it, we copy the
169
169
  # existing literal forward so secrets survive a save without round-tripping.
170
170
  existing_providers = existing.get("providers") or {}
171
- for name, entry in (incoming.get("providers") or {}).items():
172
- if not isinstance(entry, dict) or entry.get("api_key") is not None:
173
- continue
174
- prior = existing_providers.get(name) if isinstance(existing_providers, dict) else None
175
- if isinstance(prior, dict) and "api_key" in prior:
176
- entry["api_key"] = prior["api_key"]
177
- else:
178
- entry.pop("api_key", None)
171
+ incoming_providers = incoming.get("providers")
172
+ if isinstance(incoming_providers, dict):
173
+ for name, entry in incoming_providers.items():
174
+ if not isinstance(entry, dict) or entry.get("api_key") is not None:
175
+ continue
176
+ prior = existing_providers.get(name) if isinstance(existing_providers, dict) else None
177
+ if isinstance(prior, dict) and "api_key" in prior:
178
+ entry["api_key"] = prior["api_key"]
179
+ else:
180
+ entry.pop("api_key", None)
179
181
 
180
182
  existing_web = existing.get("web") or {}
181
183
  incoming_web = incoming.get("web") or {}
@@ -121,7 +121,7 @@ def list_workspace_files(
121
121
 
122
122
  target = (base / dir).resolve(strict=False)
123
123
  # Reject traversal outside cwd after resolving symlinks.
124
- if target != base and not target.is_relative_to(base):
124
+ if not target.is_relative_to(base):
125
125
  return {"entries": [], "truncated": False, "error": "path outside workspace"}
126
126
  if not target.is_dir():
127
127
  return {"entries": [], "truncated": False, "error": "not a directory"}
@@ -133,7 +133,7 @@ def list_workspace_files(
133
133
  continue
134
134
  try:
135
135
  resolved = entry.resolve()
136
- if resolved != base and not resolved.is_relative_to(base):
136
+ if not resolved.is_relative_to(base):
137
137
  continue
138
138
  is_dir = entry.is_dir()
139
139
  except OSError:
@@ -6,15 +6,17 @@ import asyncio
6
6
  import copy
7
7
  import time
8
8
  from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
9
- from contextlib import asynccontextmanager, suppress
9
+ from contextlib import asynccontextmanager
10
10
  from dataclasses import dataclass, field
11
11
  from typing import Any, Literal, Protocol
12
12
  from uuid import uuid4
13
+ from weakref import WeakValueDictionary
13
14
 
14
15
  from mycode.agent import Event
15
16
  from mycode.messages import ConversationMessage
16
17
  from mycode_cli.permissions import ToolReviewDecision
17
18
  from mycode_cli.runtime import sum_known_costs
19
+ from mycode_cli.sessions import sum_session_cost
18
20
 
19
21
  RunStatus = Literal["running", "completed", "failed", "cancelled"]
20
22
  RunKind = Literal["chat", "compact"]
@@ -60,9 +62,9 @@ class RunState:
60
62
  agent: RunAgent
61
63
  kind: RunKind = "chat"
62
64
  user_message: ConversationMessage | None = None
63
- # Session cost before this run (folded from the session JSONL by the chat
64
- # router); None means unknown. Composed with each usage event's turn cost.
65
+ # Fixed history cost, combined with cumulative turn costs on each update.
65
66
  session_cost_base: float | None = None
67
+ session_cost: float | None = None
66
68
  on_complete: RunCompletionCallback | None = None
67
69
  status: RunStatus = "running"
68
70
  error: str | None = None
@@ -97,7 +99,7 @@ class RunManager:
97
99
  self._lock = asyncio.Lock()
98
100
  self._active_by_session: dict[str, RunState] = {}
99
101
  self._runs_by_id: dict[str, RunState] = {}
100
- self._session_locks: dict[str, asyncio.Lock] = {}
102
+ self._session_locks: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary()
101
103
 
102
104
  async def start_run(
103
105
  self,
@@ -123,6 +125,7 @@ class RunManager:
123
125
  session_id: str,
124
126
  base_messages: list[ConversationMessage],
125
127
  agent: RunAgent,
128
+ session_cost_base: float | None = None,
126
129
  on_complete: RunCompletionCallback | None = None,
127
130
  ) -> dict[str, Any]:
128
131
  return await self._start(
@@ -131,6 +134,7 @@ class RunManager:
131
134
  user_message=None,
132
135
  base_messages=base_messages,
133
136
  agent=agent,
137
+ session_cost_base=session_cost_base,
134
138
  on_complete=on_complete,
135
139
  )
136
140
 
@@ -158,6 +162,7 @@ class RunManager:
158
162
  kind=kind,
159
163
  user_message=copy.deepcopy(user_message),
160
164
  session_cost_base=session_cost_base,
165
+ session_cost=session_cost_base,
161
166
  base_messages=copy.deepcopy(base_messages),
162
167
  agent=agent,
163
168
  on_complete=on_complete,
@@ -182,6 +187,8 @@ class RunManager:
182
187
  return None
183
188
 
184
189
  async with state.condition:
190
+ if state.status != "running":
191
+ return None
185
192
  messages = copy.deepcopy(state.base_messages)
186
193
  if state.user_message is not None:
187
194
  messages.append(copy.deepcopy(state.user_message))
@@ -189,10 +196,12 @@ class RunManager:
189
196
  "run": state.info(),
190
197
  "messages": messages,
191
198
  "pending_events": list(state.events),
199
+ "session_cost": state.session_cost,
192
200
  }
193
201
 
194
202
  @asynccontextmanager
195
203
  async def session_operation(self, session_id: str) -> AsyncGenerator[None]:
204
+ # Keep a strong reference while waiting and holding the session lock.
196
205
  async with self._lock:
197
206
  lock = self._session_locks.get(session_id)
198
207
  if lock is None:
@@ -217,18 +226,21 @@ class RunManager:
217
226
  last_seq = max(0, after)
218
227
  while True:
219
228
  async with state.condition:
220
- pending = [event for event in state.events if int(event.get("seq") or 0) > last_seq]
229
+ pending = []
230
+ for event in reversed(state.events):
231
+ if event["seq"] <= last_seq:
232
+ break
233
+ pending.append(event)
234
+ pending.reverse()
221
235
  finished = state.status != "running"
222
236
 
223
237
  if not pending and not finished:
224
- # Wake on the next event or re-poll after the timeout; both just re-loop.
225
- with suppress(TimeoutError):
226
- await asyncio.wait_for(state.condition.wait(), timeout=0.5)
238
+ await state.condition.wait()
227
239
  continue
228
240
 
229
241
  for payload in pending:
230
242
  yield payload
231
- last_seq = int(payload.get("seq") or last_seq)
243
+ last_seq = payload["seq"]
232
244
 
233
245
  if finished:
234
246
  break
@@ -237,14 +249,35 @@ class RunManager:
237
249
  state = await self.get_run(run_id)
238
250
  if not state:
239
251
  return None
252
+ self._request_cancel(state)
253
+ if state.task is not None:
254
+ # Request cancellation must not interrupt the run's own cleanup.
255
+ await asyncio.shield(state.task)
256
+ return state.info()
257
+
258
+ def _request_cancel(self, state: RunState) -> None:
259
+ # Repeated task.cancel() can interrupt a tool's cancellation cleanup.
260
+ if state.cancel_requested or state.status != "running":
261
+ return
240
262
  state.cancel_requested = True
241
263
  state.agent.cancel()
242
264
  for fut in state.pending_decisions.values():
243
265
  if not fut.done():
244
266
  fut.cancel()
245
- if state.task is not None:
246
- await state.task
247
- return state.info()
267
+
268
+ async def aclose(self) -> None:
269
+ """Cancel and await owned runs after the application stops handling requests."""
270
+
271
+ async with self._lock:
272
+ tasks = []
273
+ for state in self._runs_by_id.values():
274
+ if state.task is not None and not state.task.done():
275
+ self._request_cancel(state)
276
+ tasks.append(state.task)
277
+ await asyncio.gather(*tasks)
278
+ self._runs_by_id.clear()
279
+ self._active_by_session.clear()
280
+ self._session_locks.clear()
248
281
 
249
282
  async def has_active_run(self, session_id: str) -> bool:
250
283
  async with self._lock:
@@ -287,8 +320,7 @@ class RunManager:
287
320
  try:
288
321
  decision = await future
289
322
  if decision == "deny":
290
- state.cancel_requested = True
291
- state.agent.cancel()
323
+ self._request_cancel(state)
292
324
  return decision
293
325
  except asyncio.CancelledError:
294
326
  # Treat cancellation as deny so the agent loop unwinds via its own cancel check.
@@ -323,15 +355,20 @@ class RunManager:
323
355
  async def _run(self, state: RunState) -> None:
324
356
  """Run the agent and store streamed events."""
325
357
 
358
+ # Agent entrypoints reset their cancellation flags for a new turn.
359
+ if state.cancel_requested:
360
+ await self._finish_run(state, status="cancelled")
361
+ return
362
+
326
363
  last_error: str | None = None
327
364
 
328
365
  try:
329
366
  if state.kind == "compact":
330
367
  # The agent persists the compact marker before this returns.
331
- await state.agent.acompact()
368
+ marker = await state.agent.acompact()
332
369
  if state.on_complete is not None:
333
370
  await state.on_complete(state.session_id)
334
- await self._append_event(state, Event("compact", {}))
371
+ await self._append_event(state, Event("compact", {}), compact_cost=sum_session_cost([marker]))
335
372
  else:
336
373
  assert state.user_message is not None
337
374
  async for event in state.agent.achat(state.user_message):
@@ -339,22 +376,6 @@ class RunManager:
339
376
  continue
340
377
  if event.type == "error":
341
378
  last_error = str(event.data.get("message") or "unknown error")
342
- elif event.type == "usage":
343
- turn_cost = event.data.get("turn_cost")
344
- turn_total = turn_cost.get("total") if isinstance(turn_cost, dict) else None
345
- session_cost = sum_known_costs(
346
- state.session_cost_base,
347
- turn_total,
348
- )
349
- event = Event(
350
- "usage",
351
- {
352
- **event.data,
353
- "context_window": state.agent.context_window,
354
- "model": state.agent.model,
355
- "session_cost": session_cost,
356
- },
357
- )
358
379
  await self._append_event(state, event)
359
380
  except asyncio.CancelledError:
360
381
  # BaseException, not caught by ``except Exception`` — without this
@@ -377,8 +398,24 @@ class RunManager:
377
398
 
378
399
  await self._finish_run(state, status="completed")
379
400
 
380
- async def _append_event(self, state: RunState, event: Event) -> None:
401
+ async def _append_event(self, state: RunState, event: Event, *, compact_cost: float | None = None) -> None:
381
402
  async with state.condition:
403
+ if event.type == "usage":
404
+ turn_cost = event.data.get("turn_cost")
405
+ turn_total = turn_cost.get("total") if isinstance(turn_cost, dict) else None
406
+ state.session_cost = sum_known_costs(state.session_cost_base, turn_total)
407
+ event = Event(
408
+ "usage",
409
+ {
410
+ **event.data,
411
+ "context_window": state.agent.context_window,
412
+ "model": state.agent.model,
413
+ "session_cost": state.session_cost,
414
+ },
415
+ )
416
+ elif event.type == "compact" and state.kind == "compact":
417
+ state.session_cost = sum_known_costs(state.session_cost_base, compact_cost)
418
+
382
419
  if event.type == "tool_done":
383
420
  tool_use_id = event.data.get("tool_use_id")
384
421
  retained = [
mycode_cli/sessions.py CHANGED
@@ -23,12 +23,14 @@ import asyncio
23
23
  import json
24
24
  import os
25
25
  import shutil
26
+ from collections.abc import Iterable
26
27
  from datetime import UTC, datetime
27
28
  from pathlib import Path
28
29
  from typing import TypedDict
29
30
 
30
31
  from mycode.messages import ConversationMessage
31
32
  from mycode.session import SessionStore as TimelineStore
33
+ from mycode.session import apply_rewind
32
34
 
33
35
  DEFAULT_SESSION_TITLE = "New chat"
34
36
  _META_KEYS = ("cwd", "title", "created_at", "updated_at")
@@ -39,6 +41,23 @@ SessionMetaDict = dict[str, object]
39
41
  class SessionData(TypedDict):
40
42
  session: SessionMetaDict
41
43
  messages: list[ConversationMessage]
44
+ session_cost: float | None
45
+
46
+
47
+ def sum_session_cost(messages: Iterable[ConversationMessage]) -> float | None:
48
+ """Sum known request costs, including compact markers and rewound turns."""
49
+
50
+ total: float | None = None
51
+ for message in messages:
52
+ if message.get("role") not in {"assistant", "compact"}:
53
+ continue
54
+ cost = (message.get("meta") or {}).get("cost")
55
+ if not isinstance(cost, dict):
56
+ continue
57
+ request_total = cost.get("total")
58
+ if isinstance(request_total, int | float):
59
+ total = (total if total is not None else 0.0) + float(request_total)
60
+ return total
42
61
 
43
62
 
44
63
  def _now() -> str:
@@ -185,16 +204,27 @@ class SessionStore(TimelineStore):
185
204
  sessions = await self.list_sessions(cwd=cwd)
186
205
  return sessions[0] if sessions else None
187
206
 
207
+ async def load_metadata(self, session_id: str) -> SessionMetaDict | None:
208
+ """Load a catalog entry without reading its message timeline."""
209
+
210
+ def load() -> SessionMetaDict | None:
211
+ meta = self._read_meta(session_id)
212
+ return self._summary(session_id, meta) if meta is not None else None
213
+
214
+ return await asyncio.to_thread(load)
215
+
188
216
  async def load_session(self, session_id: str) -> SessionData | None:
189
- """Load one cataloged session's meta and visible messages."""
217
+ """Load metadata, visible messages, and cost from one raw timeline read."""
190
218
 
191
219
  def load() -> SessionData | None:
192
220
  meta = self._read_meta(session_id)
193
221
  if meta is None:
194
222
  return None
223
+ raw = self.load_raw_messages_sync(session_id)
195
224
  return {
196
225
  "session": self._summary(session_id, meta),
197
- "messages": self.load_messages_sync(session_id),
226
+ "messages": apply_rewind(raw),
227
+ "session_cost": sum_session_cost(raw),
198
228
  }
199
229
 
200
230
  return await asyncio.to_thread(load)
@@ -323,13 +323,14 @@ def discover_slash_skills(cwd: str) -> list[Skill]:
323
323
  def build_skill_snapshot_blocks(text: str, cwd: str) -> list[ContentBlock]:
324
324
  """Expand standalone ``/<skill-name>`` references in first-use order."""
325
325
 
326
+ names = [token[1:] for token in text.split() if token.startswith("/")]
327
+ if not names:
328
+ return []
329
+
326
330
  skills = {skill.name: skill for skill in discover_slash_skills(cwd)}
327
331
  blocks: list[ContentBlock] = []
328
332
  seen: set[str] = set()
329
- for token in text.split():
330
- if not token.startswith("/"):
331
- continue
332
- name = token[1:]
333
+ for name in names:
333
334
  skill = skills.get(name)
334
335
  if not skill or name in seen:
335
336
  continue
mycode_cli/tools.py CHANGED
@@ -20,6 +20,7 @@ from dataclasses import dataclass
20
20
  from difflib import SequenceMatcher, unified_diff
21
21
  from pathlib import Path
22
22
  from typing import BinaryIO
23
+ from uuid import uuid4
23
24
 
24
25
  from pydantic import BaseModel, ConfigDict, Field
25
26
 
@@ -162,16 +163,18 @@ def read_tool(
162
163
 
163
164
 
164
165
  def _atomic_write_text(path: Path, content: str, *, newline: str | None = None) -> None:
165
- tmp = path.with_suffix(path.suffix + ".tmp")
166
- if newline is None:
167
- tmp.write_text(content, encoding="utf-8")
168
- else:
169
- normalized = content.replace("\r\n", "\n")
166
+ if newline is not None:
167
+ content = content.replace("\r\n", "\n")
170
168
  if newline == "\r\n":
171
- normalized = normalized.replace("\n", "\r\n")
172
- with tmp.open("w", encoding="utf-8", newline="") as file:
173
- file.write(normalized)
174
- tmp.replace(path)
169
+ content = content.replace("\n", "\r\n")
170
+ tmp = path.with_name(f".{uuid4().hex}.tmp")
171
+ file = tmp.open("x", encoding="utf-8", newline=None if newline is None else "")
172
+ try:
173
+ with file:
174
+ file.write(content)
175
+ tmp.replace(path)
176
+ finally:
177
+ tmp.unlink(missing_ok=True)
175
178
 
176
179
 
177
180
  @tool(
@@ -238,21 +241,21 @@ def _closest_line_hint(text: str, needle: str) -> str | None:
238
241
  def _normalize_text(text: str) -> tuple[str, list[int]]:
239
242
  """Normalize text while preserving a map back to original offsets."""
240
243
 
241
- chars: list[str] = []
244
+ parts: list[str] = []
242
245
  imap: list[int] = []
243
246
  pos = 0
244
247
  for line in text.splitlines(keepends=True):
245
248
  content = line.rstrip("\r\n")
246
249
  trimmed = content.rstrip(" \t")
247
- chars.extend(trimmed)
250
+ parts.append(trimmed)
248
251
  imap.extend(range(pos, pos + len(trimmed)))
249
252
  eol = line[len(content) :]
250
253
  if eol:
251
- chars.append("\n")
254
+ parts.append("\n")
252
255
  imap.append(pos + len(content))
253
256
  pos += len(line)
254
257
 
255
- return "".join(chars), imap
258
+ return "".join(parts), imap
256
259
 
257
260
 
258
261
  @tool(
@@ -362,10 +365,13 @@ def edit_tool(ctx: ToolContext[CliDeps], path: str, edits: list[EditEntry]) -> T
362
365
  is_error=True,
363
366
  )
364
367
 
365
- # Apply replacements back-to-front so earlier offsets stay valid.
366
- updated = text
367
- for start, end, new_text, _ in reversed(matches):
368
- updated = updated[:start] + new_text + updated[end:]
368
+ parts: list[str] = []
369
+ cursor = 0
370
+ for start, end, new_text, _ in matches:
371
+ parts.extend((text[cursor:start], new_text))
372
+ cursor = end
373
+ parts.append(text[cursor:])
374
+ updated = "".join(parts)
369
375
 
370
376
  if updated == text:
371
377
  return ToolExecutionResult(output="error: edits produced no changes", is_error=True)
@@ -570,10 +576,6 @@ class _BashOutputAccumulator:
570
576
 
571
577
  def _trim_tail(self) -> None:
572
578
  encoded = self.tail_text.encode("utf-8")
573
- if len(encoded) <= DEFAULT_MAX_BYTES * 2:
574
- self.tail_bytes = len(encoded)
575
- return
576
-
577
579
  start = len(encoded) - DEFAULT_MAX_BYTES * 2
578
580
  while start < len(encoded) and encoded[start] & 0xC0 == 0x80:
579
581
  start += 1
mycode_cli/tui/chat.py CHANGED
@@ -6,7 +6,6 @@ import asyncio
6
6
  import re
7
7
  import shlex
8
8
  from collections.abc import Iterable
9
- from contextlib import suppress
10
9
  from dataclasses import dataclass
11
10
  from pathlib import Path
12
11
  from typing import Any, override
@@ -62,19 +61,18 @@ from .theme import ERROR, ERROR_MARKER, MUTED, PROMPT_CHAR, TERMINAL_THEME, TOOL
62
61
 
63
62
  _PROMPT = ANSI(f"\033[1m\033[34m{PROMPT_CHAR}\033[0m ")
64
63
 
65
- # (command, help usage, description) — the completer offers `command`, help prints `usage`.
66
64
  _COMMANDS = (
67
- ("/clear", "/c, /clear", "Clear conversation"),
68
- ("/compact", "/compact", "Compact conversation context"),
69
- ("/new", "/new", "New session"),
70
- ("/resume", "/resume", "Switch session"),
71
- ("/rewind", "/rewind", "Rewind to a previous message"),
72
- ("/provider", "/provider [name]", "Switch provider"),
73
- ("/model", "/model [name]", "Switch model"),
74
- ("/effort", "/effort [level]", "Set reasoning effort"),
75
- ("/q", "/q", "Quit"),
65
+ ("/clear", "Clear conversation"),
66
+ ("/compact", "Compact conversation context"),
67
+ ("/new", "New session"),
68
+ ("/resume", "Switch session"),
69
+ ("/rewind", "Rewind to a previous message"),
70
+ ("/provider", "Switch provider"),
71
+ ("/model", "Switch model"),
72
+ ("/effort", "Set reasoning effort"),
73
+ ("/q", "Quit"),
76
74
  )
77
- _SLASH_COMMANDS = tuple(command for command, _, _ in _COMMANDS)
75
+ _SLASH_COMMANDS = tuple(command for command, _ in _COMMANDS)
78
76
  # Only treat `@path` as a reference when it starts a standalone token.
79
77
  _AT_PATH_RE = re.compile(r"""(?<!\S)@(?:'(?P<single>[^']*)'?$|"(?P<double>[^"]*)"?$|(?P<plain>[^\s'"]*))$""")
80
78
  _SKILL_TOKEN_RE = re.compile(r"(?<!\S)/(?P<name>[a-zA-Z0-9_-]*)$")
@@ -151,7 +149,7 @@ class _PromptCompleter(Completer):
151
149
 
152
150
  text = text_before_cursor.lstrip()
153
151
  if re.fullmatch(r"/\S*", text):
154
- for cmd, _usage, desc in _COMMANDS:
152
+ for cmd, desc in _COMMANDS:
155
153
  if cmd.startswith(text):
156
154
  yield Completion(cmd, start_position=-len(text), display_meta=desc)
157
155
 
@@ -195,10 +193,9 @@ class _PromptCompleter(Completer):
195
193
 
196
194
  if not base_dir.is_dir():
197
195
  return
198
- for entry in sorted(base_dir.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())):
199
- if partial and not entry.name.startswith(partial):
200
- continue
201
- candidate = f"{base_prefix}{entry.name}{'/' if entry.is_dir() else ''}"
196
+ entries = [(entry, entry.is_dir()) for entry in base_dir.iterdir() if entry.name.startswith(partial)]
197
+ for entry, is_dir in sorted(entries, key=lambda item: (not item[1], item[0].name.lower())):
198
+ candidate = f"{base_prefix}{entry.name}{'/' if is_dir else ''}"
202
199
  if quote:
203
200
  replacement = f"@{quote}{candidate}{quote}"
204
201
  elif any(ch.isspace() for ch in candidate):
@@ -209,7 +206,7 @@ class _PromptCompleter(Completer):
209
206
  replacement,
210
207
  start_position=-len(match.group(0)),
211
208
  display="@" + candidate,
212
- display_meta="dir" if entry.is_dir() else "file",
209
+ display_meta="dir" if is_dir else "file",
213
210
  )
214
211
 
215
212
 
@@ -332,7 +329,6 @@ class ProviderOption:
332
329
  name: str
333
330
  provider: str
334
331
  models: tuple[str, ...]
335
- api_base: str | None
336
332
 
337
333
 
338
334
  def clone_agent(agent: Agent, *, store: SessionStore, session_id: str, cwd: str) -> Agent:
@@ -376,7 +372,6 @@ def list_provider_options(settings: Settings) -> list[ProviderOption]:
376
372
  name=name,
377
373
  provider=config.type,
378
374
  models=tuple(config.models),
379
- api_base=config.base_url,
380
375
  )
381
376
  )
382
377
  if provider_is_available(config):
@@ -390,7 +385,6 @@ def list_provider_options(settings: Settings) -> list[ProviderOption]:
390
385
  name=provider_name,
391
386
  provider=provider_name,
392
387
  models=provider_default_models(provider_name),
393
- api_base=None,
394
388
  )
395
389
  )
396
390
 
@@ -548,11 +542,10 @@ class TerminalChat:
548
542
  except (KeyboardInterrupt, asyncio.CancelledError):
549
543
  self.agent.cancel()
550
544
  renderer.cancel()
551
- # Python 3.11+: uncancel the task so the loop can continue after Ctrl+C.
545
+ # Uncancel the task so the loop can continue after Ctrl+C.
552
546
  task = asyncio.current_task()
553
547
  if task is not None:
554
- with suppress(AttributeError):
555
- task.uncancel()
548
+ task.uncancel()
556
549
  finally:
557
550
  self._current_renderer = None
558
551
 
@@ -655,14 +648,6 @@ class TerminalChat:
655
648
 
656
649
  return True
657
650
 
658
- def _print_help(self) -> None:
659
- self.view.console.print()
660
- for _command, usage, desc in _COMMANDS:
661
- line = Text()
662
- line.append(f" {usage:<20}", style="bold")
663
- line.append(desc, style=MUTED)
664
- self.view.console.print(line)
665
-
666
651
  def _print_runtime_status(self, action: str, value: str, *, changed: bool) -> None:
667
652
  """Print the result of a runtime-only change."""
668
653
 
@@ -690,11 +675,10 @@ class TerminalChat:
690
675
  return
691
676
  except (KeyboardInterrupt, asyncio.CancelledError):
692
677
  self.agent.cancel()
693
- # Python 3.11+: uncancel the task so the loop can continue after Ctrl+C.
678
+ # Uncancel the task so the loop can continue after Ctrl+C.
694
679
  task = asyncio.current_task()
695
680
  if task is not None:
696
- with suppress(AttributeError):
697
- task.uncancel()
681
+ task.uncancel()
698
682
  self.view.console.print(Text("cancelled", style=MUTED))
699
683
  return
700
684
  except Exception as exc:
@@ -815,7 +799,7 @@ class TerminalChat:
815
799
  """Prompt for a configured provider and apply it to the active agent."""
816
800
 
817
801
  options = list_provider_options(self.settings)
818
- current = get_provider_option(self.settings, provider_name=self.provider_name)
802
+ current = next((option for option in options if option.name == self.provider_name), None)
819
803
 
820
804
  choices: list[tuple[str, str]] = []
821
805
  for option in options:
@@ -919,10 +903,7 @@ class TerminalChat:
919
903
 
920
904
  def _restore_effort(self) -> None:
921
905
  saved = self.effort_preferences.get(self._effort_key())
922
- if saved is None:
923
- self.agent.reasoning_effort = None
924
- return
925
- if saved == "auto":
906
+ if saved is None or saved == "auto":
926
907
  self.agent.reasoning_effort = None
927
908
  return
928
909
  if self.agent.supports_reasoning_effort and saved in self.reasoning_efforts:
mycode_cli/tui/render.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
+ from collections import deque
6
7
  from datetime import datetime
7
8
  from typing import Any, ClassVar, override
8
9
 
@@ -357,7 +358,7 @@ class ReplyRenderer:
357
358
  self._context_window = context_window
358
359
  self._session_cost_base = session_cost_base
359
360
  self._live: Live | None = None
360
- self._reasoning: list[str] = []
361
+ self._reasoning: deque[str] = deque(maxlen=31)
361
362
  self._text: list[str] = []
362
363
  self._text_started = False
363
364
  # Reasoning & stats
@@ -719,7 +720,7 @@ class ReplyRenderer:
719
720
  # Thinking in progress: show rolling preview of reasoning content.
720
721
  # Join only the tail to avoid O(full_length) work on every frame.
721
722
  if self._reasoning and not self._text:
722
- tail = "".join(self._reasoning[-30:])
723
+ tail = "".join(list(self._reasoning)[-30:])
723
724
  content = " ".join(tail.split())
724
725
  if content:
725
726
  preview = content[-80:].strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mycode-cli
3
- Version: 0.14.2
3
+ Version: 0.15.0
4
4
  Summary: Minimal coding agent CLI and web UI built on mycode-sdk.
5
5
  Project-URL: Homepage, https://github.com/legibet/mycode
6
6
  Project-URL: Repository, https://github.com/legibet/mycode
@@ -25,7 +25,7 @@ Requires-Dist: beautifulsoup4>=4.15.0
25
25
  Requires-Dist: fastapi>=0.139.0
26
26
  Requires-Dist: httpx2>=2.10.0
27
27
  Requires-Dist: markdownify>=1.2.3
28
- Requires-Dist: mycode-sdk==0.14.2
28
+ Requires-Dist: mycode-sdk==0.15.0
29
29
  Requires-Dist: prompt-toolkit>=3.0.52
30
30
  Requires-Dist: pydantic>=2.13.4
31
31
  Requires-Dist: pyyaml>=6.0.3
@@ -1,23 +1,23 @@
1
1
  mycode_cli/__init__.py,sha256=K9OoBd9T-1JdpfLnjTgRoVyv8gSnA4telBRzYSl23OE,231
2
- mycode_cli/config.py,sha256=CM1G7393pRLEYvqnr3y9kpzL84tsZnYsp5XrRrPTv2U,30887
3
- mycode_cli/main.py,sha256=p2kehJ-aIN36N-RnbchaE1YvTGDB3kynfwvlmUPzMhc,11636
4
- mycode_cli/permissions.py,sha256=uzwDFnb3cskpAKy-DKHYwCg0rGeTdIOvFMtk5CsNjho,7904
5
- mycode_cli/runtime.py,sha256=hs4XLD6_c2zknD4flxpmOVDfH_KJYwp3ZaZujl8UGxA,3099
6
- mycode_cli/sessions.py,sha256=jSC9I7PqLcTVLsfJLoY2Ldl4GRwuIzT2y-wYr409Lw4,7231
7
- mycode_cli/system_prompt.py,sha256=yvCEv1-POT8axUPFy-8q8jLOvLtK7Z7ZxUwHd_WQDTU,12773
8
- mycode_cli/tools.py,sha256=P2zabvGSTG0ri-riESbJYAHsNCUKcBigq-7iOlUTGzs,26520
2
+ mycode_cli/config.py,sha256=B_kL9vijHM20JA0DrFdEN_SuBzgjozPeqtJE-PZjHJw,30274
3
+ mycode_cli/main.py,sha256=TMuD5HAH53F2XFtifd4lQeDx3RnLtmE_GsOz76fbgOI,11972
4
+ mycode_cli/permissions.py,sha256=GESSo6YxMLudE_WI_XtAHHih4sgREsIlNnXirkoR1G4,7765
5
+ mycode_cli/runtime.py,sha256=UG-ZQ3aGLC0j9LbcxK6bxG9DTA8wRo7xdjB3GkfcbMc,2702
6
+ mycode_cli/sessions.py,sha256=g97vpaTIZw5xhh1hKV0pz91496H5hBS3rMKhZ0_v5gw,8434
7
+ mycode_cli/system_prompt.py,sha256=S9ehciURi4kZgeKBOfXpUeuI1NRMKomekHdWMQDhIL8,12793
8
+ mycode_cli/tools.py,sha256=jwvQiu8U95qMj6KPI5qMJyBr73M6lX7qMOJhZWuL_hc,26476
9
9
  mycode_cli/web_tools.py,sha256=vIcrPEeUs8VmdNec9ikpqh6NxudX_TXBla4RqRqSmQQ,17936
10
10
  mycode_cli/workspace.py,sha256=n139AXmrJyfuErpfDViSzGeZE_VQz2Q3ixibmqO2NqI,961
11
11
  mycode_cli/server/__init__.py,sha256=Bt8J0HJdkPdE2VQl7zjnEjofriUHksen5tHQ8agw7RM,32
12
- mycode_cli/server/app.py,sha256=oBMPaHVJsLcv4bfj2bQ4N3_qD6s2ZxRch2UTlUhqRt8,2243
13
- mycode_cli/server/deps.py,sha256=qPJN2sqNwws0Q2W4rjZYyeTEL87JHhTCbBqpAYvTJeY,1073
14
- mycode_cli/server/run_manager.py,sha256=QQRY0NeY6aDtoMqCVsGWucPSnqcvpkSEDCoM5LStZk8,15427
12
+ mycode_cli/server/app.py,sha256=v1jdOv8qJ8FyQwd8dd8UMSvbUOIJ5i4ejjgT9M0ZC_Q,2687
13
+ mycode_cli/server/deps.py,sha256=MKwd89W0Vv954xThrbcOPyqEySCO5YJtP_WS91nUqpk,1050
14
+ mycode_cli/server/run_manager.py,sha256=qB7cxGnMtnR7O3qZh3-UAuAo9--FcpFosImcCe_unf8,16896
15
15
  mycode_cli/server/schemas.py,sha256=ZXN2L1pBqDiGDDZmHgsLoPNUFWS_SNEOz_imkX-eUFU,4999
16
16
  mycode_cli/server/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- mycode_cli/server/routers/chat.py,sha256=DP6Ab-ez2yvcX7fla365e4ZojHikrwfrdHSnp_qbc2E,18298
18
- mycode_cli/server/routers/sessions.py,sha256=bGoZZQvHz03Rv4YZ9wtVvF0ohoU77sxxpf3WfObXG20,3850
19
- mycode_cli/server/routers/settings.py,sha256=jhEhBZpt4ajRoeIYyhpkmOhlA804viGKhKh1RjxKII4,6904
20
- mycode_cli/server/routers/workspaces.py,sha256=XAzIOMaB6GEP1yDzAn96rEproyw6DeC2qA2eaY3Y6Wo,5508
17
+ mycode_cli/server/routers/chat.py,sha256=6qj96sWjAgcNHqFa90JYsDy_Qrcw_xPCW6wiAhS0oaU,18406
18
+ mycode_cli/server/routers/sessions.py,sha256=hod6havxtTE81kv88cOi7PKlPHIkG1GnApQ49aoQMBo,3692
19
+ mycode_cli/server/routers/settings.py,sha256=9fqltbqpezH5LgZKw_7tjU2nRnwfHuU3gLFi4Poe2fU,7017
20
+ mycode_cli/server/routers/workspaces.py,sha256=lPm48yLWszazi0sOODj5alqcwrollKxOFf3NCA89hwc,5468
21
21
  mycode_cli/server/static/favicon_slashes.svg,sha256=bxMiM7G7NR1zLWyUWnFBCa1FSu0AzFiDW2YY6DtcOWQ,504
22
22
  mycode_cli/server/static/index.html,sha256=rxhif41gyfCseX02a8F085uwJrDF9tNxo9G4vqDhDVI,1265
23
23
  mycode_cli/server/static/assets/EditDiff-DOjxGJl-.js,sha256=6y0JCwtOGrYKdmM0j0OT2VaE57J38IbLSiUIjc01XLU,251313
@@ -398,12 +398,12 @@ mycode_cli/server/static/fonts/OFL.txt,sha256=5ntCh7US9uFmOUyAp0oMbVNwk26XBuNd-d
398
398
  mycode_cli/server/static/fonts/SourceCodePro-Variable.woff2,sha256=i3dKqlE3o470D0rJ0225pe7hUrL2ZYnf3IL_AH_IcTU,22044
399
399
  mycode_cli/server/static/fonts/SourceSans3-Variable.woff2,sha256=ehmnAn4SUlfTEMbb14rjowteoeN5TWCxK7KCJ6ADv9o,28740
400
400
  mycode_cli/tui/__init__.py,sha256=5sPlJS3c8HdNEBwcF0bUuj8f8brAD-_6MNmF4Q_1Edg,69
401
- mycode_cli/tui/chat.py,sha256=uRqdxJgy0y_xhFYSm_TSIr8TOGoJiZ0EjdWkqsTTwZw,36118
402
- mycode_cli/tui/render.py,sha256=rTpES5SLHy23SE3Buhx9LTPXrt9-nRGcPOXm1SDnXtU,26175
401
+ mycode_cli/tui/chat.py,sha256=RwiWM0k53xC8T93yvIWuv5DImYUmvaQBIty4fBSpAlQ,35282
402
+ mycode_cli/tui/render.py,sha256=kdmYWts__qYllA_mfLkXYLDtgTIEcyo8ZRXWGJdINhQ,26226
403
403
  mycode_cli/tui/state.py,sha256=vtG1NJQl6e7hI6ovlbyIsgckQiKbApnYPbRT0N0ybS0,1175
404
404
  mycode_cli/tui/theme.py,sha256=HS3C1kSoIONrSaiSx22AWRLk1n6-T-Tn5iohCMewAg4,3455
405
- mycode_cli-0.14.2.dist-info/METADATA,sha256=u0jgvauO43NzX2Z6hPiMhWu2xbxYL3Vlf3UD8KvXf98,4907
406
- mycode_cli-0.14.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
407
- mycode_cli-0.14.2.dist-info/entry_points.txt,sha256=1R7YN8dxJUy5AMC4q2J5Ly9xuEom3ZbTaa8xXwxuZyk,48
408
- mycode_cli-0.14.2.dist-info/licenses/LICENSE,sha256=DSE6T-B9WhrWypr6H7y2kTZzoF1tef5xf7veUyImQsM,1064
409
- mycode_cli-0.14.2.dist-info/RECORD,,
405
+ mycode_cli-0.15.0.dist-info/METADATA,sha256=yn4sJVBEDJe2piB2t-XuIy7jMfgWp_cmusjpsn0GUAg,4907
406
+ mycode_cli-0.15.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
407
+ mycode_cli-0.15.0.dist-info/entry_points.txt,sha256=1R7YN8dxJUy5AMC4q2J5Ly9xuEom3ZbTaa8xXwxuZyk,48
408
+ mycode_cli-0.15.0.dist-info/licenses/LICENSE,sha256=DSE6T-B9WhrWypr6H7y2kTZzoF1tef5xf7veUyImQsM,1064
409
+ mycode_cli-0.15.0.dist-info/RECORD,,