Skip to content

Commit 9996984

Browse files
authored
feat(tui): TUI enhancements, security-audit remediation, and multi-instance hardening (#103)
* feat(config): disable automatic turn recaps by default Turn recaps now default off; the agent only recaps when asked. Add a direct toggle — /config recaps on|off (also /settings recaps ...) — that persists to the config file and reloads the shell. The interactive /settings panel keeps its existing turn-recaps item. Part of the Codex TUI adoption backlog (item 0.1). * feat(tui): adaptive theme foundation — bg probe, color depth, blending Port the Codex terminal-adaptation layer (codex-rs/tui) to Python: - ui/color_utils.py: hex parse/format, linear RGB blend, BT.601 luma. - ui/terminal_background.py: OSC 11 background probe (100ms timeout, per-process cache, PYTHINKER_NO_BG_PROBE opt-out) and theme = "auto" resolution with a dark fallback. /theme and the settings selector accept the new value. - terminal_capabilities.color_depth(): three usable color tiers (truecolor/256/16 + none) honoring FORCE_COLOR levels and the Windows Terminal WT_SESSION truecolor promotion. - get_diff_colors(): 16-color terminals now get plain green/red foreground diff styles instead of quantized hex background tints. /theme now compares against the persisted setting rather than the resolved active theme so users can pin dark/light while on auto. Backlog items 1.1-1.4. * feat(tui): renderer guards + md-fence table unwrapping Adopt the Codex renderer-safety behaviors: - Syntax-highlight size guard: code blocks beyond 512 KiB or 10k lines render as plain text with a 'highlighting skipped (N lines)' notice instead of paying an unbounded Pygments cost. - Large diff guard: expanded diffs are capped at 400 rendered lines (head + tail with an explicit omitted-line count) so one huge edit cannot freeze or flood the terminal. - Generic tool output switches from head-only to head-tail truncation, keeping the start (identifies the result) and the end (the actionable part) with an omitted-line notice. - Fence unwrapping for tables: ```md/```markdown fences whose body contains a header+delimiter table pair now render as markdown instead of opaque code. Conservative Codex heuristics: other languages, untagged fences, md fences without tables, and unclosed fences pass through unchanged. Backlog items 2.6, 3.1, 6.2, 7.5. * docs(tasks): Codex TUI adoption gap analysis + Phase 1 plan * feat(tui): reference-CLI design polish + probe hardening User-directed design wave on top of the Codex adoption Phase 1: - Transcript marker is now ⏺ (U+23FA) on macOS/Linux — Windows keeps the text circle, ASCII mode keeps the star. Tool/assistant rows blink the marker while running (reduced-motion pins it static) and settle to the solid green marker when finished; thinking rows carry the marker too. - Activity coral muted to a clay ramp (#C68D7E/#D8AC9E/#E9CDC2 dark, #B26A52/#9E563E/#82412D light). Shimmer simplified from wave+splash to a calm bidirectional sweep with settle beats; truecolor terminals get a continuous cosine-blended sheen via ui.color_utils.blend, lower tiers keep the discrete ramp. - Activity/todo headers share one metadata design: 'Verb… (12s, ↓ 2.4k tokens, 45 t/s)' — parenthesized, comma-separated, with a live tokens/sec readout on the working indicator and the pinned todo header (sliding-window rate over the turn's context tokens). - Thinking-effort frame colors form a cold→hot gradient ending on dark red for xhigh (slate→blue→teal→amber→orange→red). - Pinned todos: the active task title+box are coral; concurrent in-progress rows read light grey so the running task stays unmistakable. - Diff word-level highlights drop reverse-video for the theme's add/del highlight backgrounds (GitHub-style emphasis, no glare). - Turn recap is padded to the card inset instead of spanning edge-to-edge. Hardening (findings from the in-app review validated and fixed): - Terminal probe: catch select's ValueError (fd >= FD_SETSIZE), suppress tcsetattr restore failures, cap the OSC reply buffer at 4 KiB, and serialize the probe cache behind a lock. - Generic tool-output head/tail truncation halves the char budget per side so combined output can never exceed the limit. - /settings arg parsing splits the mode string once. - Added ~~~md tilde-fence unwrap coverage. * feat(tui): reference-CLI layout, palette, and chrome refinements Second design wave driven by side-by-side comparison with the reference transcripts: - Tool headers use the parenthesized single-line form — ⏺ Bash(cmd…), ⏺ Update(path) — ellipsizing at the terminal edge instead of wrapping. - Result gutters no longer pad rows with trailing spaces to the terminal edge (copy-clean, ragged-right like the reference). - Todo list matches the reference: coral ■ box with bold default-color (white) title for in-progress rows, green ✓ with struck muted titles for done rows; coral stays on the top activity line. - Diff palette set to the specified values: row tints #052e05/#3a0808, sign/line-number accents #81C784/#E57373, content in the terminal's default (white) over the tint. Also fixes a span-layering bug where the row restyle buried word-level highlights. - Dark text hierarchy: primary output #D4D4D4; UI chrome (gutters, line numbers, expand hints, toolbar metadata) #6F6F6F. - Thinking-effort input bars dim their gradient color 30% toward the background pole so the frame hints without shouting. - The 'N background agents' line is gone everywhere — the bottom toolbar owns that count; the verb spinner + todos remain. Background subagent status rows hang-indent under their label. - Welcome banner gains a bold /init tip when the repo has no AGENTS.md or CLAUDE.md. * fix(tui): unify the two todo-list renderers into one design The pinned todo list is drawn by two code paths — the live view during a foreground turn and the prompt-side background status between turns — and they had drifted apart (◼/◻/✔ vs ■/□/✓ glyphs, hex off-white vs terminal-default bold titles, bright pending rows, no strikethrough). That made running tasks appear to change style mid-session. Both paths now render identically: coral ■ with a bold default-color (white) title for running rows, muted □ pending, green ✓ with struck muted titles when done. * fix(tui): white running-task titles, bg-status metadata, diff palette consistency Three fixes from live-session screenshots: - Pinned todo rows no longer carry a muted base style: the bold running-task title sets no color of its own, so the muted base bled through and rendered it grey instead of the terminal-default white. The base style is gone; prefix/icon/title each carry their own style. - The background working status line ('Ebbing…') now shows the same '(elapsed, ↓ Nk tokens, N t/s)' metadata as the live working indicator: elapsed since background work appeared and a 1.5s sliding-window token rate over the status snapshot, dropped first on narrow terminals and reset when background work drains. - Word-level diff highlights are gated on line similarity (ratio >= 0.5): heavy single-line rewrites previously flooded the whole row with the brighter highlight tint, reading as a different palette from plain added/removed rows. Such rewrites now render as plain rows. * feat(tui): elapsed/tokens/t-s metadata on the background status line Completes the c8c0f05 commit: the prompt.py change was clobbered from the index by a concurrent session's git operation before that commit landed, leaving the renderer half-committed. The background working status line ('Ebbing…') now carries the same '(elapsed, ↓ Nk tokens, N t/s)' metadata as the live working indicator: elapsed since background work first appeared and a 1.5s sliding-window token rate over the status snapshot's context tokens. The metadata is dropped first on narrow terminals and both trackers reset when background work drains. * fix(tui): transcript-row bullets use the record marker, not the list dot Notification, progress-note, question-answered, and suggestion rows in the transcript still rendered with BulletColumns' default list bullet. They now carry the record marker in the block's accent color (severity for notifications, green for progress/answers, accent for suggestions). Genuine list rows — /help sections and nested subagent detail lines — deliberately keep the list dot. * feat(agents): structured prompt overhaul + subagent/background hardening Refactor all default agent YAML prompts with explicit Mission / Hard Constraints / Workflow / Output Contract sections for consistency and clarity. Update system.md overlay accordingly. Harden background manager, subagent runner, and agent tool to align with the Codex TUI adoption (Phase 1) gap map: stale-record reconciliation, resume contract enforcement, and interactive-visualizer fixes. Update all affected unit and e2e tests. * docs(changelog): add Unreleased entry for TUI enhancements * fix: remediate 65 security and correctness audit findings Implements the remediation plan for the validated audit findings (1 Critical, 15 High, 29 Medium, 20 Low) across five phases, each fix behind a TDD test and gated by `make check` plus a security review. Phase 0 — shell permission classifier: close the read-only / auto-mode bypass cluster (interior &/|& separators, casefolded base commands, wrapper value-options, find/xargs/awk payloads, glued output redirection, unsafe `git -c`, uv sub-namespaces and `uv run` option-prefix bypass). Phase 1 — confinement, egress, telemetry: symlink-resolve file read/write/edit and grep before workspace/sensitive checks; fail-closed SSRF with a connection-pinned resolver; bounded shell wait; Sentry path/home redaction; invisible-char and case-folded sensitive-file handling; sensitive-import gate; untrusted-output wrapping; subagent-id path validation. Phase 2 — tool dispatch, lifecycle, context integrity: MCP-vs-builtin tool collisions; run_soul task-leak cleanup; restore-id/path traversal guards; compaction rollback; mid-tool-cancel turn balance; cyclic-extend detection. Phase 3 — wire server, auth, web-server: wire read-loop hardening; OAuth refresh / device-id / 403 handling; provider base_url validation; replay watermark; session-leak cleanup; ZIP-import validation. Phase 4 — UI/usage/CLI: ANSI sanitization at render boundaries (incl. generic tool arg-key names); usage-meter consumed-vs-remaining; reset-window loop guard; RunAgents approval fingerprint over child prompts; owner-only MCP config; live Typer help; /restore traversal and error handling; bounded approval-request store. Review-found gaps were fixed with regression tests: uv-run option bypass, grep symlink escape, SSH-key import gate, ANSI arg-key injection, and the MCP-config / share-dir permission race. Also hardens auth JSON parsing. * fix(tui): address CodeRabbit review findings - app.py: detect AGENTS.md/CLAUDE.md in the session work_dir (awaiting the async HostPath.exists) instead of process cwd, so the /init tip is correct when cwd differs from the session path. - color_utils.py: replace EN DASH with ASCII hyphen in the luma docstring. - slash.py: clarify the recaps config-flag guidance (--config vs --config-file). - terminal_background.py: debug-log swallowed OSC11 probe failures instead of silently degrading, per the exception-handling rule. - tests: annotate the autouse fixture return type; tighten the thinking-status metadata assertion to validate the token-count block, not just any paren. * feat(llm): controllable reasoning effort for Qwen on OpenCode Go Qwen3.x/3.7 are hybrid thinking models: reasoning is toggleable per request via the standard Anthropic thinking block, which the OpenCode Go @ai-sdk/anthropic route (and Alibaba Model Studio's Anthropic-compatible endpoint) accepts as {"type": "enabled", "budget_tokens": N} / {"type": "disabled"}. Previously OpenCode Go Qwen models got no thinking capability, so their effort was uncontrollable — while the Alibaba plan already exposed it. Give the Qwen family the controllable 'thinking' capability so create_llm routes the selected effort through with_thinking, which (for these non-Claude models) emits the budget-based payload and clamps xhigh/max -> high, minimal -> low. GLM/MiniMax remain always_thinking (effort can't be turned off); Qwen can. Adds a clamp regression case pinning the budget-safe mapping for qwen3.7-max so the budgets[...] lookup can never KeyError. * feat(tui): static-grey input border with a top-right effort label Stop recoloring the whole input border by thinking effort. The border is now one static frame grey at every level; the effort signal moves to a small label flushed right on the input's top border — a level-colored dot (off->max: slate->blue->teal->amber->orange->red) plus the muted level word. The dot carries the cold->hot color at full strength (it's a single glyph), while the word uses a muted class so it never competes with the typed text. The label is hidden for native-thinking models (always_thinking with no user dial) and non-thinking models, and the rule auto-shortens by the measured label width so the top line never wraps. _prompt_separator_style no longer borrows thinking_frame_style, so all input separators (top border and footer) render the same static frame grey. * feat(tui): single top-right effort label; Qwen treated as native-thinking Two related thinking-effort changes: 1. Remove the duplicated effort indicator from the footer (the 'agent <model> • <effort>' / 'native reasoning' text under the input). Effort now shows in exactly one place — the top-border label added earlier. The footer mode line is just 'agent <model>', degrading to the bare mode on narrow terminals. Drops the now-orphaned _thinking_footer_label helper. 2. Mark all Qwen models (qwen3.7-max/plus, qwen3.6-plus/flash, qwen3 coder plus/flash on Alibaba; qwen* on OpenCode Go) as always_thinking instead of the controllable 'thinking' capability. Qwen now matches GLM/MiniMax: reasoning is native and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic thinking block both Anthropic-compatible routes accept. * fix: address CodeRabbit review findings on the security-remediation diff Critical permission-classifier bypasses: - sudo long value-options (`sudo --user alice rm -rf /`) were not consumed, so the wrapped destructive payload classified as the option's value. Add the long forms to _SUDO_VALUE_OPTS. - uv global options before `run` (`uv --directory repo run rm -rf /`) hid the subcommand; the global flag's value was mistaken for it. Add _uv_strip_global_opts and apply it in both mutation and destructive paths. Major: - file_restore: treat missing (None) or malformed-base64 content as a corrupt restore point instead of silently writing an empty/garbage file. - web/fetch: _ip_is_blocked now fails closed (blocks) on an unparseable address. - scratchpad: stop unlinking the advisory lock file (split-inode race); keep it persistent and add *.scratchpad.lock to the written .gitignore patterns. - soul shutdown: don't re-await an already-finished task in the cleanup loop — retrieve its exception without re-raising so the rest of shutdown still runs. - pythinkersoul: on mid-tool interruption, keep the real results of calls that already completed (captured via on_tool_result) and only synthesize the interruption marker for still-pending calls. Minor / nitpick: - /import arg parsing now uses shlex via a shared parse_import_args helper (soul/slash + ui/shell/export_import), preserving quoted paths. - web/runner: offload the blocking wire-file stat with asyncio.to_thread; document the intentional broad except at the per-message dispatch boundary. - cli/vis: rename unused callback param to _ctx. - tests: regression cases for both bypasses; strengthened import token-count assertions; lock-file persistence test; minor annotations. * fix: deep-scan remediation + multi-instance robustness hardening Security/correctness (multi-agent review of the branch; every finding verified against the live code before fixing): - permission gate: classify awk shell-outs (print | "cmd", getline) as mutating AND destructive; recognize xargs -L payloads - Glob: resolve symlinks before the workspace boundary check - transcript: sanitize ANSI in progress-note titles - Grep: control-char field separators end path/lineno misparsing (utf-8-codec.py) and make sensitive-file attribution exact - StrReplaceFile: CRLF-translate LF-joined multi-line old strings - /import: parse --force from the raw string; paths byte-preserved - compaction restore: keep --add-dir files in reminders - soul: settle shielded context writes across repeated cancellations - web replay: stat failure replays full history instead of none - Agent resume: malformed ids return a clean 'Agent not found' - oauth: fail loud on missing refresh_token at login; carry expires_in forward on refresh; never read an empty device id - /theme auto: failed background probe can be re-probed via /theme - markdown: fence walker honors CommonMark close rules (no info string) - /restore: cheap id-format guard replaces loading every snapshot Multi-instance robustness: - per-session writer lock (.owner.lock) in the CLI and web worker - pythinker.json mutations go through a locked read-modify-write - JSONL appenders repair torn final lines; forks materialize atomically - session/wire scanners skip non-object lines instead of crashing - project memory: strict reads on mutation (no wipe on transient EIO), journal capped at 100 recaps, atomic inbox claim, mtime-based recall re-arm, flock acquisition off the event loop Subagent orchestration: - summary-continuation failure keeps the completed result - hallucinated subagent types fail fast with the valid-type list (Agent and RunAgents, before any child launches) - background failures carry an Agent ID + resume hint; finalize is guarded; runner crashes are surfaced via the done callback - copy_for_role shares the live-task registry (no false orphans) Cleanup: shared blink_visible() (12 copies), used_from_remaining() (6 copies), is_local_host reuse, single realpath pass in write/replace, cached thinking-frame blend, MCP cross-server shadow warning. * refactor(auth): split openai.py into an auth/openai package Mechanical, behavior-preserving split by responsibility: constants, catalog, oauth_client, browser_flow, models, config_apply, login, plus a package __init__ re-exporting the existing import surface. Applies the accepted clean-code review items: JsonObject boundary typing with narrow casts, _parse_chatgpt_model_item extraction, _default_config_error / _handled_error_event helpers, and private-helper renames (_first_present_field / _first_non_empty_string_field). Test monkeypatch targets follow the moved definitions. Includes the audit's 401/403 login message split, which now lives in login.py. * style(tests): apply ruff format to retargeted blink_visible patches * fix(tui): prevent input top-border label from wrapping on narrow terminals The flushed-right effort label was appended unconditionally; when the rule was shorter than the label plus its gap, the line overflowed and wrapped, contradicting the method's stated no-wrap invariant. Fall back to the plain full-width rule when the label cannot fit, and add a regression test.
1 parent 00e7b67 commit 9996984

222 files changed

Lines changed: 9751 additions & 2889 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,4 @@ blackbox/
7777
.coverage.*
7878
coverage.xml
7979
htmlcov/
80+
*.scratchpad.lock

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Deep-audit remediation: security, correctness, and multi-instance robustness.** Permission gate: awk programs that shell out via `print | "cmd"` / `getline` are now classified as mutating AND destructive (previously only `system(`/`>` and only mutating), and `xargs -L N` no longer hides its payload from classification. Glob resolves symlinks before its workspace-boundary check (an in-workspace symlink could previously list outside content); progress-note titles are ANSI-sanitized like every other transcript field. Grep content lines are parsed with unambiguous field separators, so paths like `utf-8-codec.py` are no longer mangled with `-n=false` and sensitive-file attribution is exact. Multi-line edits on CRLF files work again (LF-joined old strings are CRLF-translated when needed). `/import` preserves paths byte-for-byte (only a standalone leading/trailing `--force` is treated as the flag). Post-compaction file reminders include `--add-dir` files. Double-interrupt can no longer orphan the interruption-marker write (unanswered tool_calls). Background web replay falls back to full history (not empty) when the watermark stat fails, and a malformed Agent resume id returns a clean "Agent not found". OAuth: login fails loud when the token response lacks a `refresh_token`; a refresh response without `expires_in` carries the previous lifetime forward instead of refreshing every tick; the device-id file can no longer be read empty mid-creation. A failed `theme="auto"` background probe can be retried by re-selecting auto via `/theme`. Multi-instance: sessions now take a per-session writer lock (a second `pythinker -r <id>`/web worker on the same session is refused instead of interleaving turns), the shared `pythinker.json` index uses a locked read-modify-write (no more lost work-dir registrations), JSONL appenders repair torn final lines after a crash, forks materialize atomically, project-memory mutations abort on read failure instead of wiping the file, the journal is capped at 100 recaps, inbox approve/reject claims candidates atomically, and recall re-arms when another instance writes new memory. Subagents: a failed summary continuation no longer discards a completed agent's work, hallucinated subagent types fail fast with the valid-type list (before any RunAgents child launches), background failures carry an `Agent ID:` + resume hint, and a crash inside the runner's own error handling is logged instead of silently lost.
19+
- **Breaking (CLI flags): `pythinker web` / `pythinker vis` host short flag is now `-H`.** `-h` is a help alias on both subcommands (matching the root CLI); previously `-h <ip>` bound the host. Scripts using `-h 0.0.0.0` now print help and exit 0 without starting a server — switch to `-H <ip>` or `--host <ip>`. Part of the security/correctness audit (which also confined Grep to the workspace, gated non-HTTPS provider URLs in the web config API to loopback, and stopped saving OpenAI keys on 401/403).
20+
- **Thinking effort moved to a single top-right label on the input border.** The input box border is now one static frame grey at every effort level instead of recoloring the whole bar cold→hot, and the effort is no longer duplicated in the footer line. It's shown once, as a small label flushed to the right of the input's top border — a level-colored dot (slate→blue→teal→amber→orange→red as `off→max`) plus the muted level word — so the dial stays glanceable without tinting the typing area or cluttering the footer. The label is hidden entirely for native-thinking models (`always_thinking`, no user dial) and non-thinking models, and the rule auto-shortens by the label width so the line never wraps.
21+
- **Qwen models treated as native-thinking across both plans.** Qwen3.x/3.7 (e.g. `qwen3.7-max`, `qwen3.6-plus`, the Qwen3 Coder models) now carry the `always_thinking` capability on both the Alibaba Model Studio and OpenCode Go plans, matching GLM/MiniMax: reasoning is built in and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic `thinking` block that both Anthropic-compatible routes accept.
22+
- **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default.
23+
1824
## 0.39.0 (2026-06-09)
1925

2026
- **Refreshed TUI theme and Catppuccin syntax highlighting.** The interface adopts a brand periwinkle/indigo accent (`#B3B9F4` dark / `#0B114E` light) with a reharmonized selection tint, and code blocks now highlight with Catppuccin Mocha (dark) / Latte (light), adaptive to the active theme — implemented as foreground-only Pygments styles with no new dependency. Markdown inline code and links render terminal-native cyan, blockquotes green, and ordered-list markers bright blue (so they adapt per terminal), and user messages sit on a neutral grey block instead of the prior blue tint.

packages/pythinker-core/tests/test_anthropic_thinking.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ def test_supports_adaptive_thinking(model: str, expected: bool) -> None:
114114
("claude-opus-4-8", "max", "max"),
115115
("claude-opus-5-0", "max", "max"),
116116
("claude-opus-5-0", "xhigh", "high"),
117+
# Qwen via the Anthropic-compatible endpoint (Alibaba Model Studio /
118+
# OpenCode Go @ai-sdk/anthropic): a non-Claude model, so it takes the
119+
# pre-4.6 budget path. Effort must land in {low, medium, high} so the
120+
# budgets[...] lookup in with_thinking can never KeyError, and xhigh/max
121+
# clamp to high while minimal floors to low.
122+
("qwen3.7-max", "off", "off"),
123+
("qwen3.7-max", "minimal", "low"),
124+
("qwen3.7-max", "low", "low"),
125+
("qwen3.7-max", "medium", "medium"),
126+
("qwen3.7-max", "high", "high"),
127+
("qwen3.7-max", "xhigh", "high"),
128+
("qwen3.7-max", "max", "high"),
117129
],
118130
)
119131
def test_clamp_effort(model: str, effort: str, expected: str) -> None:

packages/pythinker-host/src/pythinker_host/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ async def chdir(self, path: StrOrHostPath) -> None:
156156
"""Change the current working directory."""
157157
...
158158

159+
async def realpath(self, path: StrOrHostPath) -> HostPath:
160+
"""Resolve symlinks and return the real absolute path."""
161+
...
162+
159163
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
160164
"""Get the stat result for a path."""
161165
...
@@ -282,6 +286,10 @@ async def chdir(path: StrOrHostPath) -> None:
282286
await get_current_host().chdir(path)
283287

284288

289+
async def realpath(path: StrOrHostPath) -> HostPath:
290+
return await get_current_host().realpath(path)
291+
292+
285293
async def stat(path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
286294
return await get_current_host().stat(path, follow_symlinks=follow_symlinks)
287295

packages/pythinker-host/src/pythinker_host/local.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ async def chdir(self, path: StrOrHostPath) -> None:
9999
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
100100
os.chdir(local_path)
101101

102+
async def realpath(self, path: StrOrHostPath) -> HostPath:
103+
"""Resolve symlinks and return the real path (follows symlinks)."""
104+
local = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
105+
resolved = await asyncio.to_thread(os.path.realpath, str(local))
106+
return HostPath.unsafe_from_local_path(Path(resolved))
107+
102108
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
103109
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
104110
st = await aiofiles.os.stat(local_path, follow_symlinks=follow_symlinks)
@@ -143,7 +149,7 @@ async def readtext(
143149
errors: Literal["strict", "ignore", "replace"] = "strict",
144150
) -> str:
145151
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
146-
async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:
152+
async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f:
147153
return await f.read()
148154

149155
async def readlines(
@@ -154,7 +160,7 @@ async def readlines(
154160
errors: Literal["strict", "ignore", "replace"] = "strict",
155161
) -> AsyncGenerator[str]:
156162
local_path = path.unsafe_to_local_path() if isinstance(path, HostPath) else Path(path)
157-
async with aiofiles.open(local_path, encoding=encoding, errors=errors) as f:
163+
async with aiofiles.open(local_path, encoding=encoding, errors=errors, newline="") as f:
158164
async for line in f:
159165
yield line
160166

packages/pythinker-host/src/pythinker_host/path.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ def expanduser(self) -> HostPath:
118118
return home
119119
return home.joinpath(*parts[1:])
120120

121+
async def realpath(self) -> HostPath:
122+
"""Resolve symlinks and return the real absolute path."""
123+
return await pythinker_host.realpath(self)
124+
121125
async def stat(self, follow_symlinks: bool = True) -> pythinker_host.StatResult:
122126
"""Return an os.stat_result for the path."""
123127
return await pythinker_host.stat(self, follow_symlinks=follow_symlinks)

packages/pythinker-host/src/pythinker_host/ssh.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,29 @@ async def chdir(self, path: StrOrHostPath) -> None:
179179
await self._sftp.chdir(str(path))
180180
self._cwd = await self._sftp.realpath(".")
181181

182+
async def realpath(self, path: StrOrHostPath) -> HostPath:
183+
"""Resolve symlinks and return the real path via SFTP realpath.
184+
185+
``os.path.realpath`` tolerates a missing leaf (resolves the existing
186+
parent and re-appends the rest); strict SFTP servers instead error on
187+
nonexistent paths, which would break e.g. WriteFile creating a new
188+
file. Mirror the local tolerance by resolving the deepest existing
189+
ancestor and re-appending the missing suffix.
190+
"""
191+
parts: list[str] = []
192+
candidate = posixpath.normpath(str(path))
193+
while True:
194+
try:
195+
real = await self._sftp.realpath(candidate)
196+
except asyncssh.SFTPError:
197+
parent = posixpath.dirname(candidate)
198+
if parent == candidate: # filesystem root failed: give up
199+
raise OSError(f"realpath failed for {path}") from None
200+
parts.append(posixpath.basename(candidate))
201+
candidate = parent
202+
continue
203+
return HostPath(posixpath.join(real, *reversed(parts)) if parts else real)
204+
182205
async def stat(
183206
self,
184207
path: StrOrHostPath,

packages/pythinker-review/tests/unit/test_security_intel.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ def test_intel_client_disables_implicit_redirects() -> None:
4848
from pythinker_review.security_intel.client import IntelHttpClient
4949

5050
client = IntelHttpClient()
51-
assert any(isinstance(h, _NoRedirectHandler) for h in client._opener.handlers)
51+
handlers = client._opener.handlers # pyright: ignore[reportAttributeAccessIssue]
52+
assert any(isinstance(h, _NoRedirectHandler) for h in handlers)
5253

5354

5455
def test_intel_cache_roundtrip(tmp_path: Path) -> None:

src/pythinker_code/__main__.py

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,60 +8,6 @@
88
if TYPE_CHECKING:
99
from typing import TextIO
1010

11-
ROOT_HELP = """Usage: pythinker [OPTIONS] COMMAND [ARGS]...
12-
13-
Pythinker, your next CLI agent.
14-
15-
Options:
16-
-h, --help Show this message and exit.
17-
-V, --version Show version and exit.
18-
--verbose Print verbose information.
19-
--debug Log debug information.
20-
-w, --work-dir DIRECTORY Working directory for the agent.
21-
--add-dir DIRECTORY Add an additional workspace directory.
22-
-S, -r, --session, --resume TEXT Resume a session.
23-
-C, --continue Continue the previous session.
24-
--config TEXT Config TOML/JSON string to load.
25-
--config-file FILE Config TOML/JSON file to load.
26-
-m, --model TEXT LLM model to use.
27-
--thinking / --no-thinking Enable or disable thinking mode.
28-
-y, --yolo, --yes, --auto-approve
29-
Dangerously skip permission approvals.
30-
--plan Start in plan mode.
31-
--auto Run in auto mode (no user present).
32-
-p, -c, --prompt, --command TEXT User prompt to the agent.
33-
--print Run in print mode.
34-
--acp Deprecated; use `pythinker acp`.
35-
--wire Run as Wire server.
36-
--quiet Print only the final assistant message.
37-
--agent [default|okabe] Builtin agent specification to use.
38-
--agent-file FILE Custom agent specification file.
39-
--mcp-config-file FILE MCP config file to load; repeatable.
40-
--mcp-config TEXT MCP config JSON to load; repeatable.
41-
--skills-dir DIRECTORY Custom skills directory; repeatable.
42-
--no-telemetry Disable anonymous telemetry & error reporting.
43-
44-
Commands:
45-
acp Run Pythinker CLI ACP server.
46-
term Run Toad TUI backed by Pythinker CLI ACP server.
47-
login Login with a model provider.
48-
logout Logout from a model provider.
49-
info Show version and protocol information.
50-
export Export session data.
51-
mcp Manage MCP server configurations.
52-
plugin Manage plugins.
53-
review Diff-focused code review (delegates to pythinker-review).
54-
secscan Diff-focused security review (delegates to pythinker-review).
55-
security-scan Repo-wide Pythinker Security Scan pipeline (Python-native).
56-
debug Failure/log root-cause analysis (delegates to pythinker-review).
57-
update Check for and install Pythinker CLI updates.
58-
vis Run Pythinker Agent Tracing Visualizer.
59-
web Run Pythinker CLI web interface.
60-
61-
Documentation: https://pythoughts-labs.github.io/pythinker-code/
62-
LLM friendly version: https://pythoughts-labs.github.io/pythinker-code/llms.txt
63-
"""
64-
6511

6612
def _prog_name() -> str:
6713
return Path(sys.argv[0]).name or "pythinker"
@@ -126,10 +72,6 @@ def main(argv: Sequence[str] | None = None) -> int | str | None:
12672
print(f"pythinker, version {get_version()} — by {ORGANIZATION}")
12773
return 0
12874

129-
if len(args) == 1 and args[0] in {"--help", "-h"}:
130-
print(ROOT_HELP, end="")
131-
return 0
132-
13375
from pythinker_code.telemetry.crash import install_crash_handlers, set_phase
13476
from pythinker_code.utils.proxy import normalize_proxy_env
13577

src/pythinker_code/acp/host.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ def getcwd(self) -> HostPath:
212212
async def chdir(self, path: StrOrHostPath) -> None:
213213
await self._fallback.chdir(path)
214214

215+
async def realpath(self, path: StrOrHostPath) -> HostPath:
216+
return await self._fallback.realpath(path)
217+
215218
async def stat(self, path: StrOrHostPath, *, follow_symlinks: bool = True) -> StatResult:
216219
return await self._fallback.stat(path, follow_symlinks=follow_symlinks)
217220

0 commit comments

Comments
 (0)