delos-cli 1.0.1__tar.gz → 1.0.2__tar.gz

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 (73) hide show
  1. {delos_cli-1.0.1 → delos_cli-1.0.2}/.gitignore +1 -0
  2. {delos_cli-1.0.1 → delos_cli-1.0.2}/PKG-INFO +3 -1
  3. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/__init__.py +1 -1
  4. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/session.py +25 -4
  5. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/app.py +12 -0
  6. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/commands.py +43 -1
  7. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/oauth.py +44 -8
  8. delos_cli-1.0.2/delos_cli/commands/prompts.py +80 -0
  9. delos_cli-1.0.2/delos_cli/git_context.py +74 -0
  10. delos_cli-1.0.2/delos_cli/serve/app.py +125 -0
  11. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/rpc.py +64 -0
  12. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/server.py +93 -0
  13. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/state.py +4 -0
  14. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/__init__.py +3 -0
  15. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/edit_content.py +60 -29
  16. delos_cli-1.0.2/delos_cli/tools/explore.py +293 -0
  17. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/glob_tool.py +25 -2
  18. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/grep.py +50 -2
  19. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/chats.py +116 -1
  20. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/client.py +51 -6
  21. delos_cli-1.0.2/delos_cli/transport/documents.py +169 -0
  22. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/integrations.py +83 -32
  23. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/repl.py +29 -1
  24. {delos_cli-1.0.1 → delos_cli-1.0.2}/pyproject.toml +6 -1
  25. delos_cli-1.0.1/delos_cli/serve/app.py +0 -57
  26. delos_cli-1.0.1/delos_cli/transport/documents.py +0 -76
  27. {delos_cli-1.0.1 → delos_cli-1.0.2}/DELOS.md +0 -0
  28. {delos_cli-1.0.1 → delos_cli-1.0.2}/README.md +0 -0
  29. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/__init__.py +0 -0
  30. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/tools.py +0 -0
  31. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/transport.py +0 -0
  32. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/turn.py +0 -0
  33. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/__init__.py +0 -0
  34. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/base.py +0 -0
  35. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/__init__.py +0 -0
  36. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/commands.py.bak +0 -0
  37. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/render.py +0 -0
  38. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/replay.py +0 -0
  39. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/__init__.py +0 -0
  40. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/app.py +0 -0
  41. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/commands.py +0 -0
  42. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/tools.py +0 -0
  43. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/__init__.py +0 -0
  44. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/config.py +0 -0
  45. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/mfa.py +0 -0
  46. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/token_manager.py +0 -0
  47. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/commands/__init__.py +0 -0
  48. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/commands/base.py +0 -0
  49. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/commands/builtin.py +0 -0
  50. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ctx.py +0 -0
  51. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/loop.py +0 -0
  52. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/main.py +0 -0
  53. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/project_context.py +0 -0
  54. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/__init__.py +0 -0
  55. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/confirm.py +0 -0
  56. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/protocol.py +0 -0
  57. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/read.py +0 -0
  58. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/run_shell.py +0 -0
  59. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/task.py +0 -0
  60. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/todo_write.py +0 -0
  61. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/write_content.py +0 -0
  62. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/__init__.py +0 -0
  63. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/models.py +0 -0
  64. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/__init__.py +0 -0
  65. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/banner.py +0 -0
  66. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/chat_picker.py +0 -0
  67. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/completer.py +0 -0
  68. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/document_picker.py +0 -0
  69. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/integrations_picker.py +0 -0
  70. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/lexer.py +0 -0
  71. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/model_picker.py +0 -0
  72. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/output.py +0 -0
  73. {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/style.py +0 -0
@@ -27,6 +27,7 @@ backend/.python-version
27
27
  backend/scripts/*
28
28
  !backend/scripts/__init__.py
29
29
  !backend/scripts/export_tools_catalog.py
30
+ !backend/scripts/check_supabase_models.py
30
31
 
31
32
  .claude/worktrees
32
33
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: delos-cli
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Terminal REPL for the Delos agent — the Claude-Code-style CLI for Delos.
5
5
  Project-URL: Homepage, https://delos.so
6
6
  Project-URL: Repository, https://github.com/Delos-Intelligence/cosmos-saas
@@ -24,6 +24,8 @@ Requires-Dist: httpx>=0.27.0
24
24
  Requires-Dist: prompt-toolkit>=3.0.47
25
25
  Requires-Dist: questionary>=2.0.1
26
26
  Requires-Dist: rich>=13.8.0
27
+ Requires-Dist: ripgrep>=15; sys_platform == 'darwin' and platform_machine == 'arm64'
28
+ Requires-Dist: ripgrep>=15; sys_platform == 'linux' and platform_machine == 'x86_64'
27
29
  Requires-Dist: typer>=0.12.5
28
30
  Description-Content-Type: text/markdown
29
31
 
@@ -3,7 +3,7 @@
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
  from pathlib import Path
5
5
 
6
- try:
6
+ try: # noqa: RUF067
7
7
  __version__ = version("delos-cli")
8
8
  except PackageNotFoundError: # editable checkout without installed metadata
9
9
  __version__ = "0.0.0+dev"
@@ -11,6 +11,8 @@ how client-tool calls collect input from the user.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import asyncio
15
+ from itertools import starmap
14
16
  from typing import TYPE_CHECKING, Any
15
17
 
16
18
  if TYPE_CHECKING:
@@ -90,18 +92,37 @@ class AgentSession:
90
92
  if stopped or not pending:
91
93
  return
92
94
 
93
- for tcid, meta in pending.items():
95
+ # The backend only batches non-interactive client tools
96
+ # (read/grep/glob…) in the same turn, so the pending calls are
97
+ # safe to EXECUTE concurrently. Results are then POSTed
98
+ # sequentially: the ``app_cli_messages`` seq trigger computes
99
+ # ``MAX(seq)+1``, so concurrent inserts on the same chat race
100
+ # into a unique-constraint 500. Execution is the slow part;
101
+ # the posts are cheap.
102
+ async def _execute(
103
+ tcid: str, meta: dict[str, Any],
104
+ ) -> tuple[str, dict[str, Any], str]:
94
105
  handler = self._registry.handler_for(meta["name"])
95
106
  result = await handler(meta["input"], ctx)
107
+ return tcid, meta, result
108
+
109
+ outcomes = await asyncio.gather(
110
+ *starmap(_execute, pending.items()),
111
+ return_exceptions=True,
112
+ )
113
+ for outcome in outcomes:
114
+ if isinstance(outcome, BaseException):
115
+ raise outcome
116
+ tcid, meta, result = outcome
117
+ await self._transport.submit_tool_result(
118
+ self._chat_id, tcid, meta["name"], result,
119
+ )
96
120
  if emit_tool_output:
97
121
  yield {
98
122
  "type": "tool-output-available",
99
123
  "toolCallId": tcid,
100
124
  "output": result,
101
125
  }
102
- await self._transport.submit_tool_result(
103
- self._chat_id, tcid, meta["name"], result,
104
- )
105
126
 
106
127
  current_body = {**body, "messages": [], "tool_continuation": True}
107
128
 
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import contextlib
16
16
  from dataclasses import dataclass, field
17
+ from pathlib import Path
17
18
  from typing import TYPE_CHECKING, Any
18
19
 
19
20
  from rich.text import Text
@@ -21,9 +22,11 @@ from rich.text import Text
21
22
  from delos_cli.agent import AgentSession, AgentTransport, ToolRegistry
22
23
  from delos_cli.apps.base import App
23
24
  from delos_cli.apps.replay import replay_messages
25
+ from delos_cli.git_context import collect_git_context
24
26
  from delos_cli.state import TodoStore
25
27
  from delos_cli.tools import (
26
28
  handle_edit_content,
29
+ handle_explore,
27
30
  handle_glob,
28
31
  handle_grep,
29
32
  handle_read,
@@ -31,6 +34,7 @@ from delos_cli.tools import (
31
34
  handle_todo_write,
32
35
  handle_write_content,
33
36
  render_edit_content,
37
+ render_explore,
34
38
  render_glob,
35
39
  render_grep,
36
40
  render_read,
@@ -74,6 +78,10 @@ def default_tool_registry() -> ToolRegistry:
74
78
  # `task` is server-side (no handler needed); the renderer shows the
75
79
  # sub-agent's input/output lifecycle in the terminal.
76
80
  reg.renderers["task"] = render_task
81
+ # `explore` spawns a local read-only sub-agent conversation and returns
82
+ # only its report — see delos_cli.tools.explore.
83
+ reg.handlers["explore"] = handle_explore
84
+ reg.renderers["explore"] = render_explore
77
85
  return reg
78
86
 
79
87
 
@@ -136,6 +144,10 @@ class ChatApp(App):
136
144
  }
137
145
  if ctx.custom_instructions:
138
146
  body["custom_instructions"] = ctx.custom_instructions
147
+ # Fresh git snapshot per turn — rendered into the system prompt so
148
+ # the agent doesn't burn its first tool call on `git status`.
149
+ if (git_ctx := await collect_git_context(Path.cwd())) is not None:
150
+ body["git_context"] = git_ctx
139
151
 
140
152
  self._current_session = session
141
153
  try:
@@ -24,6 +24,7 @@ from prompt_toolkit.application import get_app
24
24
  from rich.text import Text
25
25
 
26
26
  from delos_cli.commands.base import CommandSpec, registry_from
27
+ from delos_cli.commands.prompts import EXECUTE_PROMPT, INIT_PROMPT_NEW, INIT_PROMPT_UPDATE
27
28
  from delos_cli.transport.chats import patch_chat_model
28
29
  from delos_cli.transport.client import TransportError
29
30
 
@@ -86,4 +87,45 @@ MODEL = CommandSpec(
86
87
  )
87
88
 
88
89
 
89
- CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL])
90
+ # ---------------------------------------------------------------------------
91
+ # /init — generate (or refresh) the project's DELOS.md
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ async def _handle_init(ctx: Ctx, args: str) -> None: # noqa: RUF029 — CommandSpec handlers are async
96
+ """Stage the DELOS.md generation prompt as the next agent turn."""
97
+ _ = args
98
+ ctx.state.staged_message = (
99
+ INIT_PROMPT_UPDATE if ctx.custom_instructions else INIT_PROMPT_NEW
100
+ )
101
+
102
+
103
+ INIT = CommandSpec(
104
+ name="/init",
105
+ summary="generate or refresh the project's DELOS.md by exploring the codebase",
106
+ handler=_handle_init,
107
+ )
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # /execute — hand a plan-mode plan over to execution
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ async def _handle_execute(ctx: Ctx, args: str) -> None: # noqa: RUF029 — CommandSpec handlers are async
116
+ """Leave plan permission-mode and stage the execute prompt."""
117
+ _ = args
118
+ if ctx.state.permission_mode == "plan":
119
+ ctx.state.permission_mode = "default"
120
+ _sink(ctx).print(Text("permission mode → default", style="dim"))
121
+ ctx.state.staged_message = EXECUTE_PROMPT
122
+
123
+
124
+ EXECUTE = CommandSpec(
125
+ name="/execute",
126
+ summary="execute the plan from the previous plan-mode turn (loads it into todos)",
127
+ handler=_handle_execute,
128
+ )
129
+
130
+
131
+ CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE])
@@ -54,16 +54,52 @@ HTTP_ERROR_THRESHOLD = 400
54
54
  CLIENT_ID = "delos-code"
55
55
  LOOPBACK_PORT = 52700
56
56
 
57
+ # Self-contained sign-in result pages. No external assets (the loopback server
58
+ # only serves these two responses), brand teal matching the apps, dark-mode
59
+ # aware via prefers-color-scheme.
60
+ _PAGE_STYLE = """
61
+ :root { color-scheme: light dark; --brand: #3dd6b9; --bg: #f7f8f8; --card: #ffffff;
62
+ --fg: #1a1a1a; --muted: #6b7280; --border: rgba(0,0,0,.08); --shadow: rgba(0,0,0,.08); }
63
+ @media (prefers-color-scheme: dark) { :root { --bg: #0e1110; --card: #171a19;
64
+ --fg: #e8eae9; --muted: #9aa3a0; --border: rgba(255,255,255,.08); --shadow: rgba(0,0,0,.4); } }
65
+ * { box-sizing: border-box; }
66
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
67
+ background: var(--bg); color: var(--fg); padding: 1.5rem;
68
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
69
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 16px;
70
+ padding: 2.5rem 2.75rem; max-width: 380px; width: 100%; text-align: center;
71
+ box-shadow: 0 8px 30px var(--shadow); animation: rise .35s ease both; }
72
+ @keyframes rise { from { opacity: 0; transform: translateY(8px); } }
73
+ .badge { width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center;
74
+ justify-content: center; margin: 0 auto 1.25rem; }
75
+ .badge svg { width: 28px; height: 28px; }
76
+ .ok { background: rgba(61,214,185,.14); color: var(--brand); }
77
+ .err { background: rgba(239,68,68,.14); color: #ef4444; }
78
+ h1 { margin: 0 0 .4rem; font-size: 1.3rem; font-weight: 650; letter-spacing: -.01em; }
79
+ p { margin: 0; color: var(--muted); font-size: .92rem; line-height: 1.5; }
80
+ """.strip()
81
+
57
82
  _SUCCESS_HTML = (
58
- b"<!doctype html><html><head><meta charset='utf-8'>"
59
- b"<title>Delos CLI</title></head><body style='font-family:sans-serif;padding:3rem'>"
60
- b"<h1>Signed in.</h1><p>You can close this tab.</p></body></html>"
61
- )
83
+ "<!doctype html><html lang='en'><head><meta charset='utf-8'>"
84
+ "<meta name='viewport' content='width=device-width, initial-scale=1'>"
85
+ "<title>Signed in · Delos</title><style>" + _PAGE_STYLE + "</style></head>"
86
+ "<body><main class='card'><div class='badge ok'>"
87
+ "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' "
88
+ "stroke-linecap='round' stroke-linejoin='round'><path d='M20 6 9 17l-5-5'/></svg>"
89
+ "</div><h1>You're signed in</h1>"
90
+ "<p>You can close this tab and return to your editor.</p></main></body></html>"
91
+ ).encode("utf-8")
92
+
62
93
  _ERROR_HTML = (
63
- b"<!doctype html><html><head><meta charset='utf-8'>"
64
- b"<title>Delos CLI</title></head><body style='font-family:sans-serif;padding:3rem'>"
65
- b"<h1>Sign-in failed.</h1><p>Check the terminal for details.</p></body></html>"
66
- )
94
+ "<!doctype html><html lang='en'><head><meta charset='utf-8'>"
95
+ "<meta name='viewport' content='width=device-width, initial-scale=1'>"
96
+ "<title>Sign-in failed · Delos</title><style>" + _PAGE_STYLE + "</style></head>"
97
+ "<body><main class='card'><div class='badge err'>"
98
+ "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' "
99
+ "stroke-linecap='round' stroke-linejoin='round'><path d='M18 6 6 18M6 6l12 12'/></svg>"
100
+ "</div><h1>Sign-in failed</h1>"
101
+ "<p>Something went wrong. Check the terminal for details and try again.</p></main></body></html>"
102
+ ).encode("utf-8")
67
103
 
68
104
 
69
105
  class OAuthError(RuntimeError):
@@ -0,0 +1,80 @@
1
+ """Slash commands that expand into agent prompts — shared REPL/serve.
2
+
3
+ The REPL surfaces these as ``CommandSpec`` handlers (``/init``, ``/execute``
4
+ in ``apps/chat/commands.py``); serve mode expands them inline when a
5
+ ``user_message`` starts with the command, so typing ``/init`` in the VSCode
6
+ input works without any extension change. ``list_commands`` (RPC) exposes
7
+ the catalogue so the webview can offer autocomplete.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ INIT_PROMPT_NEW = """\
13
+ Generate this project's DELOS.md — the instructions file that will be loaded \
14
+ into your system prompt for every future conversation in this repository.
15
+
16
+ Method: use `explore` (several calls in the same turn, they run in parallel) \
17
+ to map the project — build/run/test/lint commands, architecture and directory \
18
+ layout, tech stack, code conventions, anything a coding agent must know to \
19
+ work here safely. Read the key config files (package manifests, CI, existing \
20
+ README) yourself where precision matters.
21
+
22
+ Then write a DELOS.md at the repository root with `write_content` (create it \
23
+ with `run_shell touch DELOS.md` first if needed). Keep it under ~150 lines: \
24
+ dense, factual, imperative. Sections: project overview (2-3 lines), commands, \
25
+ architecture, conventions, warnings/pitfalls. No filler prose."""
26
+
27
+ INIT_PROMPT_UPDATE = """\
28
+ This repository already has DELOS.md content (it is loaded in your system \
29
+ prompt under "Project instructions"). Review it against the actual codebase \
30
+ and update it: use `explore` (several calls in the same turn, they run in \
31
+ parallel) to verify the documented commands, architecture and conventions \
32
+ still match reality, then edit DELOS.md at the repository root to fix what \
33
+ drifted, remove what's dead, and add what's missing. Keep it under ~150 \
34
+ lines: dense, factual, imperative."""
35
+
36
+ EXECUTE_PROMPT = """\
37
+ Execute the plan you presented above. First mirror its steps into \
38
+ `todo_write`, then work through them in order, marking each step completed \
39
+ as you go. If no plan exists in this conversation, say so instead of \
40
+ improvising one."""
41
+
42
+ #: Catalogue served to frontends (``list_commands`` RPC) for autocomplete.
43
+ PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
44
+ {
45
+ "name": "/init",
46
+ "summary": "generate or refresh the project's DELOS.md by exploring the codebase",
47
+ },
48
+ {
49
+ "name": "/execute",
50
+ "summary": "execute the plan from the previous plan-mode turn (loads it into todos)",
51
+ },
52
+ )
53
+
54
+
55
+ def expand_slash_command(
56
+ content: str,
57
+ mode: str,
58
+ *,
59
+ has_project_instructions: bool,
60
+ ) -> tuple[str, str]:
61
+ """Expand a leading slash command into its agent prompt.
62
+
63
+ Returns ``(content, mode)`` — unchanged when the message isn't a known
64
+ command (unknown ``/xyz`` passes through as plain text; the agent can
65
+ answer helpfully). ``/execute`` forces build mode: executing a plan is
66
+ pointless with read-only tools.
67
+ """
68
+ head, _, rest = content.strip().partition(" ")
69
+ rest = rest.strip()
70
+ if head == "/init":
71
+ prompt = INIT_PROMPT_UPDATE if has_project_instructions else INIT_PROMPT_NEW
72
+ if rest:
73
+ prompt += f"\n\nAdditional user instructions: {rest}"
74
+ return prompt, mode
75
+ if head == "/execute":
76
+ prompt = EXECUTE_PROMPT
77
+ if rest:
78
+ prompt += f"\n\nAdditional user instructions: {rest}"
79
+ return prompt, "build"
80
+ return content, mode
@@ -0,0 +1,74 @@
1
+ """Collect a compact git snapshot at the start of each agent turn.
2
+
3
+ Sent to the backend as ``git_context`` on every ``/completions`` request and
4
+ rendered into the system prompt, so the agent knows the branch, dirty state,
5
+ and recent history without burning its first tool call on ``git status``.
6
+
7
+ Collection is best-effort: any failure (not a repo, git missing, timeout)
8
+ returns ``None`` and the turn proceeds without the section.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from pathlib import Path
18
+
19
+ _CMD_TIMEOUT_S = 2.0
20
+ _MAX_STATUS_LINES = 20
21
+ _LOG_COUNT = 5
22
+
23
+
24
+ async def _git(workspace: Path, *args: str) -> str | None:
25
+ """Run one git command in ``workspace``; None on any failure."""
26
+ try:
27
+ proc = await asyncio.create_subprocess_exec(
28
+ "git",
29
+ "-C",
30
+ str(workspace),
31
+ *args,
32
+ stdout=asyncio.subprocess.PIPE,
33
+ stderr=asyncio.subprocess.DEVNULL,
34
+ )
35
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=_CMD_TIMEOUT_S)
36
+ except (OSError, TimeoutError):
37
+ return None
38
+ if proc.returncode != 0:
39
+ return None
40
+ return stdout.decode("utf-8", errors="replace").rstrip()
41
+
42
+
43
+ async def collect_git_context(workspace: Path) -> str | None:
44
+ """Return a compact ``branch / status / recent commits`` block, or None.
45
+
46
+ ``None`` when the workspace isn't a git repo or git isn't usable —
47
+ callers just omit the field.
48
+ """
49
+ inside = await _git(workspace, "rev-parse", "--is-inside-work-tree")
50
+ if inside != "true":
51
+ return None
52
+
53
+ branch, status, log = await asyncio.gather(
54
+ _git(workspace, "branch", "--show-current"),
55
+ _git(workspace, "status", "--porcelain"),
56
+ _git(workspace, "log", "--oneline", f"-{_LOG_COUNT}"),
57
+ )
58
+
59
+ lines: list[str] = [f"Branch: {branch or '(detached HEAD)'}"]
60
+
61
+ if status:
62
+ status_lines = status.splitlines()
63
+ shown = status_lines[:_MAX_STATUS_LINES]
64
+ lines.append(f"Status ({len(status_lines)} changed file(s)):")
65
+ lines.extend(shown)
66
+ if len(status_lines) > len(shown):
67
+ lines.append(f"... ({len(status_lines) - len(shown)} more)")
68
+ else:
69
+ lines.append("Status: clean")
70
+
71
+ if log:
72
+ lines.extend(("Recent commits:", log))
73
+
74
+ return "\n".join(lines)
@@ -0,0 +1,125 @@
1
+ """Minimal :class:`App` for headless serve mode.
2
+
3
+ The stdio server drives :class:`~delos_cli.agent.AgentSession` directly and
4
+ forwards raw v6 events to the frontend — there is no renderer and no REPL
5
+ command set. This class only exists so ``Ctx.app`` is populated with the
6
+ bits tool handlers actually reach for: the :class:`ToolRegistry` and the
7
+ ``todo_store`` the ``todo_write`` handler looks up via ``getattr``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from delos_cli.agent import ToolRegistry
16
+ from delos_cli.apps.base import App
17
+ from delos_cli.apps.chat.app import default_tool_registry
18
+ from delos_cli.state import TodoStore
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import AsyncIterator, Awaitable, Callable
22
+
23
+ from delos_cli.agent.tools import ToolHandler
24
+ from delos_cli.apps.base import Renderer
25
+ from delos_cli.commands.base import CommandSpec
26
+ from delos_cli.ctx import Ctx
27
+ from delos_cli.ui.output import OutputBuffer
28
+
29
+ #: Tools resolved by the VSCode extension itself (they need the editor API);
30
+ #: the serve loop relays them over the stdio protocol. Single source of
31
+ #: truth for the forwarding wiring — must match the backend's
32
+ #: ``cosmos.agents.tools.client.vscode`` package.
33
+ VSCODE_FORWARDED_TOOLS: tuple[str, ...] = (
34
+ "get_diagnostics",
35
+ "get_active_editor_context",
36
+ "get_document_symbols",
37
+ )
38
+
39
+
40
+ class HeadlessToolRegistry(ToolRegistry):
41
+ """Registry whose fallback fails fast instead of prompting.
42
+
43
+ The base registry's default handler opens an interactive
44
+ prompt_toolkit session. In headless serve mode there is no TTY —
45
+ stdin is the extension's NDJSON pipe — so an unknown client tool
46
+ (typically a backend newer than this CLI build) would block the turn
47
+ forever waiting for keyboard input. Return a self-describing error
48
+ so the model can route around the missing tool and the user sees a
49
+ reply instead of an infinite spinner.
50
+ """
51
+
52
+ def handler_for(self, name: str) -> ToolHandler:
53
+ """Return the registered handler, or an error-returning stub."""
54
+ handler = self.handlers.get(name)
55
+ if handler is not None:
56
+ return handler
57
+
58
+ async def _unknown_tool(tool_input: dict[str, Any], ctx: Ctx) -> str: # noqa: RUF029 — ToolHandler protocol is async
59
+ _ = tool_input, ctx
60
+ return (
61
+ f"Error: client tool '{name}' is not supported by this "
62
+ "delos-cli build (backend/CLI version skew). Do not retry it. "
63
+ "Continue with the tools that work, and suggest the user "
64
+ "restart or update the Delos CLI."
65
+ )
66
+
67
+ return _unknown_tool
68
+
69
+
70
+ def _make_forwarding_handler(name: str) -> ToolHandler:
71
+ """Relay one editor tool through ``ctx.app.forward_tool`` (set by the server)."""
72
+
73
+ async def _handler(tool_input: dict[str, Any], ctx: Ctx) -> str:
74
+ forward = getattr(ctx.app, "forward_tool", None)
75
+ if forward is None:
76
+ return (
77
+ f"Error: '{name}' needs the VSCode editor bridge, which is not "
78
+ "connected. Continue without it."
79
+ )
80
+ return await forward(name, tool_input)
81
+
82
+ return _handler
83
+
84
+
85
+ def _headless_registry() -> HeadlessToolRegistry:
86
+ """The default tool set + editor forwarding, wrapped with the fail-fast fallback."""
87
+ reg = default_tool_registry()
88
+ for name in VSCODE_FORWARDED_TOOLS:
89
+ reg.handlers[name] = _make_forwarding_handler(name)
90
+ return HeadlessToolRegistry(renderers=reg.renderers, handlers=reg.handlers)
91
+
92
+
93
+ @dataclass
94
+ class VsCodeServeApp(App):
95
+ """Ctx.app placeholder for ``delos serve --app vscode``."""
96
+
97
+ name: str = "vscode"
98
+ commands: dict[str, CommandSpec] = field(default_factory=dict)
99
+ tools: ToolRegistry = field(default_factory=_headless_registry)
100
+ #: Mutated by the ``todo_write`` handler; no ``attach`` hook in
101
+ #: headless mode (the frontend renders its own plan UI, if any).
102
+ todo_store: TodoStore = field(default_factory=TodoStore)
103
+ #: Set by :class:`~delos_cli.serve.server.StdioServer` — async
104
+ #: ``(tool_name, input) -> result`` relay used by the editor-tool
105
+ #: forwarding handlers. ``None`` outside serve mode.
106
+ forward_tool: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None
107
+
108
+ async def on_enter(self, ctx: Ctx) -> None:
109
+ """Nothing to prefetch in headless mode."""
110
+
111
+ def send(
112
+ self,
113
+ ctx: Ctx,
114
+ messages: list[dict[str, str]],
115
+ ) -> AsyncIterator[dict[str, Any]]:
116
+ """Unused — the serve loop drives :class:`AgentSession` itself."""
117
+ _ = self, ctx, messages
118
+ msg = "VsCodeServeApp.send is not used; the stdio server owns the turn loop"
119
+ raise NotImplementedError(msg)
120
+
121
+ def make_renderer(self, output: OutputBuffer) -> Renderer:
122
+ """Unused — headless mode forwards raw events, nothing renders."""
123
+ _ = self, output
124
+ msg = "VsCodeServeApp has no renderer; events are forwarded raw"
125
+ raise NotImplementedError(msg)
@@ -8,10 +8,14 @@ the dispatcher in ``server.py`` turns exceptions into error responses.
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
+ import base64
12
+ import binascii
11
13
  import os
12
14
  from pathlib import Path
13
15
  from typing import TYPE_CHECKING, Any
14
16
 
17
+ from delos_cli.transport.chats import fork_chat as _fork_chat
18
+ from delos_cli.transport.documents import list_drive_documents as _list_drive_documents
15
19
  from delos_cli.transport.integrations import (
16
20
  fetch_integrations,
17
21
  fetch_manage_url,
@@ -63,6 +67,23 @@ async def delete_chat(server: StdioServer, params: dict[str, Any]) -> dict[str,
63
67
  )
64
68
 
65
69
 
70
+ async def fork_chat(server: StdioServer, params: dict[str, Any]) -> dict[str, Any]:
71
+ """Duplicate a chat's context into a new conversation.
72
+
73
+ ``userMessageCount`` bounds the copied prefix (see
74
+ :func:`delos_cli.transport.chats.fork_chat`); ``0`` copies everything.
75
+ """
76
+ http = server.require_http()
77
+ chat_id = _required_str(params, "chatId")
78
+ user_message_count = int(params.get("userMessageCount") or 0)
79
+ new_id, title = await _fork_chat(
80
+ http,
81
+ source_chat_id=chat_id,
82
+ user_message_count=user_message_count,
83
+ )
84
+ return {"id": new_id, "title": title}
85
+
86
+
66
87
  async def find_files(server: StdioServer, params: dict[str, Any]) -> list[str]:
67
88
  """Fuzzy-ish file lookup: ``rg --files`` + case-insensitive substring filter.
68
89
 
@@ -152,6 +173,49 @@ async def toggle_integration(
152
173
  return await set_integration_active(server.require_http(), service, active)
153
174
 
154
175
 
176
+ async def upload_file(server: StdioServer, params: dict[str, Any]) -> dict[str, Any]:
177
+ """Upload one attachment to ``/files/upload`` (origin=chat); return its id.
178
+
179
+ The frontend sends ``filename``, ``mime`` and base64 ``content``. Returns
180
+ ``{"fileId": str, "name": str, "fileType": str | None, "kind": "doc"}`` —
181
+ the shape the frontend turns into a ``message_files`` ref for the next turn.
182
+ """
183
+ http = server.require_http()
184
+ filename = _required_str(params, "filename")
185
+ mime = str(params.get("mime") or "application/octet-stream")
186
+ b64 = str(params.get("content") or "")
187
+ try:
188
+ content = base64.b64decode(b64.rsplit(",", maxsplit=1)[-1], validate=False)
189
+ except (binascii.Error, ValueError) as exc:
190
+ msg = "invalid base64 file content"
191
+ raise ValueError(msg) from exc
192
+
193
+ result = await http.upload_file(
194
+ "/files/upload",
195
+ filename=filename,
196
+ content=content,
197
+ mime=mime,
198
+ data={"org_uuid": str(server.cfg.org_uuid), "origin": "chat", "language": "en"},
199
+ )
200
+ file_id = str(result.get("file_id") or "")
201
+ if not file_id:
202
+ msg = "upload did not return a file_id"
203
+ raise ValueError(msg)
204
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else None
205
+ return {"fileId": file_id, "name": filename, "fileType": ext, "kind": "doc"}
206
+
207
+
208
+ async def list_drive_documents(server: StdioServer, params: dict[str, Any]) -> list[dict[str, Any]]:
209
+ """List Delos documents the user can attach (Import from Drive)."""
210
+ _ = params
211
+ http = server.require_http()
212
+ items = await _list_drive_documents(http, str(server.cfg.org_uuid))
213
+ return [
214
+ {"id": it.id, "name": it.name, "kind": it.kind, "fileType": it.file_type}
215
+ for it in items
216
+ ]
217
+
218
+
155
219
  def _required_str(params: dict[str, Any], key: str) -> str:
156
220
  value = str(params.get(key) or "").strip()
157
221
  if not value: