35 KiB
AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project overview
Plyngent is an LLM chat and agent toolkit (Python 3.14+, PDM-managed). Single-user CLI is usable: protocol clients, config, async memory, agent tool loop, workspace tools, skill discovery, and REPL/one-shot chat. Multi-tenant web (web/) remains Phase H.
Commands
pdm install # first-time dependency setup
pdm sync # sync after pulling changes
pdm run ruff check . # linting
pdm run ruff format . # apply formatting
pdm run ruff format --check . # CI: fail if unformatted (do not skip)
pdm run basedpyright . # type checking (basedpyright, "recommended" strictness)
pdm run pytest # tests (pytest-asyncio auto mode)
# Commit gateway (prek — https://prek.j178.dev/): ruff check/format + basedpyright
uv tool install prek # once
prek install # wire git pre-commit hook (once per clone)
prek run --all-files # run all hooks on demand
GitHub Actions runs ruff check, ruff format --check, basedpyright, then pytest. Local commits run the same checks via prek.toml so ruff format is not forgotten.
Architecture
Data modeling: msgspec.Struct
All protocol models use msgspec.Struct — not dataclasses, not Pydantic. Optional fields use msgspec.field(default=UNSET) / default=UNSET with type T | Unset.
typedef.py: Unset = UnsetType (plain assignment so msgspec recognizes it; do not use PEP 695 type Unset = ...). Multi-struct unions must be tagged via tag_field / tag (e.g. role, type) for decode. JSONSchema is dict[str, Any].
Protocol layering (lmproto/)
lmproto/openai_compatible/ ← Base: model, config, client
lmproto/openai/ ← OpenAI platform: Responses + chat (extends base)
lmproto/anthropic/ ← Anthropic Messages: model, config, client
lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
lmproto/deepseek/responses/ ← DeepSeek Responses client (extends OpenAI Responses client; SSE stops at terminal events, no `[DONE]`)
lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends AnthropicClient; no `GET /models`)
openai_compatible/model.py— tagged chat messages (SystemChatMessage,UserChatMessage, …), tools, request/response, streaming chunks.openai_compatible/client.py—BaseOpenAIClient/OpenAICompatibleClientvianiquestsasync + SSE;chat_completions,modelsonly.openai_compatible/config.py—OpenAIConfig(token + base URL).openai/model.py— OpenAI Responses API (ResponsesCreateParam,Response, function_call items, stream events).openai/client.py— platformOpenAIClient(kind="responses"): chat completions +responses/get_response/delete_response.anthropic/model.py— Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (thinking,server_tool_use,web_search_tool_resultwith its verbatimencrypted_content), andtoolsaccepts opaque server-tool dicts.AnthropicRawContentBlocknames the same fields on the streaming payloads, and an assistant message'scontentaccepts the opaque block dicts it echoes back.anthropic/client.py—AnthropicClient(kind="messages"):POST /messages+GET /models.- DeepSeek —
DeepseekOpenAIClient(kind="chat_completions"); models addreasoning_content,prefix,ThinkingOptions, and it injects thethinkingflag (enabled, ordisabledforreasoning_effort = "none") after encoding, plus an emptyreasoning_contenton any assistant message that carries none (thinking mode refuses any round — a call or a plain answer — whose reasoning never came back). Config default model ids:deepseek-flash,deepseek-v4-pro.DeepseekResponsesClient(kind="responses") extends the OpenAI Responses client forconvention = "responses"(SSE stops at terminal events — DeepSeek sends no[DONE]).DeepseekAnthropicClient(kind="messages") extendsAnthropicClientforconvention = "anthropic"(basehttps://api.deepseek.com/anthropic;models()returns[]).
Config (config/)
TOML load/store (ConfigStore): [providers] tagged union presets, [database] section. Default path via platformdirs. Providers may set timeout as a float or { connect, read } (HttpTimeoutConfig); omitted → product defaults (10s connect / 600s read). DeepseekProvider.convention (Literal: ""/"openai"/"anthropic"/"responses") selects the DeepSeek API surface — "responses" → OpenAI Responses, "anthropic" → Anthropic Messages on https://api.deepseek.com/anthropic (default url follows the convention); a per-model ModelConfig.convention overrides it (empty = inherit). [mcp] (McpConfig): servers.<name> stdio server definitions (McpServerConfig: command/args/env/cwd/timeout/read_only, url reserved for streamable HTTP) + disable gate; defining a server opts it in. [skills] (SkillsConfig): paths (explicit roots first), discover (plyngent user dir + claude dirs) with include_project_roots, enabled, and allow_write (standing write grant); a malformed section degrades to defaults like [agent] / [mcp].
reasoning.py: thinking strength as reasoning_effort (ReasoningEffortConfig: "" = send nothing | none … high … max) and thinking_budget_tokens, settable on [agent], a provider, or a model entry — resolve_reasoning(agent, provider, model) merges the layers per field (model > provider > agent) and clamps max (DeepSeek chat only) to xhigh for every other surface, returning a ReasoningConfig the agent loop and create_client consume.
Runtime (runtime/)
create_client(provider, *, model=None, reasoning=None) maps config Provider → protocol client by effective preset (model-level preset/url overrides apply); reasoning (resolved ReasoningConfig) carries the Anthropic thinking_budget_tokens onto AnthropicConfig — the other surfaces read the effort off the request param — and is derived from the provider/model layers alone when omitted. normalize_http_timeout feeds OpenAI-compatible configs and Anthropic. Mapping: openai → OpenAIClient (agent uses Responses), openai-compatible / deepseek → chat-completions clients (deepseek with effective convention = "responses" → DeepseekResponsesClient, "anthropic" → DeepseekAnthropicClient), anthropic → AnthropicClient.
MCP (runtime/mcp_client.py): McpManager owns one McpServerConnection per [mcp] server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, tools/list (cursor pagination), tools/call, notifications/tools/list_changed → tools_stale. Spawn/handshake failures record connection.error (status string), never raise; ensure_started / restart(config) / aclose / refresh_tools; statuses() → (server, status, tool_count) rows; instructions() → (server, text) for every connected server that returned optional InitializeResult.instructions (usage guidance captured per connection); per-request timeout from config (default 30s).
Skills (skills/)
Discovery and parsing, independent of the agent: build_roots(config, workspace=…, user_config_dir=…, home=…) resolves [skills] into ordered SkillRoots (config explicit paths → plyngent user dir → claude user/project dirs), SkillStore scans each root for directories with a SKILL.md (a root may itself be one) and parses the frontmatter subset (frontmatter.py) on every access — nothing is cached, so an edit or a new skill made outside the chat is simply there. skills returns the visible records sorted by name (first root wins), shadowed() the losing copies, find(name) exact-then-case-insensitive, default_write_root() our own directory (or the first root); resolve_inside / resolve_skill_file keep every read and write inside a skill directory (absolute paths and symlink escapes are refused). Records carry issues (unusable declared name, no description, unparseable frontmatter) so a repaired third-party skill stays visible instead of being silently fixed.
Memory (memory/)
Async SQLAlchemy + aiosqlite. MemoryStore: schema init (+ lightweight SQLite ALTER for new columns; PRAGMA user_version 4), default local user, sessions (bound to workspace path; optional provider_name/model; todo_stack + access_grants JSON columns), messages stored as msgspec chat message JSON.
Agent (agent/)
- Kind-based dispatch: clients expose
kind(chat_completions|responses|messages). Agent history stays chat-completions-shaped; transport adapters return syntheticChatCompletionResponse/ stream chunks. responses_bridge/responses_dispatch: chat ↔ OpenAI Responses; stream text/reasoning deltas (response.reasoning_text.donerepeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage onresponse.completed; a request carryingtoolsreturns every round's reasoning as areasoningitem ofreasoning_textparts — "必须完整回传reasoning_content… 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round'sfunction_callitems stay contiguous ahead of their outputs (call, output, call, outputwas a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item'scontentparts.messages_bridge/messages_dispatch: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments;thinkingfrom the request'sreasoning_effort(level → token budget, floor 1024,max_tokensgrown when the budget would leave no room) plus the client's configuredthinking_budget_tokens; a server tool (server_tool_use+ itsweb_search_tool_result) is the surface's own business — it streamsinput_json_deltafragments like a call but nothing local asked for it, so only atool_useblock becomes a tool call and nothing is executed;thinkingand the two search blocks are kept asAssistantChatMessage.provider_blocks(the thinking text also lands inreasoning_content, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (compat.coerce_chat_messages).- Provider-side tools:
provider_toolson the OpenAI and DeepSeek presets (list of dicts;[{type="web_search"}]when omitted;[]disables) threaded viaChatAgent/run_chat_loopinto Responses and Messages; never executed byToolRegistry. The Messages surface declares its own server tool instead — a bareweb_searchbecomes{type="web_search_20250305", name="web_search"}(the search then runs inside the response, and itsencrypted_contentresults go back verbatim with the round —AssistantChatMessage.provider_blocks) — and a DeepSeek provider on a surface that ignores hosted tools (config.routing.local_web_search) gets the localweb_searchtool fromtools/net/search.pyinstead. @tool/ToolRegistry: decorator infers JSON Schema from type hints; execute tools by name. Before running,executeanswers a call that omits a required argument with the accepted names (_missing_required_error) — the handler's bareTypeErrornever reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a present argument are the tool's own job (see thechatbullet).@tool(writes=("path",))names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.run_chat_loop: multi-round tool loop; default streaming text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (API-calibrated after first usage when available); cooperative cancel points; optionalon_limit; resolvedreasoning_efforton every request (see Config).ChatAgent: optionalMemoryStore(user message persisted immediately; completed tool batches checkpointed mid-turn; unfinished assistant suffix rolled back on failure);stream; system prompt;provider_tools;retry()continues incomplete turns (user-only or after committed tools — does not re-run those tools)./compact: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → new session seeded with summary message. Soft-compact keeps the last 12 tool results full-size by default (DEFAULT_RECENT_TOOL_RESULTS); older tool payloads shrink first.- Events: text_delta, reasoning_delta, assistant_message, tool_call/result, max_rounds, error (
retryable/source), cancelled (reason), usage (TokenUsage). - Usage: API
usagefrom completions (stream withinclude_usage); char≈token fallback (~4 chars/token) when omitted; context size = last requestprompt_tokens(API preferred);last_turn_usage/session_usageare billed sums (tool rounds re-send history); CLI end-of-turn +/status. - Config
[agent]:system_prompt(persona; defaultDEFAULT_SYSTEM_PROMPT;""omits persona),tool_directives(tool playbook; defaultDEFAULT_TOOL_DIRECTIVES;""omits playbook; both empty → no system),mcp_instructions(fold connected MCP servers' initializeinstructionsinto the system prompt when tools on; default true),allow_paths(static out-of-workspace pre-allowpath → read|write|exec; never prompts),auto_continue_limits(auto-raise tool/PTY limits without prompting; default false),reasoning_effort/thinking_budget_tokens(thinking strength; see Config),max_tool_result_chars,parallel_tools,confirm_destructive,path_denylist,max_context_tokens(default 200k est. tokens). Truncated tool results carry a model hint ([Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]) so the model can ask the user to raisemax_tool_result_chars; request-time compact shrinks (hint=False) keep the terse...[truncated N characters]marker.
Tools (tools/)
Module-level @tool handlers. Call set_workspace_root() before use.
workspace: path resolve under primary root or temporary allowlist roots or a directory-access grant (mode-aware:read<write<exec, default requiredwrite; a file grant matches only that file); path substring denylist (wins over grants); command denylist viamatch_command_denylist(checks every program of the wrapper/runchain, resolved bytools/command_scan.py; an entry is a program, plus optional argument words that make it dangerous —systemctl reboot poweroff …matchessystemctl rebootandsystemctl start reboot.target, notsystemctl status; basenames are lower-cased, soRM/rm.execount; defaults cover privilegesudo/su/doas/pkexec/run0/systemd-run, destructiverm/mkfs/dd/chown/mount/…, and power stateshutdown/reboot/poweroff/halt/init/telinit/kexec/pm-*/s2*; CLI: timed human allow override, default deny on timeout; not skipped by YOLO); escape error names therequest_directory_accessmode the call needs (call request_directory_access with mode='write').access(tools/access.py):request_directory_access(LOCAL|INSTANCE|SESSION) asks the host hook for out-of-workspace access; grants are onepath → modepair in one of three stores —WorkspacePolicy.config_allow(static[agent].allow_paths),SessionState.access_grants(human-approved, persisted on the session row),WorkspacePolicy.yolo_allow(--yes//yolo, process only).AccessDecision(mode, persist)from the hook; denylisted paths can never be granted; capMAX_ACCESS_GRANTS. Not a sandbox:execgrants (and any command) reach the whole FS.file:read_file(with_lineno,max_charsdefault 32k; results start with a 1-basedL{begin}-{end}range),write_file(refuses to replace an existing file unlessoverwrite=true; that replacement is the one soft-confirmed),listdir,tree(model picksformat=markdowndefault /flatpaths /decorated; VCS + default noise dirs + optionalskip_dirs/ denylist walk),glob_paths,regex_files(Pythonreper line, not a glob; literal file/dirpath, no glob expansion; skips VCS/binary),edit_replace(max_replaces, reports remaining matches),edit_lineno(1-based range; only lines read viawith_lineno; read state resets on any file write/edit/copy/move/delete),copy_path/move_path/delete_path,new_temporary_workspace(system temp allowlist; cleanup on chat exit; no session rebind),get_truncated(resume any truncated result via itstruncate_token=...).process:run_argv(argv, no shell, timeout, optional stdin/env);run_argv_batch(serial steps,pipe_outon provider,mix_stderr,stop_on_error); PTYopen_pty/read_pty/write_pty(literal) /write_pty_keys(escapes) /ask_into_pty(human→PTY only;secretno-echo; answer never in tool result) /close_pty(POSIX:pty+fork; Windows: ConPTY viapywinptyenv marker dep).- PTY: backend in
pty_backend.py; structured status (alive/exit_code/data);read_pty(..., until=)(CSI sanitized so host TTY is never reset); keys viawrite_pty_keysonly (\xHH,ctrl+x,key=esc); secrets viaask_into_pty(notwrite_ptydata); session limit/idle TTL/output budget; close terminate→kill. - CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. The prompt accepts
y/empty (once),n, oryyy= stop asking for the rest of the turn (cli.limitsmodule flag, reset byrun_turn_with_retries,/btw, session switches, and chat exit).--auto-continue/[agent] auto_continue_limitsset the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa. - Destructive confirms:
classify_danger+ToolRegistry(on_confirm=…); CLI default deny; configconfirm_destructive/path_denylist. Soft confirm also for interpreters — bare shells/REPLs, scripts, and-cone-liners alike (-cis code only for an interpreter, sogrep -c/od -cstay unflagged; argv + code preview) —, detached runners (nohup), and wrapper options that carry a command line (env -S "…",su -c "…", whose text is printed under its ownenv -S:label), each resolved past env wrappers and<launcher> run; deny may include a user comment for the model. Session YOLO:/yolo on|off|onceand--yes(skip soft confirms; hard denylists unchanged;onceexpires after the next user turn). vcs: read-only VCS tools (vcs_kind/vcs_status/vcs_diff/vcs_log/vcs_branch) viaVcsBackendprotocol; git implemented; detectors are pluggable for other systems.chat: human prompts as tools —ask_user_line/ask_user_choice/ask_user_form(sharedpromptingcore);wait(line prompt with timeout; Enter disturbs). Container args are shape-checked (tools/chat/shape.py): a mis-typed stringoptions/fields(or a non-stringquestion) returns an explained error —options="a, b"used to iterate characters into a per-character menu.wait'sdurationaccepts numeric strings ("5"= 5s) and rejects booleans/text/containers.todo: LIFO stack of task groups (not a queue of tasks) —todo_pushcreates one group of siblings;todo_popremoves the whole top group;todo_updateby the number a listing shows (render order, TOP group first; the storedidstays internal); DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work ([TODO OPEN WORK]); all-terminal non-empty = hygiene ([TODO HYGIENE]); nag channel via[agent] todo_nag_strategy(developer/user= prose nag;synthetic_tool= forgedtodo_listcall + realstack.render()result + ToolCall/Result events;none).net/fetch: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (asynciogetaddrinfo). Private/loopback hosts need instance policy grant (CLI timed confirm; not YOLO). Public cleartext HTTP and mutating methods soft-confirm (YOLO/TRUSTABLE). HTTPS→HTTP redirects blocked unlessallow_http_downgrade.user_agenttool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.net/web_search(tools/net/search.py,tools/net/bing.py): the hosted search a DeepSeek provider cannot use, as a local tool.build_web_search_tool(backends)closes over the sources named byprovider.search_sourcesand asks them in fallback order (search_in_order):DeepseekSearchBackend(one/messagesturn with the server tool,lmproto/deepseek/anthropic/search.py) and/orBingSearchBackend(Bing's RSS results, snippets included, fetched through the same client asfetch). The handler returns onetitle — urlentry per hit (plus the snippet when the source has one); a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only whenconfig.routing.local_web_searchsays so: a DeepSeek provider whoseprovider_toolsask forweb_search, on a surface that ignores hosted tools (conventionother thananthropic), with a non-emptysearch_sources. Tags LOCAL|READ_ONLY.mcp(tools/mcp.py): register one server's advertised tools into the catalog as namespacedmcp__<server>__<tool>definitions withToolSource(kind="mcp", plugin_id=server); tags LOCAL (+ READ_ONLY when the server config setsread_only) — never YOLO/TRUSTABLE.register_mcp_toolspurges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started byMcpManager.skills(tools/skills.py): discovery, reading, and writing of skill directories (plyngent.skillsowns roots, theSKILL.mdfrontmatter subset, the uncached store, and the inside-the-skill path rule).skill_list(names, source root, directory, description, shadowed copies, frontmatter notes),skill_read(aSKILL.mdby name, or one bundled file — reusesread_filefor ranges/line numbers/truncate_token),skill_search(regex over every visible skill viaregex_files, hits rebased on<skill>/<file>),skill_create(newSKILL.md+ optionalfiles),skill_edit(replace /append/ rewrite one file; a rewrittenSKILL.mdis re-parsed and notes a lost description). Reads need no grant (the host publishes the roots asWorkspacePolicy.static_read, soread_file/regex_filessee them while writes stay denied); writes go throughSkillWritePolicyon the instance — denylist first, thenSessionState.skill_write_roots(asessionanswer), then[skills].allow_write, then the host's confirm hook (CLIprompt_skill_write_confirm:once/session/no, timed, default deny, not skipped by YOLO); a host with no hook denies. Grants are per skill directory;[skills].enabled = falsedrops the tools from the registry (SKILL_TOOL_NAMES).DEFAULT_TOOLS/ catalog: access + file + process + vcs + chat + todo + skills + net (fetch) for local surface.ToolTag.READ_ONLYmarks non-mutating tools (read_file,get_truncated,listdir,glob_paths,regex_files,tree,todo_list,wait,ask_user_*,vcs_*,skill_list/skill_read/skill_search,fetch);ToolRegistry.clone(read_only_only=True)selects just those for safe side turns / planning, andread_only_context()(intools/context.py) makesfetchaccept GET only.
Prompting (prompting.py)
Shared interactive I/O: ask / choose / form / confirm with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (run_prompt_async).
CLI (cli/)
Click app + readline REPL. Entry: plyngent / python -m plyngent.
plyngent chat: provider/model (flags or interactive; Tab via readline inprompting); sessions storeprovider_name/modeland restore on resume; SQLite via[database](file DB under user data when url unset; expliciturl = ":memory:"kept + warn); workspace-bound; resume latest for cwd/--workspaceby default (--new/--session). One-shot:-p/--promptand non-TTY stdin; exit codes 0/1/2/3;--yes(YOLO on),--auto-continue(auto-raise limits),--stream/--no-stream,--quiet. Root--log-level. Enabled[mcp]servers start before the REPL (_start_mcp_manager, per-server failures warn on stderr) and are closed on exit (McpManager.aclose); each server spawns in its own session/process group (start_new_session/CREATE_NEW_PROCESS_GROUP), so a terminal Ctrl+C does not kill it.- Slash: Click group in
cli/slash.py+awaitletfor async work; Tab completer from registry + ParamTypeshell_complete./historyis turn-oriented (cli/transcript.py): a turn runs from a user row to the next one, rows carry display ordinals (never DB row numbers; restart with the session), the turn's ends (user + final answer) always print in full,-vexpands every row (one-liners),-vvprints full bodies + local rows,--previewcollapses every row, andN/last [N]address turns while--message Naddresses one row. Multiline"""…""";/editvia$VISUAL/$EDITOR(blocking only)./yolo on|off|oncefor soft destructive confirms./model --persist//models --persistwrite model catalog entries into TOML./todosfor human show/push/pop/clear of the todo stack./skills(listdefault) prints the discovered skills with their roots, shadowed copies, and frontmatter notes;/skills search PATTERNruns the matching tool handler (the slash path binds the tool context itself);/skills open NAMEreveals the skill's directory in the system file manager./mcp(listdefault) prints per-server status/tool counts + aninstructionspreview when the server sent usage guidance, + last stderr lines;/mcp reconnectre-reads config, restarts every enabled server, and rebuilds the tool registry./grantslists directory-access grants (revoke <index|all>; config grants point at the TOML);/statusincludes the grant count and the visible skill count (oroffwhen[skills].enabledis false). /btwside questions: forked session; main transcript/DB untouched.--tools=read(default) exposes onlyREAD_ONLYtools underread_only_context()(fetch=GET) with the main session view;--tools=nooff;--tools=fullwhole-registry clone with fresh session. Tools off: default falls back to no tools; explicitread/fullerrors. The model gets anasidenotice as its last message before the question (unsaved exchange + tool scope), shown to the user as[notice] aside: ….- Explicit
/resumeor--sessionfrom another workspace prompts: keep / update / abort. A session resumed by a freshly started process (--session/ default latest) also gets aresumenotice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing. - Host notices (
agent/notices.py): system-level messages appended as trailingdeveloperrows, never system-prompt edits —Notice.to_message()for the model,.summaryfor the user viacli.display.echo_notice(--quiethides the user line only). Durable hosts persist them (ChatAgent.push_notice); a side agent without memory keeps its notice local.has_notice()dedupes a session-scoped notice: theskillsone is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask withskill_list/skill_search. Trailing developer messages never end a turn, so/retrystill sees an interrupted turn behind a notice or checkpoint. - Failed/cancelled turns: user kept; committed tool rounds kept, and a batch cut short keeps the calls that already produced a result (
rollback_tail; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on/retry, and only unfinished assistant text is dropped; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it,cli/interrupt.py— a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (waitreportscancelled by user) or is ignored with a hint for readline prompts (ask_user_*, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget resets when an attempt completes a model round (connection recovered); then/retry. - Pretty tool lines: every builtin tool prints its
* Verb 'target'prefix as the call starts and appends the outcome (L1-4 (done),(exit code 0),(session 3)) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (* MCP <server>:<tool> (done)). Plugin tools and custom tool calls keep the[tool] …/[tool ok] …whole-line style. Interactive TTY only (interactive_terminal()): piped/plain output and--verbosekeep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs ends the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one* Todo x<N>: Push, Updateline plus its last result, whose text already carries the final stack, instead of one header and a full list render per call.run_argv_batchlists its commands above its row: one$ commandline per step, apipe_outchain joined into one line with|, each line elided past 80 characters. plyngent providers,config path|edit. Config open:$VISUAL/$EDITOR(wait), else system default (xdg-open/open/startfile, non-blocking)./editstays blocking-only. No providers → optional edit then reload when waited.- Tools default on; workspace defaults to cwd;
--max-roundsdefault 32. Readline history under platformdirs (repl_history). PTY:close_allon chat exit.
Composition utility: Forward descriptor
utils/components.py — Forward[T] / forward() for attribute forwarding on composed objects.
Type annotations are mandatory
Basedpyright recommended. Ruff includes ANN (private return types ANN202 ignored). Prefer PEP 695 aliases except where msgspec requires plain assignment (Unset).
Function complexity (C901)
Ruff's C901 stays at its default 10 (no mccabe section in pyproject.toml): one decision point per if / elif / for / while / except handler, one per match case past the first, plus one per nested def. Boolean operators, ternaries, comprehensions, with, and assert are free — so a wide flat guard is fine and a case-per-branch dispatcher is not. That is the intent: the few functions over it here keep a # noqa: C901 with the reason after the codes (— one branch per event type), because the count is what those functions are. PLR0912 is ignored (same branches, looser default, and C901 already flags every function it would); PLR0911 / PLR0915 stay — returns and statements are different axes.
A nested function is counted twice: its decision points are added to the enclosing function and reported on the nested function itself (def outer with one helper if and one own if scores 3; the helper scores 2 on its own). A function that only scores high because of its closures is asking for those to be hoisted to module level, not for a wider limit.
Roadmap notes (single-user → platform)
-
Phase D (context quality): soft char budget, request compact,
/compact, richer errors/cancel, workspace sessions. Context size is char estimate plus optional API usage when reported. -
No local tokenizer stage for now.
-
Phase E: tooling depth (grep/glob, VCS backends; prefer
edit_replace/edit_linenoover model-generated patches). -
Phase F (providers + usage v2): API
usage+ char-based estimate fallback; session/turn totals;/status+ end-of-turn. Optional later: cost, real tokenizer. -
Phase G (CLI polish + hardening) — single-user only; multi-tenant stays Phase H.
Done
- G0:
prompting(ask/choose/form/confirm) + chat tools (ask_user_line/ask_user_choice/ask_user_form) - G1:
ReasoningDeltaEvent,/stream,/verbose - G2:
/rename,/delete(confirm),/export md|json - G2.5: Click slash registry (
cli/slash.py) +awaitletfor sync Click / async memory; auto/help; completer fromslash.list_commands - G3: multiline
"""…"""input (cli/input_text.py);/editvia$EDITOR(edit_text_in_editor) - G4:
plyngent chat -p/--prompt(+ non-TTY stdin); exit codes 0/1/2/3;--yes/ non-interactive confirm deny;--stream/--no-stream,--quiet - G5: PTY master FD non-inheritable;
read_pty/close_ptyviato_thread;PtyManager.close_all()on chat exit;--log-level; clearer invalid TOML errors; export/status stay secret-free - G6: README,
doc/plyngent.example.toml, AGENTS overview/CLI notes
Phase G complete for single-user CLI polish. Next roadmap work is Phase H or optional F (cost/tokenizer).
PTY / process model (decision)
Today
PtyManager(tools/process/pty_session.py) is in-process:pty.openpty+os.fork→ childexecvp, parent holds master FD;read_pty/close_ptyrun viato_thread. Session registry is process-global. Safe enough for single-user CLI if the child path stays fork-then-exec only.Question Answer Separate PTY supervisor process so the main app is safer with threads/greenlets? Not for Phase G. Defer to Phase H (sandbox / multi-tenant) or if we hit real FD-leak / freeze bugs. Why not now? Fork-then-exec is already the right shape; rewrite cost (IPC, lifecycle, tests) dwarfs single-user CLI risk. awaitlet greenlets are slash-only; PTY is not forked from a worker thread today. G5 (done) Master FD non-inheritable; read_pty/close_ptyviato_thread; chat exitclose_all; fork stays on loop/main thread.Phase H Optional PTY helper process (JSON/Unix socket), or subprocess+PTY with asyncio reaping; sandboxed tools, multi-session isolation. - G0:
-
Phase H: multi-tenant platform (auth, sandboxed tools,
web/). Optional out-of-process PTY host if isolation is required.
Commit messages
Scoped commit messages, not conventional commits:
<scope>: <brief description>
Examples: deps: add fastapi, core/mq: fix incorrect message sending, router: add routing service for xxx, test/webserver: change test client, ci/lint: run ruff style check.
Check git log for the full convention.