u1s1-cli 1.14.6 → 1.15.0
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.
- package/dist/agent-setup.d.ts +7 -0
- package/dist/agent-setup.js +14 -0
- package/dist/agents/agent-config.d.ts +83 -0
- package/dist/agents/agent-config.js +154 -0
- package/dist/agents/claude.d.ts +80 -0
- package/dist/agents/claude.js +233 -0
- package/dist/agents/codex.d.ts +111 -0
- package/dist/agents/codex.js +392 -0
- package/dist/agents/launch.d.ts +109 -0
- package/dist/agents/launch.js +444 -0
- package/dist/agents/pi.d.ts +9 -0
- package/dist/agents/pi.js +562 -0
- package/dist/agents/prefs.d.ts +35 -0
- package/dist/agents/prefs.js +96 -0
- package/dist/agents/runtime.d.ts +354 -0
- package/dist/agents/runtime.js +884 -0
- package/dist/agents/status.d.ts +20 -0
- package/dist/agents/status.js +241 -0
- package/dist/config-command.d.ts +18 -0
- package/dist/config-command.js +335 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +35 -1
- package/dist/device-auth.d.ts +37 -1
- package/dist/device-auth.js +43 -8
- package/dist/embed.d.ts +13 -0
- package/dist/embed.js +15 -0
- package/dist/index.js +22 -556
- package/dist/launcher.d.ts +48 -0
- package/dist/launcher.js +235 -0
- package/dist/mcp-server.d.ts +105 -0
- package/dist/mcp-server.js +449 -0
- package/dist/pi-ui-text.d.ts +57 -0
- package/dist/pi-ui-text.js +148 -0
- package/dist/usage.js +2 -2
- package/package.json +1 -1
- package/scripts/p0-claude-code/README.md +39 -0
- package/scripts/p0-claude-code/launch-claude.ts +94 -0
- package/scripts/p0-claude-code/replay.ts +74 -0
- package/scripts/p0-claude-code/seed-local.mjs +145 -0
- package/scripts/p0-claude-code/tap.mjs +105 -0
- package/scripts/patch-pi.js +76 -55
- package/scripts/pi-patch-report.mjs +224 -0
- package/scripts/pi-upgrade.mjs +829 -0
- package/scripts/render-regression.mjs +67 -5
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi:内置客户端,以库的方式在本进程内运行(不是子进程)。原 index.ts 的 runAgent,
|
|
3
|
+
* 拆分时整体搬来,行为不变。启动器(launcher.ts)按需 import 本模块,
|
|
4
|
+
* 跑其他客户端时不加载 Pi 的任何东西。
|
|
5
|
+
*/
|
|
6
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
|
+
import { cleanupBrandThemes, endpointKeyEnv, endpointProviderEntry, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, removeLegacyWorkflowPromptTemplate, scrubForeignProviderEnv, toProviderModels, writeAnnouncementsExtension, writeAttributionExtension, writeMcpExtension, removeLegacyCompactUiExtension, removeLegacyUiTextExtension, writeErrorHumanizeExtension, writeWebToolsExtension, } from "../agent-setup.js";
|
|
9
|
+
import { registerUiText } from "../pi-ui-text.js";
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import { activeSite, agentDir, apiModelToDef, CUSTOM_ENDPOINTS, isPortableInstall, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, readCompactionBudgetSetting, readCompactionReserveTokens, readSettings, refValid, resolvePreferredModel, setModelsFromApi, VERSION, } from "../config.js";
|
|
12
|
+
import { registerLoopCommand } from "../loop.js";
|
|
13
|
+
import { ensureSearchTools } from "../search-tools.js";
|
|
14
|
+
import { ensureUsableShell } from "../shell-doctor.js";
|
|
15
|
+
import { applyBrandUi, setAnnouncement, setUpdateNotice } from "../style.js";
|
|
16
|
+
import { AccessDeniedError, AuthError, fetchModels, loadCustomEndpoints, readJsonResponseCapped } from "../api.js";
|
|
17
|
+
import { ensureSigningProxy, onGatewayFetchFailure } from "../device-auth.js";
|
|
18
|
+
import { installChildEnvGuard } from "../secret-env.js";
|
|
19
|
+
import { deployHintText, markNudge, shouldShowDeployHint } from "../nudges.js";
|
|
20
|
+
import { createGatewayTelemetry } from "../telemetry.js";
|
|
21
|
+
import { installCommand, siteLinks, siteOf } from "../site.js";
|
|
22
|
+
import { lang, tr } from "../i18n.js";
|
|
23
|
+
const PACKAGE_NAME = "u1s1-cli";
|
|
24
|
+
/** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
|
|
25
|
+
let pendingUpdate;
|
|
26
|
+
/**
|
|
27
|
+
* 启动时自动检查 npm 最新版:autoUpdate 开着就记下退出后安装,关着也在启动横幅的
|
|
28
|
+
* 版本号后面提示一句(u1s1 vX.Y.Z 后跟升级状态,见 setUpdateNotice)。
|
|
29
|
+
* 不阻塞启动流程,失败也不报错(留到手动 `u1s1 update`)。
|
|
30
|
+
*
|
|
31
|
+
* 绝不能边跑 TUI 边后台 npm install -g 原地更新:npm 会整棵重铺
|
|
32
|
+
* u1s1-cli/node_modules,而 pi-ai 的 API 适配器要到首次模型调用才按路径懒加载
|
|
33
|
+
* (openai-completions.lazy.js),路径被砸掉就 ERR_MODULE_NOT_FOUND 当场炸
|
|
34
|
+
* (0.13.2 之前云端沙箱首任务必崩的现场)。所以这里只记 pending,退出后再装。
|
|
35
|
+
*/
|
|
36
|
+
async function checkForUpdate() {
|
|
37
|
+
let latest;
|
|
38
|
+
try {
|
|
39
|
+
const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
40
|
+
const res = await fetch(url, {
|
|
41
|
+
headers: { Accept: "application/json" },
|
|
42
|
+
signal: AbortSignal.timeout(5_000),
|
|
43
|
+
});
|
|
44
|
+
if (res.ok) {
|
|
45
|
+
const body = await readJsonResponseCapped(res, 64 * 1024);
|
|
46
|
+
latest = typeof body.version === "string"
|
|
47
|
+
&& body.version.length <= 100
|
|
48
|
+
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(body.version)
|
|
49
|
+
? body.version
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return; // 网络不可用,静默跳过
|
|
55
|
+
}
|
|
56
|
+
if (!latest)
|
|
57
|
+
return;
|
|
58
|
+
// parseInt 而非 Number:预发布段("0-beta.1")取前导数字,避免 NaN 让比较全军覆没
|
|
59
|
+
const pa = VERSION.split(".").map((s) => parseInt(s, 10) || 0);
|
|
60
|
+
const pb = latest.split(".").map((s) => parseInt(s, 10) || 0);
|
|
61
|
+
let newer = false;
|
|
62
|
+
for (let i = 0; i < 3; i++) {
|
|
63
|
+
const diff = (pb[i] ?? 0) - (pa[i] ?? 0);
|
|
64
|
+
if (diff > 0) {
|
|
65
|
+
newer = true;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (diff < 0)
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (!newer)
|
|
72
|
+
return;
|
|
73
|
+
const settings = readSettings();
|
|
74
|
+
// 便携版没法用 npm 自更新;autoUpdate 关闭同理只提示。u1s1 update 会给出正确升级方式
|
|
75
|
+
if (settings.autoUpdate === false || isPortableInstall()) {
|
|
76
|
+
setUpdateNotice(tr(`⬆ 新版 v${latest} 可用 · u1s1 update 升级`, `⬆ v${latest} available · run u1s1 update`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// 检测包管理器
|
|
80
|
+
const userAgent = process.env.npm_config_user_agent ?? "";
|
|
81
|
+
const pm = userAgent.startsWith("pnpm") ? "pnpm"
|
|
82
|
+
: userAgent.startsWith("yarn") ? "yarn"
|
|
83
|
+
: userAgent.startsWith("bun") ? "bun"
|
|
84
|
+
: "npm";
|
|
85
|
+
const installCmd = pm === "npm"
|
|
86
|
+
? `npm install -g --loglevel=error ${PACKAGE_NAME}@latest`
|
|
87
|
+
: `${pm} add -g ${PACKAGE_NAME}@latest`;
|
|
88
|
+
pendingUpdate = { latest, installCmd };
|
|
89
|
+
setUpdateNotice(tr(`⬆ 发现新版 v${latest},退出后自动更新`, `⬆ v${latest} available, will update on exit`));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* TUI 退出后前台安装新版:此刻不再有模型调用和懒加载,重铺 node_modules 才安全。
|
|
93
|
+
* 不设 timeout——半路杀掉 npm 会留下残缺的全局安装,之后连启动都起不来;
|
|
94
|
+
* stdio 直通,装多久用户看得见。(同机另开的 u1s1 实例仍会被重铺波及,属残余
|
|
95
|
+
* 风险,但远好于更新自己脚下这棵树。)
|
|
96
|
+
*/
|
|
97
|
+
export function installPendingUpdate() {
|
|
98
|
+
if (!pendingUpdate)
|
|
99
|
+
return;
|
|
100
|
+
const { latest, installCmd } = pendingUpdate;
|
|
101
|
+
console.log(tr(`\n⬆ 正在更新到 v${latest}…`, `\n⬆ Updating to v${latest}…`));
|
|
102
|
+
console.log(tr(" (可能需要几分钟,期间请不要关闭窗口或按 Ctrl+C,中途打断会装出半截)", " (This may take a few minutes. Don't close the window or press Ctrl+C; interrupting leaves a broken install.)"));
|
|
103
|
+
try {
|
|
104
|
+
execSync(installCmd, { stdio: "inherit" });
|
|
105
|
+
console.log(tr(`✅ 已更新到 v${latest},下次运行 u1s1 生效。`, `✅ Updated to v${latest}. It takes effect the next time you run u1s1.`));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// 失败不挡退出,下次启动还会再试。npm 全局目录没写权限时 `u1s1 update` 也会
|
|
109
|
+
// 走同一条命令再失败一次,所以这里直接给出能跳出循环的路径(官网安装脚本)
|
|
110
|
+
console.error(tr("自动更新失败(上面是 npm 的报错,常见原因是没有全局安装权限)。", "Auto-update failed (npm's error is above; usually missing permission for global installs)."));
|
|
111
|
+
console.error(tr("可以改用官网安装脚本重装,一步到位:", "Reinstall with the official install script instead:"));
|
|
112
|
+
console.error(process.platform === "win32"
|
|
113
|
+
? ` powershell -c "${installCommand(activeSite(), "ps1")}"`
|
|
114
|
+
: ` ${installCommand(activeSite(), "sh")}`);
|
|
115
|
+
console.error(tr("不想每次退出都尝试更新的话,可在 ~/.u1s1/agent/settings.json 里设 \"autoUpdate\": false", "To stop trying to update on every exit, set \"autoUpdate\": false in ~/.u1s1/agent/settings.json"));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* tmux 默认不转发键盘修饰键,会把 Shift+Enter 当成普通回车发出去,消息没写完就被发送。
|
|
120
|
+
* 只开 extended-keys 不够:默认 terminal-features 里 xterm* 没有 extkeys,tmux 根本不会
|
|
121
|
+
* 向外层终端要 modifyOtherKeys,S-Enter 绑定也就永远触发不了。
|
|
122
|
+
* 启动时自动做这些事(运行时生效,不写用户 tmux.conf;失败则静默忽略,仍可用 Ctrl+J 换行):
|
|
123
|
+
* 1. 开启 extended-keys,并给当前终端补上 extkeys;
|
|
124
|
+
* 2. tmux 3.5+ 再切到 csi-u(3.4 及以下没有这个选项,忽略即可);
|
|
125
|
+
* 3. 兜底把 S-Enter / C-Enter 原样转成 pi 认得的 CSI-u 序列。
|
|
126
|
+
*/
|
|
127
|
+
function ensureTmuxKeyboardProtocol() {
|
|
128
|
+
if (!process.env.TMUX)
|
|
129
|
+
return;
|
|
130
|
+
const runTmux = (args) => spawnSync("tmux", args, { timeout: 1500, encoding: "utf8" });
|
|
131
|
+
if (runTmux(["-V"]).status !== 0)
|
|
132
|
+
return; // tmux 不可用(如沙箱)则跳过
|
|
133
|
+
// 当前会话 + 全局默认都开,避免只改 -g 时已有会话仍是 off
|
|
134
|
+
runTmux(["set-option", "-g", "extended-keys", "on"]);
|
|
135
|
+
runTmux(["set-option", "extended-keys", "on"]);
|
|
136
|
+
// 3.5+; 3.4 会失败,忽略
|
|
137
|
+
runTmux(["set-option", "-g", "extended-keys-format", "csi-u"]);
|
|
138
|
+
runTmux(["set-option", "extended-keys-format", "csi-u"]);
|
|
139
|
+
const features = runTmux(["show", "-gv", "terminal-features"]);
|
|
140
|
+
if (features.status === 0 && !/\bextkeys\b/.test(features.stdout ?? "")) {
|
|
141
|
+
runTmux(["set-option", "-ga", "terminal-features", "xterm*:extkeys"]);
|
|
142
|
+
}
|
|
143
|
+
// 已挂上的客户端不会自动重读 terminal-features;直接往当前客户端 tty
|
|
144
|
+
// 发 modifyOtherKeys,让外层终端立刻开始区分 Shift+Enter。
|
|
145
|
+
const tty = runTmux(["display-message", "-p", "#{client_tty}"]).stdout?.trim();
|
|
146
|
+
if (tty) {
|
|
147
|
+
try {
|
|
148
|
+
writeFileSync(tty, "\x1b[>4;2m");
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// 没权限或不是 tty 就忽略
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// -l: 按字面量发送,避免 send-keys 把转义序列拆成一串普通按键
|
|
155
|
+
runTmux(["bind-key", "-n", "S-Enter", "send-keys", "-l", "\x1b[13;2u"]);
|
|
156
|
+
runTmux(["bind-key", "-n", "C-Enter", "send-keys", "-l", "\x1b[13;5u"]);
|
|
157
|
+
}
|
|
158
|
+
export async function runAgent(cfg, args) {
|
|
159
|
+
cleanupBrandThemes();
|
|
160
|
+
ensureDefaultSettings();
|
|
161
|
+
// Set constant environment values before startup I/O; values derived from
|
|
162
|
+
// responses are configured below, before loading the kernel.
|
|
163
|
+
process.env["PI_CODING_AGENT_DIR"] = agentDir;
|
|
164
|
+
process.env["U1S1_TOOLS_VIA_EXTENSION"] = "1";
|
|
165
|
+
// hide pi's own "Run pi update" banner; users should run `u1s1 update` instead
|
|
166
|
+
process.env["PI_SKIP_VERSION_CHECK"] = "1";
|
|
167
|
+
// Windows shell 体检异步先行,与取模型列表/搜索工具下载并行;首启验活
|
|
168
|
+
// (Defender 首扫 bash.exe 可达秒级)藏进网络等待,进 pi 前再 await
|
|
169
|
+
const shellReady = ensureUsableShell();
|
|
170
|
+
// 预 seed fd/rg(国内直连 GitHub 不通,pi 自己下不动);与取模型列表并行,
|
|
171
|
+
// 但必须在进 pi 之前就位,否则 pi 会自己去 GitHub 下载
|
|
172
|
+
const searchToolsReady = ensureSearchTools(cfg);
|
|
173
|
+
// Fetch model list from server; fall back to built-in MODELS on error.
|
|
174
|
+
// 服务端没开搜索(或老网关没有 features 字段)时不注册 web_search,模型就不会白调
|
|
175
|
+
// 自定义端点与模型列表并行拉取,失败各自兜底(内置列表 / 本地缓存)
|
|
176
|
+
let webSearchEnabled = true;
|
|
177
|
+
// 老网关没有 /v1/fetch,web_fetch_render 缺失时按关闭处理,直连失败不去白调
|
|
178
|
+
let webFetchRenderEnabled = false;
|
|
179
|
+
// 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
|
|
180
|
+
let imageGenEnabled = false;
|
|
181
|
+
const endpointsReady = loadCustomEndpoints(cfg);
|
|
182
|
+
const modelsReady = fetchModels(cfg);
|
|
183
|
+
let modelsResp;
|
|
184
|
+
try {
|
|
185
|
+
modelsResp = await modelsReady;
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
if (e instanceof AuthError) {
|
|
189
|
+
// 凭证形状还在但服务端已作废(设备被移除/换过钥匙):直接进 TUI 每条消息都会失败,
|
|
190
|
+
// 不如现在就引导重新登录
|
|
191
|
+
console.error(tr(" 登录已失效(这台设备可能被移除,或账号更换过钥匙),需要重新登录。", " Your session has expired (this device may have been removed, or the account's keys changed). Please sign in again."));
|
|
192
|
+
const { login } = await import("../login.js");
|
|
193
|
+
cfg = await login();
|
|
194
|
+
modelsResp = await fetchModels(cfg).catch((e2) => {
|
|
195
|
+
console.error(tr(" 获取模型列表失败,使用内置列表:", " Couldn't fetch the model list; using the built-in list:"), e2.message);
|
|
196
|
+
return undefined;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (e instanceof AccessDeniedError) {
|
|
200
|
+
// 封禁/停用/设备不受信任:进 TUI 后每条消息都会失败,不如现在就把原因说清楚并退出
|
|
201
|
+
console.error(` ${e.message}`);
|
|
202
|
+
const site = siteOf(cfg);
|
|
203
|
+
console.error(tr(` 这个账号当前无法使用 u1s1。如有疑问可发邮件到 ${site.contactEmail},或在 ${siteLinks(site).dashboard} 提交工单。`, ` This account can't use u1s1 right now. Questions? Email ${site.contactEmail} or open a support ticket at ${siteLinks(site).dashboard}.`));
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
console.error(tr(" 获取模型列表失败,使用内置列表:", " Couldn't fetch the model list; using the built-in list:"), e.message);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (modelsResp) {
|
|
211
|
+
const { models, features, announcement } = modelsResp;
|
|
212
|
+
setModelsFromApi(models.map(apiModelToDef));
|
|
213
|
+
webSearchEnabled = features.web_search !== false;
|
|
214
|
+
webFetchRenderEnabled = features.web_fetch_render === true;
|
|
215
|
+
imageGenEnabled = features.image_gen === true;
|
|
216
|
+
// 后台下发的启动公告(与模型列表同一次请求捎回,零额外开销);空/老网关不显示
|
|
217
|
+
if (announcement?.text)
|
|
218
|
+
setAnnouncement(announcement);
|
|
219
|
+
}
|
|
220
|
+
await endpointsReady;
|
|
221
|
+
// Gateway metadata owns each official model's default thinking level. Seed
|
|
222
|
+
// it after the live model list arrives; explicit user choices remain intact.
|
|
223
|
+
ensureDefaultSettings(MODELS);
|
|
224
|
+
// pi provider 只支持静态 header;指向本机 signing proxy,由它逐请求附 DPoP proof。
|
|
225
|
+
const signing = await ensureSigningProxy(cfg, "terminal", {
|
|
226
|
+
token: modelsResp?.clientAttestation,
|
|
227
|
+
expiresInSeconds: modelsResp?.clientAttestationExpiresInSeconds,
|
|
228
|
+
refresh: async () => {
|
|
229
|
+
const refreshed = await fetchModels(cfg);
|
|
230
|
+
return {
|
|
231
|
+
token: refreshed.clientAttestation,
|
|
232
|
+
expiresInSeconds: refreshed.clientAttestationExpiresInSeconds,
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
|
|
237
|
+
ensureBrandPrompt(await shellReady);
|
|
238
|
+
ensureProviderModels(officialCfg);
|
|
239
|
+
removeLegacyWorkflowPromptTemplate();
|
|
240
|
+
// 联网工具经 agentDir/extensions 投影,TUI 和 Desktop App 共用一份注册
|
|
241
|
+
writeWebToolsExtension(officialCfg, {
|
|
242
|
+
webSearch: webSearchEnabled,
|
|
243
|
+
webFetchRender: webFetchRenderEnabled,
|
|
244
|
+
imageGen: imageGenEnabled,
|
|
245
|
+
});
|
|
246
|
+
// 精简 UI 已下线,界面回到 Pi 默认;清掉旧版写进 agentDir/extensions 的扩展,否则 Pi 仍会加载它
|
|
247
|
+
removeLegacyCompactUiExtension();
|
|
248
|
+
// 模型调用错误人话化(同样投影,Desktop App 共享)
|
|
249
|
+
writeErrorHumanizeExtension();
|
|
250
|
+
// 斜杠命令补全描述、「思考中」提示改由下面的进程内扩展注册(registerUiText);
|
|
251
|
+
// 删掉上一版投影到 agentDir/extensions 的 u1s1-ui-text.js,免得两边各包一层
|
|
252
|
+
removeLegacyUiTextExtension();
|
|
253
|
+
// 会话内公告触达:5 分钟轮询,公告变化时推进会话(同样投影,Desktop 共享)
|
|
254
|
+
writeAnnouncementsExtension();
|
|
255
|
+
// OpenRouter 应用归因:直连流量计入 u1s1 的公开排行
|
|
256
|
+
writeAttributionExtension();
|
|
257
|
+
// 自定义 MCP 服务:TUI 用 pi 内置 MCP 读 agent/mcp.json;这里迁旧配置,并给 Desktop 的旧引擎投影兜底扩展
|
|
258
|
+
writeMcpExtension();
|
|
259
|
+
ensureTmuxKeyboardProtocol();
|
|
260
|
+
// must be set before pi reads them (getAgentDir() reads at call time);
|
|
261
|
+
// 常量部分已在 runAgent 开头、发起 import 之前设好
|
|
262
|
+
process.env["U1S1_API_KEY"] = officialCfg.apiKey;
|
|
263
|
+
process.env["U1S1_SIGNING_PROXY_URL"] = officialCfg.baseUrl;
|
|
264
|
+
// 自定义端点的密钥走环境变量引用(models.json 里只有 $VAR,不落明文)
|
|
265
|
+
Object.assign(process.env, endpointKeyEnv());
|
|
266
|
+
// 剔掉环境里的其他厂商密钥,/model 只展示 u1s1 官方模型 + 自定义端点
|
|
267
|
+
scrubForeignProviderEnv();
|
|
268
|
+
// 密钥只留在本进程:模型跑的 shell 命令、子 agent、扩展派生的任何子进程都拿不到
|
|
269
|
+
installChildEnvGuard();
|
|
270
|
+
// 客户端事件上报(见 telemetry.ts):状态放在 runAgent 作用域,/clear、/resume
|
|
271
|
+
// 会重建扩展运行时、重跑下面的 factory,但一个 TUI 进程只算一个会话
|
|
272
|
+
const telemetry = createGatewayTelemetry(cfg);
|
|
273
|
+
const sessionStartedAt = Date.now();
|
|
274
|
+
const stats = { turns: 0, toolCalls: 0, filesWritten: 0, writesThisRun: 0 };
|
|
275
|
+
let firstToolSuccessSent = false;
|
|
276
|
+
let firstTaskDoneSent = false;
|
|
277
|
+
let sessionEndSent = false;
|
|
278
|
+
telemetry.track("session_start", { surface: "terminal" });
|
|
279
|
+
// 「连不上 u1s1 服务器」只在用户本机可见;记下底层错误码,网络恢复后随批次上报
|
|
280
|
+
onGatewayFetchFailure((failure) => telemetry.track("gateway_unreachable", { code: failure.code, surface: "terminal" }));
|
|
281
|
+
const trackSessionEnd = () => {
|
|
282
|
+
if (sessionEndSent)
|
|
283
|
+
return;
|
|
284
|
+
sessionEndSent = true;
|
|
285
|
+
telemetry.track("session_end", {
|
|
286
|
+
duration_ms: Date.now() - sessionStartedAt,
|
|
287
|
+
turns: stats.turns,
|
|
288
|
+
tool_calls: stats.toolCalls,
|
|
289
|
+
files_written: stats.filesWritten,
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
await searchToolsReady;
|
|
293
|
+
// 界面语言跟站点走(见 i18n.ts)。pi 引擎的补丁文案(patches/)读不到 u1s1 的配置,
|
|
294
|
+
// 只认 U1S1_LANG;登录/换站都已在上面处理完,这里定下来再加载内核,TUI 与 -p 模式都覆盖
|
|
295
|
+
process.env["U1S1_LANG"] = lang();
|
|
296
|
+
// Module evaluation can block the event loop for seconds on slow filesystems.
|
|
297
|
+
// Finish startup requests and fallback/login handling before loading the kernel.
|
|
298
|
+
const { CustomEditor, main } = await import("@earendil-works/pi-coding-agent");
|
|
299
|
+
// Start this optional request after module evaluation so its timeout can progress.
|
|
300
|
+
void checkForUpdate();
|
|
301
|
+
// 网页终端(如 Taikula)把 Shift+Enter 发成 ESC+CR(\x1b\r)。pi 在没开 Kitty
|
|
302
|
+
// 协议时把它当成 Alt+Enter:空闲就直接发送,忙碌才排队。先改成 CSI-u 的
|
|
303
|
+
// Shift+Enter,输入框就会换行,真·Alt+Enter(\x1b[13;3u)不受影响。
|
|
304
|
+
// TUI 里 console.log 会被下一帧重绘吞掉,拦截提示必须走 ui.notify;
|
|
305
|
+
// session_start 时把 notify 句柄存出来给编辑器拦截用
|
|
306
|
+
let notifyUi;
|
|
307
|
+
class ShiftEnterEditor extends CustomEditor {
|
|
308
|
+
handleInput(data) {
|
|
309
|
+
// 拦截 /login 和 /logout,不让 pi 弹出内置供应商列表
|
|
310
|
+
if (data === "\r" || data === "\n") {
|
|
311
|
+
const text = this.getText().trim();
|
|
312
|
+
const tip = (msg) => (notifyUi ? notifyUi(msg) : console.log(` ${msg}`));
|
|
313
|
+
if (text.startsWith("/login")) {
|
|
314
|
+
tip(tr("切换模型用 /model;要换 u1s1 账号:先 /exit 退出,再在终端运行 u1s1 logout && u1s1 login", "Use /model to switch models. To switch u1s1 accounts, /exit first, then run u1s1 logout && u1s1 login in your terminal"));
|
|
315
|
+
this.setText("");
|
|
316
|
+
this.addToHistory?.(text);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (text === "/logout") {
|
|
320
|
+
tip(tr("退出登录请先 /exit 回到终端,再运行:u1s1 logout", "To sign out, /exit to your terminal first, then run: u1s1 logout"));
|
|
321
|
+
this.setText("");
|
|
322
|
+
this.addToHistory?.(text);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
super.handleInput(data === "\x1b\r" ? "\x1b[13;2u" : data);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const extension = [
|
|
330
|
+
{
|
|
331
|
+
name: "u1s1",
|
|
332
|
+
factory: (pi) => {
|
|
333
|
+
applyBrandUi(pi, VERSION);
|
|
334
|
+
// 斜杠命令补全描述(按 U1S1_LANG 换文案、去掉 /bug)和「思考中」提示,以前是 Pi 补丁。
|
|
335
|
+
// 放在进程内扩展里:--no-extensions、设置里禁用 agentDir 扩展都不影响,跟补丁一样总是生效
|
|
336
|
+
registerUiText(pi);
|
|
337
|
+
// /loop: 会话级定时循环执行提示词(对标 Claude Code),实现见 loop.ts
|
|
338
|
+
registerLoopCommand(pi);
|
|
339
|
+
// 会话标题:拿用户第一条正经输入的前半截当名字(像 Claude Code 那样),
|
|
340
|
+
// /resume 的会话列表里就能一眼认出每个会话。/clear 后 session_start
|
|
341
|
+
// 会重置标记,新会话重新起标题。
|
|
342
|
+
let sessionTitled = false;
|
|
343
|
+
pi.on("session_start", () => {
|
|
344
|
+
sessionTitled = false;
|
|
345
|
+
});
|
|
346
|
+
pi.on("input", async (event) => {
|
|
347
|
+
if (sessionTitled || event.source === "extension")
|
|
348
|
+
return;
|
|
349
|
+
const text = event.text.trim().replace(/\s+/g, " ");
|
|
350
|
+
// 空输入(纯图片粘贴)和斜杠命令不当标题
|
|
351
|
+
if (!text || text.startsWith("/"))
|
|
352
|
+
return;
|
|
353
|
+
sessionTitled = true;
|
|
354
|
+
const title = text.length > 30 ? `${text.slice(0, 30)}…` : text;
|
|
355
|
+
pi.setSessionName(title);
|
|
356
|
+
});
|
|
357
|
+
// pi 内置只有 /quit;补一个更常见的 /exit,方便新手退出
|
|
358
|
+
pi.registerCommand("exit", {
|
|
359
|
+
description: tr("退出 u1s1", "Exit u1s1"),
|
|
360
|
+
handler: async (_args, ctx) => {
|
|
361
|
+
ctx.shutdown();
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
// /help:启动横幅和官网教程都在引导用户输入它,必须真实存在
|
|
365
|
+
pi.registerEntryRenderer("u1s1-help", (_entry, _opts, theme) => {
|
|
366
|
+
const site = siteOf(cfg);
|
|
367
|
+
const text = [
|
|
368
|
+
theme.bold(theme.fg("text", tr("u1s1 常用操作", "u1s1 basics"))),
|
|
369
|
+
theme.fg("text", tr("直接打字说需求,回车发送 · Shift+Enter 换行 · Ctrl+V 贴图 · Esc 中断", "Type what you need, Enter to send · Shift+Enter newline · Ctrl+V paste image · Esc stop")),
|
|
370
|
+
theme.fg("text", tr("/model 切换模型(留意免费/价格提示) · /clear 清空上下文开新会话", "/model switch models (check the price hints) · /clear clear context, start a new session")),
|
|
371
|
+
theme.fg("text", tr("/usage 查剩余额度 · /context 看上下文占用与压缩预算 · /resume 恢复历史会话", "/usage remaining balance · /context context usage and compaction budget · /resume past sessions")),
|
|
372
|
+
theme.fg("text", tr("/feedback 一句话反馈问题或建议(自动附带版本与请求编号建工单)", "/feedback report a problem or idea (opens a support ticket with version and request IDs)")),
|
|
373
|
+
theme.fg("dim", tr("/compact 手动压缩上下文 · /settings 设置 · /hotkeys 全部快捷键 · /exit 退出", "/compact compact context now · /settings settings · /hotkeys all shortcuts · /exit exit")),
|
|
374
|
+
// 国际站没有网页托管,不提 u1s1 deploy
|
|
375
|
+
theme.fg("dim", site.id === "intl"
|
|
376
|
+
? tr("退出后在终端:u1s1 update 升级", "Back in your terminal: u1s1 update to upgrade")
|
|
377
|
+
: tr("退出后在终端:u1s1 deploy 发布网页 · u1s1 update 升级", "Back in your terminal: u1s1 deploy to publish a web page · u1s1 update to upgrade")),
|
|
378
|
+
theme.fg("dim", tr("匿名使用统计只记会话开始/结束等节点,不含任何内容;U1S1_TELEMETRY=0 可关闭", "Anonymous usage stats only record events like session start/end, never content; U1S1_TELEMETRY=0 turns them off")),
|
|
379
|
+
theme.fg("dim", tr(`新手教程 → ${siteLinks(site).guides}`, `Guides → ${siteLinks(site).guides}`)),
|
|
380
|
+
].join("\n");
|
|
381
|
+
return new Text(text, 1, 0);
|
|
382
|
+
});
|
|
383
|
+
pi.registerCommand("help", {
|
|
384
|
+
description: tr("查看常用操作和命令", "Show common actions and commands"),
|
|
385
|
+
handler: async () => {
|
|
386
|
+
pi.appendEntry("u1s1-help");
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
// /usage:会话内查额度。此前额度只有用完撞 429 才可见(审计 G1)
|
|
390
|
+
pi.registerEntryRenderer("u1s1-usage", (entry, _opts, theme) => {
|
|
391
|
+
const data = entry.data ?? {};
|
|
392
|
+
if (data.error)
|
|
393
|
+
return new Text(theme.fg("dim", data.error), 1, 0);
|
|
394
|
+
return new Text((data.lines ?? []).map((l) => theme.fg("text", l)).join("\n"), 1, 0);
|
|
395
|
+
});
|
|
396
|
+
pi.registerCommand("usage", {
|
|
397
|
+
description: tr("查看剩余额度和本月用量", "Show remaining balance and this month's usage"),
|
|
398
|
+
handler: async () => {
|
|
399
|
+
const { usageEntryData } = await import("../usage.js");
|
|
400
|
+
pi.appendEntry("u1s1-usage", await usageEntryData());
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
// /context:上下文占用与自动压缩预算。注册给 pi 的 contextWindow 是压缩预算
|
|
404
|
+
// (默认 200K,见 effectiveContextWindow),不是模型真实窗口,这里把两者都说清楚,
|
|
405
|
+
// 免得用户看着 1M 的模型却在 20 万 token 就被压缩时以为出了问题
|
|
406
|
+
pi.registerEntryRenderer("u1s1-context", (entry, _opts, theme) => {
|
|
407
|
+
const data = entry.data ?? {};
|
|
408
|
+
return new Text((data.lines ?? []).map((l) => theme.fg("text", l)).join("\n"), 1, 0);
|
|
409
|
+
});
|
|
410
|
+
pi.registerCommand("context", {
|
|
411
|
+
description: tr("查看上下文占用与自动压缩预算", "Show context usage and the auto-compaction budget"),
|
|
412
|
+
handler: async (_args, ctx) => {
|
|
413
|
+
const { contextEntryLines } = await import("../context-usage.js");
|
|
414
|
+
const settings = readSettings();
|
|
415
|
+
pi.appendEntry("u1s1-context", {
|
|
416
|
+
lines: contextEntryLines({
|
|
417
|
+
usage: ctx.getContextUsage(),
|
|
418
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
419
|
+
budgetSetting: readCompactionBudgetSetting(settings),
|
|
420
|
+
reserveTokens: readCompactionReserveTokens(settings),
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
// /feedback:会话内直接建工单,附版本/平台/最近请求编号(运营清单 E4)
|
|
426
|
+
pi.registerEntryRenderer("u1s1-feedback", (entry, _opts, theme) => {
|
|
427
|
+
const data = entry.data ?? {};
|
|
428
|
+
return new Text((data.lines ?? []).map((l) => theme.fg("text", l)).join("\n"), 1, 0);
|
|
429
|
+
});
|
|
430
|
+
pi.registerCommand("feedback", {
|
|
431
|
+
description: tr("反馈问题或建议(自动附带诊断信息建工单)", "Report a problem or idea (opens a support ticket with diagnostics)"),
|
|
432
|
+
handler: async (cmdArgs, ctx) => {
|
|
433
|
+
const { feedbackInSession, parseFeedbackArgs } = await import("../feedback.js");
|
|
434
|
+
let raw = cmdArgs.trim();
|
|
435
|
+
let parsed;
|
|
436
|
+
try {
|
|
437
|
+
parsed = parseFeedbackArgs(raw.split(/\s+/).filter(Boolean));
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
pi.appendEntry("u1s1-feedback", { lines: [e instanceof Error ? e.message : String(e)] });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (!parsed.help && !parsed.text && ctx.hasUI) {
|
|
444
|
+
const text = (await ctx.ui.input(tr("反馈内容", "Feedback"), tr("一句话说清楚问题或建议", "Describe the problem or idea in a sentence")))?.trim();
|
|
445
|
+
if (!text)
|
|
446
|
+
return;
|
|
447
|
+
raw = `--${parsed.category} ${text}`;
|
|
448
|
+
}
|
|
449
|
+
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
450
|
+
pi.appendEntry("u1s1-feedback", { lines: await feedbackInSession(raw, model) });
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
// ---- 事件上报 + 首次写文件后的 deploy 提示 ----
|
|
454
|
+
pi.on("agent_start", () => {
|
|
455
|
+
stats.writesThisRun = 0;
|
|
456
|
+
});
|
|
457
|
+
pi.on("turn_end", () => {
|
|
458
|
+
stats.turns++;
|
|
459
|
+
});
|
|
460
|
+
pi.on("tool_result", (event) => {
|
|
461
|
+
stats.toolCalls++;
|
|
462
|
+
if (event.isError)
|
|
463
|
+
return;
|
|
464
|
+
if (!firstToolSuccessSent) {
|
|
465
|
+
firstToolSuccessSent = true;
|
|
466
|
+
telemetry.track("first_tool_success", { tool: event.toolName });
|
|
467
|
+
}
|
|
468
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
469
|
+
stats.filesWritten++;
|
|
470
|
+
stats.writesThisRun++;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
474
|
+
if (stats.writesThisRun === 0)
|
|
475
|
+
return;
|
|
476
|
+
if (!firstTaskDoneSent) {
|
|
477
|
+
firstTaskDoneSent = true;
|
|
478
|
+
telemetry.track("first_task_done", { files_written: stats.filesWritten });
|
|
479
|
+
}
|
|
480
|
+
// 每台机器只提示一次;跑过 u1s1 deploy 后永不再提(见 nudges.ts)。国际站没有网页托管,不提示
|
|
481
|
+
if (ctx.hasUI && siteOf(cfg).id !== "intl" && shouldShowDeployHint()) {
|
|
482
|
+
markNudge("deploy_hint_shown");
|
|
483
|
+
ctx.ui.notify(deployHintText(), "info");
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
// pi 在 session_shutdown 处理完后直接 process.exit,session_end 只能在这里
|
|
487
|
+
// 发并等冲刷(最多 1 秒);/clear、/resume 的 reason 不是 quit,不算会话结束
|
|
488
|
+
pi.on("session_shutdown", async (event) => {
|
|
489
|
+
if (event.reason !== "quit")
|
|
490
|
+
return;
|
|
491
|
+
trackSessionEnd();
|
|
492
|
+
await telemetry.shutdown();
|
|
493
|
+
});
|
|
494
|
+
// 模型调用错误人话化不在这里注册:走 writeErrorHumanizeExtension 投影,
|
|
495
|
+
// TUI 和 Desktop App 共用一份(见 agent-setup.ts)
|
|
496
|
+
// /clear: 清空当前对话上下文,开始新会话
|
|
497
|
+
pi.registerCommand("clear", {
|
|
498
|
+
description: tr("清除当前对话上下文,开始新会话", "Clear the conversation context and start a new session"),
|
|
499
|
+
handler: async (_args, ctx) => {
|
|
500
|
+
await ctx.newSession({
|
|
501
|
+
withSession: async (sessionContext) => {
|
|
502
|
+
sessionContext.ui.notify(tr("🗑️ 上下文已清除", "🗑️ Context cleared"), "info");
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
// 进程内注册直接给字面量密钥:不经 $VAR 环境变量引用,也不落文件
|
|
508
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
509
|
+
name: "u1s1",
|
|
510
|
+
baseUrl: officialCfg.baseUrl,
|
|
511
|
+
api: "openai-completions",
|
|
512
|
+
apiKey: officialCfg.apiKey,
|
|
513
|
+
models: toProviderModels(MODELS),
|
|
514
|
+
});
|
|
515
|
+
// 用户在云端配置的自定义端点,一个端点一个 provider,/model 里即可切换
|
|
516
|
+
for (const ep of CUSTOM_ENDPOINTS) {
|
|
517
|
+
pi.registerProvider(ep.id, endpointProviderEntry(ep, { literalKey: true }));
|
|
518
|
+
}
|
|
519
|
+
// /model and Ctrl+P already write pi settings; also keep ~/.u1s1/config.json in sync
|
|
520
|
+
pi.on("model_select", (event) => {
|
|
521
|
+
if (event.source === "restore")
|
|
522
|
+
return;
|
|
523
|
+
const ref = { provider: event.model.provider, id: event.model.id };
|
|
524
|
+
if (!refValid(ref))
|
|
525
|
+
return;
|
|
526
|
+
persistPreferredModel(loadConfig(), ref.provider, ref.id);
|
|
527
|
+
});
|
|
528
|
+
pi.on("session_start", (_event, ctx) => {
|
|
529
|
+
notifyUi = (msg) => ctx.ui.notify(msg, "info");
|
|
530
|
+
const previous = ctx.ui.getEditorComponent();
|
|
531
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
532
|
+
if (previous) {
|
|
533
|
+
const inner = previous(tui, theme, keybindings);
|
|
534
|
+
const orig = inner.handleInput.bind(inner);
|
|
535
|
+
inner.handleInput = (data) => orig(data === "\x1b\r" ? "\x1b[13;2u" : data);
|
|
536
|
+
return inner;
|
|
537
|
+
}
|
|
538
|
+
return new ShiftEnterEditor(tui, theme, keybindings);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
];
|
|
544
|
+
const hasModelArg = args.some((a) => a === "--model" || a.startsWith("--model=") || a === "--provider");
|
|
545
|
+
const pref = resolvePreferredModel(cfg);
|
|
546
|
+
// 拿到了目录但本站一个模型都没上(国际站上线初期就是这样):别让 pi 报「Model not found」
|
|
547
|
+
if (!hasModelArg && modelsResp && MODELS.length === 0 && pref.provider === PROVIDER_ID) {
|
|
548
|
+
console.error(tr(` ${siteOf(cfg).origin} 暂时还没有可用的模型,请稍后再试。`, ` No models are available on ${siteOf(cfg).origin} yet. Please try again later.`));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
if (cfg.model !== pref.id || (cfg.modelProvider ?? PROVIDER_ID) !== pref.provider) {
|
|
552
|
+
persistPreferredModel(cfg, pref.provider, pref.id);
|
|
553
|
+
}
|
|
554
|
+
const finalArgs = hasModelArg ? args : ["--model", `${pref.provider}/${pref.id}`, ...args];
|
|
555
|
+
try {
|
|
556
|
+
await main(finalArgs, { extensionFactories: extension });
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
trackSessionEnd();
|
|
560
|
+
await telemetry.shutdown();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AgentConfig } from "./agent-config.js";
|
|
2
|
+
import type { ManagedAgent } from "./runtime.js";
|
|
3
|
+
export interface AgentPrefs {
|
|
4
|
+
model?: string;
|
|
5
|
+
autoUpdate: boolean;
|
|
6
|
+
/** 已经提示过「不在阵容里」的那个 model(内部用) */
|
|
7
|
+
modelNotice?: string;
|
|
8
|
+
}
|
|
9
|
+
/** 用户能填的模型 id:不带空白、引号等(会原样写进 TOML / JSON)。 */
|
|
10
|
+
export declare function isModelId(value: unknown): value is string;
|
|
11
|
+
export declare function readAgentPrefs(agent: ManagedAgent, raw?: Record<string, unknown> | undefined): AgentPrefs;
|
|
12
|
+
/** null = 删掉这一项(恢复默认);undefined = 不动。 */
|
|
13
|
+
export interface AgentPrefsPatch {
|
|
14
|
+
model?: string | null;
|
|
15
|
+
autoUpdate?: boolean | null;
|
|
16
|
+
modelNotice?: string | null;
|
|
17
|
+
}
|
|
18
|
+
export declare function saveAgentPrefs(agent: ManagedAgent, patch: AgentPrefsPatch): void;
|
|
19
|
+
export interface ModelChoice {
|
|
20
|
+
/** 这次启动用的模型;null = 网关也没给默认(交给客户端自己) */
|
|
21
|
+
model: string | null;
|
|
22
|
+
/** 用户选的模型不在当前阵容里,退回了网关默认 */
|
|
23
|
+
missing?: string;
|
|
24
|
+
}
|
|
25
|
+
/** 纯函数:用户偏好在阵容里就用它,否则网关默认。 */
|
|
26
|
+
export declare function chooseAgentModel(config: Pick<AgentConfig, "defaultModel" | "models">, preferred: string | undefined): ModelChoice;
|
|
27
|
+
/**
|
|
28
|
+
* 启动时选模型。用户选的模型不在阵容里时退回网关默认,同一个 id 只提示一次;
|
|
29
|
+
* 它回到阵容后清掉提示记录(再消失时会再提示)。偏好本身不删:模型可能只是暂时下架。
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveLaunchModel(agent: ManagedAgent, config: Pick<AgentConfig, "defaultModel" | "models">, deps?: {
|
|
32
|
+
prefs?: AgentPrefs;
|
|
33
|
+
save?: (patch: AgentPrefsPatch) => void;
|
|
34
|
+
notify?: (missing: string, fallback: string | null) => void;
|
|
35
|
+
}): string | null;
|