@rexvane/piweb 0.3.3 → 0.3.5

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.
@@ -62,6 +62,10 @@ interface WorkspaceState {
62
62
  interface Registry {
63
63
  workspaces: Map<string, Promise<WorkspaceState>>;
64
64
  gitOk?: Promise<boolean>;
65
+ /** git 不可用的上次确认时间(否定结果短缓存,避免重复探测超时) */
66
+ gitFailedAt?: number;
67
+ /** 解析后的 git 可执行文件(macOS GUI PATH 兜底) */
68
+ gitBin?: string;
65
69
  }
66
70
  const globalForGrowth = globalThis as typeof globalThis & { __piWebGrowth?: Registry };
67
71
  const registry: Registry = (globalForGrowth.__piWebGrowth ??= { workspaces: new Map() });
@@ -106,62 +110,102 @@ interface RunResult {
106
110
 
107
111
  type Env = Record<string, string | undefined>;
108
112
 
109
- function runGit(args: string[], opts: { cwd?: string; input?: Buffer | string; maxBuffer?: number; env?: Env } = {}): Promise<RunResult> {
110
- const maxBuffer = opts.maxBuffer ?? 64 * 1024 * 1024;
111
- return new Promise((resolve, reject) => {
112
- const child = spawn("git", args, {
113
- cwd: opts.cwd,
114
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", ...opts.env },
115
- windowsHide: true,
116
- stdio: ["pipe", "pipe", "pipe"],
113
+ /**
114
+ * 解析 git 可执行文件:macOS GUI/launchd 启动的进程 PATH 常不含 Homebrew,
115
+ * spawn("git") 会 ENOENT → 生长快照整体误判不可用。PATH 找不到时退回常见安装位置。
116
+ * 只在非 Windows 生效;结果缓存到 registry(成功后不再变)。
117
+ */
118
+ async function resolveGitBin(): Promise<string> {
119
+ if (registry.gitBin) return registry.gitBin;
120
+ let bin = "git";
121
+ if (process.platform !== "win32") {
122
+ const candidates = ["/opt/homebrew/bin/git", "/usr/local/bin/git", "/usr/bin/git", "/usr/local/git/bin/git"];
123
+ const inPath = await new Promise<boolean>((resolve) => {
124
+ const child = spawn("git", ["--version"], { stdio: "ignore" });
125
+ child.on("error", () => resolve(false));
126
+ child.on("close", (code) => resolve(code === 0));
117
127
  });
118
- const out: Buffer[] = [];
119
- const err: Buffer[] = [];
120
- let size = 0;
121
- let settled = false;
122
- const timer = setTimeout(() => fail(new GrowthError("git timed out", "failed")), 30_000);
123
- const fail = (error: Error) => {
124
- if (settled) return;
125
- settled = true;
126
- clearTimeout(timer);
127
- child.kill();
128
- reject(error);
129
- };
130
- child.stdout.on("data", (chunk: Buffer) => {
131
- size += chunk.length;
132
- if (size > maxBuffer) {
133
- child.kill();
134
- fail(new GrowthError("git output exceeded the buffer limit", "too-large"));
135
- return;
128
+ if (!inPath) {
129
+ for (const candidate of candidates) {
130
+ const ok = await new Promise<boolean>((resolve) => {
131
+ const child = spawn(candidate, ["--version"], { stdio: "ignore" });
132
+ child.on("error", () => resolve(false));
133
+ child.on("close", (code) => resolve(code === 0));
134
+ });
135
+ if (ok) {
136
+ bin = candidate;
137
+ break;
138
+ }
136
139
  }
137
- out.push(chunk);
138
- });
139
- child.stderr.on("data", (chunk: Buffer) => err.push(chunk));
140
- child.on("error", (error: NodeJS.ErrnoException) => {
141
- if (error.code === "ENOENT" && opts.cwd && !existsSync(opts.cwd)) fail(new GrowthError("workspace directory not found", "failed"));
142
- else fail(error.code === "ENOENT" ? new GrowthError("git is not installed", "unavailable") : error);
143
- });
144
- child.on("close", (code) => {
145
- if (settled) return;
146
- settled = true;
147
- clearTimeout(timer);
148
- resolve({ stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString("utf8"), code: code ?? -1 });
149
- });
150
- if (opts.input !== undefined) child.stdin.end(opts.input);
151
- else child.stdin.end();
152
- });
140
+ }
141
+ }
142
+ registry.gitBin = bin;
143
+ return bin;
153
144
  }
154
145
 
146
+ function runGit(args: string[], opts: { cwd?: string; input?: Buffer | string; maxBuffer?: number; env?: Env } = {}): Promise<RunResult> {
147
+ const maxBuffer = opts.maxBuffer ?? 64 * 1024 * 1024;
148
+ return resolveGitBin().then(
149
+ (gitBin) =>
150
+ new Promise<RunResult>((resolve, reject) => {
151
+ const child = spawn(gitBin, args, {
152
+ cwd: opts.cwd,
153
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", ...opts.env },
154
+ windowsHide: true,
155
+ stdio: ["pipe", "pipe", "pipe"],
156
+ });
157
+ const out: Buffer[] = [];
158
+ const err: Buffer[] = [];
159
+ let size = 0;
160
+ let settled = false;
161
+ const timer = setTimeout(() => fail(new GrowthError("git timed out", "failed")), 30_000);
162
+ const fail = (error: Error) => {
163
+ if (settled) return;
164
+ settled = true;
165
+ clearTimeout(timer);
166
+ child.kill();
167
+ reject(error);
168
+ };
169
+ child.stdout.on("data", (chunk: Buffer) => {
170
+ size += chunk.length;
171
+ if (size > maxBuffer) {
172
+ child.kill();
173
+ fail(new GrowthError("git output exceeded the buffer limit", "too-large"));
174
+ return;
175
+ }
176
+ out.push(chunk);
177
+ });
178
+ child.stderr.on("data", (chunk: Buffer) => err.push(chunk));
179
+ child.on("error", (error: NodeJS.ErrnoException) => {
180
+ if (error.code === "ENOENT" && opts.cwd && !existsSync(opts.cwd)) fail(new GrowthError("workspace directory not found", "failed"));
181
+ else fail(error.code === "ENOENT" ? new GrowthError("git is not installed", "unavailable") : error);
182
+ });
183
+ child.on("close", (code) => {
184
+ if (settled) return;
185
+ settled = true;
186
+ clearTimeout(timer);
187
+ resolve({ stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString("utf8"), code: code ?? -1 });
188
+ });
189
+ if (opts.input !== undefined) child.stdin.end(opts.input);
190
+ else child.stdin.end();
191
+ }),
192
+ );
193
+ }
194
+
195
+ /** git 不可用时短时间缓存否定结果:避免每个会话重复 30s 超时把 UI 拖死 */
196
+ const GIT_FAIL_RETRY_MS = 60_000;
197
+
155
198
  async function gitOk(): Promise<boolean> {
156
- registry.gitOk ??= runGit(["--version"]).then((r) => {
157
- const ok = r.code === 0;
158
- if (!ok) registry.gitOk = undefined;
159
- return ok;
160
- }, () => {
199
+ if (registry.gitOk) return registry.gitOk;
200
+ if (registry.gitFailedAt && Date.now() - registry.gitFailedAt < GIT_FAIL_RETRY_MS) return false;
201
+ const check = runGit(["--version"]).then((r) => r.code === 0, () => false);
202
+ registry.gitOk = check;
203
+ void check.then((ok) => {
161
204
  registry.gitOk = undefined;
162
- return false;
205
+ if (ok) registry.gitFailedAt = undefined;
206
+ else registry.gitFailedAt = Date.now();
163
207
  });
164
- return registry.gitOk;
208
+ return check;
165
209
  }
166
210
 
167
211
  /** 影子仓库命令:固定 git-dir / work-tree,关掉换行转换与路径转义,输出按字节原样 */
@@ -11,6 +11,7 @@ import { BlockList, isIP } from "node:net";
11
11
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
12
12
  import { getNodeValue, parseTree, printParseErrorCode, type ParseError } from "jsonc-parser";
13
13
  import { getAgentDir, getModelRuntime, resetModelRuntime } from "./pi";
14
+ import { reloadSessionsForCwd } from "./agent-manager";
14
15
  import {
15
16
  getSupportedThinkingLevels, InMemoryCredentialStore, InMemoryModelsStore,
16
17
  type AuthInteraction, type AuthPrompt, type AuthType,
@@ -45,6 +46,8 @@ export interface ProviderView {
45
46
  resolvedAuthSource?: string;
46
47
  authError?: string;
47
48
  keyManaged: boolean;
49
+ /** auth.json 里已存凭证的类型:OAuth 条目才有专门的删除入口与文案 */
50
+ storedAuthType?: "api_key" | "oauth";
48
51
  modelCount: number;
49
52
  }
50
53
 
@@ -97,6 +100,26 @@ export function listModels(): Promise<{
97
100
  return value;
98
101
  }
99
102
 
103
+ /** auth.json 各条目的凭证类型(与 storedCredentialFor 同样的两层数据结构);读取失败不影响目录 */
104
+ async function readStoredAuthTypes(): Promise<Map<string, "api_key" | "oauth">> {
105
+ const out = new Map<string, "api_key" | "oauth">();
106
+ try {
107
+ const parsed = JSON.parse(await fs.readFile(path.join(getAgentDir(), "auth.json"), "utf8")) as Record<string, any>;
108
+ const entries: Array<[string, any]> = [
109
+ ...Object.entries(parsed ?? {}),
110
+ ...Object.entries(parsed?.providers ?? {}),
111
+ ];
112
+ for (const [id, entry] of entries) {
113
+ if (out.has(id)) continue;
114
+ if (entry?.type === "oauth") out.set(id, "oauth");
115
+ else if (entry && typeof entry === "object" && [entry.apiKey, entry.api_key, entry.key].some((v) => typeof v === "string" && v.trim())) out.set(id, "api_key");
116
+ }
117
+ } catch {
118
+ /* missing or malformed auth.json: no stored credentials to classify */
119
+ }
120
+ return out;
121
+ }
122
+
100
123
  async function listModelsUncached(): Promise<{
101
124
  providers: ProviderView[];
102
125
  models: ModelView[];
@@ -104,6 +127,7 @@ async function listModelsUncached(): Promise<{
104
127
  const rt = await getModelRuntime();
105
128
  const builtInProviderIds = await getBuiltInProviderIds();
106
129
  const providers = rt.getProviders() as any[];
130
+ const storedAuthTypes = await readStoredAuthTypes();
107
131
  const out: ProviderView[] = [];
108
132
  const models: ModelView[] = [];
109
133
  // 认证探测逐个 await 时,N 个已配置供应商就是 N 次串行网络往返;并发做,单个超时不拖累整体
@@ -155,6 +179,7 @@ async function listModelsUncached(): Promise<{
155
179
  resolvedAuthSource,
156
180
  authError,
157
181
  keyManaged: auth?.source === "stored",
182
+ storedAuthType: auth?.source === "stored" ? storedAuthTypes.get(pid) : undefined,
158
183
  modelCount: list.length,
159
184
  });
160
185
  for (const mm of list) {
@@ -263,22 +288,23 @@ export function isPublicModelDiscoveryAddress(address: string): boolean {
263
288
  return false;
264
289
  }
265
290
 
266
- async function requestModelCatalog(endpoint: URL, headers: Record<string, string>, signal: AbortSignal): Promise<Response> {
267
- const allowPrivate = process.env.PI_WEB_ALLOW_PRIVATE_MODEL_DISCOVERY === "1";
291
+ async function requestModelCatalog(endpoint: URL, headers: Record<string, string>, signal: AbortSignal, allowPrivate = false): Promise<Response> {
292
+ // 显式勾选(本次允许访问本机/私网)或全局环境变量,二选一放行
293
+ const allowed = allowPrivate || process.env.PI_WEB_ALLOW_PRIVATE_MODEL_DISCOVERY === "1";
268
294
  const literalFamily = isIP(endpoint.hostname.replace(/^\[|\]$/g, ""));
269
- if (!allowPrivate && literalFamily && !isPublicModelDiscoveryAddress(endpoint.hostname.replace(/^\[|\]$/g, ""))) {
270
- throw new Error("模型目录地址指向本机或私有网络;如需访问本地网关,请设置 PI_WEB_ALLOW_PRIVATE_MODEL_DISCOVERY=1");
295
+ if (!allowed && literalFamily && !isPublicModelDiscoveryAddress(endpoint.hostname.replace(/^\[|\]$/g, ""))) {
296
+ throw new Error("模型目录地址指向本机或私有网络;勾选「允许访问本机/私有网络地址」后重试,或设置 PI_WEB_ALLOW_PRIVATE_MODEL_DISCOVERY=1");
271
297
  }
272
298
  return new Promise<Response>((resolve, reject) => {
273
299
  const request = (endpoint.protocol === "https:" ? httpsRequest : httpRequest)(endpoint, {
274
300
  headers,
275
301
  signal,
276
302
  agent: false,
277
- lookup: allowPrivate ? undefined : (hostname, options, callback) => {
303
+ lookup: allowed ? undefined : (hostname, options, callback) => {
278
304
  void dns.lookup(hostname, { all: true }).then((addresses) => {
279
305
  const publicAddress = addresses.find(({ address }) => isPublicModelDiscoveryAddress(address));
280
306
  if (!publicAddress) {
281
- callback(new Error("模型目录地址指向本机或私有网络;如需访问本地网关,请设置 PI_WEB_ALLOW_PRIVATE_MODEL_DISCOVERY=1"), "", 0);
307
+ callback(new Error("模型目录地址指向本机或私有网络;勾选「允许访问本机/私有网络地址」后重试,或设置 PI_WEB_ALLOW_PRIVATE_MODEL_DISCOVERY=1"), "", 0);
282
308
  return;
283
309
  }
284
310
  if (options.all) callback(null, [publicAddress]);
@@ -343,7 +369,7 @@ async function readModelCatalog(response: Response): Promise<unknown> {
343
369
  }
344
370
 
345
371
  /** Fetch a provider's conventional /models catalog without persisting credentials. */
346
- export async function discoverModels(input: { baseUrl: string; api?: string; apiKey?: string; providerId?: string }): Promise<DiscoveredModel[]> {
372
+ export async function discoverModels(input: { baseUrl: string; api?: string; apiKey?: string; providerId?: string; allowPrivate?: boolean }): Promise<DiscoveredModel[]> {
347
373
  const rawBaseUrl = input.baseUrl.trim();
348
374
  if (!rawBaseUrl) throw new Error("缺少 API 地址");
349
375
  const base = new URL(rawBaseUrl);
@@ -383,7 +409,7 @@ export async function discoverModels(input: { baseUrl: string; api?: string; api
383
409
  const controller = new AbortController();
384
410
  const timeout = setTimeout(() => controller.abort(), 15000);
385
411
  try {
386
- const response = await requestModelCatalog(endpoint, headers, controller.signal);
412
+ const response = await requestModelCatalog(endpoint, headers, controller.signal, input.allowPrivate === true);
387
413
  if (response.status === 401) {
388
414
  throw new Error(apiKey ? "模型目录请求失败(HTTP 401):密钥或令牌无效" : "模型目录请求失败(HTTP 401):请先填写 API 密钥");
389
415
  }
@@ -753,12 +779,21 @@ function mergeCustomProviderApiKeys(parsed: CustomProvidersFile, previous: Custo
753
779
  // 字段缺失(包括 JSON.stringify 省略的 undefined)保留旧密钥;只有显式 null 删除。
754
780
  if (provider.apiKey === null) {
755
781
  delete provider.apiKey;
756
- continue;
782
+ } else if (provider.apiKey === undefined) {
783
+ const previousProvider = previous.providers[id];
784
+ if (previousProvider && Object.prototype.hasOwnProperty.call(previousProvider, "apiKey")) {
785
+ provider.apiKey = previousProvider.apiKey;
786
+ }
757
787
  }
758
- const previousProvider = previous.providers[id];
759
- if (!Object.prototype.hasOwnProperty.call(provider, "apiKey") && previousProvider
760
- && Object.prototype.hasOwnProperty.call(previousProvider, "apiKey")) {
761
- provider.apiKey = previousProvider.apiKey;
788
+ }
789
+ }
790
+
791
+ /** 本地/回环地址的无鉴权网关(Ollama 等):SDK 设计上必须给 apiKey 才算「已配置」;占位值 "unused" 是官方认可模式 */
792
+ function applyLocalPlaceholderKeys(parsed: CustomProvidersFile): void {
793
+ for (const provider of Object.values(parsed.providers)) {
794
+ if (provider.apiKey === undefined && typeof provider.baseUrl === "string"
795
+ && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(provider.baseUrl.trim())) {
796
+ provider.apiKey = "unused";
762
797
  }
763
798
  }
764
799
  }
@@ -835,6 +870,8 @@ export async function writeCustomProviders(content: string, revision?: string):
835
870
  await validateStagedCustomProviders(temporary);
836
871
  }
837
872
  mergeCustomProviderApiKeys(parsed, old);
873
+ // 本地网关无 key 也得给占位值,否则 SDK 判定「未配置」→ 发消息报 No API key
874
+ applyLocalPlaceholderKeys(parsed);
838
875
  validateTokenLimits(parsed);
839
876
  await fs.writeFile(temporary, JSON.stringify(parsed, null, 2), {
840
877
  encoding: "utf8", flag: previous.content === undefined ? "wx" : "w", mode: 0o600,
@@ -849,4 +886,7 @@ export async function writeCustomProviders(content: string, revision?: string):
849
886
  }));
850
887
  resetModelRuntime();
851
888
  invalidateModelList();
889
+ // 已打开的会话持有保存前的旧 runtime:不清掉的话,在里面选新 provider 的模型
890
+ // 会报 "No API key for X"(新 runtime 有配置,旧 runtime 查不到)。让活跃会话就地重建。
891
+ await reloadSessionsForCwd();
852
892
  }
@@ -71,6 +71,51 @@ export function cleanCommand(command: string, cwd?: string): { text: string; lin
71
71
  return { text, lines: lines.length };
72
72
  }
73
73
 
74
+ /** 上下文 token 估算口径:与 SDK estimateTokens 一致的字符/4 */
75
+ export function estimateTokensOf(text: string): number {
76
+ return Math.ceil(text.length / 4);
77
+ }
78
+
79
+ /** 上下文 13 类分段中的 5 个消息分项:按 role / content.type 归类字符数 */
80
+ export function classifyMessageChars(
81
+ messages: Array<{ role?: string; content: Array<{ type: string; text?: string; thinking?: string; arguments?: unknown }> }>,
82
+ ): { user: number; agentText: number; agentThinking: number; agentToolCall: number; toolOutput: number } {
83
+ const out = { user: 0, agentText: 0, agentThinking: 0, agentToolCall: 0, toolOutput: 0 };
84
+ for (const message of messages) {
85
+ for (const content of message.content) {
86
+ if (content.type === "text") {
87
+ const chars = content.text?.length ?? 0;
88
+ if (message.role === "user") out.user += chars;
89
+ else out.agentText += chars;
90
+ } else if (content.type === "thinking") {
91
+ out.agentThinking += content.thinking?.length ?? 0;
92
+ } else if (content.type === "toolCall") {
93
+ out.agentToolCall += JSON.stringify(content.arguments ?? "").length;
94
+ } else if (content.type === "toolResult") {
95
+ out.toolOutput += (content as { text?: string }).text?.length ?? 0;
96
+ }
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** bash/PowerShell 删除文件的命令判定:rm / Remove-Item / del / trash 等,且至少带一个非选项参数 */
103
+ export function isDeleteCommand(command: string): boolean {
104
+ const first = command.trim().split(/\s+/)[0] ?? "";
105
+ const base = first.split(/[\\/]/).pop() ?? first;
106
+ const name = base.toLowerCase().replace(/\.(exe|ps1|bat|cmd)$/, "");
107
+ if (!["rm", "remove-item", "ri", "del", "erase", "unlink", "trash"].includes(name)) return false;
108
+ const rest = command.trim().split(/\s+/).slice(1);
109
+ return rest.some((tok) => !tok.startsWith("-"));
110
+ }
111
+
112
+ /** 从删除命令里提取第一个目标路径(去引号;相对路径解析交给展示层) */
113
+ export function deleteTargetOf(command: string): string {
114
+ const tokens = command.trim().match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
115
+ const target = tokens.slice(1).find((tok) => !tok.startsWith("-"));
116
+ return target ? target.replace(/^["']|["']$/g, "") : "";
117
+ }
118
+
74
119
  /** 按顶层的 ; && || 切分(引号内不切) */
75
120
  function splitTopLevel(cmd: string): string[] {
76
121
  const parts: string[] = [];
@@ -194,11 +239,14 @@ export function firstSentence(text: string, max = 140): string {
194
239
  return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s;
195
240
  }
196
241
 
197
- export type ModelErrorKind = "rateLimit" | "billing" | "auth" | "notFound" | "context" | "timeout" | "server" | "network";
242
+ export type ModelErrorKind = "rateLimit" | "billing" | "auth" | "notFound" | "context" | "timeout" | "server" | "network" | "moderation";
198
243
 
199
244
  /** 把供应商原始报错归到少数几类,界面给一句人话;认不出返回 null(只显示原文) */
200
245
  export function classifyModelError(message: string): ModelErrorKind | null {
201
246
  const m = message.toLowerCase();
247
+ // Content moderation (gateway-side sensitive-word filters) must be checked before
248
+ // the generic 5xx rule: such gateways often answer with a misleading 500.
249
+ if (/sensitive[ _-]?words?|content (moderation|filter)|moderation|content_policy|content policy|input blocked/.test(m)) return "moderation";
202
250
  if (/\b429\b|rate.?limit|too many requests|requests per (minute|second)/.test(m)) return "rateLimit";
203
251
  if (/\b402\b|insufficient (balance|credits|quota|funds)|billing|quota exceeded|余额/.test(m)) return "billing";
204
252
  if (/\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid.*(api.?key|token)|authentication|permission denied/.test(m)) return "auth";
@@ -95,3 +95,26 @@ export async function readSkillFile(filePath: string, cwd?: string): Promise<str
95
95
  const authorized = await resolveDiscoveredPath(filePath, skills.map((skill) => skill.filePath));
96
96
  return fs.readFile(authorized, "utf8");
97
97
  }
98
+
99
+ /** 删除技能(SKILL.md 所在目录)。包技能(node_modules 内)随包安装,删除会被还原 → 拒绝并用禁用管理。 */
100
+ export async function deleteSkill(filePath: string, cwd?: string): Promise<{ removed: string }> {
101
+ const skills = await listSkills(cwd);
102
+ const authorized = await resolveDiscoveredPath(filePath, skills.map((skill) => skill.filePath));
103
+ if (classifyScope(authorized, cwd) === "package") {
104
+ throw new Error("package-managed skill: disable it instead (it is reinstalled with the package)");
105
+ }
106
+ const skillDir = path.dirname(authorized);
107
+ // 只删技能目录本身,不能误删父目录(全局技能目录 ~/.pi/agent/skills、项目 .agents/skills 等)
108
+ const agentDir = getAgentDir().replace(/\\/g, "/");
109
+ const normalizedSkillDir = skillDir.replace(/\\/g, "/");
110
+ const knownRoots = [`${agentDir}/skills`, `${agentDir}/web-skills`];
111
+ if (!knownRoots.some((root) => normalizedSkillDir.toLowerCase() === root.toLowerCase())) {
112
+ // 非已知根:项目内技能目录,至少确认 SKILL.md 直接位于其下且目录名非空
113
+ if (path.basename(skillDir) === "") throw new Error("refusing to delete workspace root");
114
+ }
115
+ await fs.rm(skillDir, { recursive: true, force: true });
116
+ await reloadAllLoaders();
117
+ const scope = classifyScope(authorized, cwd);
118
+ await reloadSessionsForCwd(scope === "project" ? cwd : undefined);
119
+ return { removed: skillDir };
120
+ }
package/src/lib/types.ts CHANGED
@@ -211,6 +211,28 @@ export interface ContextResource {
211
211
  source: "project" | "extension";
212
212
  }
213
213
 
214
+ /**
215
+ * 上下文占用的服务端估算分项(全部为字符/4 启发式,与 SDK estimateTokens 口径一致)。
216
+ * 消息类分项(User/Agent Text/Thinking/Tool Call/Tool Output)由前端按消息统计,
217
+ * 这里只放服务端才能拿到的部分。
218
+ */
219
+ export interface ContextBreakdown {
220
+ /** SDK 内建 system prompt 正文(不含 memory/skills/tools 段)的估算 token */
221
+ systemPrompt: number;
222
+ /** 内建工具(SDK 自带 bash/read/edit 等)定义的估算 token */
223
+ systemTools: number;
224
+ /** 扩展注册的 custom 工具定义的估算 token */
225
+ customTools: number;
226
+ /** AGENTS.md 等注入文件(contextResources 的 project 部分) */
227
+ memory: number;
228
+ /** 系统提示里的 skills 段 */
229
+ skills: number;
230
+ /** 最新一次压缩的摘要正文 */
231
+ compacted: number;
232
+ /** auto-compact 预留(compaction.reserveTokens 设置值;未启用为 0) */
233
+ autoCompactBuffer: number;
234
+ }
235
+
214
236
  /** SSE 连接建立时先发的完整快照 */
215
237
  export interface WebSnapshot {
216
238
  seq: number;
@@ -221,6 +243,8 @@ export interface WebSnapshot {
221
243
  contextFiles: string[];
222
244
  /** 注入的上下文资源(AGENTS.md 与扩展附加 prompt,来自 Pi 资源加载器)。 */
223
245
  contextResources: ContextResource[];
246
+ /** 上下文占用的服务端估算分项(13 类分段里的非消息部分) */
247
+ contextBreakdown?: ContextBreakdown;
224
248
  /** 斜杠命令数据源:技能 */
225
249
  skills: { name: string; description: string }[];
226
250
  /** 斜杠命令数据源:提示模板(~/.pi/agent/prompts、<cwd>/.pi/prompts 里的 .md) */
@@ -75,7 +75,7 @@ export function createUpdateService({ root = APP_ROOT, run = runCommand, version
75
75
  const behind = Number((await command("git", ["rev-list", "--count", "HEAD..origin/main"], 15_000)).trim());
76
76
  if (!Number.isSafeInteger(behind) || behind < 0) throw new Error("invalid upstream commit count");
77
77
  const remotePkg = JSON.parse(await command("git", ["show", "origin/main:package.json"], 15_000)) as { name?: string; version?: string };
78
- if ((remotePkg.name !== "piweb" && remotePkg.name !== "pi-web") || typeof remotePkg.version !== "string" || !remotePkg.version) throw new Error("upstream is not a PiWeb package");
78
+ if (!["piweb", "pi-web", "@rexvane/piweb"].includes(String(remotePkg.name)) || typeof remotePkg.version !== "string" || !remotePkg.version) throw new Error("upstream is not a PiWeb package");
79
79
  // Do not advertise a tag version that `git pull origin main` will never install.
80
80
  return { current: piWeb, latest: remotePkg.version, behind, canUpdate: isNewer(remotePkg.version, piWeb) || behind > 0 };
81
81
  }
@@ -3,6 +3,7 @@
3
3
  * 会话 cwd + 手动添加的目录共同构成侧栏「工作区」列表;pi 不受影响(独立文件)。
4
4
  */
5
5
  import fs from "node:fs/promises";
6
+ import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { APP_ROOT } from "./app-root";
8
9
  import { spawn } from "node:child_process";
@@ -207,54 +208,91 @@ export async function removeWorkspace(dir: string): Promise<{ workspaces: string
207
208
 
208
209
  let picking = false;
209
210
 
210
- /** 弹出系统原生文件夹选择对话框(Windows 现代资源管理器风格,屏幕居中) */
211
+ /** 收集子进程 stdout(utf8)与 stderr 尾部,带超时;退出码非 0 抛错 */
212
+ function runDialog(argv: string[], opts: { timeoutMs?: number; windowsHide?: boolean } = {}): Promise<string> {
213
+ const { timeoutMs = 5 * 60 * 1000, windowsHide = false } = opts;
214
+ return new Promise<string>((resolve, reject) => {
215
+ const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], windowsHide });
216
+ const decoder = new StringDecoder("utf8");
217
+ let buf = "";
218
+ let stderr = "";
219
+ let decoderEnded = false;
220
+ child.stdout.on("data", (data: Buffer) => {
221
+ buf += decoder.write(data);
222
+ });
223
+ child.stderr.on("data", (data: Buffer) => { stderr = (stderr + data.toString("utf8")).slice(-1000); });
224
+ let settled = false;
225
+ let timeout: ReturnType<typeof setTimeout>;
226
+ const finish = (error?: Error) => {
227
+ if (settled) return;
228
+ settled = true;
229
+ clearTimeout(timeout);
230
+ if (!decoderEnded) {
231
+ decoderEnded = true;
232
+ buf += decoder.end();
233
+ }
234
+ if (error) reject(error);
235
+ else resolve(buf.trim());
236
+ };
237
+ child.on("error", (error) => finish(error));
238
+ child.on("close", (code) => finish(code === 0 ? undefined : new Error(stderr || `folder picker exited with code ${code}`)));
239
+ timeout = setTimeout(() => {
240
+ try {
241
+ child.kill();
242
+ } catch {
243
+ /* ignore */
244
+ }
245
+ finish(new Error("folder picker timed out"));
246
+ }, timeoutMs);
247
+ });
248
+ }
249
+
250
+ /** osascript 的 POSIX path 输出带前后引号(可能含转义),剥成普通路径 */
251
+ function unquoteApplePath(out: string): string {
252
+ const m = out.match(/^alias "?(.*?)"?$/s) ?? out.match(/^(\/.*)$/s);
253
+ return (m ? m[1] : out).trim();
254
+ }
255
+
256
+ /**
257
+ * 弹出系统原生文件夹选择对话框。
258
+ * - Windows:PowerShell 脚本(现代资源管理器风格,屏幕居中)
259
+ * - macOS:osascript choose folder(原生选择框)
260
+ * - Linux:zenity / kdialog(哪个可用用哪个)
261
+ * 都不可用时抛错,前端有手动输入路径的兜底入口。
262
+ */
211
263
  export async function pickFolderNative(): Promise<{ path: string | null; canceled: boolean }> {
212
- if (process.platform !== "win32") throw new Error("native folder picker is currently available only on Windows");
213
264
  if (picking) return { path: null, canceled: true };
214
265
  picking = true;
215
266
  try {
216
- const script = path.join(APP_ROOT, "scripts", "pick-folder.ps1");
217
- const out = await new Promise<string>((resolve, reject) => {
218
- const child = spawn(
219
- "powershell.exe",
220
- ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", script, "选择工作区文件夹"],
221
- // windowsHide:不再先闪出一个黑色 PowerShell 控制台窗口,只出现资源管理器风格的选择对话框
222
- { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
267
+ if (process.platform === "win32") {
268
+ const script = path.join(APP_ROOT, "scripts", "pick-folder.ps1");
269
+ const out = await runDialog(
270
+ ["powershell.exe", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", script, "选择工作区文件夹"],
271
+ { windowsHide: true },
223
272
  );
224
- const decoder = new StringDecoder("utf8");
225
- let buf = "";
226
- let stderr = "";
227
- let decoderEnded = false;
228
- child.stdout.on("data", (data: Buffer) => {
229
- buf += decoder.write(data);
230
- });
231
- child.stderr.on("data", (data: Buffer) => { stderr = (stderr + data.toString("utf8")).slice(-1000); });
232
- let settled = false;
233
- let timeout: ReturnType<typeof setTimeout>;
234
- const finish = (error?: Error) => {
235
- if (settled) return;
236
- settled = true;
237
- clearTimeout(timeout);
238
- if (!decoderEnded) {
239
- decoderEnded = true;
240
- buf += decoder.end();
241
- }
242
- if (error) reject(error);
243
- else resolve(buf.trim());
244
- };
245
- child.on("error", (error) => finish(error));
246
- child.on("close", (code) => finish(code === 0 ? undefined : new Error(stderr || `folder picker exited with code ${code}`)));
247
- // 5 分钟超时保护
248
- timeout = setTimeout(() => {
249
- try {
250
- child.kill();
251
- } catch {
252
- /* ignore */
253
- }
254
- finish(new Error("folder picker timed out"));
255
- }, 5 * 60 * 1000);
256
- });
257
- return out ? { path: out, canceled: false } : { path: null, canceled: true };
273
+ return out ? { path: out, canceled: false } : { path: null, canceled: true };
274
+ }
275
+ if (process.platform === "darwin") {
276
+ const out = await runDialog(["osascript", "-e", 'choose folder with prompt "选择工作区文件夹"']);
277
+ const p = unquoteApplePath(out);
278
+ return p ? { path: p, canceled: false } : { path: null, canceled: true };
279
+ }
280
+ // Linux /其他:zenity → kdialog
281
+ for (const argv of [
282
+ ["zenity", "--file-selection", "--directory", "--title=选择工作区文件夹"],
283
+ ["kdialog", "--getexistingdirectory", `${os.homedir()}`, "--title", "选择工作区文件夹"],
284
+ ]) {
285
+ try {
286
+ const out = await runDialog(argv);
287
+ return out ? { path: out, canceled: false } : { path: null, canceled: true };
288
+ } catch (error) {
289
+ // 未安装(ENOENT)→ 试下一个;用户取消的 zenity 退出码 1 但 stdout 可能为空
290
+ if (error instanceof Error && /ENOENT|not found|not recognized/i.test(error.message)) continue;
291
+ if (/exited with code (1|5)$/.test(error instanceof Error ? error.message : String(error))) return { path: null, canceled: true };
292
+ throw error;
293
+ }
294
+ }
295
+ throw new Error("no folder picker available: install zenity or kdialog, or type the path manually");
258
296
  } finally {
259
297
  picking = false;
260
298
  }