How run() Works

A walk through the synchronous task client in src/kiss/server/sorcar.py

Contents
  1. What it is
  2. The big picture
  3. Signature and parameters
  4. The five phases, step by step
  5. The event stream and how completion is detected
  6. Building the TaskResult
  7. Cleanup: the finally block
  8. Failure modes
  9. Subtle design details worth knowing

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

Your Python process sorcar.run(prompt) blocking socket client (no asyncio, no HTTP) "run" command event stream UDS · JSON lines $KISS_HOME/sorcar.sock kiss-web daemon (started separately) RemoteAccessServer UDS + WSS transports ServerApi.dispatch() validates against API catalog VSCodeServer runs the agent task, emits events per tab forward
Figure 1 — 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:
ParameterMeaning
promptThe task instruction. Must be non-blank, otherwise ValueError.
work_dirWorking directory for the task; the daemon's default when empty.
modelModel name; the daemon's selected default when empty.
chat_idExisting chat session to continue — the agent sees prior tasks of that chat as context. Empty starts a fresh chat.
toolsPath 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_worktreeRun in an isolated git worktree (default True).
auto_commitAuto-commit changes on success (default True).
max_budgetPer-task USD budget override; None uses the daemon default.
model_configPer-task custom model endpoint/headers; must be JSON-serializable.
web_toolsPer-task browser-tool enablement override.
is_parallelWhether the agent may spawn parallel sub-agents (default True).
timeoutMaximum seconds to wait (default 3600). Enforced client-side as a wall-clock deadline.
sock_pathUDS path override. Precedence: this argument → $KISS_SORCAR_SOCK$KISS_HOME/sorcar.sock (resolved by _resolve_sock_path).

4. The five phases, step by step

1. Validate prompt, tools file 2. Connect UDS, ≤10 s connect 3. Send "run" one JSON line 4. Stream filter events by tab 5. Cleanup closeTab, close sock (runs on every exit path)
Figure 2 — The lifecycle of one 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

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:

run() client daemon {"type":"run", "tabId":"api-…", "prompt":…} {"type":"clear", "chat_id":…} → remember chat_id {"type":"status", "running":true} → started = True …progress events (step, tool output, …)… any non-status event with "taskId" → remember task_id {"type":"result", "success":…, "summary":…, "cost":…} → stash it {"type":"status", "running":false} → task is over return TaskResult {"type":"closeTab", "tabId":"api-…"} (finally block)
Figure 3 — One successful run, as seen on the wire. Note that the result event alone does not end the loop; the trailing status running:false does.

Filtering and robustness rules inside the loop

What each event type does

Event typeClient reaction
clearThe 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 taskIdThe persisted task_history row id is captured — usable later to look the run up in the daemon's history.
resultStashed as the candidate final outcome — but the loop keeps going.
status with running: trueSets 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.
Why wait for 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

7. Cleanup: the finally block

Three best-effort actions run on every exit path — normal return, timeout, connection error, even a failed connect:

  1. 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's frontend_closed flag — the task keeps running and its state is disposed when it ends; for a finished task the state is disposed immediately.
  2. 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.
  3. Close the socket. Each step swallows OSError, since the daemon may already be gone.

8. Failure modes

ExceptionWhen
ValueErrorEmpty/blank prompt, or tools is not an existing .py file.
ConnectionErrorNo 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.
TimeoutErrorThe 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).
Timeout ≠ cancellation. On 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