@aiti-ai/aiti-claude-code-agent 0.0.0-stage → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * controlled-login — 外部 Agent 受控登录标准 · 前端统一弹窗(原生 h() 渲染,零构建步骤)。
3
+ *
4
+ * ⚠ 副本同步(字节级拷贝,改动须同步到全部副本;测试 `_shared/controlled-login.test.mjs` 钉住一致性)。
5
+ * 权威源:pi-web-ext/_shared/controlled-login-dialog.mjs
6
+ * 部署副本(client 侧走部署树内相对导入 —— 零构建 client 无法像 vite 客户端那样在构建期
7
+ * 内联 ../../_shared(资产路由拒绝越目录穿越),与 draft-keeper 的 ./core.mjs 同一惯例):
8
+ * pi-web-ext/aiti-{zcode,cursor,qoder,claude-code,codex}-agent/client/controlled-login-dialog.mjs
9
+ *
10
+ * 契约(docs/ext-controlled-login-standard.md §3.5):
11
+ * 🔗「打开授权页」按钮(target=_blank)+ URL 文本可复制 + 模式说明行
12
+ * + 状态区(waiting 转圈 / success ✅ 用户名 / failed / timeout / cancelled,均带重试)
13
+ * + B 模式(paste)专属 code 输入框+提交。
14
+ * 状态刷新:弹窗打开期间每 2s 轮询 GET /login/status,关闭即停
15
+ * (方案 §3.6 已论证并拍板的轮询例外:登录是分钟级一次性交互)。
16
+ *
17
+ * 浮动契约:面板根节点 data-ext-float="1" + data-ext-float-w/-h + data-ext-float-full="1"
18
+ * (aiti-float 接管拖拽/缩放/持久化/小屏全屏;未装时自身 style 居中兜底)。
19
+ *
20
+ * 接入(各扩展 client/controlled-login-client.mjs,两行):
21
+ * import { registerControlledLoginClient } from "./controlled-login-dialog.mjs";
22
+ * export default function register(ctx) { registerControlledLoginClient(ctx, { … }); }
23
+ */
24
+
25
+ // ---------- 模块内 open 状态(toolbar 入口与 dialogs 条目同模块共享,不落 window 全局) ----------
26
+ let moduleSetOpen = null;
27
+ // 懒加载(clientImmediately:false)下 toolbar 静态条目点击时弹窗组件可能尚未挂载:
28
+ // 记 pending,挂载 effect 消费——避免首点失效。
29
+ let pendingOpen = false;
30
+
31
+ /** 打开本扩展的受控登录弹窗(toolbar 🔑 按钮 / 静态条目 meta.onClick 用)。 */
32
+ export function openControlledLogin() {
33
+ if (moduleSetOpen) moduleSetOpen(true);
34
+ else pendingOpen = true;
35
+ }
36
+
37
+ // ---------- 自持样式层(幂等注入;clg- 前缀 + var(--x, 兜底),零宿主耦合) ----------
38
+ const STYLE_ID = "aiti-controlled-login-styles";
39
+ const STYLES = `
40
+ .clg-mask { position: fixed; inset: 0; z-index: 2147483000; background: rgba(0,0,0,.32); display: flex; align-items: center; justify-content: center; }
41
+ .clg-panel { width: 520px; max-width: calc(100vw - 16px); display: flex; flex-direction: column; overflow: hidden;
42
+ background: var(--bg,#fff); color: var(--text,#1a1a1a); border: 1px solid var(--border,#e0e0e0); border-radius: 12px;
43
+ box-shadow: 0 16px 64px rgba(0,0,0,.25); font-size: 13px; line-height: 1.55; }
44
+ .clg-head { display: flex; align-items: center; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border,#e0e0e0); flex-shrink: 0; background: var(--bg-subtle,#f7f7f7); }
45
+ .clg-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
46
+ .clg-x { margin-left: auto; border: none; background: none; cursor: pointer; color: var(--text-muted,#888); font-size: 16px; line-height: 1; padding: 4px 6px; border-radius: 6px; }
47
+ .clg-x:hover { background: var(--bg-hover,#eee); color: var(--text,#1a1a1a); }
48
+ .clg-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 10px; }
49
+ .clg-intro { color: var(--text-muted,#777); font-size: 12.5px; }
50
+ .clg-urlrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
51
+ .clg-open { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border: 1px solid var(--accent,#2563eb); border-radius: 8px;
52
+ cursor: pointer; background: var(--accent,#2563eb); color: #fff; font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap; }
53
+ .clg-open:hover { background: var(--accent-hover,#1d4ed8); }
54
+ .clg-url { flex: 1; min-width: 200px; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted,#777);
55
+ word-break: break-all; cursor: pointer; padding: 5px 8px; border: 1px dashed var(--border,#d8d8d8); border-radius: 6px; }
56
+ .clg-url:hover { border-color: var(--accent,#2563eb); color: var(--text,#1a1a1a); }
57
+ .clg-copied { color: #16a34a; font-size: 11.5px; }
58
+ .clg-status { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 8px; font-size: 12.5px; flex-wrap: wrap; }
59
+ .clg-status.waiting { background: rgba(37,99,235,.08); color: var(--accent,#2563eb); }
60
+ .clg-status.success { background: color-mix(in srgb, var(--ok, #22c55e) 12%, transparent); color: #16a34a; }
61
+ .clg-status.failed, .clg-status.timeout, .clg-status.cancelled { background: color-mix(in srgb, var(--danger, #ef4444) 8%, transparent); color: #dc2626; }
62
+ .clg-spin { width: 14px; height: 14px; flex-shrink: 0; border: 2px solid color-mix(in srgb, currentColor 25%, transparent); border-top-color: currentColor;
63
+ border-radius: 50%; animation: clg-rotate .9s linear infinite; }
64
+ @keyframes clg-rotate { to { transform: rotate(360deg); } }
65
+ .clg-errline { font-size: 12px; color: #dc2626; word-break: break-all; }
66
+ .clg-tail { font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted,#888); background: var(--bg-subtle,#f5f5f5);
67
+ border: 1px solid var(--border,#e0e0e0); border-radius: 8px; padding: 8px 10px; max-height: 140px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
68
+ .clg-coderow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
69
+ .clg-input { flex: 1; min-width: 180px; padding: 7px 10px; border: 1px solid var(--border,#d8d8d8); border-radius: 8px;
70
+ background: var(--bg,#fff); color: var(--text,#1a1a1a); font-size: 12.5px; outline: none; }
71
+ .clg-input:focus { border-color: var(--accent,#2563eb); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent,#2563eb) 18%, transparent); }
72
+ .clg-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border: 1px solid var(--border,#d8d8d8); border-radius: 7px;
73
+ cursor: pointer; background: var(--bg,#fff); color: var(--text,#1a1a1a); font-size: 12.5px; font-weight: 500; white-space: nowrap; }
74
+ .clg-btn:hover { background: var(--bg-hover,#f0f0f0); }
75
+ .clg-btn:disabled { opacity: .5; cursor: not-allowed; }
76
+ .clg-btn.primary { background: var(--accent,#2563eb); border-color: var(--accent,#2563eb); color: #fff; }
77
+ .clg-btn.primary:hover { background: var(--accent-hover,#1d4ed8); }
78
+ .clg-foot { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
79
+ .clg-hint { color: var(--text-dim,#999); font-size: 11.5px; }
80
+ `;
81
+
82
+ let stylesInjected = false;
83
+ function ensureStyles() {
84
+ if (stylesInjected) return;
85
+ if (typeof document === "undefined") return;
86
+ if (!document.getElementById(STYLE_ID)) {
87
+ const el = document.createElement("style");
88
+ el.id = STYLE_ID;
89
+ el.textContent = STYLES;
90
+ document.head.appendChild(el);
91
+ }
92
+ stylesInjected = true;
93
+ }
94
+
95
+ /**
96
+ * 统一弹窗注册(每扩展 client 的薄壳调用一次)。
97
+ *
98
+ * @param {*} ctx 宿主 client 上下文(h/useState/useEffect/apiUrl/registerItems)
99
+ * @param {object} config
100
+ * @param {string} config.extId 扩展 id(API 前缀 / toolbar item id 用)
101
+ * @param {string} config.toolbarLabel 工具条按钮 title/label(如 "登录 ZCode")
102
+ * @param {string} config.title 弹窗标题
103
+ * @param {string} [config.urlHint] A url-only 模式说明行
104
+ * @param {string} [config.pasteHint] B paste-relay 模式说明行
105
+ * @param {string} [config.successHint] 成功后的补充说明(如凭据换 key 提示)
106
+ */
107
+ export function registerControlledLoginClient(ctx, config) {
108
+ ensureStyles();
109
+ const h = (...a) => ctx.h(...a);
110
+ const useState = (v) => ctx.useState(v);
111
+ const useEffect = (fn, deps) => ctx.useEffect(fn, deps);
112
+ const api = (p, init) => fetch(ctx.apiUrl("/api/extensions/" + config.extId + p), { cache: "no-store", ...init });
113
+ const jsonInit = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
114
+
115
+ // ---- 工具条入口:声明式图标按钮(meta.icon + meta.onClick、无 render —— 宿主统一渲染 36×36 线图按钮,禁 emoji) ----
116
+ ctx.registerItems("toolbar", [{
117
+ id: `${config.extId}-controlled-login`,
118
+ meta: {
119
+ label: config.toolbarLabel,
120
+ title: config.toolbarLabel,
121
+ icon: "icons/key-round.svg",
122
+ onClick: () => openControlledLogin(),
123
+ },
124
+ }]);
125
+ // ---- 弹窗条目(dialogs slot;宿主包 display:contents 壳,float 持久化键认它) ----
126
+ ctx.registerItems("dialogs", [{
127
+ id: `${config.extId}-controlled-login-dialog`,
128
+ render: () => h(ControlledLoginDialog),
129
+ }]);
130
+
131
+ function ControlledLoginDialog() {
132
+ const [open, setOpen] = useState(false);
133
+ const [status, setStatus] = useState(null); // /login/status 响应({state,...})
134
+ const [busy, setBusy] = useState(false);
135
+ const [reqError, setReqError] = useState(null); // 请求层错误(网络/400)
136
+ const [code, setCode] = useState("");
137
+ const [copied, setCopied] = useState(false);
138
+
139
+ useEffect(() => {
140
+ moduleSetOpen = setOpen;
141
+ if (pendingOpen) { pendingOpen = false; setOpen(true); }
142
+ return () => { if (moduleSetOpen === setOpen) moduleSetOpen = null; };
143
+ }, []);
144
+
145
+ // 打开期间 2s 轮询 /login/status(§3.6 拍板例外);关闭即停
146
+ useEffect(() => {
147
+ if (!open) return;
148
+ let stopped = false;
149
+ let timer = null;
150
+ const tick = async () => {
151
+ try {
152
+ const r = await api("/login/status");
153
+ const d = await r.json();
154
+ if (!stopped) { setStatus(d); setReqError(null); }
155
+ } catch { /* 网络抖动忽略,下轮再试 */ }
156
+ if (!stopped) timer = setTimeout(tick, 2000);
157
+ };
158
+ tick();
159
+ return () => { stopped = true; if (timer) clearTimeout(timer); };
160
+ }, [open]);
161
+
162
+ if (!open) return null;
163
+
164
+ const st = status?.state ?? "idle";
165
+ const mode = status?.mode ?? "url";
166
+ const pasteMode = mode === "paste";
167
+
168
+ async function call(p, init) {
169
+ setBusy(true); setReqError(null);
170
+ try {
171
+ const r = await api(p, init);
172
+ const d = await r.json();
173
+ if (d && d.error) setReqError(d.error);
174
+ if (d) setStatus(d);
175
+ } catch (e) {
176
+ setReqError(e?.message || String(e));
177
+ }
178
+ setBusy(false);
179
+ }
180
+ const startLogin = () => call("/login/start", jsonInit({}));
181
+ const cancelLogin = () => call("/login/cancel", jsonInit({}));
182
+ const submitCode = () => { if (code.trim()) call("/login/submit", jsonInit({ code })); };
183
+
184
+ async function copyUrl() {
185
+ const url = status?.url || "";
186
+ if (!url) return;
187
+ try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 剪贴板不可用:URL 文本本身可选中复制 */ }
188
+ }
189
+
190
+ // ---- 状态区 ----
191
+ let statusLine = null;
192
+ if (st === "idle") {
193
+ statusLine = h("div", { className: "clg-status waiting" }, "尚未发起登录");
194
+ } else if (st === "waiting") {
195
+ statusLine = h("div", { className: "clg-status waiting" },
196
+ h("span", { className: "clg-spin" }),
197
+ status?.url ? "等待你在浏览器完成授权…(本页自动感知,约 2s 内)" : "正在启动官方登录进程,等待授权链接…",
198
+ h("button", { className: "clg-btn", disabled: busy, style: { marginLeft: "auto" }, onClick: cancelLogin }, "取消"),
199
+ );
200
+ } else if (st === "success") {
201
+ statusLine = h("div", { className: "clg-status success" },
202
+ "登录成功", status?.user ? h("span", { style: { fontWeight: 700 } }, status.user) : null,
203
+ config.successHint ? h("span", { className: "clg-hint", style: { color: "inherit" } }, config.successHint) : null,
204
+ );
205
+ } else if (st === "failed" || st === "timeout" || st === "cancelled") {
206
+ const label = st === "failed" ? "登录失败" : st === "timeout" ? "登录超时" : "已取消";
207
+ statusLine = h("div", { className: "clg-status " + st },
208
+ h("span", null, (st === "timeout" ? "⏱ " : st === "cancelled" ? "⚪ " : "❌ ") + label),
209
+ status?.error ? h("span", { className: "clg-errline" }, status.error) : null,
210
+ );
211
+ }
212
+
213
+ return h("div", { className: "clg-mask", onClick: () => setOpen(false) },
214
+ h("div", {
215
+ className: "clg-panel",
216
+ "data-ext-float": "1", "data-ext-float-w": 520, "data-ext-float-h": 380, "data-ext-float-full": "1",
217
+ onClick: (e) => e.stopPropagation(),
218
+ },
219
+ // ── 标题栏(首个子元素 = float 拖拽把手)──
220
+ h("div", { className: "clg-head" },
221
+ h("span", { className: "clg-title" }, "🔑 " + config.title),
222
+ h("button", { className: "clg-x", title: "关闭", onClick: () => setOpen(false) }, "✕"),
223
+ ),
224
+ h("div", { className: "clg-body" },
225
+ config.urlHint && !pasteMode ? h("div", { className: "clg-intro" }, config.urlHint) : null,
226
+ pasteMode && config.pasteHint ? h("div", { className: "clg-intro" }, config.pasteHint) : null,
227
+
228
+ // ── URL 区(waiting 且已抓到链接)──
229
+ st === "waiting" && status?.url
230
+ ? h("div", { className: "clg-urlrow" },
231
+ h("a", { className: "clg-open", href: status.url, target: "_blank", rel: "noopener noreferrer" }, "打开授权页"),
232
+ h("span", { className: "clg-url", title: "点击复制链接", onClick: copyUrl }, status.url),
233
+ copied ? h("span", { className: "clg-copied" }, "已复制") : null,
234
+ )
235
+ : null,
236
+
237
+ // ── B 模式 code 输入(paste 且 waiting)──
238
+ pasteMode && st === "waiting"
239
+ ? h("div", { className: "clg-coderow" },
240
+ h("input", {
241
+ className: "clg-input",
242
+ placeholder: "粘贴授权完成页显示的 code(或完整回调链接)",
243
+ value: code,
244
+ onChange: (e) => setCode(e.target.value),
245
+ onKeyDown: (e) => { if (e.key === "Enter") submitCode(); },
246
+ }),
247
+ h("button", { className: "clg-btn primary", disabled: busy || !code.trim(), onClick: submitCode }, busy ? "…" : "提交 code"),
248
+ )
249
+ : null,
250
+
251
+ statusLine,
252
+ reqError ? h("div", { className: "clg-errline" }, reqError) : null,
253
+
254
+ // CLI 升级改输出格式导致 URL 抓取失败时:原文透出(人工可读可复制,方案 §7)
255
+ (st === "failed" || st === "timeout") && status?.outputTail
256
+ ? h("div", { className: "clg-tail" }, status.outputTail)
257
+ : null,
258
+
259
+ // ── 底部动作:idle/终态带(重)试 ──
260
+ h("div", { className: "clg-foot" },
261
+ st === "idle" || st === "failed" || st === "timeout" || st === "cancelled" || st === "success"
262
+ ? h("button", { className: "clg-btn primary", disabled: busy, onClick: startLogin }, busy ? "…" : st === "idle" ? "发起登录" : "重试")
263
+ : null,
264
+ h("span", { className: "clg-hint" }, "授权在浏览器完成,凭据由官方 CLI 落盘;本页关闭不影响进行中的登录。"),
265
+ ),
266
+ ),
267
+ ),
268
+ );
269
+ }
270
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * external-cli-governance —— 外部 CLI provider 的治理模式(共享源,复制到各扩展目录部署)
3
+ *
4
+ * 来源:pi-subagents 0.58 外部 runner 的治理模式抄写(MIT),适配本仓库 provider 槽。
5
+ * 权威源:pi-web-ext/_shared/external-cli-governance.ts(改动后同步到各扩展副本)。
6
+ * 当前副本:aiti-{claude-code,codex,pi,acp}-agent/external-governance.ts
7
+ *
8
+ * 四件治理:
9
+ * 1. env allowlist(governanceEnv)——spawn 只透传白名单环境变量,父进程的
10
+ * 其他密钥(各家 API key/auth token)不再泄入子 CLI 进程。
11
+ * 2. preflight(preflightCli)——启动前探测二进制存在 + 版本(带 TTL 缓存),
12
+ * fail-loud 提示替代运行时 spawn ENOENT。
13
+ * 3. 能力收窄文案表(CAPABILITY_NOTICE 模式)——不支持的能力带人话理由
14
+ * (我们 provider 槽 capabilities 布尔位之上的文案层,guard 消费后续接)。
15
+ * 4. 读写分型(READONLY_TOOL_ARGS 模式)——只读变体显式声明(-writer 后缀
16
+ * 反向约定:默认只读,writer 才放开写)。
17
+ */
18
+
19
+ import { execFile } from "node:child_process";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // 1) env allowlist
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** 所有外部 CLI 通用的基础白名单(系统定位 + 代理 + TLS)。 */
26
+ export const BASE_ENV_ALLOW = [
27
+ "PATH", "HOME", "USERPROFILE", "USER", "LOGNAME", "TMPDIR", "TEMP", "TMP",
28
+ "LANG", "LC_ALL", "TERM",
29
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
30
+ "http_proxy", "https_proxy", "no_proxy",
31
+ "SSL_CERT_FILE", "SSL_CERT_DIR",
32
+ ] as const;
33
+
34
+ /** 各 CLI 专属键(与 pi-subagents claude-code adapter 对齐 + 本仓库常用 provider)。 */
35
+ export const CLI_ENV_ALLOW: Record<string, string[]> = {
36
+ "claude-code": [
37
+ "CLAUDE_CONFIG_DIR",
38
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
39
+ "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX",
40
+ "CLAUDE_CODE_USE_FOUNDRY",
41
+ "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION",
42
+ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK",
43
+ "GOOGLE_APPLICATION_CREDENTIALS", "CLOUD_ML_REGION", "ANTHROPIC_VERTEX_PROJECT_ID",
44
+ ],
45
+ "codex": [
46
+ "OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_HOME", "CODEX_API_KEY",
47
+ // 自定义 provider 认证(~/.codex/config.toml 的 model_providers.*.env_key),
48
+ // 如 openrouter 桥接 provider——缺了它轮次直接 turn.failed(Missing environment variable)。
49
+ "OPENROUTER_API_KEY",
50
+ ],
51
+ "pi": [
52
+ // pi 自身及其 provider 认证走 agent 目录(models.json/auth.json),
53
+ // 环境变量只留运行时与常见 provider 前缀(防误伤自定义 provider)。
54
+ "PI_CODING_AGENT_DIR", "PI_WEB_PKG_DIR",
55
+ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
56
+ "ZAI_API_KEY", "DEEPSEEK_API_KEY", "MOONSHOT_API_KEY", "KIMI_API_KEY",
57
+ "QWEN_API_KEY", "DASHSCOPE_API_KEY", "OPENROUTER_API_KEY",
58
+ "SILICONFLOW_API_KEY", "XAI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY",
59
+ ],
60
+ "acp": [
61
+ "ACP_*",
62
+ ],
63
+ };
64
+
65
+ /**
66
+ * 生成治理后的子进程 env:白名单键(精确 + `前缀*` 通配)+ extra 覆盖。
67
+ * 替代 `env: { ...process.env }` 的全量透传。
68
+ */
69
+ export function governanceEnv(cli: keyof typeof CLI_ENV_ALLOW | string, extra?: Record<string, string>): Record<string, string> {
70
+ const allow = [...BASE_ENV_ALLOW, ...(CLI_ENV_ALLOW[cli as string] ?? [])];
71
+ const out: Record<string, string> = {};
72
+ for (const k of Object.keys(process.env)) {
73
+ const hit = allow.some((a) => (a.endsWith("*") ? k.startsWith(a.slice(0, -1)) : k === a));
74
+ if (hit && process.env[k] !== undefined) out[k] = process.env[k] as string;
75
+ }
76
+ return { ...out, ...(extra ?? {}) };
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // 2) preflight
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const preflightCache = new Map<string, { at: number; version: string | null; ok: boolean }>();
84
+
85
+ /**
86
+ * 启动前探测:二进制可执行 + 可选版本(`--version`)。TTL 缓存,失败不缓存负面结果以外的信息。
87
+ * 返回 { ok, version, error }——ok=false 时 error 是给人看的失败原因(fail-loud)。
88
+ */
89
+ export function preflightCli(bin: string, opts: { versionArgs?: string[]; timeoutMs?: number; ttlMs?: number } = {}): Promise<{ ok: boolean; version: string | null; error?: string }> {
90
+ const ttl = opts.ttlMs ?? 60_000;
91
+ const cached = preflightCache.get(bin);
92
+ if (cached && Date.now() - cached.at < ttl) {
93
+ return Promise.resolve(cached.ok
94
+ ? { ok: true, version: cached.version }
95
+ : { ok: false, version: null, error: cached.version ?? `${bin} 探测失败` });
96
+ }
97
+ return new Promise((resolve) => {
98
+ execFile(bin, opts.versionArgs ?? ["--version"], { timeout: opts.timeoutMs ?? 5_000 }, (err, stdout) => {
99
+ if (err) {
100
+ const msg = String((err as NodeJS.ErrnoException).code) === "ENOENT"
101
+ ? `${bin} 未安装或不在 PATH`
102
+ : `${bin} 探测失败: ${err.message.split("\n")[0]}`;
103
+ preflightCache.set(bin, { at: Date.now(), version: msg, ok: false });
104
+ resolve({ ok: false, version: null, error: msg });
105
+ return;
106
+ }
107
+ const version = String(stdout).trim().split("\n")[0]?.slice(0, 64) ?? null;
108
+ preflightCache.set(bin, { at: Date.now(), version, ok: true });
109
+ resolve({ ok: true, version });
110
+ });
111
+ });
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // 3) 能力收窄文案表(模式示例——provider 按需声明,guard 消费后续接)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** 不支持能力的人话理由(替代裸 false;provider 的 capabilities 旁路声明)。 */
119
+ export interface CapabilityNotices {
120
+ [capability: string]: string;
121
+ }
122
+
123
+ /** one-shot stdin 型外部 CLI 的通用收窄理由(pi-subagents external-cli-contract 对齐)。 */
124
+ export const ONESHOT_CLI_NOTICES: CapabilityNotices = {
125
+ steer: "one-shot stdin 模式在启动后关闭输入,无法接受运行中导向消息。",
126
+ fork: "外部 CLI 会话无原生 fork 语义(无 pi session 树)。",
127
+ compact: "外部 CLI 不暴露上下文压缩接口。",
128
+ setActiveTools: "外部 CLI 的工具白名单只能启动时声明,不能运行中切换。",
129
+ };
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // 4) 读写分型
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /** 只读变体的 CLI 参数(writer 变体反向约定:默认只读,writer 才放开写)。 */
136
+ export const READONLY_TOOL_ARGS: Record<string, string[]> = {
137
+ "claude-code": ["--allowedTools", "Read,Glob,Grep"],
138
+ };