How run() Works
A walk through the synchronous task client in src/kiss/server/sorcar.py
1. What it is
run() is the module-level function at the bottom of
src/kiss/server/sorcar.py (not a method of ServerApi — the module has no
class with a run method; the "run" entry in the API catalog is a
wire command name serviced by the generic forward handler). It is the
minimal synchronous Python client for the kiss-web daemon: any Python
process can call it to launch an agent task on an already-running daemon and block
until the task finishes.
from kiss.server import sorcar
result = sorcar.run("Summarize README.md", work_dir="/path/to/repo")
print(result.text, result.success, result.cost, result.tokens, result.steps)
# Continue the same chat (the agent sees the prior task as context):
follow_up = sorcar.run("Now fix the typos you found", chat_id=result.chat_id)
Under the hood it speaks the exact same protocol the VS Code extension uses:
newline-delimited JSON over the daemon's Unix-domain socket (UDS). No HTTP server,
no password, no extra dependency — access is gated purely by POSIX file permissions
(mode 0o600) on the socket file.
2. The big picture
run() is a plain blocking socket client. The daemon treats it exactly like a chat webview: same command, same event stream, same validation path.
The key idea: the daemon already knows how to run agent tasks for its user interfaces
(VS Code chat webview, remote webapp). run() simply pretends to be another
UI tab. It invents a synthetic tab id, sends the same {"type": "run", ...}
command a webview would send, filters the daemon's broadcast event stream down to that
tab, and converts the terminal events into a TaskResult.
3. Signature and parameters
def run(
prompt: str,
*,
work_dir: str = "",
model: str = "",
chat_id: str = "",
tools: str | Path | None = None,
use_worktree: bool = True,
auto_commit: bool = True,
max_budget: float | None = None,
model_config: dict[str, Any] | None = None,
web_tools: bool | None = None,
is_parallel: bool = True,
timeout: float = 3600.0,
sock_path: str | Path | None = None,
) -> TaskResult:
| Parameter | Meaning |
|---|---|
prompt | The task instruction. Must be non-blank, otherwise ValueError. |
work_dir | Working directory for the task; the daemon's default when empty. |
model | Model name; the daemon's selected default when empty. |
chat_id | Existing chat session to continue — the agent sees prior tasks of that chat as context. Empty starts a fresh chat. |
tools | Path to a Python file supplying extra agent tools. The daemon imports the file — the functions are never serialized; they execute in the daemon process. The file must define a top-level get_tools() function returning the functions the agent may call. |
use_worktree | Run in an isolated git worktree (default True). |
auto_commit | Auto-commit changes on success (default True). |
max_budget | Per-task USD budget override; None uses the daemon default. |
model_config | Per-task custom model endpoint/headers; must be JSON-serializable. |
web_tools | Per-task browser-tool enablement override. |
is_parallel | Whether the agent may spawn parallel sub-agents (default True). |
timeout | Maximum seconds to wait (default 3600). Enforced client-side as a wall-clock deadline. |
sock_path | UDS path override. Precedence: this argument → $KISS_SORCAR_SOCK → $KISS_HOME/sorcar.sock (resolved by _resolve_sock_path). |
4. The five phases, step by step
run() call.Phase 1 — Validation and setup
if not prompt or not prompt.strip():
raise ValueError("prompt must be a non-empty string")
tools_file = resolve_tools_file(tools)
path = _resolve_sock_path(sock_path)
tab_id = f"api-{uuid.uuid4().hex}"
deadline = time.monotonic() + timeout
- Blank prompts are rejected immediately.
resolve_tools_file()checks thattools, if given, is an existing.pyfile, and resolves it against the client's working directory (raisingValueErrorotherwise).- A synthetic, globally-unique tab id like
api-3f9c…is generated. This is how the client later recognizes its own task's events in the daemon's shared event stream. - The timeout is captured once as an absolute
time.monotonic()deadline, so it stays correct across many socket reads.
Phase 2 — Connect
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(min(timeout, 10.0))
try:
sock.connect(str(path))
except OSError as exc:
raise ConnectionError(
f"Cannot connect to the sorcar daemon at {path}: {exc} "
f"— start it with `kiss-web`."
) from exc
A plain blocking Unix-domain stream socket — no asyncio in the client. Connection attempts
are capped at 10 seconds (or less, if the overall timeout is smaller). A failure is
re-raised as a ConnectionError with an actionable hint (start it with
`kiss-web`).
Phase 3 — Send the run command
cmd = {
"type": "run",
"prompt": prompt,
"tabId": tab_id,
"taskId": uuid.uuid4().hex,
"chatId": chat_id,
"workDir": work_dir,
"model": model,
"toolsFile": tools_file,
"useWorktree": use_worktree,
"autoCommit": auto_commit,
"maxBudget": max_budget,
"modelConfig": model_config,
"webTools": web_tools,
"useParallel": is_parallel,
}
sock.sendall(json.dumps(cmd).encode("utf-8") + b"\n")
One JSON object, one line, newline-terminated — the daemon's UDS framing. On the daemon side
this passes through ServerApi.dispatch(): the API catalog entry
ApiCommand("run", required=("prompt",)) validates it, the connection id is stamped,
and the default "forward" handler hands it to the backend agent server
(VSCodeServer), which actually starts the agent.
Phase 4 — Stream events until the task ends
reader = sock.makefile("rb", buffering=_MAX_LINE_BYTES)
result_event = None
task_id = ""
started = False
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(...)
sock.settimeout(remaining)
line = reader.readline(_MAX_LINE_BYTES)
...
The socket is wrapped in a buffered binary reader with a 64 MiB buffer
(_MAX_LINE_BYTES), deliberately matching the daemon-side frame limit — the daemon
emits large single-line events (e.g. system_prompt carrying the full SYSTEM.md),
and a smaller client cap would split a frame into fragments that get discarded as invalid
JSON. Each loop iteration re-arms the socket timeout with the time remaining until the
deadline. The loop is covered in detail in the next section.
Phase 5 — Cleanup (always)
Covered in section 7: a best-effort closeTab is sent and both the buffered reader and the socket are closed, on every exit path.
5. The event stream and how completion is detected
The daemon broadcasts events for all activity on the connection; the client keeps only what belongs to its synthetic tab and reacts to four event types:
result event alone does not end the loop; the trailing status running:false does.Filtering and robustness rules inside the loop
- Unparseable lines are skipped. A line that fails
json.loadsor UTF-8 decoding is silently ignored (continue) — one bad frame never kills the run. - Foreign events are skipped. Anything that is not a dict, or whose
tabIddiffers from the client's synthetic tab id, is ignored. This is what makes it safe for the daemon to share one connection's stream across activity. - EOF is an error. An empty read means the daemon closed the connection mid-task →
ConnectionError. - Oversized frames fail loudly. If
readline()returns a full-size 64 MiB chunk without a trailing newline, the daemon sent a frame beyond the client cap. Silently skipping the fragments could discard the terminalresultevent and misreport a successful task as failed — so the client raisesConnectionErrorinstead.
What each event type does
Event type | Client reaction |
|---|---|
clear | The daemon opened/attached the chat session. Its chat_id is captured (kept only if non-empty) so the caller can continue the chat later. |
any non-status event carrying taskId | The persisted task_history row id is captured — usable later to look the run up in the daemon's history. |
result | Stashed as the candidate final outcome — but the loop keeps going. |
status with running: true | Sets started = True: the task has actually begun on this tab. |
status with running: false (after started) | The completion signal. The stashed result event (possibly None) is converted via _to_task_result() and returned. |
status running:false instead of returning on result?
The status transition is the daemon's authoritative "this tab's task is over" signal.
The two-flag handshake (started must be seen first) also protects against a stale
running:false that could arrive before the task starts — without it, the client
could return an empty failure instantly. And a task can end without ever emitting a
result (e.g. stopped, or the daemon has no model configured); the client still returns
cleanly, with an empty, unsuccessful TaskResult that nevertheless carries any
chat_id/task_id it managed to observe.
6. Building the TaskResult
_to_task_result(event, chat_id, task_id) converts the final result event:
@dataclass(frozen=True)
class TaskResult:
text: str # "summary" preferred over raw "text"
success: bool # daemon-parsed success flag from the agent's YAML result
cost: float # parsed by _parse_cost: "$0.1234", "N/A", or a number → USD float
tokens: int # event "total_tokens"
steps: int # event "step_count"
chat_id: str = "" # from the "clear" event; pass back to run() to continue the chat
task_id: str = "" # persisted task_history row id
textprefers the daemon-enrichedsummaryfield, falling back to the rawtext._parse_cost()tolerates all three shapes the daemon may send — a number, a"$0.1234"string, or"N/A"— returning0.0when unparseable.- If the task ended with no
resultevent at all, everything defaults to empty/zero/False, butchat_idandtask_idare still filled in when observed.
7. Cleanup: the finally block
Three best-effort actions run on every exit path — normal return, timeout, connection error, even a failed connect:
- Send
closeTab. Daemon tabs are global state shared by every client, and a disconnect no longer tears them down. The synthetic tab belongs to this client alone, so it explicitly asks the daemon to close it (with a short 5-second send timeout). For a still-running task (the timeout case) this merely flips the tab'sfrontend_closedflag — the task keeps running and its state is disposed when it ends; for a finished task the state is disposed immediately. - Close the buffered reader first.
sock.makefile()holds an independent reference to the socket descriptor. If a caller retains a raised exception whose traceback pins this stack frame, closing only the socket object would leave the reader — and its multi-MiB buffer — alive. Closing the reader first also lets the daemon promptly see EOF. - Close the socket. Each step swallows
OSError, since the daemon may already be gone.
8. Failure modes
| Exception | When |
|---|---|
ValueError | Empty/blank prompt, or tools is not an existing .py file. |
ConnectionError | No daemon listening on the socket; the daemon closed the connection before the task finished; or the daemon sent a frame larger than the 64 MiB client limit. |
TimeoutError | The task did not finish within timeout seconds — checked both proactively (deadline elapsed before a read) and via the socket read timing out. The task itself is not cancelled; only the client detaches (after the closeTab courtesy). |
TimeoutError the agent task keeps running
inside the daemon. The client only stops watching. If you need to actually stop the task, send
the daemon a stop command for that tab (e.g. via a UI attached to the same daemon).
9. Subtle design details worth knowing
- Matched frame limits. The client's
_MAX_LINE_BYTES(64 MiB) deliberately equals the daemon'sweb_server._MAX_LINE_BYTES. If the client cap were smaller, an oversizedresultframe would be split, each fragment dropped as invalid JSON, and a successful task would be reported as an empty failure. - Tools run in the daemon, not the client. Passing
tools="my_tools.py"sends only the resolved path; the daemon imports the file itself. Nothing is pickled — the functions execute in the daemon process like native agent tools. - Monotonic deadline. Using
time.monotonic()once and re-armingsock.settimeout(remaining)before every read makes the overall timeout immune to wall-clock adjustments and to the number of intermediate events received. - Chaining runs. Because
TaskResult.chat_idis returned, callers can build multi-turn workflows: each subsequentrun(..., chat_id=prev.chat_id)executes in the same chat, so the agent sees earlier tasks and results as context. - No special daemon path. On the server side the command travels the ordinary route:
ServerApi.dispatch()→ catalog validation (ApiCommand("run", required=("prompt",))) → connection stamping (connId,workDirpinning) → the defaultforwardhandler → the backendVSCodeServer. The Python client gets exactly the behavior a human gets from the chat UI. - Local-only by design. The UDS transport skips the WSS password handshake entirely; the socket file's
0o600permissions restrict access to the owning user, which is the whole authentication story for this client.