stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Token completion independent of terminal widgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
from stcode.cli.models import Row
|
|
6
|
+
|
|
7
|
+
TRIGGERS = ("/", "@")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def token_trigger(text: str, cursor: int) -> tuple[str, str] | None:
|
|
11
|
+
"""The `/` or `@` token the cursor is inside, as `(symbol, filter)`.
|
|
12
|
+
|
|
13
|
+
A token starts at the beginning of the line or after whitespace, which is the whole
|
|
14
|
+
rule: `src/api` is a path and `me@example.com` is an address, and neither should
|
|
15
|
+
hijack the input. It ends at whitespace, so `/mode plan` is past the command.
|
|
16
|
+
"""
|
|
17
|
+
if cursor <= 0 or cursor > len(text):
|
|
18
|
+
return None
|
|
19
|
+
start = cursor
|
|
20
|
+
while start > 0 and not text[start - 1].isspace():
|
|
21
|
+
start -= 1
|
|
22
|
+
token = text[start:cursor]
|
|
23
|
+
if not token or token[0] not in TRIGGERS:
|
|
24
|
+
return None
|
|
25
|
+
return token[0], token[1:]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def filter_rows(rows: Sequence[Row], query: str) -> list[Row]:
|
|
29
|
+
"""Narrow on a substring of the **label**, case-insensitively.
|
|
30
|
+
|
|
31
|
+
The label only, and that is the interesting part. Searching the descriptions too
|
|
32
|
+
looks generous and reads as noise: typing `/mod` would offer `/effort`, whose
|
|
33
|
+
description happens to say "how hard the model should think". What you are typing
|
|
34
|
+
is a name, so a name is what it matches — and `?` already lists all twelve with
|
|
35
|
+
their descriptions for the times you do not know the name.
|
|
36
|
+
|
|
37
|
+
Substring, not fuzzy. The fuzzy command palette was removed on purpose: two ways to
|
|
38
|
+
reach the same twelve commands is one way for them to disagree.
|
|
39
|
+
"""
|
|
40
|
+
if not query:
|
|
41
|
+
return list(rows)
|
|
42
|
+
needle = query.lower()
|
|
43
|
+
return [row for row in rows if needle in row[0].lower()]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Connection ownership and cleanup; interaction with the user stays in the UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
|
|
7
|
+
from stcode.cli.services.client import Address, DaemonClient
|
|
8
|
+
from stcode.cli.services.launcher import Launcher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Connection:
|
|
12
|
+
def __init__(self, launcher: Launcher) -> None:
|
|
13
|
+
self.launcher = launcher
|
|
14
|
+
self.client: DaemonClient | None = None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def address(self) -> Address:
|
|
18
|
+
return self.launcher.address
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def started(self) -> bool:
|
|
22
|
+
return self.launcher.started
|
|
23
|
+
|
|
24
|
+
async def close_client(self) -> None:
|
|
25
|
+
client, self.client = self.client, None
|
|
26
|
+
if client is not None:
|
|
27
|
+
with contextlib.suppress(Exception):
|
|
28
|
+
await client.aclose()
|
|
29
|
+
|
|
30
|
+
async def open(
|
|
31
|
+
self, *, address: Address | None = None, restart: bool = False
|
|
32
|
+
) -> DaemonClient:
|
|
33
|
+
await self.close_client()
|
|
34
|
+
if address is None:
|
|
35
|
+
client = await self.launcher.connect(restart=restart)
|
|
36
|
+
else:
|
|
37
|
+
client = await DaemonClient.connect(address)
|
|
38
|
+
self.launcher.address = address
|
|
39
|
+
self.client = client
|
|
40
|
+
return client
|
|
41
|
+
|
|
42
|
+
async def aclose(self) -> None:
|
|
43
|
+
try:
|
|
44
|
+
await self.close_client()
|
|
45
|
+
finally:
|
|
46
|
+
await self.launcher.stop()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""FIFO requests keep parallel approvals and questions individually answerable."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RequestQueue:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._pending: deque[dict[str, Any]] = deque()
|
|
10
|
+
|
|
11
|
+
def append(self, frame: dict[str, Any]) -> None:
|
|
12
|
+
self._pending.append(frame)
|
|
13
|
+
|
|
14
|
+
def pop(self) -> dict[str, Any] | None:
|
|
15
|
+
return self._pending.popleft() if self._pending else None
|
|
16
|
+
|
|
17
|
+
def clear(self) -> None:
|
|
18
|
+
self._pending.clear()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Session operations preserve the distinction between local and daemon workspaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from stcode.cli.models import StartOptions
|
|
8
|
+
from stcode.cli.services.client import DaemonClient
|
|
9
|
+
from .connection import Connection
|
|
10
|
+
from .state import ClientState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Sessions:
|
|
14
|
+
def __init__(
|
|
15
|
+
self, connection: Connection, state: ClientState, options: StartOptions
|
|
16
|
+
) -> None:
|
|
17
|
+
self.connection = connection
|
|
18
|
+
self.state = state
|
|
19
|
+
self.options = options
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def client(self) -> DaemonClient:
|
|
23
|
+
client = self.connection.client
|
|
24
|
+
if client is None:
|
|
25
|
+
raise ConnectionError("No daemon is connected")
|
|
26
|
+
return client
|
|
27
|
+
|
|
28
|
+
async def open(self) -> dict[str, Any]:
|
|
29
|
+
if self.options.resume:
|
|
30
|
+
return await self.client.attach(self.options.resume)
|
|
31
|
+
cwd = (
|
|
32
|
+
self.options.cwd
|
|
33
|
+
if self.options.explicit_cwd or not self.options.daemonless
|
|
34
|
+
else None
|
|
35
|
+
)
|
|
36
|
+
return await self.client.create(
|
|
37
|
+
cwd=cwd, role=self.options.role, approval_mode=self.options.mode
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
41
|
+
return await self.client.sessions(50)
|
|
42
|
+
|
|
43
|
+
async def detach(self) -> None:
|
|
44
|
+
await self.client.detach(self.state.session_id)
|
|
45
|
+
|
|
46
|
+
async def attach(self, session_id: str) -> dict[str, Any]:
|
|
47
|
+
return await self.client.attach(session_id)
|
|
48
|
+
|
|
49
|
+
async def clear(self) -> dict[str, Any]:
|
|
50
|
+
previous = self.state.session_id
|
|
51
|
+
frame = await self.client.create(
|
|
52
|
+
cwd=self.options.cwd,
|
|
53
|
+
role=self.options.role,
|
|
54
|
+
approval_mode=self.options.mode,
|
|
55
|
+
)
|
|
56
|
+
if previous:
|
|
57
|
+
await self.client.detach(previous)
|
|
58
|
+
return frame
|
|
59
|
+
|
|
60
|
+
async def load_info(self) -> None:
|
|
61
|
+
self.state.info = await self.client.info()
|
|
62
|
+
|
|
63
|
+
async def push(self, text: str) -> None:
|
|
64
|
+
await self.client.push(text)
|
|
65
|
+
|
|
66
|
+
async def interrupt(self) -> None:
|
|
67
|
+
await self.client.interrupt()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Write defaults before session overrides; the daemon is authoritative."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from stcode.cli.models import ApprovalMode
|
|
8
|
+
from stcode.cli.services.client import DaemonClient
|
|
9
|
+
from .state import ClientState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings:
|
|
13
|
+
def __init__(self, state: ClientState) -> None:
|
|
14
|
+
self.state = state
|
|
15
|
+
|
|
16
|
+
async def set_config(self, client: DaemonClient, **fields: Any) -> None:
|
|
17
|
+
self.state.config = await client.set_config(**fields)
|
|
18
|
+
|
|
19
|
+
async def apply(self, client: DaemonClient, patch: dict[str, Any]) -> None:
|
|
20
|
+
await self.set_config(client, **patch)
|
|
21
|
+
if self.state.session_id:
|
|
22
|
+
await client.set_meta(
|
|
23
|
+
provider=str(self.state.config.get("provider", "")),
|
|
24
|
+
model=str(self.state.config.get("model", "")),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
async def set_mode(self, client: DaemonClient, mode: ApprovalMode) -> None:
|
|
28
|
+
await self.set_config(client, approval_mode=mode)
|
|
29
|
+
if self.state.session_id:
|
|
30
|
+
await client.set_mode(mode)
|
|
31
|
+
|
|
32
|
+
async def set_effort(self, client: DaemonClient, effort: str) -> None:
|
|
33
|
+
await self.set_config(client, reasoning_effort=effort)
|
|
34
|
+
if self.state.session_id:
|
|
35
|
+
await client.set_meta(reasoning_effort=effort)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The last facts reported by the daemon, independent of terminal widgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ClientState:
|
|
11
|
+
session_id: str = ""
|
|
12
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
13
|
+
effective: dict[str, str] = field(default_factory=dict)
|
|
14
|
+
info: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def adopt_session(self, frame: dict[str, Any]) -> None:
|
|
17
|
+
session_id = str(frame.get("id", ""))
|
|
18
|
+
if session_id:
|
|
19
|
+
self.session_id = session_id
|
|
20
|
+
self.effective = {
|
|
21
|
+
"model": str(frame.get("model", "")),
|
|
22
|
+
"provider": str(frame.get("provider", "")),
|
|
23
|
+
"effort": str(frame.get("reasoning_effort", "")),
|
|
24
|
+
"mode": str(frame.get("approval_mode", "")),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def provider(self) -> str:
|
|
29
|
+
return self.effective.get("provider") or str(self.config.get("provider", ""))
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def provider_entry(self) -> dict[str, Any]:
|
|
33
|
+
return dict(self.config.get("providers", {}).get(self.provider, {}))
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def effort(self) -> str:
|
|
37
|
+
return self.effective.get("effort") or str(
|
|
38
|
+
self.config.get("reasoning_effort", "")
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def has_credential(self) -> bool:
|
|
43
|
+
return bool(self.provider_entry.get("has_key"))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Workspace requests always target the attached daemon session."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from stcode.cli.models import UiPrefs
|
|
6
|
+
from stcode.cli.services.client import DaemonClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Workspace:
|
|
10
|
+
def __init__(self, prefs: UiPrefs) -> None:
|
|
11
|
+
self.prefs = prefs
|
|
12
|
+
|
|
13
|
+
async def files(self, client: DaemonClient) -> list[str]:
|
|
14
|
+
return await client.workspace_files()
|
|
15
|
+
|
|
16
|
+
async def shell(self, client: DaemonClient, command: str) -> dict[str, Any]:
|
|
17
|
+
return await client.workspace_shell(command, self.prefs.shell_timeout)
|
stcode/cli/main.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entrypoint — one command, three shapes.
|
|
3
|
+
|
|
4
|
+
stcode chat UI + a daemon (started here if none is listening)
|
|
5
|
+
stcode --headless the daemon alone — a container's whole process
|
|
6
|
+
stcode --daemonless the chat UI alone, pointed at a daemon somewhere else
|
|
7
|
+
|
|
8
|
+
The daemon is its own program, `stcode-daemon`. `--headless` replaces this process with
|
|
9
|
+
it; the UI starts it in the background when nothing listens, and stops it on exit.
|
|
10
|
+
`--restart` stops a running local daemon first, for when it is wedged or its code
|
|
11
|
+
changed.
|
|
12
|
+
|
|
13
|
+
`stcode <path>` opens a session in that directory. It is a rewrite to `--cwd` rather
|
|
14
|
+
than an argument on the group, because a `click` group consumes its own arguments before
|
|
15
|
+
it looks for a subcommand — declaring one there makes `stcode sessions` mean "open the
|
|
16
|
+
directory ./sessions". Only the **first** token is read this way, and only when it is
|
|
17
|
+
not a subcommand, so the grammar stays "the path comes first, flags after".
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Annotated, Any, Awaitable, Callable, NoReturn
|
|
27
|
+
|
|
28
|
+
import typer
|
|
29
|
+
|
|
30
|
+
from stcode import __version__
|
|
31
|
+
from stcode.cli.services.client import Address, DaemonClient
|
|
32
|
+
from stcode.cli.models import APPROVAL_MODES, ApprovalMode
|
|
33
|
+
from stcode.cli.logic.commands import parse_approval_mode
|
|
34
|
+
from stcode.cli.services.launcher import DaemonFailed, Launcher, daemon_command
|
|
35
|
+
|
|
36
|
+
cli = typer.Typer(
|
|
37
|
+
name="stcode",
|
|
38
|
+
help="stcode — a coding agent for the terminal: a daemon holding the agent, and a UI attached to it.",
|
|
39
|
+
no_args_is_help=False,
|
|
40
|
+
add_completion=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
ConfigOption = Annotated[
|
|
44
|
+
Path | None,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--config", "-c", help="Config file to use instead of the default location."
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cli.callback(invoke_without_command=True)
|
|
52
|
+
def main(
|
|
53
|
+
ctx: typer.Context,
|
|
54
|
+
config: ConfigOption = None,
|
|
55
|
+
headless: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--headless", help="Run the daemon only, with no UI. What a container runs."
|
|
59
|
+
),
|
|
60
|
+
] = False,
|
|
61
|
+
daemonless: Annotated[
|
|
62
|
+
bool,
|
|
63
|
+
typer.Option(
|
|
64
|
+
"--daemonless",
|
|
65
|
+
help="Run the UI only, attached to a daemon elsewhere. Asks for the address if none answers.",
|
|
66
|
+
),
|
|
67
|
+
] = False,
|
|
68
|
+
restart: Annotated[
|
|
69
|
+
bool,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--restart", help="Stop the local daemon first and start a fresh one."
|
|
72
|
+
),
|
|
73
|
+
] = False,
|
|
74
|
+
cwd: Annotated[
|
|
75
|
+
Path | None,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--cwd",
|
|
78
|
+
"-C",
|
|
79
|
+
help="Workspace for the session. Defaults to the current directory. "
|
|
80
|
+
"`stcode <path>` is the same thing, spelled shorter.",
|
|
81
|
+
),
|
|
82
|
+
] = None,
|
|
83
|
+
mode: Annotated[
|
|
84
|
+
str | None,
|
|
85
|
+
typer.Option(
|
|
86
|
+
"--mode", "-m", help=f"Approval mode: {' | '.join(APPROVAL_MODES)}."
|
|
87
|
+
),
|
|
88
|
+
] = None,
|
|
89
|
+
resume: Annotated[
|
|
90
|
+
str | None,
|
|
91
|
+
typer.Option(
|
|
92
|
+
"--resume",
|
|
93
|
+
"-r",
|
|
94
|
+
help="Attach to an existing session id instead of starting one.",
|
|
95
|
+
),
|
|
96
|
+
] = None,
|
|
97
|
+
role: Annotated[
|
|
98
|
+
str | None,
|
|
99
|
+
typer.Option("--role", help="Which agent this is, e.g. backend-dev."),
|
|
100
|
+
] = None,
|
|
101
|
+
team: Annotated[
|
|
102
|
+
bool,
|
|
103
|
+
typer.Option("--team", help="Headless only: turn team mode on."),
|
|
104
|
+
] = False,
|
|
105
|
+
transport: Annotated[
|
|
106
|
+
str | None, typer.Option("--transport", help="unix | tcp.")
|
|
107
|
+
] = None,
|
|
108
|
+
socket: Annotated[
|
|
109
|
+
str | None, typer.Option("--socket", help="Unix socket path.")
|
|
110
|
+
] = None,
|
|
111
|
+
host: Annotated[
|
|
112
|
+
str | None,
|
|
113
|
+
typer.Option("--host", help="TCP host to bind (headless) or connect to."),
|
|
114
|
+
] = None,
|
|
115
|
+
port: Annotated[
|
|
116
|
+
int | None, typer.Option("--port", help="TCP port. Default 7717.")
|
|
117
|
+
] = None,
|
|
118
|
+
version: Annotated[
|
|
119
|
+
bool, typer.Option("--version", help="Print the version and exit.")
|
|
120
|
+
] = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Start stcode. With no subcommand this is the whole program."""
|
|
123
|
+
if version:
|
|
124
|
+
typer.echo(f"stcode {__version__}")
|
|
125
|
+
raise typer.Exit()
|
|
126
|
+
if ctx.invoked_subcommand is not None:
|
|
127
|
+
ctx.obj = _address(transport, socket, host, port), config
|
|
128
|
+
return
|
|
129
|
+
if headless and daemonless:
|
|
130
|
+
_fail(
|
|
131
|
+
"--headless and --daemonless are opposites: one is the daemon without a UI, "
|
|
132
|
+
"the other the UI without a daemon."
|
|
133
|
+
)
|
|
134
|
+
approval_mode = _mode(mode)
|
|
135
|
+
|
|
136
|
+
if headless:
|
|
137
|
+
# Flags pass through by name: `stcode-daemon` is the one that understands them.
|
|
138
|
+
flags = {
|
|
139
|
+
"--config": config,
|
|
140
|
+
"--transport": transport,
|
|
141
|
+
"--socket": socket,
|
|
142
|
+
"--host": host,
|
|
143
|
+
"--port": port,
|
|
144
|
+
"--mode": approval_mode,
|
|
145
|
+
"--role": role,
|
|
146
|
+
}
|
|
147
|
+
command = daemon_command()
|
|
148
|
+
command += [
|
|
149
|
+
str(item)
|
|
150
|
+
for flag, value in flags.items()
|
|
151
|
+
if value is not None
|
|
152
|
+
for item in (flag, value)
|
|
153
|
+
]
|
|
154
|
+
command += ["--team"] if team else []
|
|
155
|
+
os.execv(command[0], command)
|
|
156
|
+
|
|
157
|
+
# Resolved, not kept as typed: `.` reaches the prefs file as the trusted path, the
|
|
158
|
+
# daemon as the session's cwd and the UI as the root it lists files under, and a
|
|
159
|
+
# relative one means something different in each of them.
|
|
160
|
+
cwd = cwd.expanduser().resolve() if cwd is not None else None
|
|
161
|
+
if cwd is not None and not cwd.is_dir():
|
|
162
|
+
_fail(f"{cwd} is not a directory.")
|
|
163
|
+
|
|
164
|
+
# Imported lazily: `stcode sessions` shouldn't pay for loading textual.
|
|
165
|
+
from stcode.cli.bootstrap import run
|
|
166
|
+
|
|
167
|
+
run(
|
|
168
|
+
Launcher(_address(transport, socket, host, port), config=config),
|
|
169
|
+
daemonless=daemonless,
|
|
170
|
+
restart=restart,
|
|
171
|
+
cwd=cwd,
|
|
172
|
+
resume=resume or "",
|
|
173
|
+
mode=approval_mode,
|
|
174
|
+
role=role or "",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _address(
|
|
179
|
+
transport: str | None, socket: str | None, host: str | None, port: int | None
|
|
180
|
+
) -> Address:
|
|
181
|
+
address = Address(transport=transport or "unix")
|
|
182
|
+
if socket:
|
|
183
|
+
address.socket = Path(socket).expanduser()
|
|
184
|
+
address.host = host or address.host
|
|
185
|
+
address.port = port or address.port
|
|
186
|
+
return address
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _fail(message: str) -> NoReturn:
|
|
190
|
+
typer.secho(message, fg=typer.colors.RED, err=True)
|
|
191
|
+
raise typer.Exit(code=2)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _mode(value: str | None) -> ApprovalMode | None:
|
|
195
|
+
"""Resolve `--mode auto` to a mode name, or exit saying what the choices are."""
|
|
196
|
+
if value is None:
|
|
197
|
+
return None
|
|
198
|
+
resolved = parse_approval_mode(value)
|
|
199
|
+
if resolved is None:
|
|
200
|
+
_fail(f"Unknown mode {value!r}. One of: {', '.join(APPROVAL_MODES)}")
|
|
201
|
+
return resolved
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _with_daemon(
|
|
205
|
+
ctx: typer.Context, action: Callable[[DaemonClient], Awaitable[None]]
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Run one request against the daemon, starting it for the duration if needed."""
|
|
208
|
+
address, config = ctx.obj
|
|
209
|
+
|
|
210
|
+
async def go() -> None:
|
|
211
|
+
launcher = Launcher(address, config=config)
|
|
212
|
+
try:
|
|
213
|
+
client = await launcher.connect()
|
|
214
|
+
except (OSError, DaemonFailed) as exc:
|
|
215
|
+
_fail(f"Could not reach a daemon at {address}: {exc}")
|
|
216
|
+
try:
|
|
217
|
+
async with client:
|
|
218
|
+
await action(client)
|
|
219
|
+
finally:
|
|
220
|
+
await launcher.stop()
|
|
221
|
+
|
|
222
|
+
asyncio.run(go())
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@cli.command("sessions")
|
|
226
|
+
def list_sessions(
|
|
227
|
+
ctx: typer.Context,
|
|
228
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="How many to show.")] = 20,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""List recent sessions, newest first. Resume one with `stcode --resume <id>`."""
|
|
231
|
+
|
|
232
|
+
async def show(client: DaemonClient) -> None:
|
|
233
|
+
rows = await client.sessions(limit)
|
|
234
|
+
if not rows:
|
|
235
|
+
typer.echo("No sessions yet.")
|
|
236
|
+
for row in rows:
|
|
237
|
+
typer.echo(
|
|
238
|
+
f"{row.get('id', '?'):<28} {row.get('ts', ''):<26} {row.get('cwd', '')}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
_with_daemon(ctx, show)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@cli.command("config")
|
|
245
|
+
def show_config(ctx: typer.Context) -> None:
|
|
246
|
+
"""Show the daemon's config file and what it currently selects."""
|
|
247
|
+
|
|
248
|
+
async def show(client: DaemonClient) -> None:
|
|
249
|
+
frame: dict[str, Any] = await client.get_config()
|
|
250
|
+
typer.echo(f"path: {frame.get('path')}")
|
|
251
|
+
typer.echo(f"provider: {frame.get('provider')}")
|
|
252
|
+
typer.echo(f"model: {frame.get('model')}")
|
|
253
|
+
typer.echo(f"effort: {frame.get('reasoning_effort')}")
|
|
254
|
+
typer.echo(f"mode: {frame.get('approval_mode')}")
|
|
255
|
+
typer.echo(f"daemon: {ctx.obj[0]}")
|
|
256
|
+
|
|
257
|
+
_with_daemon(ctx, show)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def workspace_argv(argv: list[str], commands: set[str]) -> list[str]:
|
|
261
|
+
"""Rewrite a leading path into `--cwd <path>`. Everything else is untouched.
|
|
262
|
+
|
|
263
|
+
The first token only, and only when it is neither a flag nor a subcommand. A token
|
|
264
|
+
that is neither but also does not look like a path — `stcode sesions` — is left
|
|
265
|
+
alone, so a mistyped command still gets click's "No such command" instead of being
|
|
266
|
+
silently accepted as a directory that does not exist.
|
|
267
|
+
"""
|
|
268
|
+
if not argv:
|
|
269
|
+
return argv
|
|
270
|
+
first = argv[0]
|
|
271
|
+
if first.startswith("-") or first in commands:
|
|
272
|
+
return argv
|
|
273
|
+
looks_like_a_path = (
|
|
274
|
+
Path(first).expanduser().is_dir()
|
|
275
|
+
or first.startswith(("~", ".", "/"))
|
|
276
|
+
or "/" in first
|
|
277
|
+
)
|
|
278
|
+
if not looks_like_a_path:
|
|
279
|
+
return argv
|
|
280
|
+
return ["--cwd", first, *argv[1:]]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def run_cli() -> None:
|
|
284
|
+
"""The console entry point. → `workspace_argv`."""
|
|
285
|
+
commands = set(typer.main.get_command(cli).commands) # type: ignore[attr-defined]
|
|
286
|
+
cli(args=workspace_argv(sys.argv[1:], commands))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
run_cli()
|
stcode/cli/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Client-owned vocabulary and values; never imports the engine or terminal UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
ApprovalMode = Literal["plan", "suggest", "auto-edit", "full-auto"]
|
|
10
|
+
APPROVAL_MODES: tuple[ApprovalMode, ...] = ("plan", "suggest", "auto-edit", "full-auto")
|
|
11
|
+
REASONING_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
12
|
+
Row = tuple[str, str]
|
|
13
|
+
ThemePreference = Literal["auto", "dark", "light"]
|
|
14
|
+
THEME_PREFERENCES: tuple[ThemePreference, ...] = ("auto", "dark", "light")
|
|
15
|
+
DEFAULT_THEME: ThemePreference = "auto"
|
|
16
|
+
DEFAULT_SHELL_TIMEOUT = 10.0
|
|
17
|
+
MAX_SHELL_TIMEOUT = 120.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class StartOptions:
|
|
22
|
+
cwd: Path
|
|
23
|
+
explicit_cwd: bool = False
|
|
24
|
+
daemonless: bool = False
|
|
25
|
+
resume: str = ""
|
|
26
|
+
mode: ApprovalMode | None = None
|
|
27
|
+
role: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UiPrefs(BaseModel):
|
|
31
|
+
"""What the terminal remembers between runs."""
|
|
32
|
+
|
|
33
|
+
theme: ThemePreference = DEFAULT_THEME
|
|
34
|
+
trusted: list[str] = Field(default_factory=list)
|
|
35
|
+
"""Absolute paths the user has approved the agent working in. A folder's children
|
|
36
|
+
are covered by it — a project has subdirectories, and asking again for each one
|
|
37
|
+
trains people to click through the question."""
|
|
38
|
+
|
|
39
|
+
shell_timeout: float = DEFAULT_SHELL_TIMEOUT
|
|
40
|
+
"""Seconds a `!` command may run before it is killed. Capped at
|
|
41
|
+
`MAX_SHELL_TIMEOUT`. A client preference sent with each daemon workspace command;
|
|
42
|
+
the command never enters the agent's history."""
|
|
43
|
+
|
|
44
|
+
def is_trusted(self, path: str | Path) -> bool:
|
|
45
|
+
"""Whether `path`, or a parent of it, has been trusted.
|
|
46
|
+
|
|
47
|
+
Compared as paths, not as strings: `startswith` would let `/home/me/work`
|
|
48
|
+
trust `/home/me/work-other`, which is a different project.
|
|
49
|
+
"""
|
|
50
|
+
target = Path(path).expanduser().resolve()
|
|
51
|
+
for entry in self.trusted:
|
|
52
|
+
root = Path(entry).expanduser()
|
|
53
|
+
if target == root or root in target.parents:
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def trust(self, path: str | Path) -> None:
|
|
58
|
+
"""Approve `path`. Idempotent, and stored resolved so `.` never gets in."""
|
|
59
|
+
resolved = str(Path(path).expanduser().resolve())
|
|
60
|
+
if resolved not in self.trusted:
|
|
61
|
+
self.trusted.append(resolved)
|
|
File without changes
|