From e9b602e06473119fcbe1006d0c4e0014d802d59a Mon Sep 17 00:00:00 2001 From: bbingz Date: Mon, 15 Jun 2026 07:47:33 +0800 Subject: [PATCH] Harden Claude tmux audit follow-up --- CHANGELOG.md | 25 +- CLAUDE.md | 5 +- README.ja.md | 19 +- README.md | 21 +- README.zh-CN.md | 19 +- .../third-party-review-followup-2026-06-15.md | 49 ++ docs/polycli-v1-public-surface.md | 6 +- docs/provider-paths.md | 9 +- docs/release-notes-v0.6.20.md | 37 + docs/release-notes-v0.6.21.md | 28 + docs/roadmap.md | 7 +- package-lock.json | 243 +++---- package.json | 4 +- packages/polycli-runtime/README.md | 23 +- packages/polycli-runtime/src/claude.js | 494 +++++++++++++- packages/polycli-runtime/src/qwen.js | 9 +- packages/polycli-runtime/src/registry.js | 35 +- packages/polycli-runtime/test/claude.test.js | 383 ++++++++++- packages/polycli-runtime/test/exports.test.js | 1 + packages/polycli-runtime/test/qwen.test.js | 18 + .../polycli-runtime/test/registry.test.js | 67 ++ .../bin/polycli-companion.bundle.mjs | 640 +++++++++++++++--- packages/polycli-timing/README.md | 1 + packages/polycli-timing/test/validate.test.js | 29 + packages/polycli-timing/timing.schema.json | 70 +- packages/polycli-utils/src/args.js | 10 +- packages/polycli-utils/src/atomic-save.js | 44 +- .../polycli-utils/src/parse-stream-json.js | 37 +- packages/polycli-utils/src/process.js | 18 +- packages/polycli-utils/test/args.test.js | 5 + .../polycli-utils/test/atomic-save.test.js | 39 +- .../test/parse-stream-json.test.js | 9 +- packages/polycli-utils/test/process.test.js | 35 +- .../scripts/polycli-companion.bundle.mjs | 640 +++++++++++++++--- .../scripts/polycli-companion.bundle.mjs | 640 +++++++++++++++--- .../scripts/polycli-companion.bundle.mjs | 640 +++++++++++++++--- plugins/polycli/README.md | 9 + .../polycli/scripts/lib/prompt-runtime.mjs | 2 +- plugins/polycli/scripts/lib/review.mjs | 2 +- .../scripts/polycli-companion.bundle.mjs | 640 +++++++++++++++--- plugins/polycli/scripts/polycli-companion.mjs | 68 +- .../scripts/session-lifecycle-hook.mjs | 31 +- plugins/polycli/scripts/tests/hooks.test.mjs | 34 + .../scripts/tests/integration.test.mjs | 332 +++++++-- .../scripts/tests/prompt-runtime.test.mjs | 2 +- plugins/polycli/scripts/tests/review.test.mjs | 3 +- scripts/check-fixture-freshness.mjs | 2 + .../tests/check-fixture-freshness.test.mjs | 5 +- 48 files changed, 4763 insertions(+), 726 deletions(-) create mode 100644 docs/audit/third-party-review-followup-2026-06-15.md create mode 100644 docs/release-notes-v0.6.20.md create mode 100644 docs/release-notes-v0.6.21.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e85b54..1796e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,32 @@ Separate from `docs/release.md` (release-focused) and `docs/archive/session-memo --- +## 2026-06-15 — Codex — Qwen audit checklist remediation + +- Consolidated the Qwen third-party review batch into `docs/audit/third-party-review-followup-2026-06-15.md`, then verified all 11 claims with independent subagents against the current worktree before editing. All 11 were confirmed still-present before remediation. +- Fixed the three behavior/security issues with regressions: `writeFileAtomicSync` now removes its temp file on failed rename/write paths; no-diff review cleanup now runs in a `finally` so Gemini isolated tempdirs are removed; unsafe pid values (`<=1` / non-integers) are rejected before process-group termination. +- Added the missing Claude health logged-out integration coverage by making the fake Claude auth fixture emit `loggedIn:false` via env, and asserting the companion reports Claude unhealthy with a populated probe error. +- Closed the docs/parity findings: plugin README lists `debug` / `sessions` and terminal TUI ownership; root and translated READMEs describe Claude health as auth-only, add the terminal package badge, outcome diagnostics, and `minimax` (`mmx-cli`) alias; timing/runtime package READMEs document v1 `cold`/`retry` and `REVIEW_FLAG_EXPECTATIONS`. +- Verification: focused regressions pass; `npm test` exit 0 (508/508); `npm run release:check` exit 0 including bundle, fixture, manifest, host-map, Codex adapter, review-drift, Claude plugin validation, and npm pack dry-runs. + +## 2026-06-15 — Codex — multi-review adjudication follow-up + +- Adjudicated the Minimax/Kimi/MiMo review batch against the current source after the Claude tmux TUI remediation. Kept the user-requested Claude tmux TUI default instead of reverting `ask`/`review` to `claude -p`; treated "restore synchronous LLM answer" findings as a product-semantics conflict, not a fix to apply. +- Fixed two confirmed issues: Claude legacy `auth status` non-JSON success output is now parsed or marked inconclusive instead of treated as logout, and `session-lifecycle-hook.mjs` now removes session jobs through locked `updateState` rather than naked load/save. +- Closed release-safety/doc drift found in the review batch: fixture freshness probes now cover the 11-provider runtime surface (`cmd`, `agy`, `grok` included); README capability notes, `docs/provider-paths.md`, `docs/polycli-v1-public-surface.md`, `CLAUDE.md`, and `docs/roadmap.md` describe Claude tmux TUI startup-only timing and the `tmuxSession`/`attachCommand` response shape. +- Added draft `docs/release-notes-v0.6.21.md` for the current unreleased patch rather than rewriting the already-published v0.6.20 notes. + +## 2026-06-14 — Codex — Claude tmux TUI review remediation + +- Adjudicated the Claude/DeepSeek review findings against the current code and the user requirement that Claude subagent calls avoid the upcoming `claude -p` pay-as-you-go path. Confirmed the ask/review semantic drift, timing ambiguity, missing signal cleanup, tmux environment propagation gap, and auth-only health ambiguity; intentionally did **not** revert Claude ask/review defaults to `-p`. +- Hardened Claude tmux TUI mode: `tmux new-session` now receives an explicit allowlist of Claude/Anthropic/proxy/cert env vars via `-e`; SIGINT/SIGTERM during orchestration kill the created tmux session before process shutdown; missing tmux gets a direct install/config error; successful tmux launches return `detached:true`, `responseKind:"tmux_tui_session_started"`, `warnings`, and `timingMeta` that says timing covers only `tmux_startup` and `llmCompletionObserved:false`. +- Runtime timing now merges provider `timingMeta` and uses the run-level timing support for Claude tmux TUI, so `ttft/gen/tail` stay `unsupported`, `total` remains schema-valid `measured`, and the record explicitly marks `tmuxDetached:true` / startup-only timing. Claude health remains no-model-call/auth-only by design and now reports `probe.kind:"auth_status"` plus `authOnly:true` instead of looking like a sentinel LLM probe. +- Tests added/updated for tmux env propagation, detached payload semantics, startup-only timing metadata, signal cleanup, Claude health auth-only reporting, and companion ask/review integration. Bundles regenerated for all host surfaces. +- Verification: `npm test` exit 0 (500/500); `node --test packages/polycli-runtime/test/claude.test.js`; `node --test packages/polycli-runtime/test/registry.test.js`; `node --test plugins/polycli/scripts/tests/integration.test.mjs`; `npm run validate:bundles`; `npm run validate:manifests`; `npm run validate:host-map`. + ## 2026-06-02 — Claude — repo cleanup: removed stale R8 worktrees + `release/v0.6.19` branch -- After the v0.6.20 release, deleted the merged `release/v0.6.19` branch and the 3 abandoned `worktree-agent-*` git worktrees + their branches (all local-only — none on origin). Verified safe first: each branch had 0 commits not in `main` (so `git branch -d` succeeded, git-confirming they were merged); the worktrees' only uncommitted content was an identical, obsolete 2026-04-24 path-rewrite (`/home/user/…`→`/Users/bing/…`) on a snapshot ~41k lines behind `main`, locked by a dead pid (96484). +- After the v0.6.20 release, deleted the merged `release/v0.6.19` branch and the 3 abandoned `worktree-agent-*` git worktrees + their branches (all local-only — none on origin). Verified safe first: each branch had 0 commits not in `main` (so `git branch -d` succeeded, git-confirming they were merged); the worktrees' only uncommitted content was an identical, obsolete 2026-04-24 path-rewrite (`/home/user/…`→`/…`) on a snapshot ~41k lines behind `main`, locked by a dead pid (96484). - The single (identical across all 3) staged diff was saved to `/tmp/r8-worktree-staged-pathrewrite.patch` as insurance, but applying it is NOT advised: active files (README/docs) no longer carry those paths, and the remaining `/home/user/` references on `main` are historical records (CHANGELOG, `docs/archive/*`, `release-notes-v0.6.1`) that should not be rewritten. - Repo now has a single `main` branch, synced with origin, at v0.6.20. diff --git a/CLAUDE.md b/CLAUDE.md index 6a3f9e9..e77c696 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,13 +22,14 @@ Claude Code 专属补丁。基础规则见 [AGENTS.md](AGENTS.md),此处只列 | 任务类型 | 命令 | |---|---| | 动单个 package | `node --test packages//test/*.test.js` 先跑 | -| 改 runtime / host 之一 | 再跑 `npm test`(会先 `build:plugins` 再跑全量 119+ 测试) | +| 改 runtime / host 之一 | 再跑 `npm test`(会先 `build:plugins` 再跑全量测试) | | 要发布前校验 | `npm run release:check`(依赖 `claude plugin validate`) | 注意 `npm test` 已内含 `build:plugins`,**不要**另外先手动 build 再 test。 ## Claude-specific provider notes -- `claude` runtime 用 `--output-format stream-json` 时必须带 `--verbose`,这是 CLI 契约 +- `claude` runtime 的 print/headless 路径用 `--output-format stream-json` 时必须带 `--verbose`,这是 CLI 契约;不要把这个 `-p`/stream-json 规则套到默认 ask/review 的 tmux TUI 路径上 +- `claude` ask/review 默认启动 detached tmux TUI session,响应包含 `tmuxSession`/`attachCommand`,timing 只覆盖 tmux 启动和 prompt 提交,不代表 LLM 完成时间 - `claude` 可能通过 `subtype: "error"` 而非 `is_error` 报错,sync/streaming 两路错误处理必须对齐 - `gemini` 无独立 auth-status 子命令,auth probe 是推断式;不要把 timeout/429 倒退回 `loggedIn=false` - `pi` 在 trivial prompt 上仍可能调 tool,属上游行为;非本地解析问题 diff --git a/README.ja.md b/README.ja.md index 776af75..e30fea5 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,10 +4,11 @@ # polycli -**普段使っている AI ホストの中で、9 種類の AI コーディング CLI を 1 つのコマンド体系から操作できます。** +**普段使っている AI ホストの中で、11 種類の AI コーディング CLI を 1 つのコマンド体系から操作できます。** [![GitHub release](https://img.shields.io/github/v/release/bbingz/polycli?label=release&color=111827)](https://github.com/bbingz/polycli/releases) [![CI](https://github.com/bbingz/polycli/actions/workflows/ci.yml/badge.svg)](https://github.com/bbingz/polycli/actions/workflows/ci.yml) +[![npm: polycli](https://img.shields.io/npm/v/@bbingz/polycli?label=%40bbingz%2Fpolycli&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli) [![npm: polycli-opencode](https://img.shields.io/npm/v/@bbingz/polycli-opencode?label=%40bbingz%2Fpolycli-opencode&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli-opencode) [![npm: polycli-utils](https://img.shields.io/npm/v/@bbingz/polycli-utils?label=%40bbingz%2Fpolycli-utils&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli-utils) [![npm: polycli-timing](https://img.shields.io/npm/v/@bbingz/polycli-timing?label=%40bbingz%2Fpolycli-timing&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli-timing) @@ -22,7 +23,7 @@ ## polycli とは? -`polycli` は、Claude Code・Codex・GitHub Copilot CLI・OpenCode のいずれかのホスト上で、共通のコマンド (`health`・`ask`・`review`・`rescue`・`timing`・`debug`、加えてバックグラウンドジョブ制御とターミナル inspector) を使って 9 種類の AI コーディング CLI — **`claude`**・**`gemini`**・**`kimi`**・**`qwen`**・**`copilot`**・**`opencode`**・**`pi`**・**`cmd`** (Command Code)・**`mini-agent`** (MiniMax) — を操作できるツールです。 +`polycli` は、Claude Code・Codex・GitHub Copilot CLI・OpenCode のいずれかのホスト上で、共通のコマンド (`health`・`ask`・`review`・`rescue`・`timing`・`debug`、加えてバックグラウンドジョブ制御とターミナル inspector) を使って 11 種類の AI コーディング CLI — **`claude`**・**`gemini`**・**`kimi`**・**`qwen`**・**`copilot`**・**`opencode`**・**`pi`**・**`cmd`** (Command Code)・**`agy`** (Antigravity)・**`grok`** (xAI Grok)・**`mmx-cli`** (MiniMax) — を操作できるツールです。 これは **ユーティリティ専用の Path B モノレポ** です。プロバイダ間の差異を偽の抽象化で覆い隠したり、ランタイム基底クラスを発明したりはしません。公式の上流 CLI をサブプロセスとして組み合わせ、単一のコマンド面を公開し、4 状態の timing スキーマで能力の違いを正直に表現します。 @@ -39,7 +40,7 @@ | ホスト (polycli のインストール先) | プロバイダ (polycli が呼び出せる対象) | |---|---| -| Claude Code · Codex · GitHub Copilot CLI · OpenCode | `claude` · `copilot` · `gemini` · `kimi` · `qwen` · `opencode` · `pi` · `cmd` · `mini-agent` | +| Claude Code · Codex · GitHub Copilot CLI · OpenCode | `claude` · `copilot` · `gemini` · `kimi` · `qwen` · `opencode` · `pi` · `cmd` · `agy` · `grok` · `minimax` (`mmx-cli`) | 各プロバイダの対応能力は [Capability matrix](#capability-matrix) を参照してください。 @@ -95,7 +96,7 @@ polycli health polycli health ``` -`health` は認証済みのすべてのプロバイダに対してエンドツーエンドのプローブを実行し、生きているものを `healthyProviders` に報告します。その後の日常利用は直接呼び出すだけです: +`health` は認証済みプロバイダに対してプローブを実行し、生きているものを `healthyProviders` に報告します。Claude は例外で、`claude auth status --json` だけを使い、health prompt は送信しません。その後の日常利用は直接呼び出すだけです: ```text Choose Polycli with @, then ask it to run: ask --provider qwen "このスタックトレースを説明して ..." @@ -112,7 +113,7 @@ Choose Polycli with @, then ask it to run: rescue --provider gemini --background | コマンド | 動作 | |---|---| | `setup` | プロバイダ CLI のインストール状態と認証状態を確認 (モデル呼び出しなし、軽量) | -| `health` | 短いプロンプトでエンドツーエンド検査。`healthyProviders` を返し、timing を記録 | +| `health` | Claude 以外は短いプロンプトでエンドツーエンド検査。Claude は auth-only status を使う。`healthyProviders` を返し、適用できる場合は timing を記録 | | `ask` | 一発のプロンプト | | `review` | 現在の `git diff` に対するコードレビュー | | `rescue` | 長めのトリアージ / 解析タスク | @@ -135,15 +136,18 @@ Choose Polycli with @, then ask it to run: rescue --provider gemini --background | `gemini` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `kimi` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `qwen` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| `mini-agent` | ✓ | — | — | — | — | — | — | +| `minimax` (`mmx-cli`) | ✓ | — | ✓ | — | — | — | — | | `opencode` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `pi` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `cmd` | ✓ | — | — | ✓ | ✓ | ✓ | — | +| `agy` | ✓ | ✓ | — | ✓ | ✓ | ✓ | — | +| `grok` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | 補足: - `cold` と `retry` は全プロバイダで `unsupported` です。上流 CLI に安定したシグナルがなく、polycli は偽装を拒否します。`total` は常に `measured` です。 -- `mini-agent` はログ再生方式で、session resume・構造化出力・細粒度 streaming timing をサポートしません。`cmd` は Command Code 公式の headless mode を使うため、各呼び出しは standalone session で、stdout が可視回答になります。 +- `claude` の `ask` / `review` は、`claude -p` の従量課金パスを避けるため、デフォルトで detached tmux TUI mode を使います。この mode では `ttft` / `gen` / `tail` は `unsupported` として報告され、`total` は tmux 起動と prompt 投入だけを測ります。応答には `tmuxSession` + `attachCommand` が含まれます。 +- `minimax` は `mmx-cli` の非対話 JSON 呼び出しで、session resume・細粒度 streaming timing をサポートしません。`cmd` は Command Code 公式の headless mode を使うため、各呼び出しは standalone session で、stdout が可視回答になります。`agy` は Antigravity session mode、`grok` は xAI Grok Build CLI を使います。 - `tool: true` を宣言しているのは `qwen` のみです。`qwen` がツールを呼び出さなかったとき `missing` (観測可能だが今回は発生せず) を、他のプロバイダは `unsupported` (能力レベルで追跡しない) を報告します。両者の意味は異なるため、混同しないでください。 ## Timing のセマンティクス @@ -163,6 +167,7 @@ polycli の timing 契約が統一するのは**状態の表現**であって、 - `runtimePersistence` — `ephemeral | session | daemon` - `measurementScope` — `request | turn | job` +- outcome diagnostics — `outcome`, `exitCode`, `terminationReason`, `responseMatched`, and `errorCode` ## パッケージ diff --git a/README.md b/README.md index 54d45a4..1253d69 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # polycli -**One command surface across 9 AI coding CLIs, inside the host you already use.** +**One command surface across 11 AI coding CLIs, inside the host you already use.** [![GitHub release](https://img.shields.io/github/v/release/bbingz/polycli?label=release&color=111827)](https://github.com/bbingz/polycli/releases) [![CI](https://github.com/bbingz/polycli/actions/workflows/ci.yml/badge.svg)](https://github.com/bbingz/polycli/actions/workflows/ci.yml) @@ -29,16 +29,16 @@ It is a **utility-only Path B monorepo**: it does not unify provider differences behind fake abstractions, and it does not invent a runtime base class. It composes the official upstream CLIs as subprocesses, exposes one command surface, and surfaces honest capability differences in a four-state timing schema. -## Latest release: v0.6.19 +## Latest release: v0.6.20 -The latest patch adds upstream session-pollution control and provider-drift maintenance hardening (spec-driven, gated by two Codex review rounds): +The latest patch ships the grok provider, the kimi-code v0.6.0 migration, and the deep-review hardening set: -- New `polycli sessions [list | purge --confirm]` command cleans up the session/history files upstream CLIs leave under `$HOME`. Dry-run by default; deletion is driven only by ledger-recorded, re-validated realpaths — never a path guess or glob. -- Run-ledger events now record the upstream `sessionId` + a verified `sessionArtifactPath`, so polycli-created sessions are auditable and purgeable. -- `npm run check:fixture-freshness` flags fixtures pinned to a stale CLI version; `REVIEW_FLAG_EXPECTATIONS` is now the single source of review-flag truth (consistency-tested); `check:review-drift` is wired into the release gate. -- No provider behavior, host command grammar, or timing schema changed. +- Added `grok` (xAI Grok Build CLI) as the 11th provider across runtime, host adapters, skills, docs, and release validation. +- Migrated the kimi adapter and guidance to kimi-code v0.6.0 session semantics (`--session` / `-C`) and structured `session.resume_hint` parsing. +- Kept the Path B flat-adapter architecture intact while tightening review/deep-review hardening and bundle drift checks. +- Utility packages stay on their independent v1.x cadence. -See [`docs/release-notes-v0.6.19.md`](./docs/release-notes-v0.6.19.md). +See [`docs/release-notes-v0.6.20.md`](./docs/release-notes-v0.6.20.md). ## Why polycli? @@ -141,7 +141,7 @@ polycli health # OpenCode (tool call — call polycli_run with ["health","--json"]) ``` -`health` runs an end-to-end probe against every provider with valid auth and reports which ones are alive in `healthyProviders`. After that, daily use is direct. In Codex, either describe the task directly or type `@`, choose Polycli, and ask it to run the companion command: +`health` runs an end-to-end probe against every provider with valid auth and reports which ones are alive in `healthyProviders`. Claude is the exception: it uses `claude auth status --json` only and does not send a health prompt. After that, daily use is direct. In Codex, either describe the task directly or type `@`, choose Polycli, and ask it to run the companion command: ```text Choose Polycli with @, then ask it to run: ask --provider qwen "explain this stack trace ..." @@ -173,7 +173,7 @@ All commands work identically across hosts: | Command | What it does | |---|---| | `setup` | Check provider CLI install + auth status (cheap; no model call) | -| `health` | End-to-end short-prompt probe; returns `healthyProviders` and writes timing | +| `health` | End-to-end short-prompt probe for providers except Claude; Claude uses auth-only status; returns `healthyProviders` and writes timing where applicable | | `ask` | One-shot prompt | | `review` | Code review against the current `git diff` | | `rescue` | Longer triage / analysis task | @@ -206,6 +206,7 @@ Source of truth: [`packages/polycli-runtime/src/registry.js`](./packages/polycli Notes: - `cold` and `retry` are `unsupported` for every provider. Upstream CLIs lack a stable signal, and polycli refuses to fake them. `total` is always `measured`. +- Claude `ask` and `review` run in detached tmux TUI mode by default to avoid the `claude -p` cost path. In that mode `ttft`, `gen`, and `tail` are reported as `unsupported`; `total` measures tmux startup/prompt submission only, and the response contains `tmuxSession` + `attachCommand`. - `minimax` uses official `mmx text chat --output json --non-interactive`; no session resume and no fine-grained streaming timing. `cmd` uses documented Command Code headless mode, where each invocation is a standalone session and stdout is the visible answer. - Only `qwen` declares `tool: true`. When no tool is invoked, `qwen` reports `missing` (observable but absent); the others report `unsupported` (capability-level not tracked). The two states are not interchangeable. diff --git a/README.zh-CN.md b/README.zh-CN.md index cd471a9..e9eed6d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,10 +4,11 @@ # polycli -**在你已经在用的 AI host 里,用一套命令驱动 9 个 AI coding CLI。** +**在你已经在用的 AI host 里,用一套命令驱动 11 个 AI coding CLI。** [![GitHub release](https://img.shields.io/github/v/release/bbingz/polycli?label=release&color=111827)](https://github.com/bbingz/polycli/releases) [![CI](https://github.com/bbingz/polycli/actions/workflows/ci.yml/badge.svg)](https://github.com/bbingz/polycli/actions/workflows/ci.yml) +[![npm: polycli](https://img.shields.io/npm/v/@bbingz/polycli?label=%40bbingz%2Fpolycli&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli) [![npm: polycli-opencode](https://img.shields.io/npm/v/@bbingz/polycli-opencode?label=%40bbingz%2Fpolycli-opencode&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli-opencode) [![npm: polycli-utils](https://img.shields.io/npm/v/@bbingz/polycli-utils?label=%40bbingz%2Fpolycli-utils&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli-utils) [![npm: polycli-timing](https://img.shields.io/npm/v/@bbingz/polycli-timing?label=%40bbingz%2Fpolycli-timing&color=cb3837)](https://www.npmjs.com/package/@bbingz/polycli-timing) @@ -22,7 +23,7 @@ ## polycli 是什么? -`polycli` 让你在 Claude Code、Codex、GitHub Copilot CLI 或 OpenCode 中,用同一套命令(`health`、`ask`、`review`、`rescue`、`timing`、`debug`,以及后台作业管控和终端 inspector)驱动 9 个 AI coding CLI:**`claude`**、**`gemini`**、**`kimi`**、**`qwen`**、**`copilot`**、**`opencode`**、**`pi`**、**`cmd`**(Command Code)和 **`mini-agent`**(MiniMax)。 +`polycli` 让你在 Claude Code、Codex、GitHub Copilot CLI 或 OpenCode 中,用同一套命令(`health`、`ask`、`review`、`rescue`、`timing`、`debug`,以及后台作业管控和终端 inspector)驱动 11 个 AI coding CLI:**`claude`**、**`gemini`**、**`kimi`**、**`qwen`**、**`copilot`**、**`opencode`**、**`pi`**、**`cmd`**(Command Code)、**`agy`**(Antigravity)、**`grok`**(xAI Grok)和 **`mmx-cli`**(MiniMax)。 这是一个 **utility-only 的 Path B monorepo**:不假装能抹平 provider 之间的差异,也不引入 runtime 基类。它把官方上游 CLI 作为子进程组合起来,统一命令面,并通过四态 timing schema 如实暴露能力差异。 @@ -39,7 +40,7 @@ | Host(polycli 安装在哪里) | Provider(polycli 能调什么) | |---|---| -| Claude Code · Codex · GitHub Copilot CLI · OpenCode | `claude` · `copilot` · `gemini` · `kimi` · `qwen` · `opencode` · `pi` · `cmd` · `mini-agent` | +| Claude Code · Codex · GitHub Copilot CLI · OpenCode | `claude` · `copilot` · `gemini` · `kimi` · `qwen` · `opencode` · `pi` · `cmd` · `agy` · `grok` · `minimax` (`mmx-cli`) | 各 provider 支持的能力详见 [Capability matrix](#capability-matrix)。 @@ -95,7 +96,7 @@ polycli health polycli health ``` -`health` 会对所有已认证的 provider 跑一次端到端探针,并把存活的列在 `healthyProviders` 里。之后日常使用就直接调: +`health` 会对已认证的 provider 跑探针,并把存活的列在 `healthyProviders` 里。Claude 是例外:它只调用 `claude auth status --json`,不会发送健康检查 prompt。之后日常使用就直接调: ```text Choose Polycli with @, then ask it to run: ask --provider qwen "解释这个 stack trace ..." @@ -112,7 +113,7 @@ Choose Polycli with @, then ask it to run: rescue --provider gemini --background | 命令 | 作用 | |---|---| | `setup` | 检查 provider CLI 是否安装、是否登录(不发模型请求,便宜) | -| `health` | 端到端短 prompt 探针,返回 `healthyProviders` 并写入 timing | +| `health` | 除 Claude 外的端到端短 prompt 探针;Claude 使用 auth-only status;返回 `healthyProviders` 并在适用时写入 timing | | `ask` | 单次提问 | | `review` | 基于当前 `git diff` 做代码审查 | | `rescue` | 较长的排障 / 分析任务 | @@ -135,15 +136,18 @@ Choose Polycli with @, then ask it to run: rescue --provider gemini --background | `gemini` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `kimi` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `qwen` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| `mini-agent` | ✓ | — | — | — | — | — | — | +| `minimax` (`mmx-cli`) | ✓ | — | ✓ | — | — | — | — | | `opencode` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `pi` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | | `cmd` | ✓ | — | — | ✓ | ✓ | ✓ | — | +| `agy` | ✓ | ✓ | — | ✓ | ✓ | ✓ | — | +| `grok` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | 说明: - `cold` 和 `retry` 对所有 provider 都是 `unsupported`:上游 CLI 没有稳定信号,polycli 拒绝伪造。`total` 永远是 `measured`。 -- `mini-agent` 走日志回放协议,不支持 session resume、不支持结构化输出、不支持细粒度 streaming timing。`cmd` 走 Command Code 官方 headless 模式,每次调用都是 standalone session,stdout 就是可见答案。 +- `claude` 的 `ask` / `review` 默认走 detached tmux TUI,用来避开 `claude -p` 的按量路径。该模式下 `ttft` / `gen` / `tail` 会报 `unsupported`;`total` 只测 tmux 启动和 prompt 提交,响应里会包含 `tmuxSession` + `attachCommand`。 +- `minimax` 走 `mmx-cli` 非交互 JSON 调用,不支持 session resume、不支持细粒度 streaming timing。`cmd` 走 Command Code 官方 headless 模式,每次调用都是 standalone session,stdout 就是可见答案。`agy` 走 Antigravity session 模式但输出是纯文本;`grok` 走 xAI Grok Build CLI。 - 只有 `qwen` 声明 `tool: true`。当 `qwen` 没触发 tool 调用时报 `missing`(可观测但本次未发生),其他 provider 报 `unsupported`(能力上不跟踪)。两个状态语义不同,不要合并。 ## Timing 语义 @@ -163,6 +167,7 @@ polycli 的 timing 契约统一的是**状态表达**,不是数值。每个指 - `runtimePersistence` —— `ephemeral | session | daemon` - `measurementScope` —— `request | turn | job` +- outcome diagnostics —— `outcome`, `exitCode`, `terminationReason`, `responseMatched`, and `errorCode` ## Packages diff --git a/docs/audit/third-party-review-followup-2026-06-15.md b/docs/audit/third-party-review-followup-2026-06-15.md new file mode 100644 index 0000000..8595a94 --- /dev/null +++ b/docs/audit/third-party-review-followup-2026-06-15.md @@ -0,0 +1,49 @@ +# Third-party Review Follow-up, 2026-06-15 + +Purpose: merge the current third-party audit findings into one source-backed checklist, then track independent subagent verification and remediation. + +Scope: + +- Current worktree at ``. +- Prior Claude / DeepSeek / Minimax / Kimi / MiMo / Qwen review batches discussed in chat. +- The current active verification pass is focused on the 11 Qwen findings below, because Qwen claims all remain present after the previous remediation. + +Rules: + +- Do not treat third-party consensus as evidence. +- Each item needs a current-source verdict: `fixed`, `still-present`, `false-positive`, `mitigated`, or `not-applicable`. +- For `still-present`, add or update a focused test before production-code changes unless the item is docs-only. +- Keep the user-required Claude tmux TUI default. Do not "fix" findings by reverting Claude `ask` / `review` to `claude -p`. + +## Consolidated Findings + +| ID | Source batch | Severity | Claim | Primary paths | Verification owner | Pre-fix verdict | Handling status | +|---|---|---:|---|---|---|---|---| +| R2-M1 | Qwen | medium | Atomic save can leave orphan temp files on write/rename failure because failed paths lack tmp cleanup. | `packages/polycli-utils/src/atomic-save.js` | subagent-atomic | still-present | fixed: added temp cleanup and regression test | +| R2-M2 | Qwen | medium | Review no-changes / empty-diff early return skips runtime cleanup, leaking Gemini review temp dirs. | `plugins/polycli/scripts/polycli-companion.mjs` | subagent-review-cleanup | still-present | fixed: cleanup now runs in `finally`; integration regression added | +| R2-M3 | Qwen | medium | `terminateProcess` accepts unsafe pid values; `pid=1` can call `kill(-1)`. | `plugins/polycli/scripts/session-lifecycle-hook.mjs`, `packages/polycli-utils/src/process.js` | subagent-pid-guard | still-present | fixed: unsafe pid guards added with unit coverage | +| R1-H1 | Qwen | high | Integration tests do not cover Claude health `loggedIn:false` path. | `plugins/polycli/scripts/tests/integration.test.mjs` | subagent-claude-health-test | still-present | fixed: fake Claude auth can report logged out; integration regression added | +| R1-M1 | Qwen | medium | Plugin README command docs omit `debug` / `sessions` and TUI ownership. | `plugins/polycli/README.md` | subagent-plugin-readme | still-present | fixed: command docs and TUI ownership note added | +| R1-L3 | Qwen | low | Root README does not document Claude health as auth-only rather than prompt-probe. | `README.md`, `plugins/polycli/scripts/polycli-companion.mjs` | subagent-health-docs | still-present | fixed: health docs label Claude auth-only behavior | +| R1-L1 | Qwen | low | Translated READMEs lack the `@bbingz/polycli` terminal package badge. | `README.zh-CN.md`, `README.ja.md` | subagent-i18n-badges | still-present | fixed: badges added | +| R1-L2 | Qwen | low | Translated READMEs lack the five outcome fields. | `README.zh-CN.md`, `README.ja.md` | subagent-i18n-outcomes | still-present | fixed: outcome diagnostics fields added | +| R1-N2 | Qwen | nit | Translated capability matrices omit the `minimax` (`mmx-cli`) alias. | `README.zh-CN.md`, `README.ja.md` | subagent-i18n-minimax | still-present | fixed: matrix aliases added | +| R1-N1 | Qwen | nit | Timing schema docs lack a note that `cold` / `retry` are v1 permanently unsupported. | `packages/polycli-timing/README.md` | subagent-timing-docs | still-present | fixed: v1 unsupported note added | +| R1-N3 | Qwen | nit | Runtime README scope lacks `review-flags` / review constraint surface. | `packages/polycli-runtime/README.md` | subagent-runtime-readme | still-present | fixed: scope and public surface now list review flags | + +## Prior Batch Decisions Kept In Scope + +| Cluster | Decision | +|---|---| +| Claude ask/review synchronous response | Do not restore `claude -p` default. The product requirement is detached tmux TUI startup, with `tmuxSession` / `attachCommand` as the response. | +| Claude tmux timing | `ttft` / `gen` / `tail` are unsupported in tmux TUI mode; `total` is startup/prompt-submission time only. | +| Claude health | Auth-only health is intentional; docs and payloads must label it honestly. | +| Provider docs drift | Keep 11 providers represented across runtime, plugin docs, release validation, and host docs. | +| Path B architecture | Provider-specific parsing remains in flat runtime adapters; no shared base provider framework. | + +## Verification Log + +- 2026-06-15: spawned first explorer batch for R2-M1, R2-M2, R2-M3, R1-H1, R1-M1, R1-L3. +- 2026-06-15: remaining R1-L1, R1-L2, R1-N2, R1-N1, R1-N3 queued for second explorer batch after thread slots free. +- 2026-06-15: all 11 items independently verified as `still-present` in the current worktree before remediation. +- 2026-06-15: remediation applied for all 11 items; final status depends on the verification commands listed in the closeout response. diff --git a/docs/polycli-v1-public-surface.md b/docs/polycli-v1-public-surface.md index 67fff1b..5b04bd8 100644 --- a/docs/polycli-v1-public-surface.md +++ b/docs/polycli-v1-public-surface.md @@ -106,15 +106,17 @@ This keeps v1 small, testable, and publishable without pretending the provider m ## Provider Permission Defaults -`ask` now defaults to conservative stateless / read-only / no-tool flags wherever the upstream CLI exposes them. `rescue` remains the agentic escape hatch and may use broader provider defaults. `review` and `adversarial-review` are locked to conservative / read-only / plan mode for every provider regardless — see the override table below. +`ask` now defaults to conservative stateless / read-only / no-tool flags wherever the upstream CLI exposes them. Some upstream CLIs still expose only prompt-only or agentic session modes; those exceptions are explicit in the table below instead of being hidden behind fake flags. `rescue` remains the agentic escape hatch and may use broader provider defaults. `review` and `adversarial-review` are locked to conservative / read-only / plan mode where the provider exposes an enforceable mode; otherwise review is either prompt-only (`kimi`, `minimax`) or unsupported (`agy`). | Provider | Default flag in `ask` | Effective stance | |---|---|---| -| `claude` | `--permission-mode plan --max-turns 1 --tools "" --mcp-config '{"mcpServers":{}}' --strict-mcp-config` | plan/no tools/no MCP | +| `claude` | detached tmux TUI with `--permission-mode plan --tools "" --mcp-config '{"mcpServers":{}}' --strict-mcp-config`; no `-p`/`--max-turns` in default ask/review | plan/no tools/no MCP; returns `tmuxSession`/`attachCommand` and startup-only timing | | `gemini` | `--approval-mode plan --extensions "" --allowed-mcp-server-names __polycli_prompt_no_mcp__` | plan/no extensions/MCP | | `qwen` | `--approval-mode plan --max-session-turns 20` plus repeated `--exclude-tools ...` | bounded multi-turn/no tools; no forced one-turn cap | | `kimi` | none — `-p` one-shot rejects `--plan`/`--auto`/`--yolo` | non-interactive single-shot (kimi-code v0.6.0) | | `cmd` | `--permission-mode plan` | plan | +| `agy` | `--dangerously-skip-permissions` for ask/rescue; `/review` rejected | agentic session mode; no enforceable non-interactive plan mode | +| `grok` | `-p ... --output-format json --always-approve` for ask; review adds `--permission-mode plan` and disables always-approve | structured one-shot; plan review | | `copilot` | `--no-ask-user --excluded-tools ` without allow-all tool/path/url flags | programmatic but restricted | | `opencode` | `--agent plan` plus deny-permission config | plan/deny permissions | | `pi` | `--no-session --no-tools --no-extensions --no-skills --no-context-files` | stateless/no tools/context | diff --git a/docs/provider-paths.md b/docs/provider-paths.md index 5cab0f0..cb50683 100644 --- a/docs/provider-paths.md +++ b/docs/provider-paths.md @@ -1,6 +1,6 @@ # Provider Paths -Snapshot: 2026-05-07. Review monthly, before release, and whenever a provider CLI or local default model changes. +Snapshot: 2026-06-15. Review monthly, before release, and whenever a provider CLI or local default model changes. This table is a routing reference for humans and host adapters. It is not an automatic routing oracle. `opencode`, `pi`, and `cmd` are model routers, so their "best path" depends on the user's authenticated local model set. @@ -8,7 +8,7 @@ This table is a routing reference for humans and host adapters. It is not an aut | Model family / need | Primary Polycli provider path | Secondary path | Notes | |---|---|---|---| -| Claude Code / Anthropic coding agent | `claude` | `opencode` Anthropic models | Official CLI has headless `-p`, JSON/stream JSON, `--permission-mode`, `--tools`, and `--max-turns`. Use plan/no-tools for prompt/review, agentic mode only for rescue. | +| Claude Code / Anthropic coding agent | `claude` | `opencode` Anthropic models | Default polycli `ask`/`review` starts a detached Claude tmux TUI with plan/no-tools/no-MCP constraints and returns `tmuxSession` + `attachCommand`; it intentionally does not pass `-p`/`--max-turns`. Rescue and explicit print/headless calls still use the official headless JSON/stream JSON CLI path. | | Gemini | `gemini` | none | Official CLI headless `-p`, `--approval-mode plan`, JSON/stream JSON. Keep isolated cwd and disabled extensions/MCP for review. | | Qwen Code / Qwen Coding Plan | `qwen` | `opencode` Alibaba Coding Plan models | Official Qwen Code default `maxSessionTurns=-1` means do not force ask to one turn. Polycli ask uses a bounded `maxSteps=20`, `approvalMode=plan`, and `--exclude-tools`; review uses the same no-tool stance. SDK `canUseTool` is a better future path if Polycli moves beyond CLI wrapping. | | Kimi coding | `kimi` (kimi-code v0.6.0) | `opencode` Kimi For Coding models | The `-p` one-shot runner is non-interactive and rejects `--plan`/`--auto`/`--yolo`, so ask uses a plain `-p` invocation and review is prompt-only (like minimax). Default model from `~/.kimi-code/config.toml`. | @@ -16,6 +16,8 @@ This table is a routing reference for humans and host adapters. It is not an aut | OpenCode Go / Xiaomi MiMo / Alibaba / multi-provider routing | `opencode` | `pi` for Xiaomi MiMo | Local `opencode auth list` is the source of truth; `~/.config/opencode/opencode.json` can show an empty provider object even when credentials and models exist. Current local OpenCode includes Xiaomi Token Plan, Alibaba Coding Plan, Kimi, MiniMax, Anthropic, and OpenCode Go routes. | | Xiaomi MiMo-V2.5-Pro | `opencode` with OpenCode Go/Xiaomi Token Plan | `pi` default Xiaomi route | Screenshot state is consistent with OpenCode using Xiaomi Token Plan / OpenCode Go, not an empty provider. | | DeepSeek V4 Pro | `cmd` | `opencode-go/deepseek-v4-pro` | User's current Command Code setup routes `cmd` to DeepSeek V4 Pro. Keep `cmd` ask/review in `--permission-mode plan`; rescue may use broader agent mode. | +| Antigravity coding agent | `agy` | none | Ask/rescue use Antigravity's session mode with `--dangerously-skip-permissions` because the CLI has no enforceable non-interactive plan/read-only mode. `/review` is intentionally unsupported until the upstream CLI exposes a safe review mode. | +| xAI Grok Build CLI | `grok` | none | Uses Grok one-shot JSON/streaming JSON mode. `ask` uses `--always-approve`; `review` composes the one-shot path with `--permission-mode plan`. | | Copilot / Codex-backed fallback | `copilot` | OpenAI Responses API / Agents SDK for new direct integrations | Keep Copilot provider as a fallback, but Polycli ask/review must not pass allow-all tool/path/url flags. Use restricted `--excluded-tools` and retain `--no-ask-user` only for programmatic execution. | | OpenAI GPT / Codex direct programmatic work | not a Polycli CLI provider today | OpenAI Responses API, Agents SDK | For new stateless direct integrations, official SDK/API is more appropriate than wrapping another CLI. | @@ -35,6 +37,8 @@ claude --help copilot --help mmx text chat --help pi --help +agy --help +grok --help ``` If a CLI is not installed locally, record it as skipped rather than failing the release. If a checked flag disappears, update `plugins/polycli/scripts/lib/review.mjs`, `plugins/polycli/scripts/lib/prompt-runtime.mjs`, tests, and this table in the same change. @@ -50,4 +54,5 @@ If a CLI is not installed locally, record it as skipped rather than failing the - GitHub Copilot CLI command reference: https://docs.github.com/copilot/reference/cli-command-reference - GitHub Copilot CLI modes/autopilot: https://docs.github.com/copilot/concepts/agents/copilot-cli/about-copilot-cli - MiniMax CLI docs: https://platform.minimax.io/docs/token-plan/minimax-cli and https://github.com/MiniMax-AI/cli +- xAI Grok Build CLI docs: https://docs.x.ai/docs/grok-build/introduction - OpenAI Responses API: https://platform.openai.com/docs/api-reference/responses/create diff --git a/docs/release-notes-v0.6.20.md b/docs/release-notes-v0.6.20.md new file mode 100644 index 0000000..d7fd68d --- /dev/null +++ b/docs/release-notes-v0.6.20.md @@ -0,0 +1,37 @@ +# polycli v0.6.20 + +Patch on top of `v0.6.19` that ships **grok as the 11th provider**, migrates the kimi adapter/guidance to **kimi-code v0.6.0**, and closes the deep-review hardening slice. The Path B stance remains intact: provider modules stay flat and provider-specific parsing stays in runtime. + +## What changed + +### grok provider + +- Added `grok` for the xAI Grok Build CLI across runtime, registry, host adapter guidance, bundled plugins, release validation, and docs. +- The adapter reads structured text/session metadata from Grok output without scanning answer prose for fabricated session IDs. + +### kimi-code v0.6.0 migration + +- Updated kimi resume semantics from the legacy `-r` path to kimi-code's `--session` / `-C` contract. +- Tightened kimi session parsing around the documented `session.resume_hint` event. +- Refreshed provider guidance and provider-path docs so host agents do not use stale kimi-cli flags. + +### Deep-review hardening and release hygiene + +- Integrated the deep-review hardening set and regenerated companion bundles from source after merge. +- Kept release gates aligned with the 11-provider runtime surface, including host-map, Codex adapter, bundle, manifest, and review-drift checks. + +## Verification + +- `npm test` (483/483) +- `npm run validate:host-map` +- `npm run validate:codex-adapter` +- `npm run check:review-drift` +- `npm run release:check` + +## Release artifacts + +- GitHub release `v0.6.20` +- npm `@bbingz/polycli-opencode@0.6.20` +- npm `@bbingz/polycli@0.6.20` + +Utility packages stay on the independent v1.x cadence (`@bbingz/polycli-utils@1.0.2`, `@bbingz/polycli-timing@1.0.1`). diff --git a/docs/release-notes-v0.6.21.md b/docs/release-notes-v0.6.21.md new file mode 100644 index 0000000..3cc3d8b --- /dev/null +++ b/docs/release-notes-v0.6.21.md @@ -0,0 +1,28 @@ +# polycli v0.6.21 (draft) + +Draft patch on top of `v0.6.20`. This is not a published release note yet; keep it aligned with the current workspace until the release is cut. + +## What changed + +### Claude tmux TUI defaults + +- Claude `ask` and `review` now start a detached tmux TUI session by default instead of using the `claude -p` path. +- Successful Claude tmux launches return `detached: true`, `responseKind: "tmux_tui_session_started"`, `tmuxSession`, `attachCommand`, and a warning that the model response is visible inside the tmux session. +- Fine-grained `ttft` / `gen` / `tail` timing is `unsupported` for Claude tmux TUI mode. `total` measures only tmux startup and prompt submission, with `timingMeta.tmuxDetached: true` and `llmCompletionObserved: false`. +- Claude tmux orchestration forwards only an allowlist of Claude/Anthropic/proxy/cert environment variables into the tmux server and cleans up the created tmux session on SIGINT/SIGTERM during startup. + +### Review-remediation fixes + +- Claude auth status now treats legacy non-JSON success output as authenticated/unauthenticated when explicit text is present, and marks unknown successful output as inconclusive instead of a logout. +- The session lifecycle hook now cleans up session jobs through the locked `updateState` path instead of a naked load/save write cycle. +- Fixture freshness probes now include the current 11-provider runtime surface, including `cmd`, `agy`, and `grok`. + +### Docs and release hygiene + +- README capability notes document Claude tmux TUI timing semantics. +- Provider path and v1 public-surface docs document `agy`, `grok`, and the Claude tmux TUI default. +- `CLAUDE.md` scopes the Claude `stream-json` + `--verbose` rule to print/headless mode. + +## Verification + +- To be filled at release cut. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0a63915..ab7bdf5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -Snapshot: 2026-06-02 (PRs #5/#6/#7 merged to `main`: deep-review hardening + kimi→kimi-code v0.6.0 migration + grok as the 11th provider. Unreleased — latest published release is still v0.6.19). +Snapshot: 2026-06-15 (v0.6.20 is still the latest public release; post-v0.6.20 workspace work is tracking Claude tmux TUI defaults and review-remediation follow-up). This file lives next to `docs/release.md` (what's shipped) and `CHANGELOG.md` (what happened). It answers the complementary question: **what's open, how it's prioritized, and what we're deliberately not doing.** @@ -10,8 +10,9 @@ Living document — update when items land, when priorities shift, or when a def ## Current state -- Latest public release: **v0.6.19** — see `docs/release-notes-v0.6.19.md`. Published 2026-05-29: GitHub release + `@bbingz/polycli-opencode@0.6.19` + `@bbingz/polycli@0.6.19` all on the registry. Patch on top of v0.6.18 adds fixture/review drift guardrails and records upstream session artifacts for explicit `polycli sessions` list/purge cleanup. -- 11 providers in `main` (claude / gemini / kimi / qwen / minimax / copilot / opencode / pi / cmd / agy / grok). grok (xAI Grok Build CLI), the kimi→kimi-code v0.6.0 migration, and the deep-review hardening are merged to `main` (2026-06-02) but NOT yet in a published release — the latest release (v0.6.19) still ships 10. +- Latest public release: **v0.6.20** — see `docs/release-notes-v0.6.20.md`. Published 2026-06-02: GitHub release + `@bbingz/polycli-opencode@0.6.20` + `@bbingz/polycli@0.6.20` all on the registry. +- 11 providers ship in the latest release (claude / gemini / kimi / qwen / minimax / copilot / opencode / pi / cmd / agy / grok). v0.6.20 adds grok (xAI Grok Build CLI), the kimi→kimi-code v0.6.0 migration, and the deep-review hardening. +- Current unreleased workspace work: Claude `ask`/`review` defaults now launch a detached tmux TUI session instead of the `claude -p` path; docs/tests track the resulting `tmuxSession`/`attachCommand` response shape, startup-only timing, and auth-only health probe semantics. - 4 host plugins (polycli / polycli-codex / polycli-copilot / polycli-opencode) plus the optional `@bbingz/polycli` terminal CLI, each with an independent release manifest. - Path B architectural stance is intact: `@bbingz/polycli-utils` / `@bbingz/polycli-timing` are public v1 npm packages; `@bbingz/polycli` is the public terminal CLI surface; `@bbingz/polycli-runtime` remains an internal bundler input (`private: true`); provider modules are flat, not inherited; timing four-state semantics preserved. diff --git a/package-lock.json b/package-lock.json index d4a0dfd..44366aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,17 @@ "packages/*" ], "devDependencies": { - "esbuild": "^0.28.0", - "zod": "^4.3.6" + "esbuild": "^0.28.1", + "zod": "^4.4.3" }, "engines": { "node": ">=20" } }, + "node_modules/@bbingz/polycli": { + "resolved": "packages/polycli-terminal", + "link": true + }, "node_modules/@bbingz/polycli-runtime": { "resolved": "packages/polycli-runtime", "link": true @@ -31,9 +35,9 @@ "link": true }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -48,9 +52,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -65,9 +69,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -82,9 +86,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -99,9 +103,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -116,9 +120,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -133,9 +137,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -150,9 +154,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -167,9 +171,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -184,9 +188,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -201,9 +205,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -218,9 +222,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -235,9 +239,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -252,9 +256,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -269,9 +273,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -286,9 +290,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -303,9 +307,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -320,9 +324,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -337,9 +341,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -354,9 +358,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -371,9 +375,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -388,9 +392,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -405,9 +409,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -422,9 +426,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -439,9 +443,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -456,9 +460,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -473,9 +477,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -486,38 +490,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { @@ -529,7 +533,18 @@ "version": "1.0.0", "dependencies": { "@bbingz/polycli-timing": "1.0.1", - "@bbingz/polycli-utils": "1.0.1" + "@bbingz/polycli-utils": "1.0.2" + } + }, + "packages/polycli-terminal": { + "name": "@bbingz/polycli", + "version": "0.6.20", + "license": "MIT", + "bin": { + "polycli": "bin/polycli.mjs" + }, + "engines": { + "node": ">=20" } }, "packages/polycli-timing": { @@ -542,7 +557,7 @@ }, "packages/polycli-utils": { "name": "@bbingz/polycli-utils", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 0bb23f7..ea2e853 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "release:check": "node scripts/check-release.mjs" }, "devDependencies": { - "esbuild": "^0.28.0", - "zod": "^4.3.6" + "esbuild": "^0.28.1", + "zod": "^4.4.3" } } diff --git a/packages/polycli-runtime/README.md b/packages/polycli-runtime/README.md index aa243b9..de3bd51 100644 --- a/packages/polycli-runtime/README.md +++ b/packages/polycli-runtime/README.md @@ -2,7 +2,7 @@ `@bbingz/polycli-runtime` 是 `polycli v2` 的 provider runtime 集成层。 -它提供八家 adapter: +它提供十一家 adapter: - `claude` - `copilot` @@ -12,6 +12,9 @@ - `kimi` - `qwen` - `minimax` +- `cmd` +- `agy` +- `grok` ## Scope @@ -23,6 +26,7 @@ - foreground prompt execution - streaming execution - stream / log parsing +- review / ask hard-constraint flag vocabulary for host guards and drift checks 这个包不负责: @@ -45,6 +49,10 @@ Provider registry 常量: - `PROVIDER_IDS` - `PROVIDER_OPERATION_NAMES` +Review constraint surface: + +- `REVIEW_FLAG_EXPECTATIONS` from `review-flags.js`, used by host review read-only constraint guards and CLI drift checks. + `PROVIDER_OPERATION_NAMES` 当前明确只有: - `prompt` @@ -62,6 +70,9 @@ Provider-specific helpers: - `buildMiniMaxInvocation()` - `buildOpenCodeInvocation()` - `buildPiInvocation()` +- `buildCmdInvocation()` +- `buildAgyInvocation()` +- `buildGrokInvocation()` - `parseClaudeJsonResult()` - `parseClaudeStreamText()` - `parseCopilotStreamText()` @@ -72,6 +83,10 @@ Provider-specific helpers: - `parseOpenCodeJsonResult()` - `parseOpenCodeStreamText()` - `parsePiStreamText()` +- `parseCmdTextResult()` +- `parseAgyTextResult()` +- `parseGrokJsonResult()` +- `parseGrokStreamText()` - `extractMiniMaxResponseFromLogText()` - `extractMiniMaxLogPath()` - `extractClaudeText()` @@ -80,6 +95,9 @@ Provider-specific helpers: - `extractKimiText()` - `extractOpenCodeText()` - `extractPiText()` +- `extractCmdText()` +- `extractAgyText()` +- `extractGrokText()` - `stripAnsiSgr()` Provider-specific runtime methods: @@ -92,6 +110,9 @@ Provider-specific runtime methods: - `getMiniMaxAvailability()` / `getMiniMaxAuthStatus()` / `runMiniMaxPrompt()` / `runMiniMaxPromptStreaming()` - `getOpenCodeAvailability()` / `getOpenCodeAuthStatus()` / `runOpenCodePrompt()` / `runOpenCodePromptStreaming()` - `getPiAvailability()` / `getPiAuthStatus()` / `runPiPrompt()` / `runPiPromptStreaming()` +- `getCmdAvailability()` / `getCmdAuthStatus()` / `runCmdPrompt()` / `runCmdPromptStreaming()` +- `getAgyAvailability()` / `getAgyAuthStatus()` / `runAgyPrompt()` / `runAgyPromptStreaming()` +- `getGrokAvailability()` / `getGrokAuthStatus()` / `runGrokPrompt()` / `runGrokPromptStreaming()` Intentional omission: diff --git a/packages/polycli-runtime/src/claude.js b/packages/polycli-runtime/src/claude.js index 703cb31..46ca929 100644 --- a/packages/polycli-runtime/src/claude.js +++ b/packages/polycli-runtime/src/claude.js @@ -1,19 +1,83 @@ import { parseStreamJsonLine } from "@bbingz/polycli-utils/parse-stream-json"; import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; +import { randomUUID } from "node:crypto"; import { formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; const CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; +const CLAUDE_TMUX_BIN = process.env.POLYCLI_TMUX_BIN || "tmux"; const DEFAULT_TIMEOUT_MS = 900_000; +const TMUX_START_TIMEOUT_MS = 30_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; const PROMPT_STDIN_THRESHOLD = 100_000; const CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +const CLAUDE_TMUX_ENV_EXACT = new Set([ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BETA", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "CLAUDE_PROJECT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy", +]); +const CLAUDE_TMUX_DETACHED_WARNING = "Claude tmux TUI mode starts a detached interactive Claude TUI session; attach to read the model response. Timing covers tmux startup and prompt submission only, not LLM completion."; +const TMUX_CLEANUP_SIGNALS = ["SIGINT", "SIGTERM"]; export const TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, ]; +function shellQuote(value) { + const text = String(value ?? ""); + if (text === "") return "''"; + if (/^[A-Za-z0-9_./:=,+@%-]+$/.test(text)) return text; + return `'${text.replaceAll("'", "'\\''")}'`; +} + +function sanitizeTmuxName(value) { + const text = String(value ?? "").trim(); + const sanitized = text.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized || `polycli-claude-${randomUUID().slice(0, 8)}`; +} + +function createTmuxSessionName() { + return `polycli-claude-${randomUUID().slice(0, 8)}`; +} + +function shouldForwardClaudeTmuxEnv(key) { + return CLAUDE_TMUX_ENV_EXACT.has(key); +} + +function buildClaudeTmuxEnvironmentArgs(env) { + if (!env || typeof env !== "object") { + return []; + } + + return Object.entries(env) + .filter(([key, value]) => ( + /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) + && shouldForwardClaudeTmuxEnv(key) + && value != null + && !String(value).includes("\0") + )) + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([key, value]) => ["-e", `${key}=${String(value)}`]); +} + function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -49,6 +113,27 @@ function getClaudeErrorText(event) { return "claude returned an error"; } +function firstNonEmptyLine(text) { + return String(text || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) || ""; +} + +function parseClaudeLegacyAuthText(text) { + const detail = firstNonEmptyLine(text); + if (!detail) { + return null; + } + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail) || /\b(not authenticated|not logged in|logged out)\b/i.test(detail)) { + return { loggedIn: false, detail, model: null }; + } + if (/\b(authenticated|logged in|signed in)\b/i.test(detail)) { + return { loggedIn: true, detail, model: null }; + } + return null; +} + export function buildClaudeInvocation({ prompt, model = null, @@ -95,6 +180,338 @@ export function buildClaudeInvocation({ }; } +export function buildClaudeTuiInvocation({ + prompt, + model = null, + permissionMode = "bypassPermissions", + resumeSessionId = null, + extraArgs = [], + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + cwd = null, + env = process.env, +} = {}) { + const promptText = String(prompt ?? ""); + const sessionName = sanitizeTmuxName(tmuxSessionName || createTmuxSessionName()); + const bufferName = `${sessionName}-prompt`; + const claudeArgs = []; + + if (permissionMode) { + claudeArgs.push("--permission-mode", permissionMode); + } + if (model) { + claudeArgs.push("--model", model); + } + if (resumeSessionId) { + claudeArgs.push("--resume", resumeSessionId); + } + if (extraArgs.length > 0) { + claudeArgs.push(...extraArgs); + } + + const shellCommand = [bin, ...claudeArgs].map(shellQuote).join(" "); + const startArgs = ["new-session", "-d", "-s", sessionName]; + startArgs.push(...buildClaudeTmuxEnvironmentArgs(env)); + if (cwd) { + startArgs.push("-c", cwd); + } + startArgs.push(shellCommand); + + return { + bin: tmuxBin, + sessionName, + bufferName, + startArgs, + loadBufferArgs: ["load-buffer", "-b", bufferName, "-"], + pasteBufferArgs: ["paste-buffer", "-d", "-b", bufferName, "-t", sessionName], + sendEnterArgs: ["send-keys", "-t", sessionName, "Enter"], + input: promptText, + attachCommand: `tmux attach -t ${shellQuote(sessionName)}`, + }; +} + +function runTmuxStep(invocation, args, options = {}) { + return runCommand(invocation.bin, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + timeout: options.timeout, + }); +} + +function sleepSync(ms) { + if (!Number.isFinite(ms) || ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function describeTmuxFailure(step, result) { + if (result.error) { + if (step === "new-session" && result.error.code === "ENOENT") { + return "tmux new-session failed: tmux is required for Claude TUI mode but was not found. Install tmux or set POLYCLI_TMUX_BIN."; + } + return `tmux ${step} failed: ${result.error.message}`; + } + const detail = String(result.stderr || result.stdout || "").trim(); + return `tmux ${step} exited with code ${result.status}${detail ? `: ${detail}` : ""}`; +} + +function installTmuxSignalCleanup(invocation, { cwd, env, timeout, signalEmitter = process }) { + const state = { signal: null }; + const handlers = new Map(); + const remove = () => { + for (const [signal, handler] of handlers) { + if (typeof signalEmitter.off === "function") { + signalEmitter.off(signal, handler); + } else if (typeof signalEmitter.removeListener === "function") { + signalEmitter.removeListener(signal, handler); + } + } + handlers.clear(); + }; + const killSession = () => { + runTmuxStep(invocation, ["kill-session", "-t", invocation.sessionName], { cwd, env, timeout }); + }; + const handleSignal = (signal) => { + if (state.signal) { + return; + } + state.signal = signal; + killSession(); + remove(); + if (signalEmitter === process) { + try { + process.kill(process.pid, signal); + } catch { + process.exitCode = signal === "SIGINT" ? 130 : 143; + } + } + }; + + for (const signal of TMUX_CLEANUP_SIGNALS) { + if (typeof signalEmitter.once !== "function") { + continue; + } + const handler = () => handleSignal(signal); + handlers.set(signal, handler); + signalEmitter.once(signal, handler); + } + + return { state, remove, killSession }; +} + +function waitForClaudeTuiReady(invocation, { cwd, env, timeout }) { + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1_000 } + ); + last = captured; + if (captured.status === 0 && /Claude Code/.test(captured.stdout || "")) { + return { ok: true }; + } + sleepSync(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not report Claude readiness", + }; +} + +function firstPromptNeedle(input) { + return String(input || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) + ?.slice(0, 80) || ""; +} + +function pasteReadySignal(text, promptNeedle) { + return String(text || "") + .split(/\r?\n/) + .filter((line) => /Pasted text #|paste again to expand/i.test(line) || (promptNeedle && line.includes(promptNeedle))) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout }) { + if (!String(invocation.input || "").trim()) { + return { ok: true }; + } + + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + const promptNeedle = firstPromptNeedle(invocation.input); + let lastReadyText = null; + let stableSince = null; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1_000 } + ); + last = captured; + const text = captured.stdout || ""; + const readyText = pasteReadySignal(text, promptNeedle); + if (captured.status === 0 && readyText) { + if (readyText && readyText === lastReadyText) { + if (stableSince != null && Date.now() - stableSince >= 750) { + return { ok: true }; + } + } else { + lastReadyText = readyText; + stableSince = Date.now(); + } + } + sleepSync(100); + } + + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not show pasted prompt", + }; +} + +function runClaudeTuiPrompt({ + prompt, + model = null, + permissionMode = "bypassPermissions", + cwd, + timeout = DEFAULT_TIMEOUT_MS, + extraArgs = [], + resumeSessionId = null, + defaultModel = null, + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + env = process.env, + signalEmitter = process, +} = {}) { + const invocation = buildClaudeTuiInvocation({ + prompt, + model, + permissionMode, + resumeSessionId, + extraArgs, + bin, + tmuxBin, + tmuxSessionName, + cwd, + env, + }); + const startTimeout = Math.min(timeout || TMUX_START_TIMEOUT_MS, TMUX_START_TIMEOUT_MS); + + const start = runTmuxStep(invocation, invocation.startArgs, { cwd, env, timeout: startTimeout }); + if (start.error || start.status !== 0) { + return { ok: false, error: describeTmuxFailure("new-session", start), stdout: start.stdout, stderr: start.stderr }; + } + + const signalCleanup = installTmuxSignalCleanup(invocation, { cwd, env, timeout: startTimeout, signalEmitter }); + const interrupted = () => signalCleanup.state.signal + ? { ok: false, error: `Claude TUI tmux session interrupted by ${signalCleanup.state.signal}` } + : null; + const finish = (result) => { + signalCleanup.remove(); + return result; + }; + const killAndFinish = (result) => { + signalCleanup.killSession(); + return finish(result); + }; + + const initialInterrupt = interrupted(); + if (initialInterrupt) { + return finish(initialInterrupt); + } + + const ready = waitForClaudeTuiReady(invocation, { cwd, env, timeout: startTimeout }); + const readyInterrupt = interrupted(); + if (readyInterrupt) { + return finish(readyInterrupt); + } + if (!ready.ok) { + return killAndFinish({ ok: false, error: ready.error }); + } + + const load = runTmuxStep(invocation, invocation.loadBufferArgs, { + cwd, + env, + input: invocation.input, + timeout: startTimeout, + }); + const loadInterrupt = interrupted(); + if (loadInterrupt) { + return finish(loadInterrupt); + } + if (load.error || load.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("load-buffer", load), stdout: load.stdout, stderr: load.stderr }); + } + + const cleanupBuffer = () => { + runTmuxStep(invocation, ["delete-buffer", "-b", invocation.bufferName], { cwd, env, timeout: startTimeout }); + }; + + const paste = runTmuxStep(invocation, invocation.pasteBufferArgs, { cwd, env, timeout: startTimeout }); + const pasteInterrupt = interrupted(); + if (pasteInterrupt) { + return finish(pasteInterrupt); + } + if (paste.error || paste.status !== 0) { + cleanupBuffer(); + return killAndFinish({ ok: false, error: describeTmuxFailure("paste-buffer", paste), stdout: paste.stdout, stderr: paste.stderr }); + } + + const pasteReady = waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout: startTimeout }); + const pasteReadyInterrupt = interrupted(); + if (pasteReadyInterrupt) { + return finish(pasteReadyInterrupt); + } + if (!pasteReady.ok) { + return killAndFinish({ ok: false, error: pasteReady.error }); + } + sleepSync(250); + + const enter = runTmuxStep(invocation, invocation.sendEnterArgs, { cwd, env, timeout: startTimeout }); + const enterInterrupt = interrupted(); + if (enterInterrupt) { + return finish(enterInterrupt); + } + if (enter.error || enter.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("send-keys", enter), stdout: enter.stdout, stderr: enter.stderr }); + } + + const response = [ + `Started Claude TUI tmux session '${invocation.sessionName}'.`, + `Attach with: ${invocation.attachCommand}`, + "The prompt was pasted into the interactive session.", + ].join("\n"); + + return finish({ + ok: true, + response, + model: model ?? defaultModel, + sessionId: null, + detached: true, + responseKind: "tmux_tui_session_started", + tmuxSession: invocation.sessionName, + attachCommand: invocation.attachCommand, + warnings: [CLAUDE_TMUX_DETACHED_WARNING], + timingMeta: { + tmuxDetached: true, + timingScope: "tmux_startup", + llmCompletionObserved: false, + }, + stdout: "", + stderr: "", + }); +} + export function extractClaudeText(event) { if (!event || typeof event !== "object") { return ""; @@ -217,29 +634,63 @@ export function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -export function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { - const result = promptRunner({ - prompt: "ping", +export function getClaudeAuthStatus(cwd, { + authRunner = (options = {}) => runCommand(CLAUDE_BIN, ["auth", "status", "--json"], options), +} = {}) { + const result = authRunner({ cwd, timeout: AUTH_CHECK_TIMEOUT_MS, }); - if (result.ok) { + if (result.error) { + const detail = result.error.message || "claude auth status failed"; + if ( + result.error.code === "ETIMEDOUT" + || TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail)) + ) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; + } + + if (result.status === 0) { + let parsed = null; + try { + parsed = JSON.parse(String(result.stdout || "{}")); + } catch { + const legacy = parseClaudeLegacyAuthText(`${result.stdout || ""}\n${result.stderr || ""}`); + if (legacy) { + return legacy; + } + const detail = firstNonEmptyLine(`${result.stdout || ""}\n${result.stderr || ""}`); + return { + loggedIn: true, + detail: `auth probe inconclusive: claude auth status returned non-json output${detail ? `: ${detail}` : ""}`, + model: null, + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned non-object output", model: null }; + } + const loggedIn = parsed.loggedIn ?? parsed.authenticated; + if (typeof loggedIn !== "boolean") { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned no authentication state", model: parsed.model ?? null }; + } return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null, + loggedIn, + detail: loggedIn ? "authenticated" : "not authenticated", + model: parsed?.model ?? null, }; } // A timeout / 429 / transient probe failure must NOT regress to loggedIn:false // (the probe is inconclusive, not proof of logout). - const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + const detail = String(result.stderr || result.stdout || "").trim() || `claude auth status exited with code ${result.status}`; if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { - return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; } @@ -299,8 +750,31 @@ export function runClaudePromptStreaming({ defaultModel = null, onEvent = () => {}, bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + executionMode = "print", + env = process.env, + signalEmitter = process, spawnImpl, } = {}) { + if (executionMode === "tmux-tui") { + return Promise.resolve(runClaudeTuiPrompt({ + prompt, + model, + permissionMode, + cwd, + timeout, + extraArgs, + resumeSessionId, + defaultModel, + bin, + tmuxBin, + tmuxSessionName, + env, + signalEmitter, + })); + } + const invocation = buildClaudeInvocation({ prompt, model, @@ -316,7 +790,7 @@ export function runClaudePromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env, input: invocation.input, timeout, spawnImpl, diff --git a/packages/polycli-runtime/src/qwen.js b/packages/polycli-runtime/src/qwen.js index c10a675..f8caa21 100644 --- a/packages/polycli-runtime/src/qwen.js +++ b/packages/polycli-runtime/src/qwen.js @@ -301,14 +301,17 @@ export function runQwenPrompt({ priority: ["stdout", "stderr", "file"], }); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.status === 0 && !resultEventError && parsed.response.trim() + const hasVisibleText = Boolean(parsed.response.trim()); + const error = result.status === 0 && !resultEventError && hasVisibleText ? null - : result.stderr.trim() || resultEventError || formatProviderExitError("qwen", result.status); + : result.stderr.trim() + || resultEventError + || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); const errorCode = resultEventError ? (classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error") : classifyProviderFailure(error, { provider: "qwen" }); return { - ok: result.status === 0 && !resultEventError && Boolean(parsed.response.trim()), + ok: result.status === 0 && !resultEventError && hasVisibleText, status: result.status, stderr: result.stderr, ...parsed, diff --git a/packages/polycli-runtime/src/registry.js b/packages/polycli-runtime/src/registry.js index c709885..345c2b0 100644 --- a/packages/polycli-runtime/src/registry.js +++ b/packages/polycli-runtime/src/registry.js @@ -244,21 +244,36 @@ function getTimingSupport(provider) { }; } -function inferRuntimePersistence(provider, result) { +function getTimingSupportForRun(provider, options = {}) { const support = getTimingSupport(provider); - return support.runtimePersistence; + if (provider === "claude" && options.executionMode === "tmux-tui") { + return { ...support, ttft: false, gen: false, tail: false }; + } + return support; } -function buildTimingMeta(provider, result, meta) { +function inferRuntimePersistence(provider, result) { const support = getTimingSupport(provider); - if (support.runtimePersistence !== "session" || result?.sessionId) { - return meta; - } + return support.runtimePersistence; +} - return { +function buildTimingMeta(provider, result, meta, support = getTimingSupport(provider)) { + const merged = { ...(meta || {}), - sessionIdMissing: true, + ...(result?.timingMeta || {}), }; + + if (provider === "claude" && result?.detached === true) { + merged.tmuxDetached = true; + merged.timingScope = merged.timingScope || "tmux_startup"; + merged.llmCompletionObserved = false; + } + + if (support.runtimePersistence === "session" && !result?.sessionId) { + merged.sessionIdMissing = true; + } + + return Object.keys(merged).length > 0 ? merged : null; } function trackQwenToolTiming(event, timestamp, state) { @@ -361,7 +376,7 @@ export async function runProviderPromptStreaming({ ...options }) { const startedAt = nowMs(); - const timingSupport = getTimingSupport(provider); + const timingSupport = getTimingSupportForRun(provider, options); const selectedRuntime = runtime ?? getProviderRuntime(provider); let firstTextAt = null; let lastTextAt = null; @@ -403,6 +418,6 @@ export async function runProviderPromptStreaming({ tailMs: lastTextAt == null ? null : Math.max(finishedAt - lastTextAt, 0), toolMs: toolState.toolMs, supportedMetrics: timingSupport, - meta: buildTimingMeta(provider, result, meta), + meta: buildTimingMeta(provider, result, meta, timingSupport), }); } diff --git a/packages/polycli-runtime/test/claude.test.js b/packages/polycli-runtime/test/claude.test.js index 1c3e0e6..03984c0 100644 --- a/packages/polycli-runtime/test/claude.test.js +++ b/packages/polycli-runtime/test/claude.test.js @@ -8,6 +8,7 @@ import path from "node:path"; import { loadStreamFixture } from "./helpers/fixture-replay.mjs"; import { buildClaudeInvocation, + buildClaudeTuiInvocation, extractClaudeText, getClaudeAuthStatus, parseClaudeJsonResult, @@ -28,6 +29,28 @@ function withFakeClaudeBin(source, fn) { } } +function withFakeBin(name, source, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `polycli-${name}-sync-`)); + const bin = path.join(root, name); + fs.writeFileSync(bin, source, { mode: 0o755 }); + + let deferCleanup = false; + try { + const result = fn({ root, bin }); + if (result && typeof result.finally === "function") { + deferCleanup = true; + return result.finally(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + } + return result; + } finally { + if (!deferCleanup) { + fs.rmSync(root, { recursive: true, force: true }); + } + } +} + test("buildClaudeInvocation uses stdin for large prompts and preserves session options", () => { const prompt = "x".repeat(100_001); const invocation = buildClaudeInvocation({ @@ -73,6 +96,48 @@ test("buildClaudeInvocation enables verbose output for stream-json mode", () => ]); }); +test("buildClaudeTuiInvocation starts an interactive claude session through tmux", () => { + const invocation = buildClaudeTuiInvocation({ + prompt: "review this", + model: "claude-sonnet-4-20250514", + permissionMode: "plan", + maxTurns: 1, + resumeSessionId: "123e4567-e89b-12d3-a456-426614174000", + extraArgs: ["--tools", "", "--mcp-config", "{\"mcpServers\":{}}", "--strict-mcp-config"], + bin: "/opt/bin/claude", + tmuxBin: "/opt/bin/tmux", + tmuxSessionName: "polycli-claude-test", + cwd: "/repo", + env: { + ANTHROPIC_API_KEY: "test-key", + CLAUDE_CONFIG_DIR: "/tmp/claude", + POLYCLI_INTERNAL_SECRET: "do-not-forward", + }, + }); + + assert.equal(invocation.bin, "/opt/bin/tmux"); + assert.deepEqual(invocation.startArgs, [ + "new-session", + "-d", + "-s", + "polycli-claude-test", + "-e", + "ANTHROPIC_API_KEY=test-key", + "-e", + "CLAUDE_CONFIG_DIR=/tmp/claude", + "-c", + "/repo", + "/opt/bin/claude --permission-mode plan --model claude-sonnet-4-20250514 --resume 123e4567-e89b-12d3-a456-426614174000 --tools '' --mcp-config '{\"mcpServers\":{}}' --strict-mcp-config", + ]); + assert.deepEqual(invocation.loadBufferArgs, ["load-buffer", "-b", "polycli-claude-test-prompt", "-"]); + assert.deepEqual(invocation.pasteBufferArgs, ["paste-buffer", "-d", "-b", "polycli-claude-test-prompt", "-t", "polycli-claude-test"]); + assert.deepEqual(invocation.sendEnterArgs, ["send-keys", "-t", "polycli-claude-test", "Enter"]); + assert.equal(invocation.input, "review this"); + assert.equal(invocation.attachCommand, "tmux attach -t polycli-claude-test"); + assert.doesNotMatch(invocation.startArgs.at(-1), /(^| )-p( |$)|--print|--output-format|--max-turns/); + assert.equal(invocation.startArgs.includes("POLYCLI_INTERNAL_SECRET=do-not-forward"), false); +}); + test("parseClaudeStreamText collects session id, result metadata, and assistant text", () => { const parsed = parseClaudeStreamText( [ @@ -288,6 +353,282 @@ test("runClaudePromptStreaming treats a successful final result before timeout a assert.equal(result.sessionId, "claude-stream-timeout"); }); +test("runClaudePromptStreaming passes caller env through print mode", async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write() {}, end() {}, on() {} }; + child.kill = () => {}; + const env = { PATH: "/bin", POLYCLI_SENTINEL: "present" }; + let observedEnv = null; + + const result = await runClaudePromptStreaming({ + prompt: "ping", + env, + spawnImpl(_bin, _args, options) { + observedEnv = options.env; + queueMicrotask(() => { + child.stdout.emit("data", '{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}\n'); + child.stdout.emit("data", '{"type":"result","subtype":"success","is_error":false,"result":"pong"}\n'); + child.emit("close", 0, null); + }); + return child; + }, + }); + + assert.equal(result.ok, true); + assert.equal(observedEnv, env); +}); + +test("runClaudePromptStreaming returns a structured failure when tmux cannot start", async () => { + await withFakeBin( + "tmux", + `#!/usr/bin/env node +process.stderr.write("session failed\\n"); +process.exit(2); +`, + async ({ root, bin }) => { + const result = await runClaudePromptStreaming({ + prompt: "ping", + cwd: root, + bin: "/usr/bin/false", + tmuxBin: bin, + tmuxSessionName: "polycli-claude-fail", + executionMode: "tmux-tui", + }); + + assert.equal(result.ok, false); + assert.match(result.error, /tmux new-session exited with code 2: session failed/); + } + ); +}); + +test("runClaudePromptStreaming submits folded Claude paste markers", async () => { + await withFakeBin( + "tmux", + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +const stdin = fs.readFileSync(0, "utf8"); +if (process.env.TMUX_ARGV_LOG) { + fs.appendFileSync(process.env.TMUX_ARGV_LOG, JSON.stringify({ argv: args, stdin }) + "\\n"); +} +if (args[0] === "capture-pane") { + process.stdout.write("Claude Code\\npaste again to expand\\n"); +} +process.exit(0); +`, + async ({ root, bin }) => { + const logFile = path.join(root, "tmux.jsonl"); + const result = await runClaudePromptStreaming({ + prompt: "review this", + cwd: root, + bin: "/usr/bin/false", + tmuxBin: bin, + tmuxSessionName: "polycli-claude-folded-paste", + executionMode: "tmux-tui", + timeout: 2_000, + env: { ...process.env, TMUX_ARGV_LOG: logFile }, + }); + + const commands = fs.readFileSync(logFile, "utf8") + .trim() + .split(/\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)); + assert.equal(result.ok, true); + assert.equal(result.detached, true); + assert.equal(result.responseKind, "tmux_tui_session_started"); + assert.equal(result.timingMeta.tmuxDetached, true); + assert.equal(result.timingMeta.timingScope, "tmux_startup"); + assert.equal(result.timingMeta.llmCompletionObserved, false); + assert.match(result.warnings.join("\n"), /detached interactive Claude TUI/i); + assert.equal(commands.at(-1).argv[0], "send-keys"); + assert.match(commands.find((entry) => entry.argv[0] === "load-buffer").stdin, /review this/); + } + ); +}); + +test("runClaudePromptStreaming kills tmux session when signalled during TUI orchestration", async () => { + await withFakeBin( + "tmux", + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (process.env.TMUX_ARGV_LOG) { + fs.appendFileSync(process.env.TMUX_ARGV_LOG, JSON.stringify({ argv: args }) + "\\n"); +} +if (args[0] === "capture-pane") { + process.stdout.write("Claude Code\\n"); +} +process.exit(0); +`, + async ({ root, bin }) => { + const logFile = path.join(root, "tmux.jsonl"); + class ImmediateSigtermEmitter extends EventEmitter { + once(event, listener) { + super.once(event, listener); + if (event === "SIGTERM") { + listener(); + } + return this; + } + } + + const result = await runClaudePromptStreaming({ + prompt: "ping", + cwd: root, + bin: "/usr/bin/false", + tmuxBin: bin, + tmuxSessionName: "polycli-claude-signal", + executionMode: "tmux-tui", + timeout: 1_000, + env: { ...process.env, TMUX_ARGV_LOG: logFile }, + signalEmitter: new ImmediateSigtermEmitter(), + }); + + const commands = fs.readFileSync(logFile, "utf8") + .trim() + .split(/\n/) + .filter(Boolean) + .map((line) => JSON.parse(line).argv); + assert.equal(result.ok, false); + assert.match(result.error, /interrupted by SIGTERM/); + assert.deepEqual(commands[0].slice(0, 4), ["new-session", "-d", "-s", "polycli-claude-signal"]); + assert.match(commands[0].at(-1), /\/usr\/bin\/false/); + assert.deepEqual(commands[1], ["kill-session", "-t", "polycli-claude-signal"]); + } + ); +}); + +test("runClaudePromptStreaming kills tmux session when the TUI never becomes ready", async () => { + await withFakeBin( + "tmux", + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (process.env.TMUX_ARGV_LOG) { + fs.appendFileSync(process.env.TMUX_ARGV_LOG, JSON.stringify({ argv: args }) + "\\n"); +} +if (args[0] === "capture-pane") { + process.stdout.write("not ready\\n"); +} +process.exit(0); +`, + async ({ root, bin }) => { + const logFile = path.join(root, "tmux.jsonl"); + const result = await runClaudePromptStreaming({ + prompt: "ping", + cwd: root, + bin: "/usr/bin/false", + tmuxBin: bin, + tmuxSessionName: "polycli-claude-not-ready", + executionMode: "tmux-tui", + timeout: 1_000, + env: { ...process.env, TMUX_ARGV_LOG: logFile }, + }); + + const commands = fs.readFileSync(logFile, "utf8") + .trim() + .split(/\n/) + .filter(Boolean) + .map((line) => JSON.parse(line).argv[0]); + assert.equal(result.ok, false); + assert.match(result.error, /capture-pane/); + assert.equal(commands.includes("kill-session"), true); + } + ); +}); + +test("runClaudePromptStreaming kills tmux session when the pasted prompt never appears", async () => { + await withFakeBin( + "tmux", + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (process.env.TMUX_ARGV_LOG) { + fs.appendFileSync(process.env.TMUX_ARGV_LOG, JSON.stringify({ argv: args }) + "\\n"); +} +if (args[0] === "capture-pane") { + const stateFile = process.env.TMUX_STATE_FILE; + const count = stateFile && fs.existsSync(stateFile) ? Number.parseInt(fs.readFileSync(stateFile, "utf8"), 10) : 0; + const next = Number.isFinite(count) ? count + 1 : 1; + if (stateFile) fs.writeFileSync(stateFile, String(next)); + process.stdout.write(next === 1 ? "Claude Code\\n" : "Claude Code\\nno pasted prompt\\n"); +} +process.exit(0); +`, + async ({ root, bin }) => { + const logFile = path.join(root, "tmux.jsonl"); + const stateFile = path.join(root, "state"); + const result = await runClaudePromptStreaming({ + prompt: "ping", + cwd: root, + bin: "/usr/bin/false", + tmuxBin: bin, + tmuxSessionName: "polycli-claude-no-paste", + executionMode: "tmux-tui", + timeout: 1_000, + env: { ...process.env, TMUX_ARGV_LOG: logFile, TMUX_STATE_FILE: stateFile }, + }); + + const commands = fs.readFileSync(logFile, "utf8") + .trim() + .split(/\n/) + .filter(Boolean) + .map((line) => JSON.parse(line).argv[0]); + assert.equal(result.ok, false); + assert.match(result.error, /capture-pane/); + assert.deepEqual(commands.slice(0, 4), ["new-session", "capture-pane", "load-buffer", "paste-buffer"]); + assert.equal(commands.at(-1), "kill-session"); + } + ); +}); + +test("runClaudePromptStreaming deletes the tmux prompt buffer when paste fails", async () => { + await withFakeBin( + "tmux", + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (process.env.TMUX_ARGV_LOG) { + fs.appendFileSync(process.env.TMUX_ARGV_LOG, JSON.stringify({ argv: args }) + "\\n"); +} +if (args[0] === "capture-pane") { + process.stdout.write("Claude Code\\n"); +} +if (args[0] === "paste-buffer") { + process.stderr.write("paste failed\\n"); + process.exit(2); +} +process.exit(0); +`, + async ({ root, bin }) => { + const logFile = path.join(root, "tmux.jsonl"); + const result = await runClaudePromptStreaming({ + prompt: "ping", + cwd: root, + bin: "/usr/bin/false", + tmuxBin: bin, + tmuxSessionName: "polycli-claude-paste-fails", + executionMode: "tmux-tui", + timeout: 1_000, + env: { ...process.env, TMUX_ARGV_LOG: logFile }, + }); + + const commands = fs.readFileSync(logFile, "utf8") + .trim() + .split(/\n/) + .filter(Boolean) + .map((line) => JSON.parse(line).argv); + assert.equal(result.ok, false); + assert.match(result.error, /paste-buffer exited with code 2/); + assert.deepEqual(commands.at(-2), ["delete-buffer", "-b", "polycli-claude-paste-fails-prompt"]); + assert.deepEqual(commands.at(-1), ["kill-session", "-t", "polycli-claude-paste-fails"]); + } + ); +}); + test("runClaudePromptStreaming still fails timeout recovery when no visible text exists", async () => { const child = new EventEmitter(); child.stdout = new EventEmitter(); @@ -329,7 +670,45 @@ test("parseClaudeStreamText replays a captured real cli fixture", () => { test("getClaudeAuthStatus keeps loggedIn=true for a transient/timeout probe failure", () => { const auth = getClaudeAuthStatus(process.cwd(), { - promptRunner: () => ({ ok: false, error: "claude timed out after 30s" }), + authRunner: () => ({ status: 1, stdout: "", stderr: "claude timed out after 30s", error: null }), + }); + + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); +}); + +test("getClaudeAuthStatus keeps loggedIn=true when auth status times out", () => { + const timeout = new Error("spawnSync claude ETIMEDOUT"); + timeout.code = "ETIMEDOUT"; + const auth = getClaudeAuthStatus(process.cwd(), { + authRunner: () => ({ status: null, stdout: "", stderr: "", error: timeout }), + }); + + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); +}); + +test("getClaudeAuthStatus reads legacy non-json authenticated output", () => { + const auth = getClaudeAuthStatus(process.cwd(), { + authRunner: () => ({ status: 0, stdout: "authenticated\n", stderr: "", error: null }), + }); + + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /authenticated/i); +}); + +test("getClaudeAuthStatus reads legacy non-json logged-out output", () => { + const auth = getClaudeAuthStatus(process.cwd(), { + authRunner: () => ({ status: 0, stdout: "not authenticated\n", stderr: "", error: null }), + }); + + assert.equal(auth.loggedIn, false); + assert.match(auth.detail, /not authenticated/i); +}); + +test("getClaudeAuthStatus treats unknown non-json success output as inconclusive", () => { + const auth = getClaudeAuthStatus(process.cwd(), { + authRunner: () => ({ status: 0, stdout: "Claude Code auth status unavailable\n", stderr: "", error: null }), }); assert.equal(auth.loggedIn, true); @@ -338,7 +717,7 @@ test("getClaudeAuthStatus keeps loggedIn=true for a transient/timeout probe fail test("getClaudeAuthStatus reports loggedIn=false only on an explicit auth error", () => { const auth = getClaudeAuthStatus(process.cwd(), { - promptRunner: () => ({ ok: false, error: "401 Unauthorized: invalid api key" }), + authRunner: () => ({ status: 1, stdout: "", stderr: "401 Unauthorized: invalid api key", error: null }), }); assert.equal(auth.loggedIn, false); diff --git a/packages/polycli-runtime/test/exports.test.js b/packages/polycli-runtime/test/exports.test.js index e1da70f..1e3c413 100644 --- a/packages/polycli-runtime/test/exports.test.js +++ b/packages/polycli-runtime/test/exports.test.js @@ -11,6 +11,7 @@ test("runtime index exports expected surface", () => { "applyGeminiEffort", "buildAgyInvocation", "buildClaudeInvocation", + "buildClaudeTuiInvocation", "buildCmdInvocation", "buildCopilotInvocation", "buildGeminiEnv", diff --git a/packages/polycli-runtime/test/qwen.test.js b/packages/polycli-runtime/test/qwen.test.js index 77013f4..fc8bf1f 100644 --- a/packages/polycli-runtime/test/qwen.test.js +++ b/packages/polycli-runtime/test/qwen.test.js @@ -350,6 +350,24 @@ process.exit(2); ); }); +test("runQwenPrompt returns an explicit empty-output error on zero exit with no visible text", () => { + withFakeQwenBin( + `#!/usr/bin/env node +process.exit(0); +`, + ({ root, env }) => { + const result = runQwenPrompt({ + prompt: "ping", + cwd: root, + env, + }); + + assert.equal(result.ok, false); + assert.equal(result.error, "qwen produced no visible text"); + } + ); +}); + test("getQwenAuthStatus keeps loggedIn=true for transient probe failures", () => { const auth = getQwenAuthStatus(process.cwd(), { envBuilder() { diff --git a/packages/polycli-runtime/test/registry.test.js b/packages/polycli-runtime/test/registry.test.js index 138fb71..f4926e6 100644 --- a/packages/polycli-runtime/test/registry.test.js +++ b/packages/polycli-runtime/test/registry.test.js @@ -261,6 +261,73 @@ test("runProviderPromptStreaming passes defaultModel as final model fallback", a assert.equal(result.model, "fallback-model"); }); +test("runProviderPromptStreaming marks claude tmux TUI text timings as unsupported", async () => { + let now = 1_000; + const result = await runProviderPromptStreaming({ + provider: "claude", + prompt: "ping", + cwd: process.cwd(), + executionMode: "tmux-tui", + timeout: 5_000, + nowMs: () => now, + runtime: { + runPromptStreaming: async ({ onEvent }) => { + now = 1_200; + onEvent({ type: "content_block_delta", delta: { type: "text_delta", text: "started" } }); + now = 1_400; + return { + ok: true, + response: "Started Claude TUI tmux session 'polycli-claude-test'.", + model: "claude-test", + sessionId: null, + tmuxSession: "polycli-claude-test", + detached: true, + }; + }, + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.timing.runtimePersistence, "session"); + assert.equal(result.timing.metrics.ttft.status, "unsupported"); + assert.equal(result.timing.metrics.gen.status, "unsupported"); + assert.equal(result.timing.metrics.tail.status, "unsupported"); + assert.equal(result.timing.metrics.total.status, "measured"); + assert.equal(result.timing.meta.tmuxDetached, true); + assert.equal(result.timing.meta.timingScope, "tmux_startup"); + assert.equal(result.timing.meta.llmCompletionObserved, false); +}); + +test("runProviderPromptStreaming keeps non-claude text timing support with executionMode options", async () => { + let now = 1_000; + const result = await runProviderPromptStreaming({ + provider: "gemini", + prompt: "ping", + cwd: process.cwd(), + executionMode: "tmux-tui", + timeout: 5_000, + nowMs: () => now, + runtime: { + runPromptStreaming: async ({ onEvent }) => { + now = 1_200; + onEvent({ type: "message", role: "assistant", content: "pong" }); + now = 1_500; + return { + ok: true, + response: "pong", + model: "gemini-test", + sessionId: "123e4567-e89b-42d3-a456-426614174000", + }; + }, + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.timing.metrics.ttft.status, "measured"); + assert.equal(result.timing.metrics.gen.status, "measured"); + assert.equal(result.timing.metrics.tail.status, "measured"); +}); + test("runProviderPrompt marks supported sync-only metrics as missing instead of unsupported", async () => { const result = await runProviderPrompt({ provider: "gemini", diff --git a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs index 61c156f..c9424e0 100755 --- a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs +++ b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs @@ -4,7 +4,7 @@ import fs9 from "node:fs"; import path8 from "node:path"; import process5 from "node:process"; -import { randomUUID as randomUUID2 } from "node:crypto"; +import { randomUUID as randomUUID3 } from "node:crypto"; import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -130,23 +130,31 @@ function writeFileAtomicSync(filePath, contents, options = {}) { ensureParentDir(filePath); const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs.openSync(tmpPath, flag, mode); - try { - fs.writeFileSync(fd, contents, writeOptions); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - fs.renameSync(tmpPath, filePath); - const dirFd = fs.openSync(path.dirname(filePath), "r"); + let renamed = false; try { - fs.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + renamed = true; + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); } } finally { - fs.closeSync(dirFd); + if (!renamed) { + unlinkIfExists(tmpPath); + } } } function writeFileAtomic(filePath, contents, options = {}) { @@ -181,6 +189,7 @@ function tryReclaimStaleLock(lockPath, staleMs) { if (pid != null) { try { process2.kill(pid, 0); + return false; } catch (killError) { if (killError.code === "ESRCH") { unlinkIfExists(lockPath); @@ -190,11 +199,6 @@ function tryReclaimStaleLock(lockPath, staleMs) { throw killError; } } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } return false; } let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; @@ -253,18 +257,20 @@ var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "o var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js -function findJsonStart(text) { +function findJsonStarts(text) { + const starts = []; for (let index = 0; index < text.length; index += 1) { const slice = text.slice(index); const character = text[index]; if (character === "{" || character === "[" || character === '"' || character === "-" || /\d/.test(character)) { - return index; + starts.push(index); + continue; } if (slice.startsWith("true") || slice.startsWith("false") || slice.startsWith("null")) { - return index; + starts.push(index); } } - return -1; + return starts; } function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { const text = String(raw ?? ""); @@ -275,12 +281,31 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { let jsonCandidate = trimmed; let prefix = ""; if (allowPrefix) { - const jsonStart = findJsonStart(text); - if (jsonStart < 0) { + let lastParseError = null; + for (const jsonStart of findJsonStarts(text)) { + const candidatePrefix = text.slice(0, jsonStart); + const candidate = text.slice(jsonStart).trim(); + try { + return { + ok: true, + raw: text, + prefix: candidatePrefix, + json: candidate, + event: JSON.parse(candidate) + }; + } catch (error) { + lastParseError = { prefix: candidatePrefix, json: candidate, error: error.message }; + } + } + if (!lastParseError) { return { ok: false, kind: "non_json", raw: text }; } - prefix = text.slice(0, jsonStart); - jsonCandidate = text.slice(jsonStart).trim(); + return { + ok: false, + kind: "parse_error", + raw: text, + ...lastParseError + }; } else if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"') && !trimmed.startsWith("-") && !/^\d/.test(trimmed) && !trimmed.startsWith("true") && !trimmed.startsWith("false") && !trimmed.startsWith("null")) { return { ok: false, kind: "non_json", raw: text }; } @@ -377,7 +402,7 @@ function formatCommandFailure(result) { return parts.join(": "); } async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { - if (!Number.isInteger(pid) || pid <= 0) { + if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { @@ -399,19 +424,26 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI } return true; } + const killPid = () => { + try { + process3.kill(pid, targetSignal); + return true; + } catch (error) { + if (error.code === "ESRCH" && ignoreMissing) return false; + throw error; + } + }; try { process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { - if (ignoreMissing) return false; - throw error; + return killPid(); } if (error.code === "EINVAL") { throw error; } - process3.kill(pid, targetSignal); - return true; + return killPid(); } }; const terminated = killOnce(signal); @@ -466,6 +498,9 @@ function resolveSessionId({ return { sessionId: null, source: null }; } +// packages/polycli-runtime/src/claude.js +import { randomUUID } from "node:crypto"; + // packages/polycli-runtime/src/errors.js function formatProviderExitError(provider, status) { if (status === 124) { @@ -782,13 +817,62 @@ function spawnStreamingCommand({ // packages/polycli-runtime/src/claude.js var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; +var CLAUDE_TMUX_BIN = process.env.POLYCLI_TMUX_BIN || "tmux"; var DEFAULT_TIMEOUT_MS = 9e5; +var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BETA", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "CLAUDE_PROJECT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy" +]); +var CLAUDE_TMUX_DETACHED_WARNING = "Claude tmux TUI mode starts a detached interactive Claude TUI session; attach to read the model response. Timing covers tmux startup and prompt submission only, not LLM completion."; +var TMUX_CLEANUP_SIGNALS = ["SIGINT", "SIGTERM"]; var TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i ]; +function shellQuote(value) { + const text = String(value ?? ""); + if (text === "") return "''"; + if (/^[A-Za-z0-9_./:=,+@%-]+$/.test(text)) return text; + return `'${text.replaceAll("'", "'\\''")}'`; +} +function sanitizeTmuxName(value) { + const text = String(value ?? "").trim(); + const sanitized = text.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized || `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function createTmuxSessionName() { + return `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function shouldForwardClaudeTmuxEnv(key) { + return CLAUDE_TMUX_ENV_EXACT.has(key); +} +function buildClaudeTmuxEnvironmentArgs(env) { + if (!env || typeof env !== "object") { + return []; + } + return Object.entries(env).filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && shouldForwardClaudeTmuxEnv(key) && value != null && !String(value).includes("\0")).sort(([left], [right]) => left.localeCompare(right)).flatMap(([key, value]) => ["-e", `${key}=${String(value)}`]); +} function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -815,6 +899,22 @@ function getClaudeErrorText(event) { } return "claude returned an error"; } +function firstNonEmptyLine2(text) { + return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""; +} +function parseClaudeLegacyAuthText(text) { + const detail = firstNonEmptyLine2(text); + if (!detail) { + return null; + } + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail) || /\b(not authenticated|not logged in|logged out)\b/i.test(detail)) { + return { loggedIn: false, detail, model: null }; + } + if (/\b(authenticated|logged in|signed in)\b/i.test(detail)) { + return { loggedIn: true, detail, model: null }; + } + return null; +} function buildClaudeInvocation({ prompt, model = null, @@ -857,6 +957,299 @@ function buildClaudeInvocation({ useStdin }; } +function buildClaudeTuiInvocation({ + prompt, + model = null, + permissionMode = "bypassPermissions", + resumeSessionId = null, + extraArgs = [], + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + cwd = null, + env = process.env +} = {}) { + const promptText = String(prompt ?? ""); + const sessionName = sanitizeTmuxName(tmuxSessionName || createTmuxSessionName()); + const bufferName = `${sessionName}-prompt`; + const claudeArgs = []; + if (permissionMode) { + claudeArgs.push("--permission-mode", permissionMode); + } + if (model) { + claudeArgs.push("--model", model); + } + if (resumeSessionId) { + claudeArgs.push("--resume", resumeSessionId); + } + if (extraArgs.length > 0) { + claudeArgs.push(...extraArgs); + } + const shellCommand = [bin, ...claudeArgs].map(shellQuote).join(" "); + const startArgs = ["new-session", "-d", "-s", sessionName]; + startArgs.push(...buildClaudeTmuxEnvironmentArgs(env)); + if (cwd) { + startArgs.push("-c", cwd); + } + startArgs.push(shellCommand); + return { + bin: tmuxBin, + sessionName, + bufferName, + startArgs, + loadBufferArgs: ["load-buffer", "-b", bufferName, "-"], + pasteBufferArgs: ["paste-buffer", "-d", "-b", bufferName, "-t", sessionName], + sendEnterArgs: ["send-keys", "-t", sessionName, "Enter"], + input: promptText, + attachCommand: `tmux attach -t ${shellQuote(sessionName)}` + }; +} +function runTmuxStep(invocation, args, options = {}) { + return runCommand(invocation.bin, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + timeout: options.timeout + }); +} +function sleepSync2(ms) { + if (!Number.isFinite(ms) || ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function describeTmuxFailure(step, result) { + if (result.error) { + if (step === "new-session" && result.error.code === "ENOENT") { + return "tmux new-session failed: tmux is required for Claude TUI mode but was not found. Install tmux or set POLYCLI_TMUX_BIN."; + } + return `tmux ${step} failed: ${result.error.message}`; + } + const detail = String(result.stderr || result.stdout || "").trim(); + return `tmux ${step} exited with code ${result.status}${detail ? `: ${detail}` : ""}`; +} +function installTmuxSignalCleanup(invocation, { cwd, env, timeout, signalEmitter = process }) { + const state = { signal: null }; + const handlers = /* @__PURE__ */ new Map(); + const remove = () => { + for (const [signal, handler] of handlers) { + if (typeof signalEmitter.off === "function") { + signalEmitter.off(signal, handler); + } else if (typeof signalEmitter.removeListener === "function") { + signalEmitter.removeListener(signal, handler); + } + } + handlers.clear(); + }; + const killSession = () => { + runTmuxStep(invocation, ["kill-session", "-t", invocation.sessionName], { cwd, env, timeout }); + }; + const handleSignal = (signal) => { + if (state.signal) { + return; + } + state.signal = signal; + killSession(); + remove(); + if (signalEmitter === process) { + try { + process.kill(process.pid, signal); + } catch { + process.exitCode = signal === "SIGINT" ? 130 : 143; + } + } + }; + for (const signal of TMUX_CLEANUP_SIGNALS) { + if (typeof signalEmitter.once !== "function") { + continue; + } + const handler = () => handleSignal(signal); + handlers.set(signal, handler); + signalEmitter.once(signal, handler); + } + return { state, remove, killSession }; +} +function waitForClaudeTuiReady(invocation, { cwd, env, timeout }) { + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + if (captured.status === 0 && /Claude Code/.test(captured.stdout || "")) { + return { ok: true }; + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not report Claude readiness" + }; +} +function firstPromptNeedle(input) { + return String(input || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean)?.slice(0, 80) || ""; +} +function pasteReadySignal(text, promptNeedle) { + return String(text || "").split(/\r?\n/).filter((line) => /Pasted text #|paste again to expand/i.test(line) || promptNeedle && line.includes(promptNeedle)).join(" ").replace(/\s+/g, " ").trim(); +} +function waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout }) { + if (!String(invocation.input || "").trim()) { + return { ok: true }; + } + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + const promptNeedle = firstPromptNeedle(invocation.input); + let lastReadyText = null; + let stableSince = null; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + const text = captured.stdout || ""; + const readyText = pasteReadySignal(text, promptNeedle); + if (captured.status === 0 && readyText) { + if (readyText && readyText === lastReadyText) { + if (stableSince != null && Date.now() - stableSince >= 750) { + return { ok: true }; + } + } else { + lastReadyText = readyText; + stableSince = Date.now(); + } + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not show pasted prompt" + }; +} +function runClaudeTuiPrompt({ + prompt, + model = null, + permissionMode = "bypassPermissions", + cwd, + timeout = DEFAULT_TIMEOUT_MS, + extraArgs = [], + resumeSessionId = null, + defaultModel = null, + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + env = process.env, + signalEmitter = process +} = {}) { + const invocation = buildClaudeTuiInvocation({ + prompt, + model, + permissionMode, + resumeSessionId, + extraArgs, + bin, + tmuxBin, + tmuxSessionName, + cwd, + env + }); + const startTimeout = Math.min(timeout || TMUX_START_TIMEOUT_MS, TMUX_START_TIMEOUT_MS); + const start = runTmuxStep(invocation, invocation.startArgs, { cwd, env, timeout: startTimeout }); + if (start.error || start.status !== 0) { + return { ok: false, error: describeTmuxFailure("new-session", start), stdout: start.stdout, stderr: start.stderr }; + } + const signalCleanup = installTmuxSignalCleanup(invocation, { cwd, env, timeout: startTimeout, signalEmitter }); + const interrupted = () => signalCleanup.state.signal ? { ok: false, error: `Claude TUI tmux session interrupted by ${signalCleanup.state.signal}` } : null; + const finish = (result) => { + signalCleanup.remove(); + return result; + }; + const killAndFinish = (result) => { + signalCleanup.killSession(); + return finish(result); + }; + const initialInterrupt = interrupted(); + if (initialInterrupt) { + return finish(initialInterrupt); + } + const ready = waitForClaudeTuiReady(invocation, { cwd, env, timeout: startTimeout }); + const readyInterrupt = interrupted(); + if (readyInterrupt) { + return finish(readyInterrupt); + } + if (!ready.ok) { + return killAndFinish({ ok: false, error: ready.error }); + } + const load = runTmuxStep(invocation, invocation.loadBufferArgs, { + cwd, + env, + input: invocation.input, + timeout: startTimeout + }); + const loadInterrupt = interrupted(); + if (loadInterrupt) { + return finish(loadInterrupt); + } + if (load.error || load.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("load-buffer", load), stdout: load.stdout, stderr: load.stderr }); + } + const cleanupBuffer = () => { + runTmuxStep(invocation, ["delete-buffer", "-b", invocation.bufferName], { cwd, env, timeout: startTimeout }); + }; + const paste = runTmuxStep(invocation, invocation.pasteBufferArgs, { cwd, env, timeout: startTimeout }); + const pasteInterrupt = interrupted(); + if (pasteInterrupt) { + return finish(pasteInterrupt); + } + if (paste.error || paste.status !== 0) { + cleanupBuffer(); + return killAndFinish({ ok: false, error: describeTmuxFailure("paste-buffer", paste), stdout: paste.stdout, stderr: paste.stderr }); + } + const pasteReady = waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout: startTimeout }); + const pasteReadyInterrupt = interrupted(); + if (pasteReadyInterrupt) { + return finish(pasteReadyInterrupt); + } + if (!pasteReady.ok) { + return killAndFinish({ ok: false, error: pasteReady.error }); + } + sleepSync2(250); + const enter = runTmuxStep(invocation, invocation.sendEnterArgs, { cwd, env, timeout: startTimeout }); + const enterInterrupt = interrupted(); + if (enterInterrupt) { + return finish(enterInterrupt); + } + if (enter.error || enter.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("send-keys", enter), stdout: enter.stdout, stderr: enter.stderr }); + } + const response = [ + `Started Claude TUI tmux session '${invocation.sessionName}'.`, + `Attach with: ${invocation.attachCommand}`, + "The prompt was pasted into the interactive session." + ].join("\n"); + return finish({ + ok: true, + response, + model: model ?? defaultModel, + sessionId: null, + detached: true, + responseKind: "tmux_tui_session_started", + tmuxSession: invocation.sessionName, + attachCommand: invocation.attachCommand, + warnings: [CLAUDE_TMUX_DETACHED_WARNING], + timingMeta: { + tmuxDetached: true, + timingScope: "tmux_startup", + llmCompletionObserved: false + }, + stdout: "", + stderr: "" + }); +} function extractClaudeText(event) { if (!event || typeof event !== "object") { return ""; @@ -957,25 +1350,57 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { - const result = promptRunner({ - prompt: "ping", +function getClaudeAuthStatus(cwd, { + authRunner = (options = {}) => runCommand(CLAUDE_BIN, ["auth", "status", "--json"], options) +} = {}) { + const result = authRunner({ cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (result.ok) { + if (result.error) { + const detail2 = result.error.message || "claude auth status failed"; + if (result.error.code === "ETIMEDOUT" || TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail2))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail2}`, model: null }; + } + return { loggedIn: false, detail: detail2 }; + } + if (result.status === 0) { + let parsed = null; + try { + parsed = JSON.parse(String(result.stdout || "{}")); + } catch { + const legacy = parseClaudeLegacyAuthText(`${result.stdout || ""} +${result.stderr || ""}`); + if (legacy) { + return legacy; + } + const detail2 = firstNonEmptyLine2(`${result.stdout || ""} +${result.stderr || ""}`); + return { + loggedIn: true, + detail: `auth probe inconclusive: claude auth status returned non-json output${detail2 ? `: ${detail2}` : ""}`, + model: null + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned non-object output", model: null }; + } + const loggedIn = parsed.loggedIn ?? parsed.authenticated; + if (typeof loggedIn !== "boolean") { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned no authentication state", model: parsed.model ?? null }; + } return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null + loggedIn, + detail: loggedIn ? "authenticated" : "not authenticated", + model: parsed?.model ?? null }; } - const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + const detail = String(result.stderr || result.stdout || "").trim() || `claude auth status exited with code ${result.status}`; if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { - return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; } @@ -1029,8 +1454,30 @@ function runClaudePromptStreaming({ onEvent = () => { }, bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + executionMode = "print", + env = process.env, + signalEmitter = process, spawnImpl } = {}) { + if (executionMode === "tmux-tui") { + return Promise.resolve(runClaudeTuiPrompt({ + prompt, + model, + permissionMode, + cwd, + timeout, + extraArgs, + resumeSessionId, + defaultModel, + bin, + tmuxBin, + tmuxSessionName, + env, + signalEmitter + })); + } const invocation = buildClaudeInvocation({ prompt, model, @@ -1045,7 +1492,7 @@ function runClaudePromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env, input: invocation.input, timeout, spawnImpl, @@ -2132,10 +2579,11 @@ function runQwenPrompt({ priority: ["stdout", "stderr", "file"] }); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.status === 0 && !resultEventError && parsed.response.trim() ? null : result.stderr.trim() || resultEventError || formatProviderExitError("qwen", result.status); + const hasVisibleText = Boolean(parsed.response.trim()); + const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); return { - ok: result.status === 0 && !resultEventError && Boolean(parsed.response.trim()), + ok: result.status === 0 && !resultEventError && hasVisibleText, status: result.status, stderr: result.stderr, ...parsed, @@ -4199,19 +4647,31 @@ function getTimingSupport(provider) { runtimePersistence: "ephemeral" }; } +function getTimingSupportForRun(provider, options = {}) { + const support = getTimingSupport(provider); + if (provider === "claude" && options.executionMode === "tmux-tui") { + return { ...support, ttft: false, gen: false, tail: false }; + } + return support; +} function inferRuntimePersistence(provider, result) { const support = getTimingSupport(provider); return support.runtimePersistence; } -function buildTimingMeta(provider, result, meta) { - const support = getTimingSupport(provider); - if (support.runtimePersistence !== "session" || result?.sessionId) { - return meta; - } - return { +function buildTimingMeta(provider, result, meta, support = getTimingSupport(provider)) { + const merged = { ...meta || {}, - sessionIdMissing: true + ...result?.timingMeta || {} }; + if (provider === "claude" && result?.detached === true) { + merged.tmuxDetached = true; + merged.timingScope = merged.timingScope || "tmux_startup"; + merged.llmCompletionObserved = false; + } + if (support.runtimePersistence === "session" && !result?.sessionId) { + merged.sessionIdMissing = true; + } + return Object.keys(merged).length > 0 ? merged : null; } function trackQwenToolTiming(event, timestamp, state) { if (event?.type !== "assistant" || !Array.isArray(event.message?.content)) { @@ -4279,7 +4739,7 @@ async function runProviderPromptStreaming({ ...options }) { const startedAt = nowMs(); - const timingSupport = getTimingSupport(provider); + const timingSupport = getTimingSupportForRun(provider, options); const selectedRuntime = runtime ?? getProviderRuntime(provider); let firstTextAt = null; let lastTextAt = null; @@ -4317,7 +4777,7 @@ async function runProviderPromptStreaming({ tailMs: lastTextAt == null ? null : Math.max(finishedAt - lastTextAt, 0), toolMs: toolState.toolMs, supportedMetrics: timingSupport, - meta: buildTimingMeta(provider, result, meta) + meta: buildTimingMeta(provider, result, meta, timingSupport) }); } @@ -4672,7 +5132,7 @@ function removeJobConfigFile(workspaceRoot, jobId) { } // plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID } from "node:crypto"; +import { randomUUID as randomUUID2 } from "node:crypto"; import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js @@ -4780,7 +5240,7 @@ function resolveRunLedgerFile(workspaceRoot) { return path4.join(resolveStateDir(workspaceRoot), "run-ledger.ndjson"); } function createRunId() { - return `run_${randomUUID().replaceAll("-", "").slice(0, 20)}`; + return `run_${randomUUID2().replaceAll("-", "").slice(0, 20)}`; } function resolveRunId(options = {}, env = process.env) { const runId = options.runId || env.POLYCLI_RUN_ID || createRunId(); @@ -4894,7 +5354,7 @@ function createRunLedgerEvent(event = {}) { const commands = [...new Set(event.commands || (command ? [command] : []))].filter(Boolean).sort(); return { version: 1, - eventId: event.eventId || `evt_${randomUUID().replaceAll("-", "").slice(0, 20)}`, + eventId: event.eventId || `evt_${randomUUID2().replaceAll("-", "").slice(0, 20)}`, at, runId: event.runId || null, workspaceRoot: event.workspaceRoot || null, @@ -5528,8 +5988,8 @@ function buildPromptRuntimeOptions({ if (kind === "ask" && provider === "claude") { return { ...runtimeOptions, + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: mergeExtraArgs(runtimeOptions, [ "--tools", "", @@ -5724,8 +6184,8 @@ var REVIEW_HARD_CONSTRAINTS = { }, claude() { return { + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: ["--tools", "", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config"] }; }, @@ -6270,7 +6730,7 @@ async function inspectProviderAvailability(provider) { } function createJobId(kind) { const prefix = JOB_PREFIXES[kind] || "pj"; - return `${prefix}-${randomUUID2().slice(0, 8)}`; + return `${prefix}-${randomUUID3().slice(0, 8)}`; } function parseExecutionMode(options) { if (options.background && options.wait) { @@ -6752,6 +7212,26 @@ async function probeProviderHealth({ }; if (!inspection.available) { report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; + } else if (provider === "claude") { + try { + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + report.loggedIn = auth.loggedIn ?? false; + report.authDetail = auth.detail ?? auth.reason ?? null; + report.model = auth.model ?? report.model; + report.probe = { + ok: Boolean(auth.loggedIn), + kind: "auth_status", + authOnly: true, + responseMatched: Boolean(auth.loggedIn), + expected: "authenticated", + responsePreview: auth.detail ?? null, + error: auth.loggedIn ? null : auth.detail ?? "claude auth status did not report authenticated", + timing: null + }; + report.ok = Boolean(auth.loggedIn); + } catch (error) { + report.probe.error = error.message; + } } else { try { const result = await runProviderPromptStreaming({ @@ -7008,24 +7488,28 @@ function buildReviewExecution(rawArgs, { adversarial }) { async function runReviewCommand(rawArgs, { adversarial }) { const { options, provider, reviewContext, execution } = buildReviewExecution(rawArgs, { adversarial }); if (!reviewContext.diff.trim()) { - const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; - const workspaceRoot = resolveWorkspaceRoot(execution.cwd); - await recordRunEvent(workspaceRoot, { - command: execution.kind, - kind: execution.kind, - provider: null, - phase: "provider_decision", - status: "skipped", - reason: "no_changes" - }); - output( - options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ - ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], - "No changes to review." - ].join("\n\n"), - options.json - ); - return; + try { + const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; + const workspaceRoot = resolveWorkspaceRoot(execution.cwd); + await recordRunEvent(workspaceRoot, { + command: execution.kind, + kind: execution.kind, + provider: null, + phase: "provider_decision", + status: "skipped", + reason: "no_changes" + }); + output( + options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ + ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], + "No changes to review." + ].join("\n\n"), + options.json + ); + return; + } finally { + cleanupRuntimeOptions(execution.runtimeOptions); + } } const { background } = parseExecutionMode(options); if (background) { diff --git a/packages/polycli-timing/README.md b/packages/polycli-timing/README.md index 59bcffc..ccde98c 100644 --- a/packages/polycli-timing/README.md +++ b/packages/polycli-timing/README.md @@ -30,6 +30,7 @@ The root export mirrors `src/index.js`: ## Semantics - `unsupported`, `missing`, `zero`, and `measured` are distinct states and must not be collapsed. +- In v1, `cold` and `retry` are intentionally always `unsupported`; upstream CLIs do not expose stable signals, and polycli does not fake them. - `runtimePersistence` distinguishes `ephemeral`, `session`, and `daemon` runtimes. - `measurementScope` distinguishes `request`, `turn`, and `job` measurements. - Aggregation preserves capability-aware metric summaries plus `runtimePersistenceCounts` and `measurementScopeCounts`. diff --git a/packages/polycli-timing/test/validate.test.js b/packages/polycli-timing/test/validate.test.js index 752919d..de911b8 100644 --- a/packages/polycli-timing/test/validate.test.js +++ b/packages/polycli-timing/test/validate.test.js @@ -1,8 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; import { validateTimingRecord } from "../src/validate.js"; +const schemaPath = fileURLToPath(new URL("../timing.schema.json", import.meta.url)); + test("validateTimingRecord accepts capability-aware metric statuses", () => { const result = validateTimingRecord({ version: 1, @@ -92,3 +96,28 @@ test("validateTimingRecord rejects negative metric milliseconds", () => { assert.equal(result.ok, false); assert.match(result.errors.join("\n"), /metrics\.ttft\.ms must be > 0/); }); + +test("timing JSON schema mirrors validator status/ms and total contracts", () => { + const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + const metricBranches = schema.$defs.metric.oneOf; + const totalBranches = schema.$defs.totalMetric.oneOf; + + assert.deepEqual( + metricBranches.map((branch) => branch.properties.status.const), + ["measured", "zero", "missing", "unsupported"] + ); + assert.deepEqual( + metricBranches.map((branch) => branch.properties.ms), + [ + { type: "number", exclusiveMinimum: 0 }, + { const: 0 }, + { type: "null" }, + { type: "null" }, + ] + ); + assert.deepEqual( + totalBranches.map((branch) => branch.properties.status.const), + ["measured", "zero"] + ); + assert.equal(schema.properties.metrics.properties.total.$ref, "#/$defs/totalMetric"); +}); diff --git a/packages/polycli-timing/timing.schema.json b/packages/polycli-timing/timing.schema.json index a789ea8..19ce501 100644 --- a/packages/polycli-timing/timing.schema.json +++ b/packages/polycli-timing/timing.schema.json @@ -67,24 +67,72 @@ "tool": { "$ref": "#/$defs/metric" }, "retry": { "$ref": "#/$defs/metric" }, "tail": { "$ref": "#/$defs/metric" }, - "total": { "$ref": "#/$defs/metric" } + "total": { "$ref": "#/$defs/totalMetric" } } } }, "$defs": { "metric": { - "type": "object", - "additionalProperties": false, - "required": ["status", "ms"], - "properties": { - "status": { - "enum": ["measured", "zero", "missing", "unsupported"] + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "ms"], + "properties": { + "status": { "const": "measured" }, + "ms": { "type": "number", "exclusiveMinimum": 0 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "ms"], + "properties": { + "status": { "const": "zero" }, + "ms": { "const": 0 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "ms"], + "properties": { + "status": { "const": "missing" }, + "ms": { "type": "null" } + } }, - "ms": { - "type": ["number", "null"], - "minimum": 0 + { + "type": "object", + "additionalProperties": false, + "required": ["status", "ms"], + "properties": { + "status": { "const": "unsupported" }, + "ms": { "type": "null" } + } } - } + ] + }, + "totalMetric": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "ms"], + "properties": { + "status": { "const": "measured" }, + "ms": { "type": "number", "exclusiveMinimum": 0 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "ms"], + "properties": { + "status": { "const": "zero" }, + "ms": { "const": 0 } + } + } + ] } } } diff --git a/packages/polycli-utils/src/args.js b/packages/polycli-utils/src/args.js index e333069..d12db66 100644 --- a/packages/polycli-utils/src/args.js +++ b/packages/polycli-utils/src/args.js @@ -91,10 +91,12 @@ export function splitRawArgumentString(raw) { let current = ""; let quote = null; let escaping = false; + let tokenStarted = false; for (const character of raw) { if (escaping) { current += character; + tokenStarted = true; escaping = false; continue; } @@ -113,24 +115,28 @@ export function splitRawArgumentString(raw) { quote = null; } else { current += character; + tokenStarted = true; } continue; } if (character === "'" || character === '"') { quote = character; + tokenStarted = true; continue; } if (/\s/.test(character)) { - if (current) { + if (tokenStarted) { tokens.push(current); current = ""; + tokenStarted = false; } continue; } current += character; + tokenStarted = true; } if (escaping) { @@ -140,7 +146,7 @@ export function splitRawArgumentString(raw) { throw new Error(`Unterminated ${quote} quote in raw argument string`); } - if (current) { + if (tokenStarted) { tokens.push(current); } diff --git a/packages/polycli-utils/src/atomic-save.js b/packages/polycli-utils/src/atomic-save.js index 4f2069c..e50c309 100644 --- a/packages/polycli-utils/src/atomic-save.js +++ b/packages/polycli-utils/src/atomic-save.js @@ -50,26 +50,34 @@ function writeFileAtomicSync(filePath, contents, options = {}) { ensureParentDir(filePath); const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs.openSync(tmpPath, flag, mode); + let renamed = false; try { - fs.writeFileSync(fd, contents, writeOptions); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } - fs.renameSync(tmpPath, filePath); + fs.renameSync(tmpPath, filePath); + renamed = true; - const dirFd = fs.openSync(path.dirname(filePath), "r"); - try { - fs.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); } } finally { - fs.closeSync(dirFd); + if (!renamed) { + unlinkIfExists(tmpPath); + } } } @@ -118,6 +126,7 @@ function tryReclaimStaleLock(lockPath, staleMs) { if (pid != null) { try { process.kill(pid, 0); + return false; } catch (killError) { if (killError.code === "ESRCH") { unlinkIfExists(lockPath); @@ -126,12 +135,7 @@ function tryReclaimStaleLock(lockPath, staleMs) { if (killError.code !== "EPERM") { throw killError; } - // EPERM: owner is alive but not ours — fall through to the stale-age check. - } - const ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs != null && ageMs > staleMs) { - unlinkIfExists(lockPath); - return true; + // EPERM: owner is alive but not ours. } return false; } diff --git a/packages/polycli-utils/src/parse-stream-json.js b/packages/polycli-utils/src/parse-stream-json.js index 17d50c9..18406a6 100644 --- a/packages/polycli-utils/src/parse-stream-json.js +++ b/packages/polycli-utils/src/parse-stream-json.js @@ -1,15 +1,17 @@ -function findJsonStart(text) { +function findJsonStarts(text) { + const starts = []; for (let index = 0; index < text.length; index += 1) { const slice = text.slice(index); const character = text[index]; if (character === "{" || character === "[" || character === '"' || character === "-" || /\d/.test(character)) { - return index; + starts.push(index); + continue; } if (slice.startsWith("true") || slice.startsWith("false") || slice.startsWith("null")) { - return index; + starts.push(index); } } - return -1; + return starts; } export function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { @@ -23,12 +25,31 @@ export function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { let prefix = ""; if (allowPrefix) { - const jsonStart = findJsonStart(text); - if (jsonStart < 0) { + let lastParseError = null; + for (const jsonStart of findJsonStarts(text)) { + const candidatePrefix = text.slice(0, jsonStart); + const candidate = text.slice(jsonStart).trim(); + try { + return { + ok: true, + raw: text, + prefix: candidatePrefix, + json: candidate, + event: JSON.parse(candidate), + }; + } catch (error) { + lastParseError = { prefix: candidatePrefix, json: candidate, error: error.message }; + } + } + if (!lastParseError) { return { ok: false, kind: "non_json", raw: text }; } - prefix = text.slice(0, jsonStart); - jsonCandidate = text.slice(jsonStart).trim(); + return { + ok: false, + kind: "parse_error", + raw: text, + ...lastParseError, + }; } else if ( !trimmed.startsWith("{") && !trimmed.startsWith("[") diff --git a/packages/polycli-utils/src/process.js b/packages/polycli-utils/src/process.js index 2ace7fd..3478511 100644 --- a/packages/polycli-utils/src/process.js +++ b/packages/polycli-utils/src/process.js @@ -96,7 +96,7 @@ export async function terminateProcessTree( pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5_000, ignoreMissing = true } = {} ) { - if (!Number.isInteger(pid) || pid <= 0) { + if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } @@ -120,19 +120,27 @@ export async function terminateProcessTree( return true; } + const killPid = () => { + try { + process.kill(pid, targetSignal); + return true; + } catch (error) { + if (error.code === "ESRCH" && ignoreMissing) return false; + throw error; + } + }; + try { process.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { - if (ignoreMissing) return false; - throw error; + return killPid(); } if (error.code === "EINVAL") { throw error; } - process.kill(pid, targetSignal); - return true; + return killPid(); } }; diff --git a/packages/polycli-utils/test/args.test.js b/packages/polycli-utils/test/args.test.js index 860e36d..18f5642 100644 --- a/packages/polycli-utils/test/args.test.js +++ b/packages/polycli-utils/test/args.test.js @@ -26,6 +26,11 @@ test("splitRawArgumentString respects quotes and escapes", () => { assert.deepEqual(tokens, ["ask", "hello world", "two words", "plain value"]); }); +test("splitRawArgumentString preserves empty quoted arguments", () => { + const tokens = splitRawArgumentString(String.raw`cmd "" '' --flag="" value''`); + assert.deepEqual(tokens, ["cmd", "", "", "--flag=", "value"]); +}); + test("parseArgs supports short value options concatenated to the flag", () => { const parsed = parseArgs(["-r123e4567-e89b-12d3-a456-426614174000"], { valueOptions: ["resume"], diff --git a/packages/polycli-utils/test/atomic-save.test.js b/packages/polycli-utils/test/atomic-save.test.js index 8fdb2bf..f3bf6a2 100644 --- a/packages/polycli-utils/test/atomic-save.test.js +++ b/packages/polycli-utils/test/atomic-save.test.js @@ -54,6 +54,26 @@ test("writeFileAtomic fsyncs the temp file before rename and fsyncs the parent d assert.ok(renameIndex < dirFsyncIndex, "directory fsync should happen after rename"); }); +test("writeFileAtomic removes the temp file when rename fails", (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-atomic-save-fail-")); + const filePath = path.join(dir, "state.json"); + let tmpPath = null; + + t.mock.method(fs, "renameSync", (from) => { + tmpPath = String(from); + const error = new Error("rename failed"); + error.code = "EXDEV"; + throw error; + }); + + assert.throws( + () => writeFileAtomic(filePath, '{"ok":true}\n', "utf8"), + /rename failed/ + ); + assert.equal(fs.existsSync(tmpPath), false); + assert.equal(fs.existsSync(filePath), false); +}); + test("withLockfile does not reclaim a live owner pid", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-lock-live-")); const lockPath = path.join(dir, "state.lock"); @@ -85,14 +105,15 @@ test("withLockfile reclaims a dead owner pid", async () => { assert.equal(fs.existsSync(lockPath), false); }); -test("withLockfile reclaims a stale lock when the recorded pid appears live", (t) => { +test("withLockfile does not reclaim a stale lock while the recorded pid is live", (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-lock-reused-")); const lockPath = path.join(dir, "state.lock"); const reusedPid = process.pid + 10_000; - fs.writeFileSync(lockPath, JSON.stringify({ + const contents = JSON.stringify({ pid: reusedPid, acquiredAt: Date.now() - 1_000, - }), "utf8"); + }); + fs.writeFileSync(lockPath, contents, "utf8"); const kill = t.mock.method(process, "kill", (pid, signal) => { assert.equal(pid, reusedPid); @@ -100,15 +121,13 @@ test("withLockfile reclaims a stale lock when the recorded pid appears live", (t return true; }); - const result = withLockfile(lockPath, () => JSON.parse(fs.readFileSync(lockPath, "utf8")), { - timeoutMs: 100, - pollMs: 1, - staleMs: 25, - }); + assert.throws( + () => withLockfile(lockPath, () => "unreachable", { timeoutMs: 25, pollMs: 1, staleMs: 25 }), + LockfileTimeoutError + ); - assert.equal(result.pid, process.pid); assert.ok(kill.mock.callCount() >= 1); - assert.equal(fs.existsSync(lockPath), false); + assert.equal(fs.readFileSync(lockPath, "utf8"), contents); }); test("withLockfile reclaims a stale no-pid (partial-write) lock by mtime", () => { diff --git a/packages/polycli-utils/test/parse-stream-json.test.js b/packages/polycli-utils/test/parse-stream-json.test.js index fc7f482..56f3800 100644 --- a/packages/polycli-utils/test/parse-stream-json.test.js +++ b/packages/polycli-utils/test/parse-stream-json.test.js @@ -14,7 +14,7 @@ test("parseStreamJsonLine surfaces malformed JSON as parse_error", () => { const parsed = parseStreamJsonLine('noise {"type":'); assert.equal(parsed.ok, false); assert.equal(parsed.kind, "parse_error"); - assert.match(parsed.error, /Unexpected end|Expected/); + assert.match(parsed.error, /Unexpected end|Expected|Unterminated string/); }); test("parseStreamJsonLine accepts prefixed JSON arrays", () => { @@ -31,6 +31,13 @@ test("parseStreamJsonLine accepts prefixed bare JSON values", () => { assert.equal(parsed.prefix, "noise before "); }); +test("parseStreamJsonLine skips timestamp and pid prefixes before JSON objects", () => { + const parsed = parseStreamJsonLine('2026-06-14T10:00:00.000Z pid=42 INFO {"type":"init","session_id":"abc"}'); + assert.equal(parsed.ok, true); + assert.deepEqual(parsed.event, { type: "init", session_id: "abc" }); + assert.equal(parsed.prefix, "2026-06-14T10:00:00.000Z pid=42 INFO "); +}); + test("parseStreamJsonLine distinguishes non-json prose from blank lines", () => { const parsed = parseStreamJsonLine("this line has no json payload"); assert.equal(parsed.ok, false); diff --git a/packages/polycli-utils/test/process.test.js b/packages/polycli-utils/test/process.test.js index 0727f1a..775f282 100644 --- a/packages/polycli-utils/test/process.test.js +++ b/packages/polycli-utils/test/process.test.js @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { binaryAvailable, formatCommandFailure, runCommand } from "../src/process.js"; +import { spawn } from "node:child_process"; + +import { binaryAvailable, formatCommandFailure, runCommand, terminateProcessTree } from "../src/process.js"; test("runCommand captures stdout and exit status", () => { const result = runCommand(process.execPath, ["-e", "console.log('pong')"]); @@ -65,3 +67,34 @@ test("formatCommandFailure includes exit and stderr", () => { assert.match(message, /exit=2/); assert.match(message, /boom/); }); + +test("terminateProcessTree kills a normal non-detached child process", async () => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); + const closed = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))); + + try { + const terminated = await terminateProcessTree(child.pid, { forceAfterMs: 0 }); + assert.equal(terminated, true); + } finally { + try { + process.kill(child.pid, "SIGKILL"); + } catch {} + } + + const { signal } = await closed; + assert.equal(signal, "SIGTERM"); +}); + +test("terminateProcessTree rejects pid 1 without sending any signal", async (t) => { + const kill = t.mock.method(process, "kill", () => { + throw new Error("process.kill should not be called for pid 1"); + }); + + await assert.rejects( + () => terminateProcessTree(1, { forceAfterMs: 0 }), + /Invalid pid/ + ); + assert.equal(kill.mock.callCount(), 0); +}); diff --git a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs index 61c156f..c9424e0 100755 --- a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs @@ -4,7 +4,7 @@ import fs9 from "node:fs"; import path8 from "node:path"; import process5 from "node:process"; -import { randomUUID as randomUUID2 } from "node:crypto"; +import { randomUUID as randomUUID3 } from "node:crypto"; import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -130,23 +130,31 @@ function writeFileAtomicSync(filePath, contents, options = {}) { ensureParentDir(filePath); const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs.openSync(tmpPath, flag, mode); - try { - fs.writeFileSync(fd, contents, writeOptions); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - fs.renameSync(tmpPath, filePath); - const dirFd = fs.openSync(path.dirname(filePath), "r"); + let renamed = false; try { - fs.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + renamed = true; + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); } } finally { - fs.closeSync(dirFd); + if (!renamed) { + unlinkIfExists(tmpPath); + } } } function writeFileAtomic(filePath, contents, options = {}) { @@ -181,6 +189,7 @@ function tryReclaimStaleLock(lockPath, staleMs) { if (pid != null) { try { process2.kill(pid, 0); + return false; } catch (killError) { if (killError.code === "ESRCH") { unlinkIfExists(lockPath); @@ -190,11 +199,6 @@ function tryReclaimStaleLock(lockPath, staleMs) { throw killError; } } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } return false; } let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; @@ -253,18 +257,20 @@ var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "o var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js -function findJsonStart(text) { +function findJsonStarts(text) { + const starts = []; for (let index = 0; index < text.length; index += 1) { const slice = text.slice(index); const character = text[index]; if (character === "{" || character === "[" || character === '"' || character === "-" || /\d/.test(character)) { - return index; + starts.push(index); + continue; } if (slice.startsWith("true") || slice.startsWith("false") || slice.startsWith("null")) { - return index; + starts.push(index); } } - return -1; + return starts; } function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { const text = String(raw ?? ""); @@ -275,12 +281,31 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { let jsonCandidate = trimmed; let prefix = ""; if (allowPrefix) { - const jsonStart = findJsonStart(text); - if (jsonStart < 0) { + let lastParseError = null; + for (const jsonStart of findJsonStarts(text)) { + const candidatePrefix = text.slice(0, jsonStart); + const candidate = text.slice(jsonStart).trim(); + try { + return { + ok: true, + raw: text, + prefix: candidatePrefix, + json: candidate, + event: JSON.parse(candidate) + }; + } catch (error) { + lastParseError = { prefix: candidatePrefix, json: candidate, error: error.message }; + } + } + if (!lastParseError) { return { ok: false, kind: "non_json", raw: text }; } - prefix = text.slice(0, jsonStart); - jsonCandidate = text.slice(jsonStart).trim(); + return { + ok: false, + kind: "parse_error", + raw: text, + ...lastParseError + }; } else if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"') && !trimmed.startsWith("-") && !/^\d/.test(trimmed) && !trimmed.startsWith("true") && !trimmed.startsWith("false") && !trimmed.startsWith("null")) { return { ok: false, kind: "non_json", raw: text }; } @@ -377,7 +402,7 @@ function formatCommandFailure(result) { return parts.join(": "); } async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { - if (!Number.isInteger(pid) || pid <= 0) { + if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { @@ -399,19 +424,26 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI } return true; } + const killPid = () => { + try { + process3.kill(pid, targetSignal); + return true; + } catch (error) { + if (error.code === "ESRCH" && ignoreMissing) return false; + throw error; + } + }; try { process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { - if (ignoreMissing) return false; - throw error; + return killPid(); } if (error.code === "EINVAL") { throw error; } - process3.kill(pid, targetSignal); - return true; + return killPid(); } }; const terminated = killOnce(signal); @@ -466,6 +498,9 @@ function resolveSessionId({ return { sessionId: null, source: null }; } +// packages/polycli-runtime/src/claude.js +import { randomUUID } from "node:crypto"; + // packages/polycli-runtime/src/errors.js function formatProviderExitError(provider, status) { if (status === 124) { @@ -782,13 +817,62 @@ function spawnStreamingCommand({ // packages/polycli-runtime/src/claude.js var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; +var CLAUDE_TMUX_BIN = process.env.POLYCLI_TMUX_BIN || "tmux"; var DEFAULT_TIMEOUT_MS = 9e5; +var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BETA", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "CLAUDE_PROJECT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy" +]); +var CLAUDE_TMUX_DETACHED_WARNING = "Claude tmux TUI mode starts a detached interactive Claude TUI session; attach to read the model response. Timing covers tmux startup and prompt submission only, not LLM completion."; +var TMUX_CLEANUP_SIGNALS = ["SIGINT", "SIGTERM"]; var TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i ]; +function shellQuote(value) { + const text = String(value ?? ""); + if (text === "") return "''"; + if (/^[A-Za-z0-9_./:=,+@%-]+$/.test(text)) return text; + return `'${text.replaceAll("'", "'\\''")}'`; +} +function sanitizeTmuxName(value) { + const text = String(value ?? "").trim(); + const sanitized = text.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized || `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function createTmuxSessionName() { + return `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function shouldForwardClaudeTmuxEnv(key) { + return CLAUDE_TMUX_ENV_EXACT.has(key); +} +function buildClaudeTmuxEnvironmentArgs(env) { + if (!env || typeof env !== "object") { + return []; + } + return Object.entries(env).filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && shouldForwardClaudeTmuxEnv(key) && value != null && !String(value).includes("\0")).sort(([left], [right]) => left.localeCompare(right)).flatMap(([key, value]) => ["-e", `${key}=${String(value)}`]); +} function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -815,6 +899,22 @@ function getClaudeErrorText(event) { } return "claude returned an error"; } +function firstNonEmptyLine2(text) { + return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""; +} +function parseClaudeLegacyAuthText(text) { + const detail = firstNonEmptyLine2(text); + if (!detail) { + return null; + } + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail) || /\b(not authenticated|not logged in|logged out)\b/i.test(detail)) { + return { loggedIn: false, detail, model: null }; + } + if (/\b(authenticated|logged in|signed in)\b/i.test(detail)) { + return { loggedIn: true, detail, model: null }; + } + return null; +} function buildClaudeInvocation({ prompt, model = null, @@ -857,6 +957,299 @@ function buildClaudeInvocation({ useStdin }; } +function buildClaudeTuiInvocation({ + prompt, + model = null, + permissionMode = "bypassPermissions", + resumeSessionId = null, + extraArgs = [], + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + cwd = null, + env = process.env +} = {}) { + const promptText = String(prompt ?? ""); + const sessionName = sanitizeTmuxName(tmuxSessionName || createTmuxSessionName()); + const bufferName = `${sessionName}-prompt`; + const claudeArgs = []; + if (permissionMode) { + claudeArgs.push("--permission-mode", permissionMode); + } + if (model) { + claudeArgs.push("--model", model); + } + if (resumeSessionId) { + claudeArgs.push("--resume", resumeSessionId); + } + if (extraArgs.length > 0) { + claudeArgs.push(...extraArgs); + } + const shellCommand = [bin, ...claudeArgs].map(shellQuote).join(" "); + const startArgs = ["new-session", "-d", "-s", sessionName]; + startArgs.push(...buildClaudeTmuxEnvironmentArgs(env)); + if (cwd) { + startArgs.push("-c", cwd); + } + startArgs.push(shellCommand); + return { + bin: tmuxBin, + sessionName, + bufferName, + startArgs, + loadBufferArgs: ["load-buffer", "-b", bufferName, "-"], + pasteBufferArgs: ["paste-buffer", "-d", "-b", bufferName, "-t", sessionName], + sendEnterArgs: ["send-keys", "-t", sessionName, "Enter"], + input: promptText, + attachCommand: `tmux attach -t ${shellQuote(sessionName)}` + }; +} +function runTmuxStep(invocation, args, options = {}) { + return runCommand(invocation.bin, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + timeout: options.timeout + }); +} +function sleepSync2(ms) { + if (!Number.isFinite(ms) || ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function describeTmuxFailure(step, result) { + if (result.error) { + if (step === "new-session" && result.error.code === "ENOENT") { + return "tmux new-session failed: tmux is required for Claude TUI mode but was not found. Install tmux or set POLYCLI_TMUX_BIN."; + } + return `tmux ${step} failed: ${result.error.message}`; + } + const detail = String(result.stderr || result.stdout || "").trim(); + return `tmux ${step} exited with code ${result.status}${detail ? `: ${detail}` : ""}`; +} +function installTmuxSignalCleanup(invocation, { cwd, env, timeout, signalEmitter = process }) { + const state = { signal: null }; + const handlers = /* @__PURE__ */ new Map(); + const remove = () => { + for (const [signal, handler] of handlers) { + if (typeof signalEmitter.off === "function") { + signalEmitter.off(signal, handler); + } else if (typeof signalEmitter.removeListener === "function") { + signalEmitter.removeListener(signal, handler); + } + } + handlers.clear(); + }; + const killSession = () => { + runTmuxStep(invocation, ["kill-session", "-t", invocation.sessionName], { cwd, env, timeout }); + }; + const handleSignal = (signal) => { + if (state.signal) { + return; + } + state.signal = signal; + killSession(); + remove(); + if (signalEmitter === process) { + try { + process.kill(process.pid, signal); + } catch { + process.exitCode = signal === "SIGINT" ? 130 : 143; + } + } + }; + for (const signal of TMUX_CLEANUP_SIGNALS) { + if (typeof signalEmitter.once !== "function") { + continue; + } + const handler = () => handleSignal(signal); + handlers.set(signal, handler); + signalEmitter.once(signal, handler); + } + return { state, remove, killSession }; +} +function waitForClaudeTuiReady(invocation, { cwd, env, timeout }) { + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + if (captured.status === 0 && /Claude Code/.test(captured.stdout || "")) { + return { ok: true }; + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not report Claude readiness" + }; +} +function firstPromptNeedle(input) { + return String(input || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean)?.slice(0, 80) || ""; +} +function pasteReadySignal(text, promptNeedle) { + return String(text || "").split(/\r?\n/).filter((line) => /Pasted text #|paste again to expand/i.test(line) || promptNeedle && line.includes(promptNeedle)).join(" ").replace(/\s+/g, " ").trim(); +} +function waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout }) { + if (!String(invocation.input || "").trim()) { + return { ok: true }; + } + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + const promptNeedle = firstPromptNeedle(invocation.input); + let lastReadyText = null; + let stableSince = null; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + const text = captured.stdout || ""; + const readyText = pasteReadySignal(text, promptNeedle); + if (captured.status === 0 && readyText) { + if (readyText && readyText === lastReadyText) { + if (stableSince != null && Date.now() - stableSince >= 750) { + return { ok: true }; + } + } else { + lastReadyText = readyText; + stableSince = Date.now(); + } + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not show pasted prompt" + }; +} +function runClaudeTuiPrompt({ + prompt, + model = null, + permissionMode = "bypassPermissions", + cwd, + timeout = DEFAULT_TIMEOUT_MS, + extraArgs = [], + resumeSessionId = null, + defaultModel = null, + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + env = process.env, + signalEmitter = process +} = {}) { + const invocation = buildClaudeTuiInvocation({ + prompt, + model, + permissionMode, + resumeSessionId, + extraArgs, + bin, + tmuxBin, + tmuxSessionName, + cwd, + env + }); + const startTimeout = Math.min(timeout || TMUX_START_TIMEOUT_MS, TMUX_START_TIMEOUT_MS); + const start = runTmuxStep(invocation, invocation.startArgs, { cwd, env, timeout: startTimeout }); + if (start.error || start.status !== 0) { + return { ok: false, error: describeTmuxFailure("new-session", start), stdout: start.stdout, stderr: start.stderr }; + } + const signalCleanup = installTmuxSignalCleanup(invocation, { cwd, env, timeout: startTimeout, signalEmitter }); + const interrupted = () => signalCleanup.state.signal ? { ok: false, error: `Claude TUI tmux session interrupted by ${signalCleanup.state.signal}` } : null; + const finish = (result) => { + signalCleanup.remove(); + return result; + }; + const killAndFinish = (result) => { + signalCleanup.killSession(); + return finish(result); + }; + const initialInterrupt = interrupted(); + if (initialInterrupt) { + return finish(initialInterrupt); + } + const ready = waitForClaudeTuiReady(invocation, { cwd, env, timeout: startTimeout }); + const readyInterrupt = interrupted(); + if (readyInterrupt) { + return finish(readyInterrupt); + } + if (!ready.ok) { + return killAndFinish({ ok: false, error: ready.error }); + } + const load = runTmuxStep(invocation, invocation.loadBufferArgs, { + cwd, + env, + input: invocation.input, + timeout: startTimeout + }); + const loadInterrupt = interrupted(); + if (loadInterrupt) { + return finish(loadInterrupt); + } + if (load.error || load.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("load-buffer", load), stdout: load.stdout, stderr: load.stderr }); + } + const cleanupBuffer = () => { + runTmuxStep(invocation, ["delete-buffer", "-b", invocation.bufferName], { cwd, env, timeout: startTimeout }); + }; + const paste = runTmuxStep(invocation, invocation.pasteBufferArgs, { cwd, env, timeout: startTimeout }); + const pasteInterrupt = interrupted(); + if (pasteInterrupt) { + return finish(pasteInterrupt); + } + if (paste.error || paste.status !== 0) { + cleanupBuffer(); + return killAndFinish({ ok: false, error: describeTmuxFailure("paste-buffer", paste), stdout: paste.stdout, stderr: paste.stderr }); + } + const pasteReady = waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout: startTimeout }); + const pasteReadyInterrupt = interrupted(); + if (pasteReadyInterrupt) { + return finish(pasteReadyInterrupt); + } + if (!pasteReady.ok) { + return killAndFinish({ ok: false, error: pasteReady.error }); + } + sleepSync2(250); + const enter = runTmuxStep(invocation, invocation.sendEnterArgs, { cwd, env, timeout: startTimeout }); + const enterInterrupt = interrupted(); + if (enterInterrupt) { + return finish(enterInterrupt); + } + if (enter.error || enter.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("send-keys", enter), stdout: enter.stdout, stderr: enter.stderr }); + } + const response = [ + `Started Claude TUI tmux session '${invocation.sessionName}'.`, + `Attach with: ${invocation.attachCommand}`, + "The prompt was pasted into the interactive session." + ].join("\n"); + return finish({ + ok: true, + response, + model: model ?? defaultModel, + sessionId: null, + detached: true, + responseKind: "tmux_tui_session_started", + tmuxSession: invocation.sessionName, + attachCommand: invocation.attachCommand, + warnings: [CLAUDE_TMUX_DETACHED_WARNING], + timingMeta: { + tmuxDetached: true, + timingScope: "tmux_startup", + llmCompletionObserved: false + }, + stdout: "", + stderr: "" + }); +} function extractClaudeText(event) { if (!event || typeof event !== "object") { return ""; @@ -957,25 +1350,57 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { - const result = promptRunner({ - prompt: "ping", +function getClaudeAuthStatus(cwd, { + authRunner = (options = {}) => runCommand(CLAUDE_BIN, ["auth", "status", "--json"], options) +} = {}) { + const result = authRunner({ cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (result.ok) { + if (result.error) { + const detail2 = result.error.message || "claude auth status failed"; + if (result.error.code === "ETIMEDOUT" || TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail2))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail2}`, model: null }; + } + return { loggedIn: false, detail: detail2 }; + } + if (result.status === 0) { + let parsed = null; + try { + parsed = JSON.parse(String(result.stdout || "{}")); + } catch { + const legacy = parseClaudeLegacyAuthText(`${result.stdout || ""} +${result.stderr || ""}`); + if (legacy) { + return legacy; + } + const detail2 = firstNonEmptyLine2(`${result.stdout || ""} +${result.stderr || ""}`); + return { + loggedIn: true, + detail: `auth probe inconclusive: claude auth status returned non-json output${detail2 ? `: ${detail2}` : ""}`, + model: null + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned non-object output", model: null }; + } + const loggedIn = parsed.loggedIn ?? parsed.authenticated; + if (typeof loggedIn !== "boolean") { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned no authentication state", model: parsed.model ?? null }; + } return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null + loggedIn, + detail: loggedIn ? "authenticated" : "not authenticated", + model: parsed?.model ?? null }; } - const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + const detail = String(result.stderr || result.stdout || "").trim() || `claude auth status exited with code ${result.status}`; if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { - return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; } @@ -1029,8 +1454,30 @@ function runClaudePromptStreaming({ onEvent = () => { }, bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + executionMode = "print", + env = process.env, + signalEmitter = process, spawnImpl } = {}) { + if (executionMode === "tmux-tui") { + return Promise.resolve(runClaudeTuiPrompt({ + prompt, + model, + permissionMode, + cwd, + timeout, + extraArgs, + resumeSessionId, + defaultModel, + bin, + tmuxBin, + tmuxSessionName, + env, + signalEmitter + })); + } const invocation = buildClaudeInvocation({ prompt, model, @@ -1045,7 +1492,7 @@ function runClaudePromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env, input: invocation.input, timeout, spawnImpl, @@ -2132,10 +2579,11 @@ function runQwenPrompt({ priority: ["stdout", "stderr", "file"] }); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.status === 0 && !resultEventError && parsed.response.trim() ? null : result.stderr.trim() || resultEventError || formatProviderExitError("qwen", result.status); + const hasVisibleText = Boolean(parsed.response.trim()); + const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); return { - ok: result.status === 0 && !resultEventError && Boolean(parsed.response.trim()), + ok: result.status === 0 && !resultEventError && hasVisibleText, status: result.status, stderr: result.stderr, ...parsed, @@ -4199,19 +4647,31 @@ function getTimingSupport(provider) { runtimePersistence: "ephemeral" }; } +function getTimingSupportForRun(provider, options = {}) { + const support = getTimingSupport(provider); + if (provider === "claude" && options.executionMode === "tmux-tui") { + return { ...support, ttft: false, gen: false, tail: false }; + } + return support; +} function inferRuntimePersistence(provider, result) { const support = getTimingSupport(provider); return support.runtimePersistence; } -function buildTimingMeta(provider, result, meta) { - const support = getTimingSupport(provider); - if (support.runtimePersistence !== "session" || result?.sessionId) { - return meta; - } - return { +function buildTimingMeta(provider, result, meta, support = getTimingSupport(provider)) { + const merged = { ...meta || {}, - sessionIdMissing: true + ...result?.timingMeta || {} }; + if (provider === "claude" && result?.detached === true) { + merged.tmuxDetached = true; + merged.timingScope = merged.timingScope || "tmux_startup"; + merged.llmCompletionObserved = false; + } + if (support.runtimePersistence === "session" && !result?.sessionId) { + merged.sessionIdMissing = true; + } + return Object.keys(merged).length > 0 ? merged : null; } function trackQwenToolTiming(event, timestamp, state) { if (event?.type !== "assistant" || !Array.isArray(event.message?.content)) { @@ -4279,7 +4739,7 @@ async function runProviderPromptStreaming({ ...options }) { const startedAt = nowMs(); - const timingSupport = getTimingSupport(provider); + const timingSupport = getTimingSupportForRun(provider, options); const selectedRuntime = runtime ?? getProviderRuntime(provider); let firstTextAt = null; let lastTextAt = null; @@ -4317,7 +4777,7 @@ async function runProviderPromptStreaming({ tailMs: lastTextAt == null ? null : Math.max(finishedAt - lastTextAt, 0), toolMs: toolState.toolMs, supportedMetrics: timingSupport, - meta: buildTimingMeta(provider, result, meta) + meta: buildTimingMeta(provider, result, meta, timingSupport) }); } @@ -4672,7 +5132,7 @@ function removeJobConfigFile(workspaceRoot, jobId) { } // plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID } from "node:crypto"; +import { randomUUID as randomUUID2 } from "node:crypto"; import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js @@ -4780,7 +5240,7 @@ function resolveRunLedgerFile(workspaceRoot) { return path4.join(resolveStateDir(workspaceRoot), "run-ledger.ndjson"); } function createRunId() { - return `run_${randomUUID().replaceAll("-", "").slice(0, 20)}`; + return `run_${randomUUID2().replaceAll("-", "").slice(0, 20)}`; } function resolveRunId(options = {}, env = process.env) { const runId = options.runId || env.POLYCLI_RUN_ID || createRunId(); @@ -4894,7 +5354,7 @@ function createRunLedgerEvent(event = {}) { const commands = [...new Set(event.commands || (command ? [command] : []))].filter(Boolean).sort(); return { version: 1, - eventId: event.eventId || `evt_${randomUUID().replaceAll("-", "").slice(0, 20)}`, + eventId: event.eventId || `evt_${randomUUID2().replaceAll("-", "").slice(0, 20)}`, at, runId: event.runId || null, workspaceRoot: event.workspaceRoot || null, @@ -5528,8 +5988,8 @@ function buildPromptRuntimeOptions({ if (kind === "ask" && provider === "claude") { return { ...runtimeOptions, + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: mergeExtraArgs(runtimeOptions, [ "--tools", "", @@ -5724,8 +6184,8 @@ var REVIEW_HARD_CONSTRAINTS = { }, claude() { return { + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: ["--tools", "", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config"] }; }, @@ -6270,7 +6730,7 @@ async function inspectProviderAvailability(provider) { } function createJobId(kind) { const prefix = JOB_PREFIXES[kind] || "pj"; - return `${prefix}-${randomUUID2().slice(0, 8)}`; + return `${prefix}-${randomUUID3().slice(0, 8)}`; } function parseExecutionMode(options) { if (options.background && options.wait) { @@ -6752,6 +7212,26 @@ async function probeProviderHealth({ }; if (!inspection.available) { report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; + } else if (provider === "claude") { + try { + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + report.loggedIn = auth.loggedIn ?? false; + report.authDetail = auth.detail ?? auth.reason ?? null; + report.model = auth.model ?? report.model; + report.probe = { + ok: Boolean(auth.loggedIn), + kind: "auth_status", + authOnly: true, + responseMatched: Boolean(auth.loggedIn), + expected: "authenticated", + responsePreview: auth.detail ?? null, + error: auth.loggedIn ? null : auth.detail ?? "claude auth status did not report authenticated", + timing: null + }; + report.ok = Boolean(auth.loggedIn); + } catch (error) { + report.probe.error = error.message; + } } else { try { const result = await runProviderPromptStreaming({ @@ -7008,24 +7488,28 @@ function buildReviewExecution(rawArgs, { adversarial }) { async function runReviewCommand(rawArgs, { adversarial }) { const { options, provider, reviewContext, execution } = buildReviewExecution(rawArgs, { adversarial }); if (!reviewContext.diff.trim()) { - const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; - const workspaceRoot = resolveWorkspaceRoot(execution.cwd); - await recordRunEvent(workspaceRoot, { - command: execution.kind, - kind: execution.kind, - provider: null, - phase: "provider_decision", - status: "skipped", - reason: "no_changes" - }); - output( - options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ - ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], - "No changes to review." - ].join("\n\n"), - options.json - ); - return; + try { + const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; + const workspaceRoot = resolveWorkspaceRoot(execution.cwd); + await recordRunEvent(workspaceRoot, { + command: execution.kind, + kind: execution.kind, + provider: null, + phase: "provider_decision", + status: "skipped", + reason: "no_changes" + }); + output( + options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ + ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], + "No changes to review." + ].join("\n\n"), + options.json + ); + return; + } finally { + cleanupRuntimeOptions(execution.runtimeOptions); + } } const { background } = parseExecutionMode(options); if (background) { diff --git a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs index 61c156f..c9424e0 100755 --- a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs @@ -4,7 +4,7 @@ import fs9 from "node:fs"; import path8 from "node:path"; import process5 from "node:process"; -import { randomUUID as randomUUID2 } from "node:crypto"; +import { randomUUID as randomUUID3 } from "node:crypto"; import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -130,23 +130,31 @@ function writeFileAtomicSync(filePath, contents, options = {}) { ensureParentDir(filePath); const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs.openSync(tmpPath, flag, mode); - try { - fs.writeFileSync(fd, contents, writeOptions); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - fs.renameSync(tmpPath, filePath); - const dirFd = fs.openSync(path.dirname(filePath), "r"); + let renamed = false; try { - fs.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + renamed = true; + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); } } finally { - fs.closeSync(dirFd); + if (!renamed) { + unlinkIfExists(tmpPath); + } } } function writeFileAtomic(filePath, contents, options = {}) { @@ -181,6 +189,7 @@ function tryReclaimStaleLock(lockPath, staleMs) { if (pid != null) { try { process2.kill(pid, 0); + return false; } catch (killError) { if (killError.code === "ESRCH") { unlinkIfExists(lockPath); @@ -190,11 +199,6 @@ function tryReclaimStaleLock(lockPath, staleMs) { throw killError; } } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } return false; } let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; @@ -253,18 +257,20 @@ var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "o var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js -function findJsonStart(text) { +function findJsonStarts(text) { + const starts = []; for (let index = 0; index < text.length; index += 1) { const slice = text.slice(index); const character = text[index]; if (character === "{" || character === "[" || character === '"' || character === "-" || /\d/.test(character)) { - return index; + starts.push(index); + continue; } if (slice.startsWith("true") || slice.startsWith("false") || slice.startsWith("null")) { - return index; + starts.push(index); } } - return -1; + return starts; } function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { const text = String(raw ?? ""); @@ -275,12 +281,31 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { let jsonCandidate = trimmed; let prefix = ""; if (allowPrefix) { - const jsonStart = findJsonStart(text); - if (jsonStart < 0) { + let lastParseError = null; + for (const jsonStart of findJsonStarts(text)) { + const candidatePrefix = text.slice(0, jsonStart); + const candidate = text.slice(jsonStart).trim(); + try { + return { + ok: true, + raw: text, + prefix: candidatePrefix, + json: candidate, + event: JSON.parse(candidate) + }; + } catch (error) { + lastParseError = { prefix: candidatePrefix, json: candidate, error: error.message }; + } + } + if (!lastParseError) { return { ok: false, kind: "non_json", raw: text }; } - prefix = text.slice(0, jsonStart); - jsonCandidate = text.slice(jsonStart).trim(); + return { + ok: false, + kind: "parse_error", + raw: text, + ...lastParseError + }; } else if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"') && !trimmed.startsWith("-") && !/^\d/.test(trimmed) && !trimmed.startsWith("true") && !trimmed.startsWith("false") && !trimmed.startsWith("null")) { return { ok: false, kind: "non_json", raw: text }; } @@ -377,7 +402,7 @@ function formatCommandFailure(result) { return parts.join(": "); } async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { - if (!Number.isInteger(pid) || pid <= 0) { + if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { @@ -399,19 +424,26 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI } return true; } + const killPid = () => { + try { + process3.kill(pid, targetSignal); + return true; + } catch (error) { + if (error.code === "ESRCH" && ignoreMissing) return false; + throw error; + } + }; try { process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { - if (ignoreMissing) return false; - throw error; + return killPid(); } if (error.code === "EINVAL") { throw error; } - process3.kill(pid, targetSignal); - return true; + return killPid(); } }; const terminated = killOnce(signal); @@ -466,6 +498,9 @@ function resolveSessionId({ return { sessionId: null, source: null }; } +// packages/polycli-runtime/src/claude.js +import { randomUUID } from "node:crypto"; + // packages/polycli-runtime/src/errors.js function formatProviderExitError(provider, status) { if (status === 124) { @@ -782,13 +817,62 @@ function spawnStreamingCommand({ // packages/polycli-runtime/src/claude.js var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; +var CLAUDE_TMUX_BIN = process.env.POLYCLI_TMUX_BIN || "tmux"; var DEFAULT_TIMEOUT_MS = 9e5; +var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BETA", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "CLAUDE_PROJECT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy" +]); +var CLAUDE_TMUX_DETACHED_WARNING = "Claude tmux TUI mode starts a detached interactive Claude TUI session; attach to read the model response. Timing covers tmux startup and prompt submission only, not LLM completion."; +var TMUX_CLEANUP_SIGNALS = ["SIGINT", "SIGTERM"]; var TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i ]; +function shellQuote(value) { + const text = String(value ?? ""); + if (text === "") return "''"; + if (/^[A-Za-z0-9_./:=,+@%-]+$/.test(text)) return text; + return `'${text.replaceAll("'", "'\\''")}'`; +} +function sanitizeTmuxName(value) { + const text = String(value ?? "").trim(); + const sanitized = text.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized || `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function createTmuxSessionName() { + return `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function shouldForwardClaudeTmuxEnv(key) { + return CLAUDE_TMUX_ENV_EXACT.has(key); +} +function buildClaudeTmuxEnvironmentArgs(env) { + if (!env || typeof env !== "object") { + return []; + } + return Object.entries(env).filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && shouldForwardClaudeTmuxEnv(key) && value != null && !String(value).includes("\0")).sort(([left], [right]) => left.localeCompare(right)).flatMap(([key, value]) => ["-e", `${key}=${String(value)}`]); +} function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -815,6 +899,22 @@ function getClaudeErrorText(event) { } return "claude returned an error"; } +function firstNonEmptyLine2(text) { + return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""; +} +function parseClaudeLegacyAuthText(text) { + const detail = firstNonEmptyLine2(text); + if (!detail) { + return null; + } + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail) || /\b(not authenticated|not logged in|logged out)\b/i.test(detail)) { + return { loggedIn: false, detail, model: null }; + } + if (/\b(authenticated|logged in|signed in)\b/i.test(detail)) { + return { loggedIn: true, detail, model: null }; + } + return null; +} function buildClaudeInvocation({ prompt, model = null, @@ -857,6 +957,299 @@ function buildClaudeInvocation({ useStdin }; } +function buildClaudeTuiInvocation({ + prompt, + model = null, + permissionMode = "bypassPermissions", + resumeSessionId = null, + extraArgs = [], + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + cwd = null, + env = process.env +} = {}) { + const promptText = String(prompt ?? ""); + const sessionName = sanitizeTmuxName(tmuxSessionName || createTmuxSessionName()); + const bufferName = `${sessionName}-prompt`; + const claudeArgs = []; + if (permissionMode) { + claudeArgs.push("--permission-mode", permissionMode); + } + if (model) { + claudeArgs.push("--model", model); + } + if (resumeSessionId) { + claudeArgs.push("--resume", resumeSessionId); + } + if (extraArgs.length > 0) { + claudeArgs.push(...extraArgs); + } + const shellCommand = [bin, ...claudeArgs].map(shellQuote).join(" "); + const startArgs = ["new-session", "-d", "-s", sessionName]; + startArgs.push(...buildClaudeTmuxEnvironmentArgs(env)); + if (cwd) { + startArgs.push("-c", cwd); + } + startArgs.push(shellCommand); + return { + bin: tmuxBin, + sessionName, + bufferName, + startArgs, + loadBufferArgs: ["load-buffer", "-b", bufferName, "-"], + pasteBufferArgs: ["paste-buffer", "-d", "-b", bufferName, "-t", sessionName], + sendEnterArgs: ["send-keys", "-t", sessionName, "Enter"], + input: promptText, + attachCommand: `tmux attach -t ${shellQuote(sessionName)}` + }; +} +function runTmuxStep(invocation, args, options = {}) { + return runCommand(invocation.bin, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + timeout: options.timeout + }); +} +function sleepSync2(ms) { + if (!Number.isFinite(ms) || ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function describeTmuxFailure(step, result) { + if (result.error) { + if (step === "new-session" && result.error.code === "ENOENT") { + return "tmux new-session failed: tmux is required for Claude TUI mode but was not found. Install tmux or set POLYCLI_TMUX_BIN."; + } + return `tmux ${step} failed: ${result.error.message}`; + } + const detail = String(result.stderr || result.stdout || "").trim(); + return `tmux ${step} exited with code ${result.status}${detail ? `: ${detail}` : ""}`; +} +function installTmuxSignalCleanup(invocation, { cwd, env, timeout, signalEmitter = process }) { + const state = { signal: null }; + const handlers = /* @__PURE__ */ new Map(); + const remove = () => { + for (const [signal, handler] of handlers) { + if (typeof signalEmitter.off === "function") { + signalEmitter.off(signal, handler); + } else if (typeof signalEmitter.removeListener === "function") { + signalEmitter.removeListener(signal, handler); + } + } + handlers.clear(); + }; + const killSession = () => { + runTmuxStep(invocation, ["kill-session", "-t", invocation.sessionName], { cwd, env, timeout }); + }; + const handleSignal = (signal) => { + if (state.signal) { + return; + } + state.signal = signal; + killSession(); + remove(); + if (signalEmitter === process) { + try { + process.kill(process.pid, signal); + } catch { + process.exitCode = signal === "SIGINT" ? 130 : 143; + } + } + }; + for (const signal of TMUX_CLEANUP_SIGNALS) { + if (typeof signalEmitter.once !== "function") { + continue; + } + const handler = () => handleSignal(signal); + handlers.set(signal, handler); + signalEmitter.once(signal, handler); + } + return { state, remove, killSession }; +} +function waitForClaudeTuiReady(invocation, { cwd, env, timeout }) { + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + if (captured.status === 0 && /Claude Code/.test(captured.stdout || "")) { + return { ok: true }; + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not report Claude readiness" + }; +} +function firstPromptNeedle(input) { + return String(input || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean)?.slice(0, 80) || ""; +} +function pasteReadySignal(text, promptNeedle) { + return String(text || "").split(/\r?\n/).filter((line) => /Pasted text #|paste again to expand/i.test(line) || promptNeedle && line.includes(promptNeedle)).join(" ").replace(/\s+/g, " ").trim(); +} +function waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout }) { + if (!String(invocation.input || "").trim()) { + return { ok: true }; + } + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + const promptNeedle = firstPromptNeedle(invocation.input); + let lastReadyText = null; + let stableSince = null; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + const text = captured.stdout || ""; + const readyText = pasteReadySignal(text, promptNeedle); + if (captured.status === 0 && readyText) { + if (readyText && readyText === lastReadyText) { + if (stableSince != null && Date.now() - stableSince >= 750) { + return { ok: true }; + } + } else { + lastReadyText = readyText; + stableSince = Date.now(); + } + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not show pasted prompt" + }; +} +function runClaudeTuiPrompt({ + prompt, + model = null, + permissionMode = "bypassPermissions", + cwd, + timeout = DEFAULT_TIMEOUT_MS, + extraArgs = [], + resumeSessionId = null, + defaultModel = null, + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + env = process.env, + signalEmitter = process +} = {}) { + const invocation = buildClaudeTuiInvocation({ + prompt, + model, + permissionMode, + resumeSessionId, + extraArgs, + bin, + tmuxBin, + tmuxSessionName, + cwd, + env + }); + const startTimeout = Math.min(timeout || TMUX_START_TIMEOUT_MS, TMUX_START_TIMEOUT_MS); + const start = runTmuxStep(invocation, invocation.startArgs, { cwd, env, timeout: startTimeout }); + if (start.error || start.status !== 0) { + return { ok: false, error: describeTmuxFailure("new-session", start), stdout: start.stdout, stderr: start.stderr }; + } + const signalCleanup = installTmuxSignalCleanup(invocation, { cwd, env, timeout: startTimeout, signalEmitter }); + const interrupted = () => signalCleanup.state.signal ? { ok: false, error: `Claude TUI tmux session interrupted by ${signalCleanup.state.signal}` } : null; + const finish = (result) => { + signalCleanup.remove(); + return result; + }; + const killAndFinish = (result) => { + signalCleanup.killSession(); + return finish(result); + }; + const initialInterrupt = interrupted(); + if (initialInterrupt) { + return finish(initialInterrupt); + } + const ready = waitForClaudeTuiReady(invocation, { cwd, env, timeout: startTimeout }); + const readyInterrupt = interrupted(); + if (readyInterrupt) { + return finish(readyInterrupt); + } + if (!ready.ok) { + return killAndFinish({ ok: false, error: ready.error }); + } + const load = runTmuxStep(invocation, invocation.loadBufferArgs, { + cwd, + env, + input: invocation.input, + timeout: startTimeout + }); + const loadInterrupt = interrupted(); + if (loadInterrupt) { + return finish(loadInterrupt); + } + if (load.error || load.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("load-buffer", load), stdout: load.stdout, stderr: load.stderr }); + } + const cleanupBuffer = () => { + runTmuxStep(invocation, ["delete-buffer", "-b", invocation.bufferName], { cwd, env, timeout: startTimeout }); + }; + const paste = runTmuxStep(invocation, invocation.pasteBufferArgs, { cwd, env, timeout: startTimeout }); + const pasteInterrupt = interrupted(); + if (pasteInterrupt) { + return finish(pasteInterrupt); + } + if (paste.error || paste.status !== 0) { + cleanupBuffer(); + return killAndFinish({ ok: false, error: describeTmuxFailure("paste-buffer", paste), stdout: paste.stdout, stderr: paste.stderr }); + } + const pasteReady = waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout: startTimeout }); + const pasteReadyInterrupt = interrupted(); + if (pasteReadyInterrupt) { + return finish(pasteReadyInterrupt); + } + if (!pasteReady.ok) { + return killAndFinish({ ok: false, error: pasteReady.error }); + } + sleepSync2(250); + const enter = runTmuxStep(invocation, invocation.sendEnterArgs, { cwd, env, timeout: startTimeout }); + const enterInterrupt = interrupted(); + if (enterInterrupt) { + return finish(enterInterrupt); + } + if (enter.error || enter.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("send-keys", enter), stdout: enter.stdout, stderr: enter.stderr }); + } + const response = [ + `Started Claude TUI tmux session '${invocation.sessionName}'.`, + `Attach with: ${invocation.attachCommand}`, + "The prompt was pasted into the interactive session." + ].join("\n"); + return finish({ + ok: true, + response, + model: model ?? defaultModel, + sessionId: null, + detached: true, + responseKind: "tmux_tui_session_started", + tmuxSession: invocation.sessionName, + attachCommand: invocation.attachCommand, + warnings: [CLAUDE_TMUX_DETACHED_WARNING], + timingMeta: { + tmuxDetached: true, + timingScope: "tmux_startup", + llmCompletionObserved: false + }, + stdout: "", + stderr: "" + }); +} function extractClaudeText(event) { if (!event || typeof event !== "object") { return ""; @@ -957,25 +1350,57 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { - const result = promptRunner({ - prompt: "ping", +function getClaudeAuthStatus(cwd, { + authRunner = (options = {}) => runCommand(CLAUDE_BIN, ["auth", "status", "--json"], options) +} = {}) { + const result = authRunner({ cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (result.ok) { + if (result.error) { + const detail2 = result.error.message || "claude auth status failed"; + if (result.error.code === "ETIMEDOUT" || TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail2))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail2}`, model: null }; + } + return { loggedIn: false, detail: detail2 }; + } + if (result.status === 0) { + let parsed = null; + try { + parsed = JSON.parse(String(result.stdout || "{}")); + } catch { + const legacy = parseClaudeLegacyAuthText(`${result.stdout || ""} +${result.stderr || ""}`); + if (legacy) { + return legacy; + } + const detail2 = firstNonEmptyLine2(`${result.stdout || ""} +${result.stderr || ""}`); + return { + loggedIn: true, + detail: `auth probe inconclusive: claude auth status returned non-json output${detail2 ? `: ${detail2}` : ""}`, + model: null + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned non-object output", model: null }; + } + const loggedIn = parsed.loggedIn ?? parsed.authenticated; + if (typeof loggedIn !== "boolean") { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned no authentication state", model: parsed.model ?? null }; + } return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null + loggedIn, + detail: loggedIn ? "authenticated" : "not authenticated", + model: parsed?.model ?? null }; } - const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + const detail = String(result.stderr || result.stdout || "").trim() || `claude auth status exited with code ${result.status}`; if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { - return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; } @@ -1029,8 +1454,30 @@ function runClaudePromptStreaming({ onEvent = () => { }, bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + executionMode = "print", + env = process.env, + signalEmitter = process, spawnImpl } = {}) { + if (executionMode === "tmux-tui") { + return Promise.resolve(runClaudeTuiPrompt({ + prompt, + model, + permissionMode, + cwd, + timeout, + extraArgs, + resumeSessionId, + defaultModel, + bin, + tmuxBin, + tmuxSessionName, + env, + signalEmitter + })); + } const invocation = buildClaudeInvocation({ prompt, model, @@ -1045,7 +1492,7 @@ function runClaudePromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env, input: invocation.input, timeout, spawnImpl, @@ -2132,10 +2579,11 @@ function runQwenPrompt({ priority: ["stdout", "stderr", "file"] }); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.status === 0 && !resultEventError && parsed.response.trim() ? null : result.stderr.trim() || resultEventError || formatProviderExitError("qwen", result.status); + const hasVisibleText = Boolean(parsed.response.trim()); + const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); return { - ok: result.status === 0 && !resultEventError && Boolean(parsed.response.trim()), + ok: result.status === 0 && !resultEventError && hasVisibleText, status: result.status, stderr: result.stderr, ...parsed, @@ -4199,19 +4647,31 @@ function getTimingSupport(provider) { runtimePersistence: "ephemeral" }; } +function getTimingSupportForRun(provider, options = {}) { + const support = getTimingSupport(provider); + if (provider === "claude" && options.executionMode === "tmux-tui") { + return { ...support, ttft: false, gen: false, tail: false }; + } + return support; +} function inferRuntimePersistence(provider, result) { const support = getTimingSupport(provider); return support.runtimePersistence; } -function buildTimingMeta(provider, result, meta) { - const support = getTimingSupport(provider); - if (support.runtimePersistence !== "session" || result?.sessionId) { - return meta; - } - return { +function buildTimingMeta(provider, result, meta, support = getTimingSupport(provider)) { + const merged = { ...meta || {}, - sessionIdMissing: true + ...result?.timingMeta || {} }; + if (provider === "claude" && result?.detached === true) { + merged.tmuxDetached = true; + merged.timingScope = merged.timingScope || "tmux_startup"; + merged.llmCompletionObserved = false; + } + if (support.runtimePersistence === "session" && !result?.sessionId) { + merged.sessionIdMissing = true; + } + return Object.keys(merged).length > 0 ? merged : null; } function trackQwenToolTiming(event, timestamp, state) { if (event?.type !== "assistant" || !Array.isArray(event.message?.content)) { @@ -4279,7 +4739,7 @@ async function runProviderPromptStreaming({ ...options }) { const startedAt = nowMs(); - const timingSupport = getTimingSupport(provider); + const timingSupport = getTimingSupportForRun(provider, options); const selectedRuntime = runtime ?? getProviderRuntime(provider); let firstTextAt = null; let lastTextAt = null; @@ -4317,7 +4777,7 @@ async function runProviderPromptStreaming({ tailMs: lastTextAt == null ? null : Math.max(finishedAt - lastTextAt, 0), toolMs: toolState.toolMs, supportedMetrics: timingSupport, - meta: buildTimingMeta(provider, result, meta) + meta: buildTimingMeta(provider, result, meta, timingSupport) }); } @@ -4672,7 +5132,7 @@ function removeJobConfigFile(workspaceRoot, jobId) { } // plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID } from "node:crypto"; +import { randomUUID as randomUUID2 } from "node:crypto"; import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js @@ -4780,7 +5240,7 @@ function resolveRunLedgerFile(workspaceRoot) { return path4.join(resolveStateDir(workspaceRoot), "run-ledger.ndjson"); } function createRunId() { - return `run_${randomUUID().replaceAll("-", "").slice(0, 20)}`; + return `run_${randomUUID2().replaceAll("-", "").slice(0, 20)}`; } function resolveRunId(options = {}, env = process.env) { const runId = options.runId || env.POLYCLI_RUN_ID || createRunId(); @@ -4894,7 +5354,7 @@ function createRunLedgerEvent(event = {}) { const commands = [...new Set(event.commands || (command ? [command] : []))].filter(Boolean).sort(); return { version: 1, - eventId: event.eventId || `evt_${randomUUID().replaceAll("-", "").slice(0, 20)}`, + eventId: event.eventId || `evt_${randomUUID2().replaceAll("-", "").slice(0, 20)}`, at, runId: event.runId || null, workspaceRoot: event.workspaceRoot || null, @@ -5528,8 +5988,8 @@ function buildPromptRuntimeOptions({ if (kind === "ask" && provider === "claude") { return { ...runtimeOptions, + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: mergeExtraArgs(runtimeOptions, [ "--tools", "", @@ -5724,8 +6184,8 @@ var REVIEW_HARD_CONSTRAINTS = { }, claude() { return { + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: ["--tools", "", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config"] }; }, @@ -6270,7 +6730,7 @@ async function inspectProviderAvailability(provider) { } function createJobId(kind) { const prefix = JOB_PREFIXES[kind] || "pj"; - return `${prefix}-${randomUUID2().slice(0, 8)}`; + return `${prefix}-${randomUUID3().slice(0, 8)}`; } function parseExecutionMode(options) { if (options.background && options.wait) { @@ -6752,6 +7212,26 @@ async function probeProviderHealth({ }; if (!inspection.available) { report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; + } else if (provider === "claude") { + try { + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + report.loggedIn = auth.loggedIn ?? false; + report.authDetail = auth.detail ?? auth.reason ?? null; + report.model = auth.model ?? report.model; + report.probe = { + ok: Boolean(auth.loggedIn), + kind: "auth_status", + authOnly: true, + responseMatched: Boolean(auth.loggedIn), + expected: "authenticated", + responsePreview: auth.detail ?? null, + error: auth.loggedIn ? null : auth.detail ?? "claude auth status did not report authenticated", + timing: null + }; + report.ok = Boolean(auth.loggedIn); + } catch (error) { + report.probe.error = error.message; + } } else { try { const result = await runProviderPromptStreaming({ @@ -7008,24 +7488,28 @@ function buildReviewExecution(rawArgs, { adversarial }) { async function runReviewCommand(rawArgs, { adversarial }) { const { options, provider, reviewContext, execution } = buildReviewExecution(rawArgs, { adversarial }); if (!reviewContext.diff.trim()) { - const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; - const workspaceRoot = resolveWorkspaceRoot(execution.cwd); - await recordRunEvent(workspaceRoot, { - command: execution.kind, - kind: execution.kind, - provider: null, - phase: "provider_decision", - status: "skipped", - reason: "no_changes" - }); - output( - options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ - ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], - "No changes to review." - ].join("\n\n"), - options.json - ); - return; + try { + const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; + const workspaceRoot = resolveWorkspaceRoot(execution.cwd); + await recordRunEvent(workspaceRoot, { + command: execution.kind, + kind: execution.kind, + provider: null, + phase: "provider_decision", + status: "skipped", + reason: "no_changes" + }); + output( + options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ + ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], + "No changes to review." + ].join("\n\n"), + options.json + ); + return; + } finally { + cleanupRuntimeOptions(execution.runtimeOptions); + } } const { background } = parseExecutionMode(options); if (background) { diff --git a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs index 61c156f..c9424e0 100755 --- a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs @@ -4,7 +4,7 @@ import fs9 from "node:fs"; import path8 from "node:path"; import process5 from "node:process"; -import { randomUUID as randomUUID2 } from "node:crypto"; +import { randomUUID as randomUUID3 } from "node:crypto"; import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -130,23 +130,31 @@ function writeFileAtomicSync(filePath, contents, options = {}) { ensureParentDir(filePath); const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs.openSync(tmpPath, flag, mode); - try { - fs.writeFileSync(fd, contents, writeOptions); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - fs.renameSync(tmpPath, filePath); - const dirFd = fs.openSync(path.dirname(filePath), "r"); + let renamed = false; try { - fs.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + renamed = true; + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); } } finally { - fs.closeSync(dirFd); + if (!renamed) { + unlinkIfExists(tmpPath); + } } } function writeFileAtomic(filePath, contents, options = {}) { @@ -181,6 +189,7 @@ function tryReclaimStaleLock(lockPath, staleMs) { if (pid != null) { try { process2.kill(pid, 0); + return false; } catch (killError) { if (killError.code === "ESRCH") { unlinkIfExists(lockPath); @@ -190,11 +199,6 @@ function tryReclaimStaleLock(lockPath, staleMs) { throw killError; } } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } return false; } let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; @@ -253,18 +257,20 @@ var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "o var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js -function findJsonStart(text) { +function findJsonStarts(text) { + const starts = []; for (let index = 0; index < text.length; index += 1) { const slice = text.slice(index); const character = text[index]; if (character === "{" || character === "[" || character === '"' || character === "-" || /\d/.test(character)) { - return index; + starts.push(index); + continue; } if (slice.startsWith("true") || slice.startsWith("false") || slice.startsWith("null")) { - return index; + starts.push(index); } } - return -1; + return starts; } function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { const text = String(raw ?? ""); @@ -275,12 +281,31 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { let jsonCandidate = trimmed; let prefix = ""; if (allowPrefix) { - const jsonStart = findJsonStart(text); - if (jsonStart < 0) { + let lastParseError = null; + for (const jsonStart of findJsonStarts(text)) { + const candidatePrefix = text.slice(0, jsonStart); + const candidate = text.slice(jsonStart).trim(); + try { + return { + ok: true, + raw: text, + prefix: candidatePrefix, + json: candidate, + event: JSON.parse(candidate) + }; + } catch (error) { + lastParseError = { prefix: candidatePrefix, json: candidate, error: error.message }; + } + } + if (!lastParseError) { return { ok: false, kind: "non_json", raw: text }; } - prefix = text.slice(0, jsonStart); - jsonCandidate = text.slice(jsonStart).trim(); + return { + ok: false, + kind: "parse_error", + raw: text, + ...lastParseError + }; } else if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"') && !trimmed.startsWith("-") && !/^\d/.test(trimmed) && !trimmed.startsWith("true") && !trimmed.startsWith("false") && !trimmed.startsWith("null")) { return { ok: false, kind: "non_json", raw: text }; } @@ -377,7 +402,7 @@ function formatCommandFailure(result) { return parts.join(": "); } async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { - if (!Number.isInteger(pid) || pid <= 0) { + if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { @@ -399,19 +424,26 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI } return true; } + const killPid = () => { + try { + process3.kill(pid, targetSignal); + return true; + } catch (error) { + if (error.code === "ESRCH" && ignoreMissing) return false; + throw error; + } + }; try { process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { - if (ignoreMissing) return false; - throw error; + return killPid(); } if (error.code === "EINVAL") { throw error; } - process3.kill(pid, targetSignal); - return true; + return killPid(); } }; const terminated = killOnce(signal); @@ -466,6 +498,9 @@ function resolveSessionId({ return { sessionId: null, source: null }; } +// packages/polycli-runtime/src/claude.js +import { randomUUID } from "node:crypto"; + // packages/polycli-runtime/src/errors.js function formatProviderExitError(provider, status) { if (status === 124) { @@ -782,13 +817,62 @@ function spawnStreamingCommand({ // packages/polycli-runtime/src/claude.js var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; +var CLAUDE_TMUX_BIN = process.env.POLYCLI_TMUX_BIN || "tmux"; var DEFAULT_TIMEOUT_MS = 9e5; +var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BETA", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "CLAUDE_PROJECT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy" +]); +var CLAUDE_TMUX_DETACHED_WARNING = "Claude tmux TUI mode starts a detached interactive Claude TUI session; attach to read the model response. Timing covers tmux startup and prompt submission only, not LLM completion."; +var TMUX_CLEANUP_SIGNALS = ["SIGINT", "SIGTERM"]; var TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i ]; +function shellQuote(value) { + const text = String(value ?? ""); + if (text === "") return "''"; + if (/^[A-Za-z0-9_./:=,+@%-]+$/.test(text)) return text; + return `'${text.replaceAll("'", "'\\''")}'`; +} +function sanitizeTmuxName(value) { + const text = String(value ?? "").trim(); + const sanitized = text.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized || `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function createTmuxSessionName() { + return `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function shouldForwardClaudeTmuxEnv(key) { + return CLAUDE_TMUX_ENV_EXACT.has(key); +} +function buildClaudeTmuxEnvironmentArgs(env) { + if (!env || typeof env !== "object") { + return []; + } + return Object.entries(env).filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && shouldForwardClaudeTmuxEnv(key) && value != null && !String(value).includes("\0")).sort(([left], [right]) => left.localeCompare(right)).flatMap(([key, value]) => ["-e", `${key}=${String(value)}`]); +} function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -815,6 +899,22 @@ function getClaudeErrorText(event) { } return "claude returned an error"; } +function firstNonEmptyLine2(text) { + return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""; +} +function parseClaudeLegacyAuthText(text) { + const detail = firstNonEmptyLine2(text); + if (!detail) { + return null; + } + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail) || /\b(not authenticated|not logged in|logged out)\b/i.test(detail)) { + return { loggedIn: false, detail, model: null }; + } + if (/\b(authenticated|logged in|signed in)\b/i.test(detail)) { + return { loggedIn: true, detail, model: null }; + } + return null; +} function buildClaudeInvocation({ prompt, model = null, @@ -857,6 +957,299 @@ function buildClaudeInvocation({ useStdin }; } +function buildClaudeTuiInvocation({ + prompt, + model = null, + permissionMode = "bypassPermissions", + resumeSessionId = null, + extraArgs = [], + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + cwd = null, + env = process.env +} = {}) { + const promptText = String(prompt ?? ""); + const sessionName = sanitizeTmuxName(tmuxSessionName || createTmuxSessionName()); + const bufferName = `${sessionName}-prompt`; + const claudeArgs = []; + if (permissionMode) { + claudeArgs.push("--permission-mode", permissionMode); + } + if (model) { + claudeArgs.push("--model", model); + } + if (resumeSessionId) { + claudeArgs.push("--resume", resumeSessionId); + } + if (extraArgs.length > 0) { + claudeArgs.push(...extraArgs); + } + const shellCommand = [bin, ...claudeArgs].map(shellQuote).join(" "); + const startArgs = ["new-session", "-d", "-s", sessionName]; + startArgs.push(...buildClaudeTmuxEnvironmentArgs(env)); + if (cwd) { + startArgs.push("-c", cwd); + } + startArgs.push(shellCommand); + return { + bin: tmuxBin, + sessionName, + bufferName, + startArgs, + loadBufferArgs: ["load-buffer", "-b", bufferName, "-"], + pasteBufferArgs: ["paste-buffer", "-d", "-b", bufferName, "-t", sessionName], + sendEnterArgs: ["send-keys", "-t", sessionName, "Enter"], + input: promptText, + attachCommand: `tmux attach -t ${shellQuote(sessionName)}` + }; +} +function runTmuxStep(invocation, args, options = {}) { + return runCommand(invocation.bin, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + timeout: options.timeout + }); +} +function sleepSync2(ms) { + if (!Number.isFinite(ms) || ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function describeTmuxFailure(step, result) { + if (result.error) { + if (step === "new-session" && result.error.code === "ENOENT") { + return "tmux new-session failed: tmux is required for Claude TUI mode but was not found. Install tmux or set POLYCLI_TMUX_BIN."; + } + return `tmux ${step} failed: ${result.error.message}`; + } + const detail = String(result.stderr || result.stdout || "").trim(); + return `tmux ${step} exited with code ${result.status}${detail ? `: ${detail}` : ""}`; +} +function installTmuxSignalCleanup(invocation, { cwd, env, timeout, signalEmitter = process }) { + const state = { signal: null }; + const handlers = /* @__PURE__ */ new Map(); + const remove = () => { + for (const [signal, handler] of handlers) { + if (typeof signalEmitter.off === "function") { + signalEmitter.off(signal, handler); + } else if (typeof signalEmitter.removeListener === "function") { + signalEmitter.removeListener(signal, handler); + } + } + handlers.clear(); + }; + const killSession = () => { + runTmuxStep(invocation, ["kill-session", "-t", invocation.sessionName], { cwd, env, timeout }); + }; + const handleSignal = (signal) => { + if (state.signal) { + return; + } + state.signal = signal; + killSession(); + remove(); + if (signalEmitter === process) { + try { + process.kill(process.pid, signal); + } catch { + process.exitCode = signal === "SIGINT" ? 130 : 143; + } + } + }; + for (const signal of TMUX_CLEANUP_SIGNALS) { + if (typeof signalEmitter.once !== "function") { + continue; + } + const handler = () => handleSignal(signal); + handlers.set(signal, handler); + signalEmitter.once(signal, handler); + } + return { state, remove, killSession }; +} +function waitForClaudeTuiReady(invocation, { cwd, env, timeout }) { + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + if (captured.status === 0 && /Claude Code/.test(captured.stdout || "")) { + return { ok: true }; + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not report Claude readiness" + }; +} +function firstPromptNeedle(input) { + return String(input || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean)?.slice(0, 80) || ""; +} +function pasteReadySignal(text, promptNeedle) { + return String(text || "").split(/\r?\n/).filter((line) => /Pasted text #|paste again to expand/i.test(line) || promptNeedle && line.includes(promptNeedle)).join(" ").replace(/\s+/g, " ").trim(); +} +function waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout }) { + if (!String(invocation.input || "").trim()) { + return { ok: true }; + } + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + const promptNeedle = firstPromptNeedle(invocation.input); + let lastReadyText = null; + let stableSince = null; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + const text = captured.stdout || ""; + const readyText = pasteReadySignal(text, promptNeedle); + if (captured.status === 0 && readyText) { + if (readyText && readyText === lastReadyText) { + if (stableSince != null && Date.now() - stableSince >= 750) { + return { ok: true }; + } + } else { + lastReadyText = readyText; + stableSince = Date.now(); + } + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not show pasted prompt" + }; +} +function runClaudeTuiPrompt({ + prompt, + model = null, + permissionMode = "bypassPermissions", + cwd, + timeout = DEFAULT_TIMEOUT_MS, + extraArgs = [], + resumeSessionId = null, + defaultModel = null, + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + env = process.env, + signalEmitter = process +} = {}) { + const invocation = buildClaudeTuiInvocation({ + prompt, + model, + permissionMode, + resumeSessionId, + extraArgs, + bin, + tmuxBin, + tmuxSessionName, + cwd, + env + }); + const startTimeout = Math.min(timeout || TMUX_START_TIMEOUT_MS, TMUX_START_TIMEOUT_MS); + const start = runTmuxStep(invocation, invocation.startArgs, { cwd, env, timeout: startTimeout }); + if (start.error || start.status !== 0) { + return { ok: false, error: describeTmuxFailure("new-session", start), stdout: start.stdout, stderr: start.stderr }; + } + const signalCleanup = installTmuxSignalCleanup(invocation, { cwd, env, timeout: startTimeout, signalEmitter }); + const interrupted = () => signalCleanup.state.signal ? { ok: false, error: `Claude TUI tmux session interrupted by ${signalCleanup.state.signal}` } : null; + const finish = (result) => { + signalCleanup.remove(); + return result; + }; + const killAndFinish = (result) => { + signalCleanup.killSession(); + return finish(result); + }; + const initialInterrupt = interrupted(); + if (initialInterrupt) { + return finish(initialInterrupt); + } + const ready = waitForClaudeTuiReady(invocation, { cwd, env, timeout: startTimeout }); + const readyInterrupt = interrupted(); + if (readyInterrupt) { + return finish(readyInterrupt); + } + if (!ready.ok) { + return killAndFinish({ ok: false, error: ready.error }); + } + const load = runTmuxStep(invocation, invocation.loadBufferArgs, { + cwd, + env, + input: invocation.input, + timeout: startTimeout + }); + const loadInterrupt = interrupted(); + if (loadInterrupt) { + return finish(loadInterrupt); + } + if (load.error || load.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("load-buffer", load), stdout: load.stdout, stderr: load.stderr }); + } + const cleanupBuffer = () => { + runTmuxStep(invocation, ["delete-buffer", "-b", invocation.bufferName], { cwd, env, timeout: startTimeout }); + }; + const paste = runTmuxStep(invocation, invocation.pasteBufferArgs, { cwd, env, timeout: startTimeout }); + const pasteInterrupt = interrupted(); + if (pasteInterrupt) { + return finish(pasteInterrupt); + } + if (paste.error || paste.status !== 0) { + cleanupBuffer(); + return killAndFinish({ ok: false, error: describeTmuxFailure("paste-buffer", paste), stdout: paste.stdout, stderr: paste.stderr }); + } + const pasteReady = waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout: startTimeout }); + const pasteReadyInterrupt = interrupted(); + if (pasteReadyInterrupt) { + return finish(pasteReadyInterrupt); + } + if (!pasteReady.ok) { + return killAndFinish({ ok: false, error: pasteReady.error }); + } + sleepSync2(250); + const enter = runTmuxStep(invocation, invocation.sendEnterArgs, { cwd, env, timeout: startTimeout }); + const enterInterrupt = interrupted(); + if (enterInterrupt) { + return finish(enterInterrupt); + } + if (enter.error || enter.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("send-keys", enter), stdout: enter.stdout, stderr: enter.stderr }); + } + const response = [ + `Started Claude TUI tmux session '${invocation.sessionName}'.`, + `Attach with: ${invocation.attachCommand}`, + "The prompt was pasted into the interactive session." + ].join("\n"); + return finish({ + ok: true, + response, + model: model ?? defaultModel, + sessionId: null, + detached: true, + responseKind: "tmux_tui_session_started", + tmuxSession: invocation.sessionName, + attachCommand: invocation.attachCommand, + warnings: [CLAUDE_TMUX_DETACHED_WARNING], + timingMeta: { + tmuxDetached: true, + timingScope: "tmux_startup", + llmCompletionObserved: false + }, + stdout: "", + stderr: "" + }); +} function extractClaudeText(event) { if (!event || typeof event !== "object") { return ""; @@ -957,25 +1350,57 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { - const result = promptRunner({ - prompt: "ping", +function getClaudeAuthStatus(cwd, { + authRunner = (options = {}) => runCommand(CLAUDE_BIN, ["auth", "status", "--json"], options) +} = {}) { + const result = authRunner({ cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (result.ok) { + if (result.error) { + const detail2 = result.error.message || "claude auth status failed"; + if (result.error.code === "ETIMEDOUT" || TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail2))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail2}`, model: null }; + } + return { loggedIn: false, detail: detail2 }; + } + if (result.status === 0) { + let parsed = null; + try { + parsed = JSON.parse(String(result.stdout || "{}")); + } catch { + const legacy = parseClaudeLegacyAuthText(`${result.stdout || ""} +${result.stderr || ""}`); + if (legacy) { + return legacy; + } + const detail2 = firstNonEmptyLine2(`${result.stdout || ""} +${result.stderr || ""}`); + return { + loggedIn: true, + detail: `auth probe inconclusive: claude auth status returned non-json output${detail2 ? `: ${detail2}` : ""}`, + model: null + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned non-object output", model: null }; + } + const loggedIn = parsed.loggedIn ?? parsed.authenticated; + if (typeof loggedIn !== "boolean") { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned no authentication state", model: parsed.model ?? null }; + } return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null + loggedIn, + detail: loggedIn ? "authenticated" : "not authenticated", + model: parsed?.model ?? null }; } - const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + const detail = String(result.stderr || result.stdout || "").trim() || `claude auth status exited with code ${result.status}`; if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { - return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; } @@ -1029,8 +1454,30 @@ function runClaudePromptStreaming({ onEvent = () => { }, bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + executionMode = "print", + env = process.env, + signalEmitter = process, spawnImpl } = {}) { + if (executionMode === "tmux-tui") { + return Promise.resolve(runClaudeTuiPrompt({ + prompt, + model, + permissionMode, + cwd, + timeout, + extraArgs, + resumeSessionId, + defaultModel, + bin, + tmuxBin, + tmuxSessionName, + env, + signalEmitter + })); + } const invocation = buildClaudeInvocation({ prompt, model, @@ -1045,7 +1492,7 @@ function runClaudePromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env, input: invocation.input, timeout, spawnImpl, @@ -2132,10 +2579,11 @@ function runQwenPrompt({ priority: ["stdout", "stderr", "file"] }); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.status === 0 && !resultEventError && parsed.response.trim() ? null : result.stderr.trim() || resultEventError || formatProviderExitError("qwen", result.status); + const hasVisibleText = Boolean(parsed.response.trim()); + const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); return { - ok: result.status === 0 && !resultEventError && Boolean(parsed.response.trim()), + ok: result.status === 0 && !resultEventError && hasVisibleText, status: result.status, stderr: result.stderr, ...parsed, @@ -4199,19 +4647,31 @@ function getTimingSupport(provider) { runtimePersistence: "ephemeral" }; } +function getTimingSupportForRun(provider, options = {}) { + const support = getTimingSupport(provider); + if (provider === "claude" && options.executionMode === "tmux-tui") { + return { ...support, ttft: false, gen: false, tail: false }; + } + return support; +} function inferRuntimePersistence(provider, result) { const support = getTimingSupport(provider); return support.runtimePersistence; } -function buildTimingMeta(provider, result, meta) { - const support = getTimingSupport(provider); - if (support.runtimePersistence !== "session" || result?.sessionId) { - return meta; - } - return { +function buildTimingMeta(provider, result, meta, support = getTimingSupport(provider)) { + const merged = { ...meta || {}, - sessionIdMissing: true + ...result?.timingMeta || {} }; + if (provider === "claude" && result?.detached === true) { + merged.tmuxDetached = true; + merged.timingScope = merged.timingScope || "tmux_startup"; + merged.llmCompletionObserved = false; + } + if (support.runtimePersistence === "session" && !result?.sessionId) { + merged.sessionIdMissing = true; + } + return Object.keys(merged).length > 0 ? merged : null; } function trackQwenToolTiming(event, timestamp, state) { if (event?.type !== "assistant" || !Array.isArray(event.message?.content)) { @@ -4279,7 +4739,7 @@ async function runProviderPromptStreaming({ ...options }) { const startedAt = nowMs(); - const timingSupport = getTimingSupport(provider); + const timingSupport = getTimingSupportForRun(provider, options); const selectedRuntime = runtime ?? getProviderRuntime(provider); let firstTextAt = null; let lastTextAt = null; @@ -4317,7 +4777,7 @@ async function runProviderPromptStreaming({ tailMs: lastTextAt == null ? null : Math.max(finishedAt - lastTextAt, 0), toolMs: toolState.toolMs, supportedMetrics: timingSupport, - meta: buildTimingMeta(provider, result, meta) + meta: buildTimingMeta(provider, result, meta, timingSupport) }); } @@ -4672,7 +5132,7 @@ function removeJobConfigFile(workspaceRoot, jobId) { } // plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID } from "node:crypto"; +import { randomUUID as randomUUID2 } from "node:crypto"; import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js @@ -4780,7 +5240,7 @@ function resolveRunLedgerFile(workspaceRoot) { return path4.join(resolveStateDir(workspaceRoot), "run-ledger.ndjson"); } function createRunId() { - return `run_${randomUUID().replaceAll("-", "").slice(0, 20)}`; + return `run_${randomUUID2().replaceAll("-", "").slice(0, 20)}`; } function resolveRunId(options = {}, env = process.env) { const runId = options.runId || env.POLYCLI_RUN_ID || createRunId(); @@ -4894,7 +5354,7 @@ function createRunLedgerEvent(event = {}) { const commands = [...new Set(event.commands || (command ? [command] : []))].filter(Boolean).sort(); return { version: 1, - eventId: event.eventId || `evt_${randomUUID().replaceAll("-", "").slice(0, 20)}`, + eventId: event.eventId || `evt_${randomUUID2().replaceAll("-", "").slice(0, 20)}`, at, runId: event.runId || null, workspaceRoot: event.workspaceRoot || null, @@ -5528,8 +5988,8 @@ function buildPromptRuntimeOptions({ if (kind === "ask" && provider === "claude") { return { ...runtimeOptions, + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: mergeExtraArgs(runtimeOptions, [ "--tools", "", @@ -5724,8 +6184,8 @@ var REVIEW_HARD_CONSTRAINTS = { }, claude() { return { + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: ["--tools", "", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config"] }; }, @@ -6270,7 +6730,7 @@ async function inspectProviderAvailability(provider) { } function createJobId(kind) { const prefix = JOB_PREFIXES[kind] || "pj"; - return `${prefix}-${randomUUID2().slice(0, 8)}`; + return `${prefix}-${randomUUID3().slice(0, 8)}`; } function parseExecutionMode(options) { if (options.background && options.wait) { @@ -6752,6 +7212,26 @@ async function probeProviderHealth({ }; if (!inspection.available) { report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; + } else if (provider === "claude") { + try { + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + report.loggedIn = auth.loggedIn ?? false; + report.authDetail = auth.detail ?? auth.reason ?? null; + report.model = auth.model ?? report.model; + report.probe = { + ok: Boolean(auth.loggedIn), + kind: "auth_status", + authOnly: true, + responseMatched: Boolean(auth.loggedIn), + expected: "authenticated", + responsePreview: auth.detail ?? null, + error: auth.loggedIn ? null : auth.detail ?? "claude auth status did not report authenticated", + timing: null + }; + report.ok = Boolean(auth.loggedIn); + } catch (error) { + report.probe.error = error.message; + } } else { try { const result = await runProviderPromptStreaming({ @@ -7008,24 +7488,28 @@ function buildReviewExecution(rawArgs, { adversarial }) { async function runReviewCommand(rawArgs, { adversarial }) { const { options, provider, reviewContext, execution } = buildReviewExecution(rawArgs, { adversarial }); if (!reviewContext.diff.trim()) { - const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; - const workspaceRoot = resolveWorkspaceRoot(execution.cwd); - await recordRunEvent(workspaceRoot, { - command: execution.kind, - kind: execution.kind, - provider: null, - phase: "provider_decision", - status: "skipped", - reason: "no_changes" - }); - output( - options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ - ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], - "No changes to review." - ].join("\n\n"), - options.json - ); - return; + try { + const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; + const workspaceRoot = resolveWorkspaceRoot(execution.cwd); + await recordRunEvent(workspaceRoot, { + command: execution.kind, + kind: execution.kind, + provider: null, + phase: "provider_decision", + status: "skipped", + reason: "no_changes" + }); + output( + options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ + ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], + "No changes to review." + ].join("\n\n"), + options.json + ); + return; + } finally { + cleanupRuntimeOptions(execution.runtimeOptions); + } } const { background } = parseExecutionMode(options); if (background) { diff --git a/plugins/polycli/README.md b/plugins/polycli/README.md index 0631c77..7d06f9a 100644 --- a/plugins/polycli/README.md +++ b/plugins/polycli/README.md @@ -32,6 +32,10 @@ Run `health` once after install, login, or provider config changes. With no prov - `/polycli:result` - `/polycli:cancel` - `/polycli:timing` +- `/polycli:debug` +- `/polycli:sessions` + +The read-only run-inspector TUI belongs to the terminal package as `polycli tui`; it is not a Claude slash command. ## Common Invocations @@ -46,6 +50,8 @@ Run `health` once after install, login, or provider config changes. With no prov /polycli:result pr-1234abcd /polycli:cancel pr-1234abcd /polycli:timing --provider qwen +/polycli:debug runs +/polycli:sessions list ``` ## Provider Model @@ -62,6 +68,9 @@ Current provider IDs: - `kimi` - `qwen` - `minimax` +- `cmd` +- `agy` +- `grok` By default, each provider uses the underlying CLI default model. Pass `--model` only when you want an override. diff --git a/plugins/polycli/scripts/lib/prompt-runtime.mjs b/plugins/polycli/scripts/lib/prompt-runtime.mjs index a989d31..074422d 100644 --- a/plugins/polycli/scripts/lib/prompt-runtime.mjs +++ b/plugins/polycli/scripts/lib/prompt-runtime.mjs @@ -63,8 +63,8 @@ export function buildPromptRuntimeOptions({ if (kind === "ask" && provider === "claude") { return { ...runtimeOptions, + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: mergeExtraArgs(runtimeOptions, [ "--tools", "", diff --git a/plugins/polycli/scripts/lib/review.mjs b/plugins/polycli/scripts/lib/review.mjs index 67632b5..9b3461a 100644 --- a/plugins/polycli/scripts/lib/review.mjs +++ b/plugins/polycli/scripts/lib/review.mjs @@ -112,8 +112,8 @@ const REVIEW_HARD_CONSTRAINTS = { }, claude() { return { + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: ["--tools", "", "--mcp-config", "{\"mcpServers\":{}}", "--strict-mcp-config"], }; }, diff --git a/plugins/polycli/scripts/polycli-companion.bundle.mjs b/plugins/polycli/scripts/polycli-companion.bundle.mjs index 61c156f..c9424e0 100755 --- a/plugins/polycli/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli/scripts/polycli-companion.bundle.mjs @@ -4,7 +4,7 @@ import fs9 from "node:fs"; import path8 from "node:path"; import process5 from "node:process"; -import { randomUUID as randomUUID2 } from "node:crypto"; +import { randomUUID as randomUUID3 } from "node:crypto"; import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -130,23 +130,31 @@ function writeFileAtomicSync(filePath, contents, options = {}) { ensureParentDir(filePath); const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs.openSync(tmpPath, flag, mode); - try { - fs.writeFileSync(fd, contents, writeOptions); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - fs.renameSync(tmpPath, filePath); - const dirFd = fs.openSync(path.dirname(filePath), "r"); + let renamed = false; try { - fs.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + renamed = true; + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); } } finally { - fs.closeSync(dirFd); + if (!renamed) { + unlinkIfExists(tmpPath); + } } } function writeFileAtomic(filePath, contents, options = {}) { @@ -181,6 +189,7 @@ function tryReclaimStaleLock(lockPath, staleMs) { if (pid != null) { try { process2.kill(pid, 0); + return false; } catch (killError) { if (killError.code === "ESRCH") { unlinkIfExists(lockPath); @@ -190,11 +199,6 @@ function tryReclaimStaleLock(lockPath, staleMs) { throw killError; } } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } return false; } let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; @@ -253,18 +257,20 @@ var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "o var PROVIDER_OPERATION_NAMES = ["prompt"]; // packages/polycli-utils/src/parse-stream-json.js -function findJsonStart(text) { +function findJsonStarts(text) { + const starts = []; for (let index = 0; index < text.length; index += 1) { const slice = text.slice(index); const character = text[index]; if (character === "{" || character === "[" || character === '"' || character === "-" || /\d/.test(character)) { - return index; + starts.push(index); + continue; } if (slice.startsWith("true") || slice.startsWith("false") || slice.startsWith("null")) { - return index; + starts.push(index); } } - return -1; + return starts; } function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { const text = String(raw ?? ""); @@ -275,12 +281,31 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { let jsonCandidate = trimmed; let prefix = ""; if (allowPrefix) { - const jsonStart = findJsonStart(text); - if (jsonStart < 0) { + let lastParseError = null; + for (const jsonStart of findJsonStarts(text)) { + const candidatePrefix = text.slice(0, jsonStart); + const candidate = text.slice(jsonStart).trim(); + try { + return { + ok: true, + raw: text, + prefix: candidatePrefix, + json: candidate, + event: JSON.parse(candidate) + }; + } catch (error) { + lastParseError = { prefix: candidatePrefix, json: candidate, error: error.message }; + } + } + if (!lastParseError) { return { ok: false, kind: "non_json", raw: text }; } - prefix = text.slice(0, jsonStart); - jsonCandidate = text.slice(jsonStart).trim(); + return { + ok: false, + kind: "parse_error", + raw: text, + ...lastParseError + }; } else if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"') && !trimmed.startsWith("-") && !/^\d/.test(trimmed) && !trimmed.startsWith("true") && !trimmed.startsWith("false") && !trimmed.startsWith("null")) { return { ok: false, kind: "non_json", raw: text }; } @@ -377,7 +402,7 @@ function formatCommandFailure(result) { return parts.join(": "); } async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { - if (!Number.isInteger(pid) || pid <= 0) { + if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { @@ -399,19 +424,26 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI } return true; } + const killPid = () => { + try { + process3.kill(pid, targetSignal); + return true; + } catch (error) { + if (error.code === "ESRCH" && ignoreMissing) return false; + throw error; + } + }; try { process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { - if (ignoreMissing) return false; - throw error; + return killPid(); } if (error.code === "EINVAL") { throw error; } - process3.kill(pid, targetSignal); - return true; + return killPid(); } }; const terminated = killOnce(signal); @@ -466,6 +498,9 @@ function resolveSessionId({ return { sessionId: null, source: null }; } +// packages/polycli-runtime/src/claude.js +import { randomUUID } from "node:crypto"; + // packages/polycli-runtime/src/errors.js function formatProviderExitError(provider, status) { if (status === 124) { @@ -782,13 +817,62 @@ function spawnStreamingCommand({ // packages/polycli-runtime/src/claude.js var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; +var CLAUDE_TMUX_BIN = process.env.POLYCLI_TMUX_BIN || "tmux"; var DEFAULT_TIMEOUT_MS = 9e5; +var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; +var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ + "ALL_PROXY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BETA", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "CLAUDE_PROJECT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy" +]); +var CLAUDE_TMUX_DETACHED_WARNING = "Claude tmux TUI mode starts a detached interactive Claude TUI session; attach to read the model response. Timing covers tmux startup and prompt submission only, not LLM completion."; +var TMUX_CLEANUP_SIGNALS = ["SIGINT", "SIGTERM"]; var TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i ]; +function shellQuote(value) { + const text = String(value ?? ""); + if (text === "") return "''"; + if (/^[A-Za-z0-9_./:=,+@%-]+$/.test(text)) return text; + return `'${text.replaceAll("'", "'\\''")}'`; +} +function sanitizeTmuxName(value) { + const text = String(value ?? "").trim(); + const sanitized = text.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized || `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function createTmuxSessionName() { + return `polycli-claude-${randomUUID().slice(0, 8)}`; +} +function shouldForwardClaudeTmuxEnv(key) { + return CLAUDE_TMUX_ENV_EXACT.has(key); +} +function buildClaudeTmuxEnvironmentArgs(env) { + if (!env || typeof env !== "object") { + return []; + } + return Object.entries(env).filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && shouldForwardClaudeTmuxEnv(key) && value != null && !String(value).includes("\0")).sort(([left], [right]) => left.localeCompare(right)).flatMap(([key, value]) => ["-e", `${key}=${String(value)}`]); +} function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -815,6 +899,22 @@ function getClaudeErrorText(event) { } return "claude returned an error"; } +function firstNonEmptyLine2(text) { + return String(text || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""; +} +function parseClaudeLegacyAuthText(text) { + const detail = firstNonEmptyLine2(text); + if (!detail) { + return null; + } + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail) || /\b(not authenticated|not logged in|logged out)\b/i.test(detail)) { + return { loggedIn: false, detail, model: null }; + } + if (/\b(authenticated|logged in|signed in)\b/i.test(detail)) { + return { loggedIn: true, detail, model: null }; + } + return null; +} function buildClaudeInvocation({ prompt, model = null, @@ -857,6 +957,299 @@ function buildClaudeInvocation({ useStdin }; } +function buildClaudeTuiInvocation({ + prompt, + model = null, + permissionMode = "bypassPermissions", + resumeSessionId = null, + extraArgs = [], + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + cwd = null, + env = process.env +} = {}) { + const promptText = String(prompt ?? ""); + const sessionName = sanitizeTmuxName(tmuxSessionName || createTmuxSessionName()); + const bufferName = `${sessionName}-prompt`; + const claudeArgs = []; + if (permissionMode) { + claudeArgs.push("--permission-mode", permissionMode); + } + if (model) { + claudeArgs.push("--model", model); + } + if (resumeSessionId) { + claudeArgs.push("--resume", resumeSessionId); + } + if (extraArgs.length > 0) { + claudeArgs.push(...extraArgs); + } + const shellCommand = [bin, ...claudeArgs].map(shellQuote).join(" "); + const startArgs = ["new-session", "-d", "-s", sessionName]; + startArgs.push(...buildClaudeTmuxEnvironmentArgs(env)); + if (cwd) { + startArgs.push("-c", cwd); + } + startArgs.push(shellCommand); + return { + bin: tmuxBin, + sessionName, + bufferName, + startArgs, + loadBufferArgs: ["load-buffer", "-b", bufferName, "-"], + pasteBufferArgs: ["paste-buffer", "-d", "-b", bufferName, "-t", sessionName], + sendEnterArgs: ["send-keys", "-t", sessionName, "Enter"], + input: promptText, + attachCommand: `tmux attach -t ${shellQuote(sessionName)}` + }; +} +function runTmuxStep(invocation, args, options = {}) { + return runCommand(invocation.bin, args, { + cwd: options.cwd, + env: options.env, + input: options.input, + timeout: options.timeout + }); +} +function sleepSync2(ms) { + if (!Number.isFinite(ms) || ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function describeTmuxFailure(step, result) { + if (result.error) { + if (step === "new-session" && result.error.code === "ENOENT") { + return "tmux new-session failed: tmux is required for Claude TUI mode but was not found. Install tmux or set POLYCLI_TMUX_BIN."; + } + return `tmux ${step} failed: ${result.error.message}`; + } + const detail = String(result.stderr || result.stdout || "").trim(); + return `tmux ${step} exited with code ${result.status}${detail ? `: ${detail}` : ""}`; +} +function installTmuxSignalCleanup(invocation, { cwd, env, timeout, signalEmitter = process }) { + const state = { signal: null }; + const handlers = /* @__PURE__ */ new Map(); + const remove = () => { + for (const [signal, handler] of handlers) { + if (typeof signalEmitter.off === "function") { + signalEmitter.off(signal, handler); + } else if (typeof signalEmitter.removeListener === "function") { + signalEmitter.removeListener(signal, handler); + } + } + handlers.clear(); + }; + const killSession = () => { + runTmuxStep(invocation, ["kill-session", "-t", invocation.sessionName], { cwd, env, timeout }); + }; + const handleSignal = (signal) => { + if (state.signal) { + return; + } + state.signal = signal; + killSession(); + remove(); + if (signalEmitter === process) { + try { + process.kill(process.pid, signal); + } catch { + process.exitCode = signal === "SIGINT" ? 130 : 143; + } + } + }; + for (const signal of TMUX_CLEANUP_SIGNALS) { + if (typeof signalEmitter.once !== "function") { + continue; + } + const handler = () => handleSignal(signal); + handlers.set(signal, handler); + signalEmitter.once(signal, handler); + } + return { state, remove, killSession }; +} +function waitForClaudeTuiReady(invocation, { cwd, env, timeout }) { + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + if (captured.status === 0 && /Claude Code/.test(captured.stdout || "")) { + return { ok: true }; + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not report Claude readiness" + }; +} +function firstPromptNeedle(input) { + return String(input || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean)?.slice(0, 80) || ""; +} +function pasteReadySignal(text, promptNeedle) { + return String(text || "").split(/\r?\n/).filter((line) => /Pasted text #|paste again to expand/i.test(line) || promptNeedle && line.includes(promptNeedle)).join(" ").replace(/\s+/g, " ").trim(); +} +function waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout }) { + if (!String(invocation.input || "").trim()) { + return { ok: true }; + } + const waitTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : TMUX_START_TIMEOUT_MS; + const deadline = Date.now() + waitTimeout; + const promptNeedle = firstPromptNeedle(invocation.input); + let lastReadyText = null; + let stableSince = null; + let last = null; + while (Date.now() <= deadline) { + const captured = runTmuxStep( + invocation, + ["capture-pane", "-pt", invocation.sessionName, "-S", "-120"], + { cwd, env, timeout: 1e3 } + ); + last = captured; + const text = captured.stdout || ""; + const readyText = pasteReadySignal(text, promptNeedle); + if (captured.status === 0 && readyText) { + if (readyText && readyText === lastReadyText) { + if (stableSince != null && Date.now() - stableSince >= 750) { + return { ok: true }; + } + } else { + lastReadyText = readyText; + stableSince = Date.now(); + } + } + sleepSync2(100); + } + return { + ok: false, + error: last ? describeTmuxFailure("capture-pane", last) : "tmux capture-pane did not show pasted prompt" + }; +} +function runClaudeTuiPrompt({ + prompt, + model = null, + permissionMode = "bypassPermissions", + cwd, + timeout = DEFAULT_TIMEOUT_MS, + extraArgs = [], + resumeSessionId = null, + defaultModel = null, + bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + env = process.env, + signalEmitter = process +} = {}) { + const invocation = buildClaudeTuiInvocation({ + prompt, + model, + permissionMode, + resumeSessionId, + extraArgs, + bin, + tmuxBin, + tmuxSessionName, + cwd, + env + }); + const startTimeout = Math.min(timeout || TMUX_START_TIMEOUT_MS, TMUX_START_TIMEOUT_MS); + const start = runTmuxStep(invocation, invocation.startArgs, { cwd, env, timeout: startTimeout }); + if (start.error || start.status !== 0) { + return { ok: false, error: describeTmuxFailure("new-session", start), stdout: start.stdout, stderr: start.stderr }; + } + const signalCleanup = installTmuxSignalCleanup(invocation, { cwd, env, timeout: startTimeout, signalEmitter }); + const interrupted = () => signalCleanup.state.signal ? { ok: false, error: `Claude TUI tmux session interrupted by ${signalCleanup.state.signal}` } : null; + const finish = (result) => { + signalCleanup.remove(); + return result; + }; + const killAndFinish = (result) => { + signalCleanup.killSession(); + return finish(result); + }; + const initialInterrupt = interrupted(); + if (initialInterrupt) { + return finish(initialInterrupt); + } + const ready = waitForClaudeTuiReady(invocation, { cwd, env, timeout: startTimeout }); + const readyInterrupt = interrupted(); + if (readyInterrupt) { + return finish(readyInterrupt); + } + if (!ready.ok) { + return killAndFinish({ ok: false, error: ready.error }); + } + const load = runTmuxStep(invocation, invocation.loadBufferArgs, { + cwd, + env, + input: invocation.input, + timeout: startTimeout + }); + const loadInterrupt = interrupted(); + if (loadInterrupt) { + return finish(loadInterrupt); + } + if (load.error || load.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("load-buffer", load), stdout: load.stdout, stderr: load.stderr }); + } + const cleanupBuffer = () => { + runTmuxStep(invocation, ["delete-buffer", "-b", invocation.bufferName], { cwd, env, timeout: startTimeout }); + }; + const paste = runTmuxStep(invocation, invocation.pasteBufferArgs, { cwd, env, timeout: startTimeout }); + const pasteInterrupt = interrupted(); + if (pasteInterrupt) { + return finish(pasteInterrupt); + } + if (paste.error || paste.status !== 0) { + cleanupBuffer(); + return killAndFinish({ ok: false, error: describeTmuxFailure("paste-buffer", paste), stdout: paste.stdout, stderr: paste.stderr }); + } + const pasteReady = waitForClaudeTuiPasteReady(invocation, { cwd, env, timeout: startTimeout }); + const pasteReadyInterrupt = interrupted(); + if (pasteReadyInterrupt) { + return finish(pasteReadyInterrupt); + } + if (!pasteReady.ok) { + return killAndFinish({ ok: false, error: pasteReady.error }); + } + sleepSync2(250); + const enter = runTmuxStep(invocation, invocation.sendEnterArgs, { cwd, env, timeout: startTimeout }); + const enterInterrupt = interrupted(); + if (enterInterrupt) { + return finish(enterInterrupt); + } + if (enter.error || enter.status !== 0) { + return killAndFinish({ ok: false, error: describeTmuxFailure("send-keys", enter), stdout: enter.stdout, stderr: enter.stderr }); + } + const response = [ + `Started Claude TUI tmux session '${invocation.sessionName}'.`, + `Attach with: ${invocation.attachCommand}`, + "The prompt was pasted into the interactive session." + ].join("\n"); + return finish({ + ok: true, + response, + model: model ?? defaultModel, + sessionId: null, + detached: true, + responseKind: "tmux_tui_session_started", + tmuxSession: invocation.sessionName, + attachCommand: invocation.attachCommand, + warnings: [CLAUDE_TMUX_DETACHED_WARNING], + timingMeta: { + tmuxDetached: true, + timingScope: "tmux_startup", + llmCompletionObserved: false + }, + stdout: "", + stderr: "" + }); +} function extractClaudeText(event) { if (!event || typeof event !== "object") { return ""; @@ -957,25 +1350,57 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { - const result = promptRunner({ - prompt: "ping", +function getClaudeAuthStatus(cwd, { + authRunner = (options = {}) => runCommand(CLAUDE_BIN, ["auth", "status", "--json"], options) +} = {}) { + const result = authRunner({ cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (result.ok) { + if (result.error) { + const detail2 = result.error.message || "claude auth status failed"; + if (result.error.code === "ETIMEDOUT" || TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail2))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail2}`, model: null }; + } + return { loggedIn: false, detail: detail2 }; + } + if (result.status === 0) { + let parsed = null; + try { + parsed = JSON.parse(String(result.stdout || "{}")); + } catch { + const legacy = parseClaudeLegacyAuthText(`${result.stdout || ""} +${result.stderr || ""}`); + if (legacy) { + return legacy; + } + const detail2 = firstNonEmptyLine2(`${result.stdout || ""} +${result.stderr || ""}`); + return { + loggedIn: true, + detail: `auth probe inconclusive: claude auth status returned non-json output${detail2 ? `: ${detail2}` : ""}`, + model: null + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned non-object output", model: null }; + } + const loggedIn = parsed.loggedIn ?? parsed.authenticated; + if (typeof loggedIn !== "boolean") { + return { loggedIn: true, detail: "auth probe inconclusive: claude auth status returned no authentication state", model: parsed.model ?? null }; + } return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null + loggedIn, + detail: loggedIn ? "authenticated" : "not authenticated", + model: parsed?.model ?? null }; } - const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + const detail = String(result.stderr || result.stdout || "").trim() || `claude auth status exited with code ${result.status}`; if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { - return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; } @@ -1029,8 +1454,30 @@ function runClaudePromptStreaming({ onEvent = () => { }, bin = CLAUDE_BIN, + tmuxBin = CLAUDE_TMUX_BIN, + tmuxSessionName = null, + executionMode = "print", + env = process.env, + signalEmitter = process, spawnImpl } = {}) { + if (executionMode === "tmux-tui") { + return Promise.resolve(runClaudeTuiPrompt({ + prompt, + model, + permissionMode, + cwd, + timeout, + extraArgs, + resumeSessionId, + defaultModel, + bin, + tmuxBin, + tmuxSessionName, + env, + signalEmitter + })); + } const invocation = buildClaudeInvocation({ prompt, model, @@ -1045,7 +1492,7 @@ function runClaudePromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env, input: invocation.input, timeout, spawnImpl, @@ -2132,10 +2579,11 @@ function runQwenPrompt({ priority: ["stdout", "stderr", "file"] }); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.status === 0 && !resultEventError && parsed.response.trim() ? null : result.stderr.trim() || resultEventError || formatProviderExitError("qwen", result.status); + const hasVisibleText = Boolean(parsed.response.trim()); + const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); return { - ok: result.status === 0 && !resultEventError && Boolean(parsed.response.trim()), + ok: result.status === 0 && !resultEventError && hasVisibleText, status: result.status, stderr: result.stderr, ...parsed, @@ -4199,19 +4647,31 @@ function getTimingSupport(provider) { runtimePersistence: "ephemeral" }; } +function getTimingSupportForRun(provider, options = {}) { + const support = getTimingSupport(provider); + if (provider === "claude" && options.executionMode === "tmux-tui") { + return { ...support, ttft: false, gen: false, tail: false }; + } + return support; +} function inferRuntimePersistence(provider, result) { const support = getTimingSupport(provider); return support.runtimePersistence; } -function buildTimingMeta(provider, result, meta) { - const support = getTimingSupport(provider); - if (support.runtimePersistence !== "session" || result?.sessionId) { - return meta; - } - return { +function buildTimingMeta(provider, result, meta, support = getTimingSupport(provider)) { + const merged = { ...meta || {}, - sessionIdMissing: true + ...result?.timingMeta || {} }; + if (provider === "claude" && result?.detached === true) { + merged.tmuxDetached = true; + merged.timingScope = merged.timingScope || "tmux_startup"; + merged.llmCompletionObserved = false; + } + if (support.runtimePersistence === "session" && !result?.sessionId) { + merged.sessionIdMissing = true; + } + return Object.keys(merged).length > 0 ? merged : null; } function trackQwenToolTiming(event, timestamp, state) { if (event?.type !== "assistant" || !Array.isArray(event.message?.content)) { @@ -4279,7 +4739,7 @@ async function runProviderPromptStreaming({ ...options }) { const startedAt = nowMs(); - const timingSupport = getTimingSupport(provider); + const timingSupport = getTimingSupportForRun(provider, options); const selectedRuntime = runtime ?? getProviderRuntime(provider); let firstTextAt = null; let lastTextAt = null; @@ -4317,7 +4777,7 @@ async function runProviderPromptStreaming({ tailMs: lastTextAt == null ? null : Math.max(finishedAt - lastTextAt, 0), toolMs: toolState.toolMs, supportedMetrics: timingSupport, - meta: buildTimingMeta(provider, result, meta) + meta: buildTimingMeta(provider, result, meta, timingSupport) }); } @@ -4672,7 +5132,7 @@ function removeJobConfigFile(workspaceRoot, jobId) { } // plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID } from "node:crypto"; +import { randomUUID as randomUUID2 } from "node:crypto"; import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js @@ -4780,7 +5240,7 @@ function resolveRunLedgerFile(workspaceRoot) { return path4.join(resolveStateDir(workspaceRoot), "run-ledger.ndjson"); } function createRunId() { - return `run_${randomUUID().replaceAll("-", "").slice(0, 20)}`; + return `run_${randomUUID2().replaceAll("-", "").slice(0, 20)}`; } function resolveRunId(options = {}, env = process.env) { const runId = options.runId || env.POLYCLI_RUN_ID || createRunId(); @@ -4894,7 +5354,7 @@ function createRunLedgerEvent(event = {}) { const commands = [...new Set(event.commands || (command ? [command] : []))].filter(Boolean).sort(); return { version: 1, - eventId: event.eventId || `evt_${randomUUID().replaceAll("-", "").slice(0, 20)}`, + eventId: event.eventId || `evt_${randomUUID2().replaceAll("-", "").slice(0, 20)}`, at, runId: event.runId || null, workspaceRoot: event.workspaceRoot || null, @@ -5528,8 +5988,8 @@ function buildPromptRuntimeOptions({ if (kind === "ask" && provider === "claude") { return { ...runtimeOptions, + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: mergeExtraArgs(runtimeOptions, [ "--tools", "", @@ -5724,8 +6184,8 @@ var REVIEW_HARD_CONSTRAINTS = { }, claude() { return { + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: ["--tools", "", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config"] }; }, @@ -6270,7 +6730,7 @@ async function inspectProviderAvailability(provider) { } function createJobId(kind) { const prefix = JOB_PREFIXES[kind] || "pj"; - return `${prefix}-${randomUUID2().slice(0, 8)}`; + return `${prefix}-${randomUUID3().slice(0, 8)}`; } function parseExecutionMode(options) { if (options.background && options.wait) { @@ -6752,6 +7212,26 @@ async function probeProviderHealth({ }; if (!inspection.available) { report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; + } else if (provider === "claude") { + try { + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + report.loggedIn = auth.loggedIn ?? false; + report.authDetail = auth.detail ?? auth.reason ?? null; + report.model = auth.model ?? report.model; + report.probe = { + ok: Boolean(auth.loggedIn), + kind: "auth_status", + authOnly: true, + responseMatched: Boolean(auth.loggedIn), + expected: "authenticated", + responsePreview: auth.detail ?? null, + error: auth.loggedIn ? null : auth.detail ?? "claude auth status did not report authenticated", + timing: null + }; + report.ok = Boolean(auth.loggedIn); + } catch (error) { + report.probe.error = error.message; + } } else { try { const result = await runProviderPromptStreaming({ @@ -7008,24 +7488,28 @@ function buildReviewExecution(rawArgs, { adversarial }) { async function runReviewCommand(rawArgs, { adversarial }) { const { options, provider, reviewContext, execution } = buildReviewExecution(rawArgs, { adversarial }); if (!reviewContext.diff.trim()) { - const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; - const workspaceRoot = resolveWorkspaceRoot(execution.cwd); - await recordRunEvent(workspaceRoot, { - command: execution.kind, - kind: execution.kind, - provider: null, - phase: "provider_decision", - status: "skipped", - reason: "no_changes" - }); - output( - options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ - ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], - "No changes to review." - ].join("\n\n"), - options.json - ); - return; + try { + const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 ? reviewContext.warnings : void 0; + const workspaceRoot = resolveWorkspaceRoot(execution.cwd); + await recordRunEvent(workspaceRoot, { + command: execution.kind, + kind: execution.kind, + provider: null, + phase: "provider_decision", + status: "skipped", + reason: "no_changes" + }); + output( + options.json ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } : [ + ...warnings ? [`Note: ${warnings.join(" | ")}`] : [], + "No changes to review." + ].join("\n\n"), + options.json + ); + return; + } finally { + cleanupRuntimeOptions(execution.runtimeOptions); + } } const { background } = parseExecutionMode(options); if (background) { diff --git a/plugins/polycli/scripts/polycli-companion.mjs b/plugins/polycli/scripts/polycli-companion.mjs index 3e17691..bee964f 100644 --- a/plugins/polycli/scripts/polycli-companion.mjs +++ b/plugins/polycli/scripts/polycli-companion.mjs @@ -825,6 +825,26 @@ async function probeProviderHealth({ if (!inspection.available) { report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; + } else if (provider === "claude") { + try { + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process.cwd())); + report.loggedIn = auth.loggedIn ?? false; + report.authDetail = auth.detail ?? auth.reason ?? null; + report.model = auth.model ?? report.model; + report.probe = { + ok: Boolean(auth.loggedIn), + kind: "auth_status", + authOnly: true, + responseMatched: Boolean(auth.loggedIn), + expected: "authenticated", + responsePreview: auth.detail ?? null, + error: auth.loggedIn ? null : (auth.detail ?? "claude auth status did not report authenticated"), + timing: null, + }; + report.ok = Boolean(auth.loggedIn); + } catch (error) { + report.probe.error = error.message; + } } else { try { const result = await runProviderPromptStreaming({ @@ -1100,28 +1120,32 @@ function buildReviewExecution(rawArgs, { adversarial }) { async function runReviewCommand(rawArgs, { adversarial }) { const { options, provider, reviewContext, execution } = buildReviewExecution(rawArgs, { adversarial }); if (!reviewContext.diff.trim()) { - const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 - ? reviewContext.warnings - : undefined; - const workspaceRoot = resolveWorkspaceRoot(execution.cwd); - await recordRunEvent(workspaceRoot, { - command: execution.kind, - kind: execution.kind, - provider: null, - phase: "provider_decision", - status: "skipped", - reason: "no_changes", - }); - output( - options.json - ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } - : [ - ...(warnings ? [`Note: ${warnings.join(" | ")}`] : []), - "No changes to review.", - ].join("\n\n"), - options.json - ); - return; + try { + const warnings = Array.isArray(reviewContext.warnings) && reviewContext.warnings.length > 0 + ? reviewContext.warnings + : undefined; + const workspaceRoot = resolveWorkspaceRoot(execution.cwd); + await recordRunEvent(workspaceRoot, { + command: execution.kind, + kind: execution.kind, + provider: null, + phase: "provider_decision", + status: "skipped", + reason: "no_changes", + }); + output( + options.json + ? { ok: true, provider, verdict: "no_changes", scope: reviewContext.scope, warnings } + : [ + ...(warnings ? [`Note: ${warnings.join(" | ")}`] : []), + "No changes to review.", + ].join("\n\n"), + options.json + ); + return; + } finally { + cleanupRuntimeOptions(execution.runtimeOptions); + } } const { background } = parseExecutionMode(options); diff --git a/plugins/polycli/scripts/session-lifecycle-hook.mjs b/plugins/polycli/scripts/session-lifecycle-hook.mjs index 87bc7e0..c5a92e8 100644 --- a/plugins/polycli/scripts/session-lifecycle-hook.mjs +++ b/plugins/polycli/scripts/session-lifecycle-hook.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import process from "node:process"; -import { loadState, resolveStateFile, resolveWorkspaceRoot, saveState } from "./lib/state.mjs"; +import { resolveStateFile, resolveWorkspaceRoot, updateState } from "./lib/state.mjs"; export const SESSION_ID_ENV = "POLYCLI_COMPANION_SESSION_ID"; @@ -33,7 +33,7 @@ function appendEnvVar(name, value) { } function terminateProcess(pid) { - if (!pid) return; + if (!Number.isInteger(pid) || pid <= 1) return; try { process.kill(-pid, "SIGTERM"); } catch { @@ -52,26 +52,29 @@ function cleanupSessionJobs(cwd, sessionId) { const stateFile = resolveStateFile(workspaceRoot); if (!fs.existsSync(stateFile)) return; - const state = loadState(workspaceRoot); - const jobs = Array.isArray(state.jobs) ? state.jobs : []; - const sessionJobs = jobs.filter((job) => job.sessionId === sessionId); - if (sessionJobs.length === 0) return; + const pidsToTerminate = []; + updateState(workspaceRoot, (state) => { + const jobs = Array.isArray(state.jobs) ? state.jobs : []; + const sessionJobs = jobs.filter((job) => job.sessionId === sessionId); + if (sessionJobs.length === 0) return; - for (const job of sessionJobs) { - if (job.status === "running" || job.status === "queued") { - terminateProcess(job.pid); + for (const job of sessionJobs) { + if (job.status === "running" || job.status === "queued") { + pidsToTerminate.push(job.pid); + } } - } - saveState(workspaceRoot, { - ...state, - jobs: jobs.filter((job) => { + state.jobs = jobs.filter((job) => { if (job.sessionId !== sessionId) return true; return job.status === "completed" || job.status === "failed" || job.status === "cancelled"; - }), + }); }); + + for (const pid of pidsToTerminate) { + terminateProcess(pid); + } } export function handleLifecycleHook(eventName, input = {}) { diff --git a/plugins/polycli/scripts/tests/hooks.test.mjs b/plugins/polycli/scripts/tests/hooks.test.mjs index c1b86c1..07ce2e4 100644 --- a/plugins/polycli/scripts/tests/hooks.test.mjs +++ b/plugins/polycli/scripts/tests/hooks.test.mjs @@ -149,6 +149,40 @@ test("SessionEnd removes only running jobs from the ended session and preserves }); }); +test("SessionEnd does not try to terminate unsafe pid values", (t) => { + withPluginData(() => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); + const kill = t.mock.method(process, "kill", () => { + throw new Error("process.kill should not be called for unsafe pid values"); + }); + try { + writeWorkspaceState(workspaceRoot, { + version: 1, + jobs: [ + { jobId: "pa-one", sessionId: "cc-session-1", status: "running", pid: 1 }, + { jobId: "pa-zero", sessionId: "cc-session-1", status: "running", pid: 0 }, + { jobId: "pa-negative", sessionId: "cc-session-1", status: "running", pid: -42 }, + { jobId: "pa-float", sessionId: "cc-session-1", status: "running", pid: 42.5 }, + ], + }); + + handleLifecycleHook("SessionEnd", { cwd: workspaceRoot, session_id: "cc-session-1" }); + + assert.equal(kill.mock.callCount(), 0); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +test("SessionEnd state cleanup uses the locked state updater", () => { + const source = fs.readFileSync(lifecycleHookPath, "utf8"); + + assert.match(source, /\bupdateState\b/); + assert.doesNotMatch(source, /\bloadState\b/); + assert.doesNotMatch(source, /\bsaveState\b/); +}); + test("parseStopReviewOutput scans all lines for a prose-prefixed BLOCK sentinel", () => { const result = parseStopReviewOutput("好的,这是审查:\nThe work is not done yet.\nBLOCK: tests were not run"); diff --git a/plugins/polycli/scripts/tests/integration.test.mjs b/plugins/polycli/scripts/tests/integration.test.mjs index bc03602..bedb143 100644 --- a/plugins/polycli/scripts/tests/integration.test.mjs +++ b/plugins/polycli/scripts/tests/integration.test.mjs @@ -6,7 +6,6 @@ import path from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { createClaudeFixtureReplay } from "./helpers/fixture-replay.mjs"; import { ensureStateDir, readLastUsedProvider, @@ -256,6 +255,11 @@ if (args.includes("--version")) { process.stdout.write("claude 0.0.0-test\\n"); process.exit(0); } +if (args[0] === "auth" && args[1] === "status") { + const loggedIn = process.env.CLAUDE_AUTH_LOGGED_IN !== "0"; + process.stdout.write(JSON.stringify({ loggedIn, model: "claude-test" }) + "\\n"); + process.exit(0); +} if (process.env.CLAUDE_ARGV_LOG) { fs.writeFileSync(process.env.CLAUDE_ARGV_LOG, JSON.stringify({ argv: args }) + "\\n"); } @@ -309,6 +313,34 @@ if (outputFormat === "stream-json" && !hasVerbose) { }; } +function createFakeTmuxBin() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-fake-tmux-")); + const bin = path.join(root, "tmux"); + fs.writeFileSync( + bin, + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +const stdin = fs.readFileSync(0, "utf8"); +if (process.env.TMUX_ARGV_LOG) { + fs.appendFileSync(process.env.TMUX_ARGV_LOG, JSON.stringify({ argv: args, stdin }) + "\\n"); +} +if (args[0] === "capture-pane") { + process.stdout.write(process.env.TMUX_CAPTURE_TEXT || "Claude Code\\npaste again to expand\\n"); +} +process.exit(Number.parseInt(process.env.TMUX_EXIT_CODE || "0", 10)); + `, + { mode: 0o755 } + ); + return { + root, + bin, + cleanup() { + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} + function createFakeCopilotBin() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-fake-copilot-")); const bin = path.join(root, "copilot"); @@ -496,6 +528,38 @@ function readJsonLine(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8").trim()); } +function readJsonLines(filePath) { + return fs.readFileSync(filePath, "utf8") + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function gitSync(cwd, args) { + const result = spawnSync("git", args, { cwd }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } +} + +function createReviewWorkspace() { + const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "polycli-review-cwd-"))); + gitInitSync(cwd); + fs.writeFileSync(path.join(cwd, "review.txt"), "before\n", "utf8"); + gitSync(cwd, ["add", "review.txt"]); + gitSync(cwd, ["commit", "-m", "base"]); + fs.writeFileSync(path.join(cwd, "review.txt"), "before\nafter\n", "utf8"); + gitSync(cwd, ["add", "review.txt"]); + gitSync(cwd, ["commit", "-m", "change"]); + return { + cwd, + cleanup() { + fs.rmSync(cwd, { recursive: true, force: true }); + }, + }; +} + async function waitForTerminalJob(jobId, context) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { @@ -728,13 +792,13 @@ test("integration: health verifies qwen with an end-to-end probe and records tim } }); -test("integration: health verifies claude with a captured cli fixture replay", async () => { +test("integration: health verifies claude with auth status without a prompt run", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); - const replay = createClaudeFixtureReplay("health-ok"); + const fake = createFakeClaudeBin(); try { const env = cleanEnv({ CLAUDE_PLUGIN_DATA: pluginData, - CLAUDE_CLI_BIN: replay.bin, + CLAUDE_CLI_BIN: fake.bin, }); const health = await runCompanion(["health", "--json", "--provider", "claude"], { cwd: process.cwd(), @@ -751,13 +815,15 @@ test("integration: health verifies claude with a captured cli fixture replay", a const report = payload.results[0]; assert.equal(report.provider, "claude"); assert.equal(report.available, true); - assert.equal(report.loggedIn, null); - assert.equal(report.authDetail, "not checked by health"); - assert.equal(report.model, replay.meta.expected.model); + assert.equal(report.loggedIn, true); + assert.equal(report.authDetail, "authenticated"); + assert.equal(report.model, "claude-test"); assert.equal(report.probe.ok, true); + assert.equal(report.probe.kind, "auth_status"); + assert.equal(report.probe.authOnly, true); assert.equal(report.probe.responseMatched, true); - assert.equal(report.probe.responsePreview, replay.meta.expected.response); - assert.ok(report.probe.timing, "health result should include timing"); + assert.equal(report.probe.responsePreview, "authenticated"); + assert.equal(report.probe.timing, null); const timing = await runCompanion(["timing", "--json", "--provider", "claude", "--history", "1"], { cwd: process.cwd(), @@ -765,10 +831,47 @@ test("integration: health verifies claude with a captured cli fixture replay", a }); assert.equal(timing.code, 0, timing.stderr); const timingPayload = JSON.parse(timing.stdout); - assert.equal(timingPayload.records.length, 1); - assert.equal(timingPayload.records[0].kind, "health"); + assert.equal(timingPayload.records.length, 0); } finally { - replay.cleanup(); + fake.cleanup(); + fs.rmSync(pluginData, { recursive: true, force: true }); + } +}); + +test("integration: health reports claude auth status logout as unhealthy", async () => { + const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const fake = createFakeClaudeBin(); + try { + const env = cleanEnv({ + CLAUDE_PLUGIN_DATA: pluginData, + CLAUDE_CLI_BIN: fake.bin, + CLAUDE_AUTH_LOGGED_IN: "0", + }); + const health = await runCompanion(["health", "--json", "--provider", "claude"], { + cwd: process.cwd(), + env, + }); + assert.equal(health.code, 2); + const payload = JSON.parse(health.stdout); + assert.equal(payload.ok, false); + assert.equal(payload.anyHealthy, false); + assert.equal(payload.allHealthy, false); + assert.deepEqual(payload.healthyProviders, []); + assert.deepEqual(payload.unhealthyProviders, ["claude"]); + assert.equal(payload.results.length, 1); + const report = payload.results[0]; + assert.equal(report.provider, "claude"); + assert.equal(report.available, true); + assert.equal(report.loggedIn, false); + assert.equal(report.authDetail, "not authenticated"); + assert.equal(report.probe.ok, false); + assert.equal(report.probe.kind, "auth_status"); + assert.equal(report.probe.authOnly, true); + assert.equal(report.probe.responseMatched, false); + assert.equal(report.probe.error, "not authenticated"); + assert.equal(report.probe.timing, null); + } finally { + fake.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); @@ -1259,6 +1362,7 @@ test("integration: ask constrains kimi to a visible non-thinking answer", async test("integration: review constrains kimi to one non-thinking turn", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const argLog = path.join(pluginData, "kimi-argv.jsonl"); const fake = createFakeKimiBin(); try { @@ -1270,7 +1374,7 @@ test("integration: review constrains kimi to one non-thinking turn", async () => }); const review = await runCompanion( ["review", "--provider", "kimi", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(review.code, 0, review.stderr); const payload = JSON.parse(review.stdout); @@ -1287,12 +1391,14 @@ test("integration: review constrains kimi to one non-thinking turn", async () => ); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: review --background preserves kimi runtime options and stored response", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const argLog = path.join(pluginData, "kimi-background-argv.jsonl"); const fake = createFakeKimiBin(); try { @@ -1304,17 +1410,17 @@ test("integration: review --background preserves kimi runtime options and stored }); const start = await runCompanion( ["review", "--provider", "kimi", "--background", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(start.code, 0, start.stderr); const started = JSON.parse(start.stdout); assert.equal(started.ok, true); - const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: process.cwd(), env }); + const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: reviewWorkspace.cwd, env }); assert.equal(finalStatus.job.status, "completed"); const stored = await runCompanion(["result", "--json", started.job.jobId], { - cwd: process.cwd(), + cwd: reviewWorkspace.cwd, env, }); assert.equal(stored.code, 0, stored.stderr); @@ -1333,12 +1439,14 @@ test("integration: review --background preserves kimi runtime options and stored ); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: review --background preserves qwen runtime options and stored response", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const argLog = path.join(pluginData, "qwen-background-argv.jsonl"); const fake = createFakeQwenBin(); try { @@ -1350,17 +1458,17 @@ test("integration: review --background preserves qwen runtime options and stored }); const start = await runCompanion( ["review", "--provider", "qwen", "--background", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(start.code, 0, start.stderr); const started = JSON.parse(start.stdout); assert.equal(started.ok, true); - const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: process.cwd(), env }); + const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: reviewWorkspace.cwd, env }); assert.equal(finalStatus.job.status, "completed"); const stored = await runCompanion(["result", "--json", started.job.jobId], { - cwd: process.cwd(), + cwd: reviewWorkspace.cwd, env, }); assert.equal(stored.code, 0, stored.stderr); @@ -1394,12 +1502,14 @@ test("integration: review --background preserves qwen runtime options and stored assert.equal(occurrences, 1); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: qwen result-only review still records timing text and preview", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const fake = createFakeQwenBin(); try { const env = cleanEnv({ @@ -1410,16 +1520,16 @@ test("integration: qwen result-only review still records timing text and preview }); const start = await runCompanion( ["review", "--provider", "qwen", "--background", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(start.code, 0, start.stderr); const started = JSON.parse(start.stdout); - const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: process.cwd(), env }); + const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: reviewWorkspace.cwd, env }); assert.equal(finalStatus.job.status, "completed"); const stored = await runCompanion(["result", "--json", started.job.jobId], { - cwd: process.cwd(), + cwd: reviewWorkspace.cwd, env, }); assert.equal(stored.code, 0, stored.stderr); @@ -1434,12 +1544,14 @@ test("integration: qwen result-only review still records timing text and preview assert.match(logText, /RESULT_ONLY_QWEN_OK/); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: kimi string-content review still records preview text", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const fake = createFakeKimiBin(); try { const env = cleanEnv({ @@ -1450,16 +1562,16 @@ test("integration: kimi string-content review still records preview text", async }); const start = await runCompanion( ["review", "--provider", "kimi", "--background", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(start.code, 0, start.stderr); const started = JSON.parse(start.stdout); - const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: process.cwd(), env }); + const finalStatus = await waitForTerminalJob(started.job.jobId, { cwd: reviewWorkspace.cwd, env }); assert.equal(finalStatus.job.status, "completed"); const stored = await runCompanion(["result", "--json", started.job.jobId], { - cwd: process.cwd(), + cwd: reviewWorkspace.cwd, env, }); assert.equal(stored.code, 0, stored.stderr); @@ -1471,44 +1583,70 @@ test("integration: kimi string-content review still records preview text", async assert.match(logText, /STRING_KIMI_OK/); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); -test("integration: review constrains claude to one turn with no tools", async () => { +test("integration: review starts claude TUI in tmux with no tools", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); - const argLog = path.join(pluginData, "claude-review-argv.jsonl"); + const reviewWorkspace = createReviewWorkspace(); + const tmuxLog = path.join(pluginData, "claude-review-tmux.jsonl"); const fake = createFakeClaudeBin(); + const fakeTmux = createFakeTmuxBin(); try { const env = cleanEnv({ CLAUDE_PLUGIN_DATA: pluginData, CLAUDE_CLI_BIN: fake.bin, - CLAUDE_ARGV_LOG: argLog, - CLAUDE_FIXED_REPLY: "CLAUDE_REVIEW_OK", + POLYCLI_TMUX_BIN: fakeTmux.bin, + TMUX_ARGV_LOG: tmuxLog, }); const review = await runCompanion( ["review", "--provider", "claude", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(review.code, 0, review.stderr); const payload = JSON.parse(review.stdout); - assert.equal(payload.response, "CLAUDE_REVIEW_OK"); - - const logged = readJsonLine(argLog); - assert.match(logged.argv.join(" "), /--max-turns 1/); - assert.match(logged.argv.join(" "), /--strict-mcp-config/); - assert.match(logged.argv.join(" "), /--mcp-config/); - const toolsIndex = logged.argv.indexOf("--tools"); - assert.notEqual(toolsIndex, -1); - assert.equal(logged.argv[toolsIndex + 1], ""); + assert.match(payload.response, /Started Claude TUI tmux session/); + assert.equal(payload.detached, true); + assert.equal(payload.responseKind, "tmux_tui_session_started"); + assert.match(payload.attachCommand, /^tmux attach -t polycli-claude-/); + assert.match(payload.tmuxSession, /^polycli-claude-/); + assert.equal(payload.timing.meta.tmuxDetached, true); + assert.equal(payload.timing.meta.timingScope, "tmux_startup"); + assert.equal(payload.timing.meta.llmCompletionObserved, false); + + const logged = readJsonLines(tmuxLog); + const commands = logged.map((entry) => entry.argv[0]); + assert.deepEqual(commands.slice(0, 4), [ + "new-session", + "capture-pane", + "load-buffer", + "paste-buffer", + ]); + assert.equal(commands.filter((command) => command === "capture-pane").length >= 2, true); + assert.equal(commands.at(-1), "send-keys"); + const startCommand = logged[0].argv.at(-1); + assert.match(startCommand, new RegExp(fake.bin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(startCommand, /--permission-mode plan/); + assert.match(startCommand, /--strict-mcp-config/); + assert.match(startCommand, /--mcp-config '\{"mcpServers":\{\}\}'/); + assert.match(startCommand, /--tools ''/); + assert.doesNotMatch(startCommand, /(^| )-p( |$)|--print|--output-format|--max-turns/); + assert.match(logged[2].stdin, /regressions only/); + assert.match(logged[2].stdin, /Git diff:/); + assert.match(payload.warnings.join("\n"), /detached interactive Claude TUI/i); } finally { fake.cleanup(); + fakeTmux.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: review constrains gemini with isolated cwd and disabled extensions/mcp", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const argLog = path.join(pluginData, "gemini-review-argv.jsonl"); const fake = createFakeGeminiBin(); try { @@ -1520,7 +1658,7 @@ test("integration: review constrains gemini with isolated cwd and disabled exten }); const review = await runCompanion( ["review", "--provider", "gemini", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(review.code, 0, review.stderr); const payload = JSON.parse(review.stdout); @@ -1539,12 +1677,14 @@ test("integration: review constrains gemini with isolated cwd and disabled exten assert.equal(logged.argv.includes("--policy"), false); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: review constrains copilot with exhaustive tool exclusion", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const argLog = path.join(pluginData, "copilot-review-argv.jsonl"); const fake = createFakeCopilotBin(); try { @@ -1556,7 +1696,7 @@ test("integration: review constrains copilot with exhaustive tool exclusion", as }); const review = await runCompanion( ["review", "--provider", "copilot", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(review.code, 0, review.stderr); const payload = JSON.parse(review.stdout); @@ -1569,12 +1709,14 @@ test("integration: review constrains copilot with exhaustive tool exclusion", as assert.match(logged.argv[excludedIndex + 1], /ask_user/); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: review constrains opencode with plan agent and deny-all config", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const argLog = path.join(pluginData, "opencode-review-argv.jsonl"); const envLog = path.join(pluginData, "opencode-review-env.jsonl"); const fake = createFakeOpenCodeBin(); @@ -1588,7 +1730,7 @@ test("integration: review constrains opencode with plan agent and deny-all confi }); const review = await runCompanion( ["review", "--provider", "opencode", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(review.code, 0, review.stderr); const payload = JSON.parse(review.stdout); @@ -1605,12 +1747,14 @@ test("integration: review constrains opencode with plan agent and deny-all confi }); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: review constrains pi with no-tools", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const argLog = path.join(pluginData, "pi-review-argv.jsonl"); const fake = createFakePiBin(); try { @@ -1622,7 +1766,7 @@ test("integration: review constrains pi with no-tools", async () => { }); const review = await runCompanion( ["review", "--provider", "pi", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(review.code, 0, review.stderr); const payload = JSON.parse(review.stdout); @@ -1632,12 +1776,14 @@ test("integration: review constrains pi with no-tools", async () => { assert.match(logged.argv.join(" "), /--no-tools/); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); test("integration: review uses mmx text chat for minimax without legacy mini-agent config", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createReviewWorkspace(); const envLog = path.join(pluginData, "minimax-review-env.jsonl"); const fake = createFakeMiniMaxFixture(); try { @@ -1649,7 +1795,7 @@ test("integration: review uses mmx text chat for minimax without legacy mini-age }); const review = await runCompanion( ["review", "--provider", "minimax", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], - { cwd: process.cwd(), env } + { cwd: reviewWorkspace.cwd, env } ); assert.equal(review.code, 0, review.stderr); const payload = JSON.parse(review.stdout); @@ -1661,6 +1807,7 @@ test("integration: review uses mmx text chat for minimax without legacy mini-age assert.equal(loggedEnv.argv.includes("-t"), false); } finally { fake.cleanup(); + reviewWorkspace.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); @@ -1728,24 +1875,66 @@ test("integration: setup and ask succeed for minimax via bundled companion", asy } }); -test("integration: setup and ask succeed for claude via bundled companion", async () => { +test("integration: setup succeeds and claude ask starts a tmux TUI session", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); - const replay = createClaudeFixtureReplay("ask-ok"); + const tmuxLog = path.join(pluginData, "claude-ask-tmux.jsonl"); + const fake = createFakeClaudeBin(); + const fakeTmux = createFakeTmuxBin(); try { - const askPayload = await assertSetupAndAsk("claude", cleanEnv({ + const env = cleanEnv({ CLAUDE_PLUGIN_DATA: pluginData, - CLAUDE_CLI_BIN: replay.bin, - }), "Reply with only: PONG"); - assert.equal(askPayload.response, replay.meta.expected.response); - assert.equal(askPayload.sessionId, replay.meta.expected.sessionId); - assert.equal(askPayload.model, replay.meta.expected.model); + CLAUDE_CLI_BIN: fake.bin, + POLYCLI_TMUX_BIN: fakeTmux.bin, + TMUX_ARGV_LOG: tmuxLog, + }); + const setup = await runCompanion(["setup", "--json", "--provider", "claude"], { + cwd: process.cwd(), + env, + }); + assert.equal(setup.code, 0, setup.stderr); + const setupPayload = JSON.parse(setup.stdout); + assert.equal(setupPayload[0].loggedIn, true); + assert.equal(setupPayload[0].model, "claude-test"); + + const ask = await runCompanion( + ["ask", "--provider", "claude", "--json", "Reply with only: PONG"], + { cwd: process.cwd(), env } + ); + assert.equal(ask.code, 0, ask.stderr); + const askPayload = JSON.parse(ask.stdout); + assert.equal(askPayload.provider, "claude"); + assert.match(askPayload.response, /Started Claude TUI tmux session/); + assert.equal(askPayload.model, "claude-test"); + assert.equal(askPayload.sessionId, null); + assert.equal(askPayload.detached, true); + assert.equal(askPayload.responseKind, "tmux_tui_session_started"); + assert.match(askPayload.tmuxSession, /^polycli-claude-/); + assert.match(askPayload.attachCommand, /^tmux attach -t polycli-claude-/); assert.equal(askPayload.timing.runtimePersistence, "session"); - assert.equal(askPayload.timing.metrics.ttft.status, "measured"); - assert.equal(askPayload.timing.metrics.gen.status, "measured"); - assert.equal(askPayload.timing.metrics.tail.status, "measured"); + assert.equal(askPayload.timing.metrics.ttft.status, "unsupported"); + assert.equal(askPayload.timing.metrics.gen.status, "unsupported"); + assert.equal(askPayload.timing.metrics.tail.status, "unsupported"); assert.equal(askPayload.timing.metrics.tool.status, "unsupported"); + assert.equal(askPayload.timing.meta.tmuxDetached, true); + assert.equal(askPayload.timing.meta.timingScope, "tmux_startup"); + assert.equal(askPayload.timing.meta.llmCompletionObserved, false); + assert.match(askPayload.warnings.join("\n"), /detached interactive Claude TUI/i); + + const logged = readJsonLines(tmuxLog); + const commands = logged.map((entry) => entry.argv[0]); + assert.deepEqual(commands.slice(0, 4), [ + "new-session", + "capture-pane", + "load-buffer", + "paste-buffer", + ]); + assert.equal(commands.filter((command) => command === "capture-pane").length >= 2, true); + assert.equal(commands.at(-1), "send-keys"); + assert.doesNotMatch(logged[0].argv.at(-1), /(^| )-p( |$)|--print|--output-format|--max-turns/); + assert.match(logged[2].stdin, /Reply with only: PONG/); } finally { - replay.cleanup(); + fake.cleanup(); + fakeTmux.cleanup(); fs.rmSync(pluginData, { recursive: true, force: true }); } }); @@ -1977,10 +2166,7 @@ function gitInitSync(cwd) { ["config", "user.name", "Test User"], ["config", "user.email", "test@example.com"], ]) { - const result = spawnSync("git", args, { cwd }); - if (result.status !== 0) { - throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`); - } + gitSync(cwd, args); } } @@ -2043,6 +2229,32 @@ test("integration: review with no changes writes no_changes skipped decision", a ); }); +test("integration: gemini no-changes review cleans up isolated cwd", async (t) => { + const context = createLedgerContext(t); + const fake = createFakeGeminiBin(); + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-gemini-clean-review-")); + gitInitSync(context.cwd); + try { + const result = await runCompanion( + ["review", "--provider", "gemini", "--json", "--run-id", "run-clean-gemini"], + { + ...context, + env: cleanEnv({ + ...context.env, + GEMINI_CLI_BIN: fake.bin, + TMPDIR: tmpRoot, + }), + }, + ); + assert.equal(result.code, 0, result.stderr); + const leftovers = fs.readdirSync(tmpRoot).filter((entry) => entry.startsWith("polycli-review-gemini-cwd-")); + assert.deepEqual(leftovers, []); + } finally { + fake.cleanup(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + test("integration: debug runs returns summarized ledger runs", async (t) => { const context = createLedgerContext(t); await appendRunLedgerEvent(context.cwd, { diff --git a/plugins/polycli/scripts/tests/prompt-runtime.test.mjs b/plugins/polycli/scripts/tests/prompt-runtime.test.mjs index d790e83..e391e44 100644 --- a/plugins/polycli/scripts/tests/prompt-runtime.test.mjs +++ b/plugins/polycli/scripts/tests/prompt-runtime.test.mjs @@ -50,8 +50,8 @@ test("buildPromptRuntimeOptions keeps qwen ask multi-step but excludes tools", ( test("buildPromptRuntimeOptions uses conservative one-shot defaults for ask", () => { assert.deepEqual(buildPromptRuntimeOptions({ provider: "claude", kind: "ask" }), { + executionMode: "tmux-tui", permissionMode: "plan", - maxTurns: 1, extraArgs: ["--tools", "", "--mcp-config", "{\"mcpServers\":{}}", "--strict-mcp-config"], }); diff --git a/plugins/polycli/scripts/tests/review.test.mjs b/plugins/polycli/scripts/tests/review.test.mjs index b454cd6..7e771ab 100644 --- a/plugins/polycli/scripts/tests/review.test.mjs +++ b/plugins/polycli/scripts/tests/review.test.mjs @@ -55,7 +55,8 @@ test("buildReviewRuntimeOptions applies claude hard constraints", () => { cwd: process.cwd(), }); - assert.equal(options.maxTurns, 1); + assert.equal(options.executionMode, "tmux-tui"); + assert.equal(options.maxTurns, undefined); assert.deepEqual(options.extraArgs, ["--tools", "", "--mcp-config", "{\"mcpServers\":{}}", "--strict-mcp-config"]); }); diff --git a/scripts/check-fixture-freshness.mjs b/scripts/check-fixture-freshness.mjs index d51d9fa..4baeeb2 100644 --- a/scripts/check-fixture-freshness.mjs +++ b/scripts/check-fixture-freshness.mjs @@ -45,7 +45,9 @@ export const PROVIDER_VERSION_PROBES = { bin: process.env.MMX_CLI_BIN || process.env.MINIMAX_CLI_BIN || "mmx", versionArgs: ["--version"], }, + cmd: { bin: process.env.CMD_CLI_BIN || "cmd", versionArgs: ["--version"] }, agy: { bin: process.env.AGY_CLI_BIN || "agy", versionArgs: ["--version"] }, + grok: { bin: process.env.GROK_CLI_BIN || "grok", versionArgs: ["--version"] }, }; // Extract a semver-ish token (`\d+\.\d+\.\d+`) from heterogeneous CLI output, diff --git a/scripts/tests/check-fixture-freshness.test.mjs b/scripts/tests/check-fixture-freshness.test.mjs index 1b87df5..627a02c 100644 --- a/scripts/tests/check-fixture-freshness.test.mjs +++ b/scripts/tests/check-fixture-freshness.test.mjs @@ -134,8 +134,9 @@ test("checkFixtureFreshness skips (not fails) when spawn errors non-ENOENT", () assert.equal(report.stale.length, 0); }); -test("PROVIDER_VERSION_PROBES covers every fixture provider with a version arg", () => { - for (const provider of ["claude", "gemini", "qwen", "copilot", "opencode", "pi", "kimi", "minimax"]) { +test("PROVIDER_VERSION_PROBES covers every runtime provider with a version arg", () => { + const providers = ["claude", "gemini", "qwen", "copilot", "opencode", "pi", "kimi", "minimax", "cmd", "agy", "grok"]; + for (const provider of providers) { const probe = PROVIDER_VERSION_PROBES[provider]; assert.ok(probe, `missing probe for ${provider}`); assert.ok(typeof probe.bin === "string" && probe.bin.length > 0, `${provider} bin`);