tamfis-code 0.2.3__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.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
"""Interactive terminal composer with multiline history, explicit chat/audit/
|
|
2
|
+
plan/agent modes, durable saved-plan execution, streamed tools and approvals,
|
|
3
|
+
session recovery, diffs/revert, model routing, context checkpoints, and queue
|
|
4
|
+
management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from prompt_toolkit import PromptSession
|
|
14
|
+
from prompt_toolkit.history import FileHistory
|
|
15
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markdown import Markdown
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from . import state as local_state
|
|
21
|
+
from .api_client import AuthRequiredError, RemoteAPIClient, RemoteAPIError
|
|
22
|
+
from .config import APPROVAL_MODES, CONFIG_DIR, Config, MODE_ALIASES
|
|
23
|
+
from .doctor import run_doctor
|
|
24
|
+
from .render import StreamRenderer, print_banner, print_error, print_recent_thread, print_unified_diff
|
|
25
|
+
from .runner import resolve_approval_decision, retry_task_and_stream, run_ai_task_and_stream, run_shell_command
|
|
26
|
+
from .runner_local import run_local_agent_turn, run_local_shell_command
|
|
27
|
+
from .safety import revert_mutation as local_revert_mutation
|
|
28
|
+
from .tasks import find_recent_task
|
|
29
|
+
from .workspace import (
|
|
30
|
+
WorkspaceContext, blocking_dirty_files, context_from_session, discover_local_repository,
|
|
31
|
+
find_resumable_session, resolve_local_workspace,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
HELP_TEXT = """\
|
|
35
|
+
Natural-language text submits a full coding-agent task (mode: coding).
|
|
36
|
+
$ <command> explicit shell command
|
|
37
|
+
/run <command> explicit shell command
|
|
38
|
+
/shell <command> explicit shell command
|
|
39
|
+
/chat <question> conversational/read-only coding assistance
|
|
40
|
+
/audit <objective> AI audit mode (read-only)
|
|
41
|
+
/plan <objective> create and save an executable plan (no changes)
|
|
42
|
+
/plans list saved plans for this session
|
|
43
|
+
/plan show [plan_id] show a saved plan (latest/active if omitted)
|
|
44
|
+
/execute-plan [plan_id] execute a saved plan (latest/active if omitted)
|
|
45
|
+
/agent <objective> full coding-agent mode (inspect + edit + verify)
|
|
46
|
+
/execute <objective> AI execute mode (tools + approval policy)
|
|
47
|
+
|
|
48
|
+
/help show this help
|
|
49
|
+
/status show session/workspace/approval status
|
|
50
|
+
/context show cached repository/task context
|
|
51
|
+
/reports show the repository report index
|
|
52
|
+
/queue show queued instructions
|
|
53
|
+
/queue <instruction> append a follow-up instruction
|
|
54
|
+
/cwd show the current workspace root
|
|
55
|
+
/doctor run connectivity/auth checks
|
|
56
|
+
/resume [session_id] switch to another session (most recent if omitted)
|
|
57
|
+
/retry [task_id] retry a failed task (most recent failure if omitted)
|
|
58
|
+
/agents list sessions and their latest task status
|
|
59
|
+
/delegate <a> | <b> run objectives a, b, ... as concurrent delegated sub-tasks
|
|
60
|
+
(requires enable_subagent_delegation = true in config.toml,
|
|
61
|
+
or TAMFIS_CODE_ENABLE_SUBAGENT_DELEGATION=1)
|
|
62
|
+
/diffs [n] show the last n file mutations in this session (default 10)
|
|
63
|
+
/diff [mutation_id] show a semantic unified diff (latest if omitted)
|
|
64
|
+
/revert <mutation_id> restore a file to its content before that mutation (or
|
|
65
|
+
delete it, if that mutation created the file)
|
|
66
|
+
/detach exit without cancelling anything server-side (same as /exit --
|
|
67
|
+
nothing in this REPL ties a task's lifetime to this process; see
|
|
68
|
+
`tamfis-code attach <session_id>` in another terminal to reconnect)
|
|
69
|
+
/clear clear the screen
|
|
70
|
+
/compact save a durable checkpoint of the current task context
|
|
71
|
+
/permissions show approval policy and immutable server safeguards
|
|
72
|
+
/mode show the active approval mode and available modes
|
|
73
|
+
/mode <name> switch mode: manual | accept-edits | auto | plan
|
|
74
|
+
/model show the active model route
|
|
75
|
+
/model list [route] list coding models (route: hf or openrouter)
|
|
76
|
+
/model auto restore Hugging Face -> OpenRouter automatic routing
|
|
77
|
+
/model <route> [id] pin a provider, optionally with a catalog model id
|
|
78
|
+
/tools show the tools exposed to tamfis-code tasks
|
|
79
|
+
/pty start [command] start a persistent background terminal (default: bash)
|
|
80
|
+
/pty list list this session's background terminals
|
|
81
|
+
/pty send <id> <text> send input to a background terminal (a trailing \\n
|
|
82
|
+
submits the line; omit it to send raw keystrokes)
|
|
83
|
+
/pty read <id> show output produced since the last /pty read
|
|
84
|
+
/pty kill <id> terminate a background terminal
|
|
85
|
+
/exit quit (also: /quit, Ctrl+D, Ctrl+C)
|
|
86
|
+
|
|
87
|
+
Not yet implemented in this pass: /notifications.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Intent:
|
|
92
|
+
def __init__(self, kind: str, *, command: str = "", objective: str = "", mode: str = "coding"):
|
|
93
|
+
self.kind = kind
|
|
94
|
+
self.command = command
|
|
95
|
+
self.objective = objective
|
|
96
|
+
self.mode = mode
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def contextualize_short_reply(raw: str, *, has_context: bool) -> str:
|
|
100
|
+
"""Turn terse conversational controls into explicit contextual intents.
|
|
101
|
+
|
|
102
|
+
Keep truly new-session input untouched; a bare ``1`` only means "step
|
|
103
|
+
one" when there is an active/prior task to refer to.
|
|
104
|
+
"""
|
|
105
|
+
text = raw.strip()
|
|
106
|
+
if not has_context:
|
|
107
|
+
return text
|
|
108
|
+
lowered = text.lower().rstrip(".! ")
|
|
109
|
+
if lowered in {"ok", "okay", "yes", "y", "sure", "go", "proceed"}:
|
|
110
|
+
return "Yes. Proceed with the action or next step you just proposed."
|
|
111
|
+
if lowered in {"no", "n", "stop"}:
|
|
112
|
+
return "No. Do not proceed with the action you just proposed."
|
|
113
|
+
if lowered.isdigit():
|
|
114
|
+
return f"Proceed with step {int(lowered)} from your immediately preceding plan."
|
|
115
|
+
match = re.fullmatch(r"(?:step\s*)?(\d+)", lowered)
|
|
116
|
+
if match:
|
|
117
|
+
return f"Proceed with step {int(match.group(1))} from your immediately preceding plan."
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_intent(raw: str) -> Intent:
|
|
122
|
+
text = raw.strip()
|
|
123
|
+
if text.startswith("$ "):
|
|
124
|
+
return Intent("shell", command=text[2:].strip())
|
|
125
|
+
if text.startswith("/run "):
|
|
126
|
+
return Intent("shell", command=text[5:].strip())
|
|
127
|
+
if text.startswith("/shell "):
|
|
128
|
+
return Intent("shell", command=text[7:].strip())
|
|
129
|
+
if text.startswith("/audit "):
|
|
130
|
+
return Intent("ai", objective=text[7:].strip(), mode="audit")
|
|
131
|
+
if text.startswith("/chat "):
|
|
132
|
+
return Intent("ai", objective=text[6:].strip(), mode="chat")
|
|
133
|
+
if text.startswith("/plan "):
|
|
134
|
+
return Intent("ai", objective=text[6:].strip(), mode="plan")
|
|
135
|
+
if text == "/execute-plan" or text.startswith("/execute-plan "):
|
|
136
|
+
return Intent("saved_plan", command=text[len("/execute-plan"):].strip())
|
|
137
|
+
if text.startswith("/agent "):
|
|
138
|
+
return Intent("ai", objective=text[7:].strip(), mode="execute")
|
|
139
|
+
if text.startswith("/execute "):
|
|
140
|
+
return Intent("ai", objective=text[9:].strip(), mode="execute")
|
|
141
|
+
if text.startswith("/ask "):
|
|
142
|
+
return Intent("ai", objective=text[5:].strip(), mode="coding")
|
|
143
|
+
return Intent("ai", objective=text, mode="coding")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def run_interactive(
|
|
147
|
+
client: Optional[RemoteAPIClient], config: Config, workspace: WorkspaceContext,
|
|
148
|
+
*, provider: str = "auto", model: Optional[str] = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Drives the interactive REPL. `client=None` means standalone mode: no
|
|
151
|
+
TamfisGPT Remote Workspace backend, no login -- AI turns/shell commands
|
|
152
|
+
run through runner_local.py's local agent loop calling `provider`
|
|
153
|
+
directly instead. A handful of features that are inherently remote-server
|
|
154
|
+
concepts (background PTY terminals, a remote model catalog) have no
|
|
155
|
+
local equivalent yet and degrade to a clear message rather than crashing;
|
|
156
|
+
everything else (diffs/revert, resume, agents listing, retry, delegate,
|
|
157
|
+
doctor) has a real local implementation in this mode.
|
|
158
|
+
"""
|
|
159
|
+
standalone = client is None
|
|
160
|
+
provider_manager = None
|
|
161
|
+
provider_type = None
|
|
162
|
+
last_turn: Optional[tuple[str, str]] = None # (objective, mode) -- standalone /retry target
|
|
163
|
+
|
|
164
|
+
if standalone:
|
|
165
|
+
from .local_chat import resolve_provider_type
|
|
166
|
+
from .providers import ProviderManager
|
|
167
|
+
|
|
168
|
+
console = Console(no_color=not config.colour)
|
|
169
|
+
try:
|
|
170
|
+
provider_type = resolve_provider_type(provider)
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
print_error(console, str(exc))
|
|
173
|
+
return
|
|
174
|
+
provider_manager = ProviderManager()
|
|
175
|
+
else:
|
|
176
|
+
console = Console(no_color=not config.colour)
|
|
177
|
+
|
|
178
|
+
print_banner(
|
|
179
|
+
console, host=(f"local:{provider}" if standalone else config.api_base),
|
|
180
|
+
workspace_root=workspace.workspace_root, mode="interactive", approval_policy=config.approval_policy,
|
|
181
|
+
)
|
|
182
|
+
console.print("[dim]Type /help for commands. Paste up to 1,000,000 characters; Alt+Enter adds a newline. Ctrl+D or Ctrl+C exits.[/dim]\n")
|
|
183
|
+
|
|
184
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
history_path = CONFIG_DIR / "history"
|
|
186
|
+
bindings = KeyBindings()
|
|
187
|
+
# Local only: which byte offset this REPL has already displayed per PTY
|
|
188
|
+
# id, so /pty read shows only new output. The server (RemotePtySession/
|
|
189
|
+
# pty_broker's ring buffer) is the durable source of truth -- losing
|
|
190
|
+
# this dict on restart just means the next /pty read re-shows output
|
|
191
|
+
# already seen, never data loss. (Remote mode only -- standalone has no
|
|
192
|
+
# PTY backend at all yet, see the /pty handler below.)
|
|
193
|
+
pty_offsets: dict[str, int] = {}
|
|
194
|
+
|
|
195
|
+
@bindings.add("enter")
|
|
196
|
+
def _submit(event) -> None:
|
|
197
|
+
event.current_buffer.validate_and_handle()
|
|
198
|
+
|
|
199
|
+
@bindings.add("escape", "enter")
|
|
200
|
+
def _newline(event) -> None:
|
|
201
|
+
event.current_buffer.insert_text("\n")
|
|
202
|
+
|
|
203
|
+
# multiline=True is essential for bracketed terminal paste: embedded
|
|
204
|
+
# newlines remain part of one objective instead of submitting the first
|
|
205
|
+
# line and treating the remainder as accidental follow-up commands. The
|
|
206
|
+
# bindings retain familiar Enter-to-submit behaviour.
|
|
207
|
+
session: PromptSession = PromptSession(
|
|
208
|
+
history=FileHistory(str(history_path)), multiline=True, key_bindings=bindings,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
while True:
|
|
212
|
+
queued_item = next((item for item in local_state.get_session_state(workspace.session_id).queued_user_instructions
|
|
213
|
+
if item.get("status") == "queued"), None)
|
|
214
|
+
if queued_item and queued_item.get("classification") not in {"pause", "cancel"}:
|
|
215
|
+
local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "running")
|
|
216
|
+
text = str(queued_item.get("text") or "")
|
|
217
|
+
console.print(f"[cyan]◆ Queued instruction[/cyan] {queued_item.get('id')}: {text}")
|
|
218
|
+
elif queued_item:
|
|
219
|
+
local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "completed")
|
|
220
|
+
console.print(f"[yellow]◆ {queued_item.get('classification')} boundary reached[/yellow]")
|
|
221
|
+
continue
|
|
222
|
+
else:
|
|
223
|
+
try:
|
|
224
|
+
text = await session.prompt_async("tamfis-code> ")
|
|
225
|
+
except KeyboardInterrupt:
|
|
226
|
+
# NOTE: this used to just `continue`, silently redrawing the
|
|
227
|
+
# prompt -- Ctrl+C appeared to do nothing at all, with no
|
|
228
|
+
# documented way to exit except Ctrl+D or typing /exit. Ctrl+C
|
|
229
|
+
# while an AI task/command is actively streaming is a separate,
|
|
230
|
+
# already-correct code path (runner.py's _install_sigint_watcher
|
|
231
|
+
# cancels just that task, not the whole process) -- this only
|
|
232
|
+
# covers the idle prompt, where Ctrl+C exiting is the standard
|
|
233
|
+
# expectation.
|
|
234
|
+
break
|
|
235
|
+
except EOFError:
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
text = text.strip()
|
|
239
|
+
if not text:
|
|
240
|
+
continue
|
|
241
|
+
if len(text) > 1_000_000:
|
|
242
|
+
print_error(console, "Objective exceeds the 1,000,000 character safety limit.")
|
|
243
|
+
continue
|
|
244
|
+
if text in ("/exit", "/quit", "/detach"):
|
|
245
|
+
# No task submitted through this REPL outlives this process's
|
|
246
|
+
# lifetime any differently based on which of these three the
|
|
247
|
+
# user types -- background durability comes from `--bg` /
|
|
248
|
+
# `attach` in a fresh invocation, not from how THIS process
|
|
249
|
+
# exits. /detach exists as a documented, discoverable alias
|
|
250
|
+
# matching the Phase 19 slash-command surface, not because it
|
|
251
|
+
# behaves differently from /exit today.
|
|
252
|
+
break
|
|
253
|
+
# A bare "/" fell all the way through to parse_intent() before, which
|
|
254
|
+
# doesn't treat "/" as a prefix of anything (every command check
|
|
255
|
+
# requires an exact match or a trailing space with content) -- it
|
|
256
|
+
# was submitted to the AI as a one-character objective instead of
|
|
257
|
+
# showing the command list the way typing "/" alone is expected to.
|
|
258
|
+
if text in ("/help", "/"):
|
|
259
|
+
console.print(HELP_TEXT)
|
|
260
|
+
if standalone:
|
|
261
|
+
console.print(
|
|
262
|
+
"[dim]Standalone mode: /pty and /model list have no local equivalent yet (they need a "
|
|
263
|
+
"persistent server / remote model catalog) -- everything else above (diffs/revert, resume, "
|
|
264
|
+
"agents, retry, delegate, doctor) runs fully locally, no TamfisGPT backend involved.[/dim]"
|
|
265
|
+
)
|
|
266
|
+
continue
|
|
267
|
+
if text == "/cwd":
|
|
268
|
+
console.print(workspace.workspace_root)
|
|
269
|
+
continue
|
|
270
|
+
if text == "/status":
|
|
271
|
+
state = local_state.get_session_state(workspace.session_id)
|
|
272
|
+
identity_line = (
|
|
273
|
+
f"session_id={workspace.session_id} (standalone, local session)"
|
|
274
|
+
if standalone else f"session_id={workspace.session_id} server_id={workspace.server_id}"
|
|
275
|
+
)
|
|
276
|
+
backend_line = (
|
|
277
|
+
f"approval_policy={config.approval_policy} provider={provider_type.value if provider_type else provider}"
|
|
278
|
+
if standalone else f"approval_policy={config.approval_policy} api_base={config.api_base}"
|
|
279
|
+
)
|
|
280
|
+
console.print(
|
|
281
|
+
f"{identity_line}\n"
|
|
282
|
+
f"workspace_root={workspace.workspace_root}\n"
|
|
283
|
+
f"repository_root={state.repository_root or '-'} branch={state.active_branch or '-'}\n"
|
|
284
|
+
f"phase={state.current_phase} execution={state.execution_status}\n"
|
|
285
|
+
f"queue={sum(1 for item in state.queued_user_instructions if item.get('status') == 'queued')} "
|
|
286
|
+
f"validations={len(state.validation_results)} issues={len(state.unresolved_issues)}\n"
|
|
287
|
+
f"saved_plans={len(state.saved_plans)} active_plan={state.active_plan_id or '-'}\n"
|
|
288
|
+
f"{backend_line}\n"
|
|
289
|
+
f"model={state.selected_model} route={state.selected_provider or 'auto'}"
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
if text == "/context":
|
|
293
|
+
context = discover_local_repository(workspace.session_id, Path(workspace.workspace_root))
|
|
294
|
+
state = local_state.get_session_state(workspace.session_id)
|
|
295
|
+
console.print(f"repository={context.get('repository_root')} branch={context.get('branch') or '-'} dirty={context.get('dirty')}")
|
|
296
|
+
console.print(f"cwd={context.get('working_directory')} indexed_files={context.get('indexed_file_count')}")
|
|
297
|
+
console.print(f"task={(state.active_task or {}).get('objective') or state.conversation_summary or '-'}")
|
|
298
|
+
for path in context.get("instruction_files", []):
|
|
299
|
+
console.print(f" instruction: {path}")
|
|
300
|
+
continue
|
|
301
|
+
if text == "/reports":
|
|
302
|
+
discover_local_repository(workspace.session_id, Path(workspace.workspace_root))
|
|
303
|
+
reports = local_state.get_session_state(workspace.session_id).discovered_reports
|
|
304
|
+
if not reports:
|
|
305
|
+
console.print("[dim]No matching reports discovered in this repository.[/dim]")
|
|
306
|
+
for report in reports:
|
|
307
|
+
console.print(f" {str(report.get('modified_at', ''))[:10]} {report.get('verification')} {report.get('path')}")
|
|
308
|
+
continue
|
|
309
|
+
if text == "/plans":
|
|
310
|
+
state = local_state.get_session_state(workspace.session_id)
|
|
311
|
+
if not state.saved_plans:
|
|
312
|
+
console.print("[dim]No saved plans yet. Create one with /plan <objective>.[/dim]")
|
|
313
|
+
continue
|
|
314
|
+
table = Table(show_header=True, header_style="bold")
|
|
315
|
+
for column in ("ID", "STATUS", "OBJECTIVE", "CREATED"):
|
|
316
|
+
table.add_column(column)
|
|
317
|
+
for item in reversed(state.saved_plans):
|
|
318
|
+
marker = " *" if item.get("id") == state.active_plan_id else ""
|
|
319
|
+
table.add_row(
|
|
320
|
+
f"{item.get('id')}{marker}", str(item.get("status") or "ready"),
|
|
321
|
+
str(item.get("objective") or "")[:80], str(item.get("created_at") or "")[:19],
|
|
322
|
+
)
|
|
323
|
+
console.print(table)
|
|
324
|
+
continue
|
|
325
|
+
if text == "/plan" or text == "/plan show" or text.startswith("/plan show "):
|
|
326
|
+
plan_id = text[len("/plan show"):].strip() if text.startswith("/plan show") else ""
|
|
327
|
+
plan = local_state.get_plan(workspace.session_id, plan_id or None)
|
|
328
|
+
if plan is None:
|
|
329
|
+
print_error(console, "Plan not found. Use /plans to list saved plans.")
|
|
330
|
+
continue
|
|
331
|
+
console.print(
|
|
332
|
+
f"[bold]{plan.get('id')}[/bold] · {plan.get('status', 'ready')}\n"
|
|
333
|
+
f"[dim]Objective:[/dim] {plan.get('objective', '')}"
|
|
334
|
+
)
|
|
335
|
+
console.print(Markdown(str(plan.get("content") or "")))
|
|
336
|
+
continue
|
|
337
|
+
if text == "/queue" or text.startswith("/queue "):
|
|
338
|
+
arg = text[len("/queue"):].strip()
|
|
339
|
+
if arg:
|
|
340
|
+
item = local_state.enqueue_instruction(workspace.session_id, arg)
|
|
341
|
+
console.print(f"[cyan]Queued[/cyan] {item.id}")
|
|
342
|
+
for item in local_state.get_session_state(workspace.session_id).queued_user_instructions:
|
|
343
|
+
console.print(f" {item.get('id')} {item.get('status')} {item.get('classification')} {item.get('text')}")
|
|
344
|
+
continue
|
|
345
|
+
if text == "/model" or text.startswith("/model "):
|
|
346
|
+
arg = text[len("/model"):].strip()
|
|
347
|
+
state = local_state.get_session_state(workspace.session_id)
|
|
348
|
+
if not arg:
|
|
349
|
+
console.print(
|
|
350
|
+
f"model={state.selected_model} "
|
|
351
|
+
f"provider={state.selected_provider or 'auto (hf -> openrouter)'}"
|
|
352
|
+
)
|
|
353
|
+
continue
|
|
354
|
+
parts = arg.split()
|
|
355
|
+
|
|
356
|
+
if standalone:
|
|
357
|
+
from .local_chat import resolve_provider_type as _resolve_provider_type
|
|
358
|
+
from .providers import ProviderManager as _ProviderManager, ProviderType
|
|
359
|
+
|
|
360
|
+
if parts[0].lower() == "list":
|
|
361
|
+
route_arg = parts[1].lower() if len(parts) > 1 else None
|
|
362
|
+
manager = _ProviderManager()
|
|
363
|
+
routes = [route_arg] if route_arg else [p.value for p in manager.PROVIDERS]
|
|
364
|
+
table = Table(show_header=True, header_style="bold")
|
|
365
|
+
for column in ("PROVIDER", "MODELS"):
|
|
366
|
+
table.add_column(column)
|
|
367
|
+
shown = False
|
|
368
|
+
for route_name in routes:
|
|
369
|
+
try:
|
|
370
|
+
route_type = _resolve_provider_type(route_name)
|
|
371
|
+
except ValueError:
|
|
372
|
+
continue
|
|
373
|
+
pcfg = manager.PROVIDERS.get(route_type)
|
|
374
|
+
if pcfg:
|
|
375
|
+
table.add_row(pcfg.name, ", ".join(pcfg.models) or pcfg.default_model)
|
|
376
|
+
shown = True
|
|
377
|
+
console.print(table if shown else "[dim]Unknown provider. Use hf, nvidia, openrouter, or ollama.[/dim]")
|
|
378
|
+
continue
|
|
379
|
+
if parts[0].lower() == "auto":
|
|
380
|
+
provider_type = ProviderType.AUTO
|
|
381
|
+
local_state.save_session_state(workspace.session_id, selected_model="auto", selected_provider=None)
|
|
382
|
+
console.print("[green]Provider routing set to automatic.[/green]")
|
|
383
|
+
continue
|
|
384
|
+
try:
|
|
385
|
+
provider_type = _resolve_provider_type(parts[0])
|
|
386
|
+
except ValueError as exc:
|
|
387
|
+
print_error(console, f"{exc} Usage: /model auto | /model <hf|nvidia|openrouter|ollama> [model-id]")
|
|
388
|
+
continue
|
|
389
|
+
model_id = parts[1] if len(parts) > 1 else "auto"
|
|
390
|
+
if len(parts) > 2:
|
|
391
|
+
print_error(console, "Model ids cannot contain spaces.")
|
|
392
|
+
continue
|
|
393
|
+
model = None if model_id == "auto" else model_id
|
|
394
|
+
local_state.save_session_state(
|
|
395
|
+
workspace.session_id, selected_model=model_id, selected_provider=parts[0].lower(),
|
|
396
|
+
)
|
|
397
|
+
console.print(f"[green]Pinned {parts[0].lower()} route[/green] model={model_id}")
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
if parts[0].lower() == "list":
|
|
401
|
+
route = parts[1].lower() if len(parts) > 1 else None
|
|
402
|
+
if route not in (None, "hf", "openrouter"):
|
|
403
|
+
print_error(console, "Usage: /model list [hf|openrouter]")
|
|
404
|
+
continue
|
|
405
|
+
api_provider = "huggingface" if route == "hf" else route
|
|
406
|
+
try:
|
|
407
|
+
result = await client.list_models(api_provider)
|
|
408
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
409
|
+
print_error(console, str(e))
|
|
410
|
+
continue
|
|
411
|
+
rows = [m for m in (result.get("models") or []) if "coding" in [str(c).lower() for c in (m.get("categories") or [])]]
|
|
412
|
+
table = Table(show_header=True, header_style="bold")
|
|
413
|
+
for column in ("ID", "PROVIDER", "REASONING", "MAX TOKENS"):
|
|
414
|
+
table.add_column(column)
|
|
415
|
+
for item in rows[:40]:
|
|
416
|
+
table.add_row(
|
|
417
|
+
str(item.get("id")), str(item.get("provider") or item.get("backend") or ""),
|
|
418
|
+
str(item.get("reasoning") or "-"), str(item.get("maxTokens") or "-"),
|
|
419
|
+
)
|
|
420
|
+
console.print(table if rows else "[dim]No coding models found for that route.[/dim]")
|
|
421
|
+
continue
|
|
422
|
+
if parts[0].lower() == "auto":
|
|
423
|
+
local_state.save_session_state(
|
|
424
|
+
workspace.session_id, selected_model="auto", selected_provider=None,
|
|
425
|
+
)
|
|
426
|
+
console.print("[green]Model routing set to automatic: Hugging Face, then OpenRouter.[/green]")
|
|
427
|
+
continue
|
|
428
|
+
route = parts[0].lower()
|
|
429
|
+
if route not in ("hf", "openrouter"):
|
|
430
|
+
print_error(console, "Usage: /model auto | /model <hf|openrouter> [catalog-model-id]")
|
|
431
|
+
continue
|
|
432
|
+
model_id = parts[1] if len(parts) > 1 else "auto"
|
|
433
|
+
if len(parts) > 2:
|
|
434
|
+
print_error(console, "Model ids cannot contain spaces.")
|
|
435
|
+
continue
|
|
436
|
+
if model_id != "auto":
|
|
437
|
+
api_provider = "huggingface" if route == "hf" else route
|
|
438
|
+
try:
|
|
439
|
+
available = await client.list_models(api_provider)
|
|
440
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
441
|
+
print_error(console, str(e))
|
|
442
|
+
continue
|
|
443
|
+
ids = {str(item.get("id")) for item in (available.get("models") or [])}
|
|
444
|
+
if model_id not in ids:
|
|
445
|
+
print_error(console, f"Unknown {route} model '{model_id}'. Use /model list {route}.")
|
|
446
|
+
continue
|
|
447
|
+
local_state.save_session_state(
|
|
448
|
+
workspace.session_id, selected_model=model_id, selected_provider=route,
|
|
449
|
+
)
|
|
450
|
+
console.print(f"[green]Pinned {route} route[/green] model={model_id}")
|
|
451
|
+
continue
|
|
452
|
+
if text == "/tools":
|
|
453
|
+
table = Table(show_header=True, header_style="bold")
|
|
454
|
+
table.add_column("Tool")
|
|
455
|
+
table.add_column("Purpose")
|
|
456
|
+
table.add_column("Safeguard")
|
|
457
|
+
if standalone:
|
|
458
|
+
table.add_row("read_file", "Read a file's contents", "Read-only")
|
|
459
|
+
table.add_row("list_directory", "List a directory's contents", "Read-only")
|
|
460
|
+
table.add_row("search_code", "ripgrep-backed content search", "Read-only")
|
|
461
|
+
table.add_row("get_git_info", "Branch/HEAD/status for a repo path", "Read-only")
|
|
462
|
+
table.add_row("edit_file", "Exact, uniqueness-checked replacement", "Local risk classifier + approval + mutation ledger")
|
|
463
|
+
table.add_row("write_file", "Create or fully replace a file", "Workspace-boundary check + approval + mutation ledger")
|
|
464
|
+
table.add_row("execute_command", "Run a shell command", "Local risk classifier + approval (no sandboxing)")
|
|
465
|
+
table.add_row("browser", "Public Chromium navigation and screenshots", "Only if a monorepo browser tool is co-located")
|
|
466
|
+
console.print(table)
|
|
467
|
+
console.print(
|
|
468
|
+
"[dim]This is the real local tool set (mcp.py) the standalone agent loop uses -- "
|
|
469
|
+
"see safety.py for how risk is classified and see /diffs for the mutation ledger.[/dim]"
|
|
470
|
+
)
|
|
471
|
+
continue
|
|
472
|
+
table.add_row("read_file", "Bounded, line-numbered file reads", "Read-only + CWD confinement")
|
|
473
|
+
table.add_row("glob_files", "Find files by filename glob, not full paths", "Read-only + CWD confinement")
|
|
474
|
+
table.add_row("grep_files", "Search repository contents", "Read-only + CWD confinement")
|
|
475
|
+
table.add_row("list_symbols", "List definitions in a source file", "Read-only + CWD confinement")
|
|
476
|
+
table.add_row("find_dependencies", "Inspect imports and require targets", "Read-only + CWD confinement")
|
|
477
|
+
table.add_row("edit_file", "Exact, uniqueness-checked replacement", "Approval + mutation ledger")
|
|
478
|
+
table.add_row("write_file", "Create or fully replace a file", "Approval + mutation ledger")
|
|
479
|
+
table.add_row("extract/repackage_archive", "Inspect and rebuild repository archives", "CWD confinement + approval for writes")
|
|
480
|
+
table.add_row("remote_exec", "Tests, builds, Git and shell operations", "CWD confinement + risk approval")
|
|
481
|
+
table.add_row("browser", "Public Chromium navigation and screenshots", "Clean session + SSRF protection")
|
|
482
|
+
table.add_row("web_search", "Current external research when needed", "Read-only; provider/network policy")
|
|
483
|
+
table.add_row("codex_apps.*", "Optional organization-operated connector gateway", "Unavailable unless explicitly configured")
|
|
484
|
+
table.add_row("claude_apps.*", "Optional organization-operated connector gateway", "Unavailable unless explicitly configured")
|
|
485
|
+
console.print(table)
|
|
486
|
+
console.print("[dim]Use glob_files with patterns like '*.tsx' or 'src/**/*.tsx'. Do not paste a full filesystem path into pattern; use path for the search root instead.[/dim]")
|
|
487
|
+
console.print("[dim]Native coding tools above do not require Codex Apps, Claude Apps, or MCP. Optional connector tools appear only when a real gateway is enabled and successfully discovered.[/dim]")
|
|
488
|
+
continue
|
|
489
|
+
if text == "/pty" or text.startswith("/pty "):
|
|
490
|
+
if standalone:
|
|
491
|
+
print_error(
|
|
492
|
+
console,
|
|
493
|
+
"Background terminals require --remote (a persistent server to host them) -- "
|
|
494
|
+
"there's no standalone equivalent yet. Use $ <command> or /run for one-off local commands.",
|
|
495
|
+
)
|
|
496
|
+
continue
|
|
497
|
+
arg = text[len("/pty"):].strip()
|
|
498
|
+
parts = arg.split(maxsplit=1)
|
|
499
|
+
sub = parts[0].lower() if parts else "list"
|
|
500
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
501
|
+
|
|
502
|
+
if sub == "start":
|
|
503
|
+
shell_command = rest.strip() or "bash"
|
|
504
|
+
try:
|
|
505
|
+
pty = await client.start_pty(workspace.session_id, shell_command=shell_command)
|
|
506
|
+
decision = resolve_approval_decision(
|
|
507
|
+
console, f"[background terminal] {shell_command}", str(pty.get("safety_tier", "medium")),
|
|
508
|
+
config.approval_policy, interactive=True,
|
|
509
|
+
)
|
|
510
|
+
if decision != "approve_once":
|
|
511
|
+
await client.deny_pty(pty["id"])
|
|
512
|
+
console.print("[dim]Terminal not started.[/dim]")
|
|
513
|
+
continue
|
|
514
|
+
pty = await client.approve_pty(pty["id"])
|
|
515
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
516
|
+
print_error(console, str(e))
|
|
517
|
+
continue
|
|
518
|
+
pty_offsets[pty["id"]] = 0
|
|
519
|
+
console.print(f"[green]Started background terminal[/green] {pty['id']} pid={pty.get('pid')}")
|
|
520
|
+
console.print(f"[dim]/pty send {pty['id']} <text> /pty read {pty['id']} /pty kill {pty['id']}[/dim]")
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
if sub == "list":
|
|
524
|
+
try:
|
|
525
|
+
sessions_list = await client.list_pty(workspace.session_id)
|
|
526
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
527
|
+
print_error(console, str(e))
|
|
528
|
+
continue
|
|
529
|
+
if not sessions_list:
|
|
530
|
+
console.print("[dim]No background terminals in this session. Use /pty start.[/dim]")
|
|
531
|
+
continue
|
|
532
|
+
table = Table(show_header=True, header_style="bold")
|
|
533
|
+
for column in ("ID", "STATUS", "COMMAND", "PID", "CREATED"):
|
|
534
|
+
table.add_column(column)
|
|
535
|
+
for item in sessions_list:
|
|
536
|
+
table.add_row(
|
|
537
|
+
str(item.get("id"))[:8], str(item.get("status")), str(item.get("shell_command"))[:40],
|
|
538
|
+
str(item.get("pid") or "-"), str(item.get("created_at") or "")[:19],
|
|
539
|
+
)
|
|
540
|
+
console.print(table)
|
|
541
|
+
continue
|
|
542
|
+
|
|
543
|
+
# Remaining subcommands (send/read/kill) take a target id as
|
|
544
|
+
# their first token; accept either the full UUID or the
|
|
545
|
+
# 8-character prefix /pty list shows.
|
|
546
|
+
id_parts = rest.split(maxsplit=1)
|
|
547
|
+
if not id_parts:
|
|
548
|
+
print_error(console, "Usage: /pty <start|list|send|read|kill> ...")
|
|
549
|
+
continue
|
|
550
|
+
prefix = id_parts[0]
|
|
551
|
+
payload = id_parts[1] if len(id_parts) > 1 else ""
|
|
552
|
+
matches = [pid for pid in pty_offsets if pid == prefix or pid.startswith(prefix)]
|
|
553
|
+
target_id = matches[0] if len(matches) == 1 else prefix
|
|
554
|
+
|
|
555
|
+
if sub == "send":
|
|
556
|
+
if not payload:
|
|
557
|
+
print_error(console, "Usage: /pty send <id> <text>")
|
|
558
|
+
continue
|
|
559
|
+
try:
|
|
560
|
+
await client.write_pty(target_id, payload)
|
|
561
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
562
|
+
print_error(console, str(e))
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
if sub == "read":
|
|
566
|
+
try:
|
|
567
|
+
result = await client.read_pty(target_id, since=pty_offsets.get(target_id, 0))
|
|
568
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
569
|
+
print_error(console, str(e))
|
|
570
|
+
continue
|
|
571
|
+
pty_offsets[target_id] = int(result.get("offset", 0))
|
|
572
|
+
if result.get("data"):
|
|
573
|
+
console.print(result["data"], end="")
|
|
574
|
+
if not result.get("alive", True):
|
|
575
|
+
console.print(f"\n[yellow]Terminal {target_id[:8]} is {result.get('status', 'exited')}.[/yellow]")
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
if sub == "kill":
|
|
579
|
+
try:
|
|
580
|
+
result = await client.kill_pty(target_id)
|
|
581
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
582
|
+
print_error(console, str(e))
|
|
583
|
+
continue
|
|
584
|
+
pty_offsets.pop(target_id, None)
|
|
585
|
+
console.print(f"[yellow]Terminal {target_id[:8]} {result.get('status', 'killed')}.[/yellow]")
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
print_error(console, "Usage: /pty <start|list|send|read|kill> ...")
|
|
589
|
+
continue
|
|
590
|
+
if text == "/permissions":
|
|
591
|
+
console.print(f"approval_policy={config.approval_policy}")
|
|
592
|
+
console.print("[dim]Server safeguards always enforce workspace scope, command risk classification, ownership, and approval state; client policy cannot widen them.[/dim]")
|
|
593
|
+
continue
|
|
594
|
+
if text == "/mode" or text.startswith("/mode "):
|
|
595
|
+
arg = text[len("/mode"):].strip().lower()
|
|
596
|
+
if not arg:
|
|
597
|
+
console.print(f"Current mode: [bold]{config.approval_policy}[/bold]")
|
|
598
|
+
console.print(
|
|
599
|
+
"[dim]/mode manual prompt for every risky action (the default)\n"
|
|
600
|
+
"/mode accept-edits auto-approve safe/medium-risk actions, still prompt for dangerous ones\n"
|
|
601
|
+
"/mode auto never prompt (server safeguards still apply)\n"
|
|
602
|
+
"/mode plan read-only: propose without executing[/dim]"
|
|
603
|
+
)
|
|
604
|
+
continue
|
|
605
|
+
resolved = MODE_ALIASES.get(arg, arg if arg in APPROVAL_MODES else None)
|
|
606
|
+
if resolved is None:
|
|
607
|
+
print_error(console, f"Unknown mode '{arg}'. Use manual, accept-edits, auto, or plan.")
|
|
608
|
+
continue
|
|
609
|
+
config.approval_policy = resolved
|
|
610
|
+
console.print(f"[green]Mode set to[/green] {arg} [dim]({resolved})[/dim]")
|
|
611
|
+
continue
|
|
612
|
+
if text == "/compact":
|
|
613
|
+
state = local_state.get_session_state(workspace.session_id)
|
|
614
|
+
summary = (state.active_task or {}).get("objective") or state.conversation_summary or "Session checkpoint"
|
|
615
|
+
local_state.checkpoint(workspace.session_id, reason="user_compact", summary=summary)
|
|
616
|
+
console.print("[green]Context checkpoint saved.[/green]")
|
|
617
|
+
continue
|
|
618
|
+
if text == "/doctor":
|
|
619
|
+
if standalone:
|
|
620
|
+
from .providers import get_provider_status as _get_provider_status
|
|
621
|
+
|
|
622
|
+
status = _get_provider_status()
|
|
623
|
+
table = Table(show_header=True, header_style="bold")
|
|
624
|
+
for column in ("PROVIDER", "CONFIGURED", "KEY"):
|
|
625
|
+
table.add_column(column)
|
|
626
|
+
for name, info in status["config"].items():
|
|
627
|
+
configured = "[green]yes[/green]" if info["api_key_set"] or name == "ollama" else "[dim]no[/dim]"
|
|
628
|
+
table.add_row(name, configured, info["key_preview"])
|
|
629
|
+
console.print(table)
|
|
630
|
+
console.print(
|
|
631
|
+
f"[dim]Currently selected: {provider_type.value if provider_type else provider} "
|
|
632
|
+
f"· auto would pick: {status['default']}[/dim]"
|
|
633
|
+
)
|
|
634
|
+
continue
|
|
635
|
+
await run_doctor(config, console, Path(workspace.workspace_root), session_id=workspace.session_id)
|
|
636
|
+
continue
|
|
637
|
+
if text == "/agents":
|
|
638
|
+
if standalone:
|
|
639
|
+
for sid in local_state.all_known_session_ids():
|
|
640
|
+
sess_state = local_state.get_session_state(sid)
|
|
641
|
+
marker = " *" if sid == workspace.session_id else ""
|
|
642
|
+
console.print(f" {sid} {sess_state.workspace_root or sess_state.primary_workspace}{marker}")
|
|
643
|
+
continue
|
|
644
|
+
try:
|
|
645
|
+
sessions_list = await client.list_sessions()
|
|
646
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
647
|
+
print_error(console, str(e))
|
|
648
|
+
continue
|
|
649
|
+
for sess in sessions_list:
|
|
650
|
+
marker = " *" if sess.get("id") == workspace.session_id else ""
|
|
651
|
+
console.print(f" {sess.get('id')} {sess.get('status')} {sess.get('working_directory') or ''}{marker}")
|
|
652
|
+
continue
|
|
653
|
+
if text == "/delegate" or text.startswith("/delegate "):
|
|
654
|
+
if not config.enable_subagent_delegation:
|
|
655
|
+
print_error(
|
|
656
|
+
console,
|
|
657
|
+
"Subagent delegation is disabled. Enable it with enable_subagent_delegation = true "
|
|
658
|
+
"in config.toml, or TAMFIS_CODE_ENABLE_SUBAGENT_DELEGATION=1.",
|
|
659
|
+
)
|
|
660
|
+
continue
|
|
661
|
+
arg = text[len("/delegate"):].strip()
|
|
662
|
+
descriptions = [part.strip() for part in arg.split("|") if part.strip()]
|
|
663
|
+
if not descriptions:
|
|
664
|
+
print_error(console, "Usage: /delegate <objective 1> | <objective 2> | ...")
|
|
665
|
+
continue
|
|
666
|
+
# Delegation always runs through the standalone local loop
|
|
667
|
+
# (agents.py's DelegatedCodingAgent) regardless of whether this
|
|
668
|
+
# REPL session itself is standalone or --remote -- there's only
|
|
669
|
+
# one implementation now (it was fully converted, not dual-mode).
|
|
670
|
+
from .agents import AgentManager
|
|
671
|
+
manager = AgentManager()
|
|
672
|
+
delegate_manager = provider_manager
|
|
673
|
+
delegate_provider = provider_type
|
|
674
|
+
if delegate_manager is None:
|
|
675
|
+
from .local_chat import resolve_provider_type as _resolve_provider_type
|
|
676
|
+
from .providers import ProviderManager as _ProviderManager
|
|
677
|
+
|
|
678
|
+
delegate_manager = _ProviderManager()
|
|
679
|
+
delegate_provider = _resolve_provider_type("auto")
|
|
680
|
+
results = await manager.execute_tasks(
|
|
681
|
+
descriptions, manager=delegate_manager, provider=delegate_provider, model=model,
|
|
682
|
+
console=console, workspace_root=workspace.workspace_root,
|
|
683
|
+
approval_policy=config.approval_policy,
|
|
684
|
+
)
|
|
685
|
+
for r in results:
|
|
686
|
+
marker = "✅" if r["status"] == "completed" else "❌"
|
|
687
|
+
console.print(f"{marker} {r['description']}")
|
|
688
|
+
summary = (r.get("result") or {}).get("summary") or (r.get("result") or {}).get("error")
|
|
689
|
+
if summary:
|
|
690
|
+
console.print(f" {summary}")
|
|
691
|
+
continue
|
|
692
|
+
if text == "/diffs" or text.startswith("/diffs "):
|
|
693
|
+
arg = text[len("/diffs"):].strip()
|
|
694
|
+
try:
|
|
695
|
+
limit = int(arg) if arg else 10
|
|
696
|
+
except ValueError:
|
|
697
|
+
print_error(console, f"'{arg}' is not a valid number.")
|
|
698
|
+
continue
|
|
699
|
+
if standalone:
|
|
700
|
+
mutations = local_state.get_session_state(workspace.session_id).modified_files[-limit:]
|
|
701
|
+
mutations = list(reversed(mutations))
|
|
702
|
+
else:
|
|
703
|
+
try:
|
|
704
|
+
result = await client.list_file_mutations(workspace.session_id, limit=limit)
|
|
705
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
706
|
+
print_error(console, str(e))
|
|
707
|
+
continue
|
|
708
|
+
mutations = result.get("mutations") or []
|
|
709
|
+
if not mutations:
|
|
710
|
+
console.print("[dim]No file mutations recorded yet in this session.[/dim]")
|
|
711
|
+
for m in mutations:
|
|
712
|
+
mutation_id = m.get("mutation_id") if standalone else m.get("id")
|
|
713
|
+
status = m.get("revert_status")
|
|
714
|
+
marker = " [reverted]" if status == "reverted" else (" [revert failed]" if status == "revert_failed" else "")
|
|
715
|
+
console.print(
|
|
716
|
+
f" {mutation_id} {m.get('operation')} {m.get('path')} "
|
|
717
|
+
f"+{m.get('lines_added')}/-{m.get('lines_removed')}{marker}"
|
|
718
|
+
)
|
|
719
|
+
continue
|
|
720
|
+
if text == "/diff" or text.startswith("/diff "):
|
|
721
|
+
mutation_id = text[len("/diff"):].strip()
|
|
722
|
+
if standalone:
|
|
723
|
+
mutations = local_state.get_session_state(workspace.session_id).modified_files
|
|
724
|
+
selected = next((item for item in mutations if item.get("mutation_id") == mutation_id), None) if mutation_id else (mutations[-1] if mutations else None)
|
|
725
|
+
if selected is None:
|
|
726
|
+
print_error(console, "Mutation not found." if mutation_id else "No file mutations recorded yet.")
|
|
727
|
+
else:
|
|
728
|
+
print_unified_diff(console, str(selected.get("unified_diff") or ""), title=str(selected.get("path") or "Changes"))
|
|
729
|
+
continue
|
|
730
|
+
try:
|
|
731
|
+
result = await client.list_file_mutations(workspace.session_id, limit=200 if mutation_id else 1)
|
|
732
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
733
|
+
print_error(console, str(e))
|
|
734
|
+
continue
|
|
735
|
+
mutations = result.get("mutations") or []
|
|
736
|
+
selected = next((item for item in mutations if str(item.get("id")) == mutation_id), None) if mutation_id else (mutations[0] if mutations else None)
|
|
737
|
+
if selected is None:
|
|
738
|
+
print_error(console, "Mutation not found." if mutation_id else "No file mutations recorded yet.")
|
|
739
|
+
else:
|
|
740
|
+
print_unified_diff(console, str(selected.get("unified_diff") or ""), title=str(selected.get("path") or "Changes"))
|
|
741
|
+
continue
|
|
742
|
+
if text == "/revert" or text.startswith("/revert "):
|
|
743
|
+
arg = text[len("/revert"):].strip()
|
|
744
|
+
if not arg:
|
|
745
|
+
print_error(console, "Usage: /revert <mutation_id> -- see /diffs for recent mutation ids.")
|
|
746
|
+
continue
|
|
747
|
+
if standalone:
|
|
748
|
+
try:
|
|
749
|
+
result = local_revert_mutation(workspace.session_id, arg)
|
|
750
|
+
except ValueError as e:
|
|
751
|
+
print_error(console, str(e))
|
|
752
|
+
continue
|
|
753
|
+
console.print(f"[green]Reverted[/green] {result.get('path')}")
|
|
754
|
+
continue
|
|
755
|
+
try:
|
|
756
|
+
result = await client.revert_file_mutation(workspace.session_id, arg)
|
|
757
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
758
|
+
print_error(console, str(e))
|
|
759
|
+
continue
|
|
760
|
+
console.print(f"[green]Reverted[/green] {result.get('path')}")
|
|
761
|
+
continue
|
|
762
|
+
if text == "/clear":
|
|
763
|
+
console.clear()
|
|
764
|
+
continue
|
|
765
|
+
if text == "/resume" or text.startswith("/resume "):
|
|
766
|
+
arg = text[len("/resume"):].strip()
|
|
767
|
+
if standalone:
|
|
768
|
+
known = local_state.all_known_session_ids()
|
|
769
|
+
if arg:
|
|
770
|
+
try:
|
|
771
|
+
target_id = int(arg)
|
|
772
|
+
except ValueError:
|
|
773
|
+
print_error(console, f"'{arg}' is not a valid session id.")
|
|
774
|
+
continue
|
|
775
|
+
if target_id not in known:
|
|
776
|
+
print_error(console, f"No known local session {target_id}. Use /agents to list known sessions.")
|
|
777
|
+
continue
|
|
778
|
+
else:
|
|
779
|
+
candidates = [sid for sid in reversed(known) if sid != workspace.session_id]
|
|
780
|
+
if not candidates:
|
|
781
|
+
console.print("[dim]No other sessions to resume.[/dim]")
|
|
782
|
+
continue
|
|
783
|
+
target_id = candidates[0]
|
|
784
|
+
target_state = local_state.get_session_state(target_id)
|
|
785
|
+
workspace = WorkspaceContext(
|
|
786
|
+
session_id=target_id,
|
|
787
|
+
workspace_root=target_state.workspace_root or target_state.primary_workspace,
|
|
788
|
+
)
|
|
789
|
+
console.print(f"[green]Resumed session {workspace.session_id}[/green] workspace_root={workspace.workspace_root}")
|
|
790
|
+
if target_state.conversation_summary:
|
|
791
|
+
console.print(f"[dim]{target_state.conversation_summary[-1000:]}[/dim]")
|
|
792
|
+
continue
|
|
793
|
+
try:
|
|
794
|
+
if arg:
|
|
795
|
+
try:
|
|
796
|
+
target_id = int(arg)
|
|
797
|
+
except ValueError:
|
|
798
|
+
print_error(console, f"'{arg}' is not a valid session id.")
|
|
799
|
+
continue
|
|
800
|
+
workspace = await context_from_session(client, target_id)
|
|
801
|
+
else:
|
|
802
|
+
target = await find_resumable_session(client, exclude_session_id=workspace.session_id)
|
|
803
|
+
if target is None:
|
|
804
|
+
console.print("[dim]No other sessions to resume.[/dim]")
|
|
805
|
+
continue
|
|
806
|
+
workspace = await context_from_session(client, target["id"])
|
|
807
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
808
|
+
print_error(console, str(e))
|
|
809
|
+
continue
|
|
810
|
+
console.print(f"[green]Resumed session {workspace.session_id}[/green] workspace_root={workspace.workspace_root}")
|
|
811
|
+
try:
|
|
812
|
+
thread = await client.get_thread(workspace.session_id)
|
|
813
|
+
print_recent_thread(console, thread.get("messages") or [])
|
|
814
|
+
except (AuthRequiredError, RemoteAPIError):
|
|
815
|
+
pass
|
|
816
|
+
continue
|
|
817
|
+
if text == "/retry" or text.startswith("/retry "):
|
|
818
|
+
if standalone:
|
|
819
|
+
if last_turn is None:
|
|
820
|
+
console.print("[dim]No previous turn in this session to retry.[/dim]")
|
|
821
|
+
continue
|
|
822
|
+
objective, mode = last_turn
|
|
823
|
+
repo_state = local_state.get_session_state(workspace.session_id).repository_context
|
|
824
|
+
if mode in {"coding", "agent", "execute"} and blocking_dirty_files(repo_state.get("dirty_files") or []):
|
|
825
|
+
print_error(console, "Existing uncommitted changes detected; retry is blocked to preserve user edits.")
|
|
826
|
+
continue
|
|
827
|
+
renderer = StreamRenderer(console)
|
|
828
|
+
outcome = await run_local_agent_turn(
|
|
829
|
+
provider_manager, provider_type, model, [{"role": "user", "content": objective}],
|
|
830
|
+
console, renderer,
|
|
831
|
+
workspace_root=workspace.workspace_root, session_id=workspace.session_id,
|
|
832
|
+
approval_policy=config.approval_policy, interactive=True,
|
|
833
|
+
read_only=mode in {"chat", "audit", "plan"},
|
|
834
|
+
)
|
|
835
|
+
renderer.finish()
|
|
836
|
+
if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
|
|
837
|
+
console.print(Markdown(outcome.summary))
|
|
838
|
+
if outcome.status != "completed":
|
|
839
|
+
print_error(console, outcome.error or f"task {outcome.status}")
|
|
840
|
+
console.print()
|
|
841
|
+
continue
|
|
842
|
+
arg = text[len("/retry"):].strip()
|
|
843
|
+
try:
|
|
844
|
+
if arg:
|
|
845
|
+
task_id = arg
|
|
846
|
+
else:
|
|
847
|
+
failed = await find_recent_task(client, workspace.session_id, only_status={"failed", "cancelled"})
|
|
848
|
+
if failed is None:
|
|
849
|
+
console.print("[dim]No recent failed task to retry.[/dim]")
|
|
850
|
+
continue
|
|
851
|
+
task_id = failed["id"]
|
|
852
|
+
renderer = StreamRenderer(console)
|
|
853
|
+
outcome = await retry_task_and_stream(
|
|
854
|
+
client, renderer, console,
|
|
855
|
+
session_id=workspace.session_id, task_id=task_id, mode=None,
|
|
856
|
+
approval_policy=config.approval_policy, interactive=True,
|
|
857
|
+
)
|
|
858
|
+
if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
|
|
859
|
+
console.print(Markdown(outcome.summary))
|
|
860
|
+
if outcome.status != "completed":
|
|
861
|
+
print_error(console, outcome.error or f"task {outcome.status}")
|
|
862
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
863
|
+
print_error(console, str(e))
|
|
864
|
+
console.print()
|
|
865
|
+
continue
|
|
866
|
+
|
|
867
|
+
# Short replies are meaningful in a stateful coding conversation.
|
|
868
|
+
# Expand them only when this session has prior task context so the
|
|
869
|
+
# server/model receives the intended reference instead of a bare
|
|
870
|
+
# token (and never reject them merely for being short).
|
|
871
|
+
reply_state = local_state.get_session_state(workspace.session_id)
|
|
872
|
+
text = contextualize_short_reply(
|
|
873
|
+
text,
|
|
874
|
+
has_context=bool(reply_state.last_task_id or reply_state.conversation_summary or reply_state.active_plan_id),
|
|
875
|
+
)
|
|
876
|
+
intent = parse_intent(text)
|
|
877
|
+
try:
|
|
878
|
+
if intent.kind == "shell":
|
|
879
|
+
if not intent.command:
|
|
880
|
+
continue
|
|
881
|
+
if standalone:
|
|
882
|
+
outcome = await run_local_shell_command(
|
|
883
|
+
console, workspace_root=workspace.workspace_root, session_id=workspace.session_id,
|
|
884
|
+
command=intent.command, approval_policy=config.approval_policy, interactive=True,
|
|
885
|
+
)
|
|
886
|
+
else:
|
|
887
|
+
outcome = await run_shell_command(
|
|
888
|
+
client, console,
|
|
889
|
+
session_id=workspace.session_id, command=intent.command,
|
|
890
|
+
approval_policy=config.approval_policy, interactive=True,
|
|
891
|
+
)
|
|
892
|
+
elif intent.kind == "saved_plan":
|
|
893
|
+
plan = local_state.get_plan(workspace.session_id, intent.command or None)
|
|
894
|
+
if plan is None:
|
|
895
|
+
print_error(console, "Plan not found. Use /plans to list saved plans.")
|
|
896
|
+
continue
|
|
897
|
+
repo_state = local_state.get_session_state(workspace.session_id).repository_context
|
|
898
|
+
if blocking_dirty_files(repo_state.get("dirty_files") or []):
|
|
899
|
+
print_error(console, "Existing uncommitted changes detected; plan execution is blocked to preserve user edits.")
|
|
900
|
+
continue
|
|
901
|
+
plan_id = str(plan["id"])
|
|
902
|
+
local_state.update_plan(workspace.session_id, plan_id, status="executing")
|
|
903
|
+
renderer = StreamRenderer(console)
|
|
904
|
+
model_state = local_state.get_session_state(workspace.session_id)
|
|
905
|
+
plan_objective = local_state.plan_execution_objective(plan)
|
|
906
|
+
if standalone:
|
|
907
|
+
outcome = await run_local_agent_turn(
|
|
908
|
+
provider_manager, provider_type, model, [{"role": "user", "content": plan_objective}],
|
|
909
|
+
console, renderer,
|
|
910
|
+
workspace_root=workspace.workspace_root, session_id=workspace.session_id,
|
|
911
|
+
approval_policy=config.approval_policy, interactive=True,
|
|
912
|
+
)
|
|
913
|
+
last_turn = (plan_objective, "execute")
|
|
914
|
+
else:
|
|
915
|
+
outcome = await run_ai_task_and_stream(
|
|
916
|
+
client, renderer, console,
|
|
917
|
+
session_id=workspace.session_id,
|
|
918
|
+
objective=plan_objective, mode="execute",
|
|
919
|
+
approval_policy=config.approval_policy, interactive=True,
|
|
920
|
+
model=model_state.selected_model, provider=model_state.selected_provider,
|
|
921
|
+
)
|
|
922
|
+
if standalone:
|
|
923
|
+
renderer.finish()
|
|
924
|
+
local_state.update_plan(
|
|
925
|
+
workspace.session_id, plan_id,
|
|
926
|
+
status="completed" if outcome.status == "completed" else "failed",
|
|
927
|
+
execution_task_id=local_state.get_session_state(workspace.session_id).last_task_id,
|
|
928
|
+
)
|
|
929
|
+
if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
|
|
930
|
+
console.print(Markdown(outcome.summary))
|
|
931
|
+
else:
|
|
932
|
+
if not intent.objective:
|
|
933
|
+
continue
|
|
934
|
+
if intent.mode == "execute":
|
|
935
|
+
repo_state = local_state.get_session_state(workspace.session_id).repository_context
|
|
936
|
+
if blocking_dirty_files(repo_state.get("dirty_files") or []):
|
|
937
|
+
print_error(console, "Existing uncommitted changes detected; execute mode is blocked to preserve user edits. Use /audit or /plan, or clean the worktree yourself.")
|
|
938
|
+
continue
|
|
939
|
+
renderer = StreamRenderer(console, objective=intent.objective)
|
|
940
|
+
model_state = local_state.get_session_state(workspace.session_id)
|
|
941
|
+
if standalone:
|
|
942
|
+
outcome = await run_local_agent_turn(
|
|
943
|
+
provider_manager, provider_type, model, [{"role": "user", "content": intent.objective}],
|
|
944
|
+
console, renderer,
|
|
945
|
+
workspace_root=workspace.workspace_root, session_id=workspace.session_id,
|
|
946
|
+
approval_policy=config.approval_policy, interactive=True,
|
|
947
|
+
read_only=intent.mode in {"chat", "audit", "plan"},
|
|
948
|
+
)
|
|
949
|
+
renderer.finish()
|
|
950
|
+
last_turn = (intent.objective, intent.mode)
|
|
951
|
+
if intent.mode == "plan" and outcome.status == "completed" and outcome.summary:
|
|
952
|
+
saved = local_state.save_plan(workspace.session_id, objective=intent.objective, content=outcome.summary)
|
|
953
|
+
console.print(f"[green]Plan saved[/green] · {saved.id} · run /execute-plan {saved.id}")
|
|
954
|
+
else:
|
|
955
|
+
outcome = await run_ai_task_and_stream(
|
|
956
|
+
client, renderer, console,
|
|
957
|
+
session_id=workspace.session_id, objective=intent.objective, mode=intent.mode,
|
|
958
|
+
approval_policy=config.approval_policy, interactive=True,
|
|
959
|
+
model=model_state.selected_model,
|
|
960
|
+
provider=model_state.selected_provider,
|
|
961
|
+
)
|
|
962
|
+
if outcome.status == "completed" and outcome.summary and not renderer.streamed_final_text:
|
|
963
|
+
console.print(Markdown(outcome.summary))
|
|
964
|
+
except AuthRequiredError:
|
|
965
|
+
if queued_item:
|
|
966
|
+
local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "failed")
|
|
967
|
+
print_error(console, "Not authenticated -- run `tamfis-code login` in another terminal, then retry.")
|
|
968
|
+
continue
|
|
969
|
+
except RemoteAPIError as e:
|
|
970
|
+
if queued_item:
|
|
971
|
+
local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "failed")
|
|
972
|
+
print_error(console, str(e))
|
|
973
|
+
continue
|
|
974
|
+
except Exception as e:
|
|
975
|
+
if queued_item:
|
|
976
|
+
local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "failed")
|
|
977
|
+
print_error(console, str(e))
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
if queued_item:
|
|
981
|
+
local_state.update_instruction(workspace.session_id, str(queued_item.get("id")), "completed")
|
|
982
|
+
|
|
983
|
+
if outcome.status not in ("completed",):
|
|
984
|
+
print_error(console, outcome.error or f"task {outcome.status}")
|
|
985
|
+
console.print()
|