u1s1-cli 0.17.0 → 0.18.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.
@@ -332,6 +332,8 @@ If it is a fit:
332
332
  - \`subagent(task)\` or \`subagent({ task, model })\` — runs one sub-agent, resolves to its final output text
333
333
  - \`parallel([() => ..., ...])\` — concurrent fan-out; each thunk's error becomes \`null\`; barrier semantics
334
334
  - \`pipeline(items, [stage1, stage2, ...])\` — each item flows through all stages independently (prefer over parallel)
335
+ - \`judge(question)\` — cheap tool-free model call returning true/false; use for loop-until-done checks
336
+ - \`subagent({ task, worktree: true })\` — run in an isolated temp git worktree (auto-cleaned) when tasks would write conflicting files
335
337
  - \`log(...)\`, \`setTimeout/clearTimeout\`, standard JS builtins (Promise/JSON/Math/...)
336
338
  - NO require/import/process/fetch/fs access — the static validator rejects scripts that try
337
339
  2. Every task string must be fully self-contained: which files to read/write, what "done" means, how to self-verify.
@@ -15,10 +15,20 @@ export interface SubagentOptions {
15
15
  model?: string;
16
16
  timeoutMs?: number;
17
17
  signal?: AbortSignal;
18
+ /** 禁用全部工具(纯文本推理,判分等廉价场景用)。 */
19
+ noTools?: boolean;
20
+ /** 子 agent 的工作目录(git worktree 隔离等场景);缺省继承主进程 cwd。 */
21
+ cwd?: string;
22
+ }
23
+ /** 单个子 agent 的 token/费用用量(汇总自会话内全部 assistant 消息)。 */
24
+ export interface SubagentUsage {
25
+ totalTokens: number;
26
+ costUsd: number;
18
27
  }
19
28
  export interface SubagentOutcome {
20
29
  ok: boolean;
21
30
  text: string;
31
+ usage: SubagentUsage;
22
32
  }
23
33
  /**
24
34
  * Spawn 一个独立上下文的子 agent 执行任务,返回其最终文本输出。
package/dist/subagent.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createAgentSession, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
1
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
2
2
  // ---- 子 agent spawn 基础设施:spawn_subagent 工具与 workflow runner 共用 ----
3
3
  /** 同时在跑的子 agent 上限;超额排队,防止打爆网关。 */
4
4
  export const SUBAGENT_CONCURRENCY = 4;
@@ -22,6 +22,36 @@ function extractFinalText(messages) {
22
22
  }
23
23
  return "(子 agent 结束但没有文本输出)";
24
24
  }
25
+ /**
26
+ * 汇总会话内全部 assistant 消息的 token 与费用。
27
+ * pi-ai 对自定义 provider(models.json)不算钱(usage.cost.total 恒 0),
28
+ * 所以优先用自带费用,否则按模型价率(美元/百万 token)自算。
29
+ */
30
+ function extractUsage(messages, model) {
31
+ let totalTokens = 0;
32
+ let costUsd = 0;
33
+ const rates = model?.cost;
34
+ for (const m of messages) {
35
+ const msg = m;
36
+ const usage = msg?.usage;
37
+ if (!usage || msg.role !== "assistant")
38
+ continue;
39
+ totalTokens += usage.totalTokens ?? 0;
40
+ const reported = usage.cost?.total ?? 0;
41
+ if (reported > 0) {
42
+ costUsd += reported;
43
+ }
44
+ else if (rates) {
45
+ costUsd +=
46
+ ((usage.input ?? 0) * rates.input +
47
+ (usage.output ?? 0) * rates.output +
48
+ (usage.cacheRead ?? 0) * rates.cacheRead +
49
+ (usage.cacheWrite ?? 0) * rates.cacheWrite) /
50
+ 1_000_000;
51
+ }
52
+ }
53
+ return { totalTokens, costUsd };
54
+ }
25
55
  /**
26
56
  * Spawn 一个独立上下文的子 agent 执行任务,返回其最终文本输出。
27
57
  * 模型解析:"provider/id" 精确匹配;裸 id(如 deepseek-v4-flash)先按原样找,
@@ -48,10 +78,25 @@ export async function runSubagent(opts) {
48
78
  else {
49
79
  model = undefined;
50
80
  }
81
+ // 子会话不加载精简 UI 扩展:它用 process.cwd() 重建内置工具,会破坏自定义
82
+ // cwd(worktree 隔离)下的路径解析;且 UI 美化对无界面的子 agent 毫无意义
83
+ const loader = new DefaultResourceLoader({
84
+ agentDir: getAgentDir(),
85
+ cwd: opts.cwd ?? process.cwd(),
86
+ extensionsOverride: (base) => ({
87
+ ...base,
88
+ extensions: base.extensions.filter((e) => !JSON.stringify(e).includes("u1s1-compact-ui")),
89
+ }),
90
+ });
91
+ await loader.reload();
51
92
  const { session } = await createAgentSession({
52
- sessionManager: SessionManager.inMemory(process.cwd()),
93
+ // cwd 必须同时传给顶层(工具的路径解析基于它)和 sessionManager
94
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
95
+ resourceLoader: loader,
96
+ sessionManager: SessionManager.inMemory(opts.cwd ?? process.cwd()),
53
97
  modelRuntime: sharedModelRuntime,
54
98
  ...(model ? { model } : {}),
99
+ ...(opts.noTools ? { noTools: "all" } : {}),
55
100
  });
56
101
  const timeoutMs = opts.timeoutMs ?? SUBAGENT_TIMEOUT_MS;
57
102
  const timer = setTimeout(() => void session.abort(), timeoutMs);
@@ -67,7 +112,8 @@ export async function runSubagent(opts) {
67
112
  }
68
113
  if (opts.signal?.aborted)
69
114
  throw new Error("已取消");
70
- return { ok: true, text: extractFinalText(session.messages) };
115
+ const messages = session.messages;
116
+ return { ok: true, text: extractFinalText(messages), usage: extractUsage(messages, model) };
71
117
  }
72
118
  /**
73
119
  * 固定并发的工作池:limit 个工人依次领任务跑完一个补位一个。
package/dist/tools.js CHANGED
@@ -213,7 +213,7 @@ export function createSubagentTool(getParentModel) {
213
213
  const outcomes = new Array(list.length);
214
214
  try {
215
215
  await runPool(list, SUBAGENT_CONCURRENCY, async (task, i) => {
216
- outcomes[i] = await runSubagent({ task, parentModel, model: params.model, timeoutMs, signal }).catch((e) => ({ ok: false, text: e.message }));
216
+ outcomes[i] = await runSubagent({ task, parentModel, model: params.model, timeoutMs, signal }).catch((e) => ({ ok: false, text: e.message, usage: { totalTokens: 0, costUsd: 0 } }));
217
217
  }, signal);
218
218
  }
219
219
  finally {
package/dist/web.js CHANGED
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join } from "node:path";
5
- import { cleanupBrandThemes, endpointKeyEnv, ensureAuthCredential, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, scrubForeignProviderEnv, writeWebToolsExtension, } from "./agent-setup.js";
5
+ import { cleanupBrandThemes, endpointKeyEnv, ensureAuthCredential, ensureBrandPrompt, ensureWorkflowPromptTemplate, ensureDefaultSettings, ensureProviderModels, scrubForeignProviderEnv, writeWebToolsExtension, } from "./agent-setup.js";
6
6
  import { agentDir, apiModelToDef, resolvePreferredModel, setModelsFromApi, u1s1Dir, writeAgentDefaultModel, } from "./config.js";
7
7
  import { ensureSearchTools } from "./search-tools.js";
8
8
  import { ensureUsableShell } from "./shell-doctor.js";
@@ -47,6 +47,8 @@ export async function prepareWebEnv(cfg) {
47
47
  }
48
48
  await endpointsReady;
49
49
  ensureBrandPrompt(await shellReady);
50
+ // /workflow 提示词模板与 TUI 同源,web/桌面版也要有
51
+ ensureWorkflowPromptTemplate();
50
52
  ensureProviderModels(cfg);
51
53
  // pi-web-ui 靠 auth.json 判断「已配置」,否则网页会弹 pi 安装引导
52
54
  ensureAuthCredential();
@@ -1,6 +1,8 @@
1
1
  import { type ParentModelRef } from "../subagent.js";
2
2
  /** 整个 run 的默认墙钟上限。 */
3
3
  export declare const WORKFLOW_TIMEOUT_MS: number;
4
+ /** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
5
+ export declare const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20000000;
4
6
  /** 静态白名单校验:语法能编译 + 不碰沙箱之外的任何能力。 */
5
7
  export declare function validateScript(code: string): string[];
6
8
  export interface WorkflowRunResult {
@@ -15,6 +17,15 @@ export interface WorkflowRunResult {
15
17
  aborted: boolean;
16
18
  };
17
19
  }
20
+ export interface WorkflowProgress {
21
+ spawns: number;
22
+ ok: number;
23
+ failed: number;
24
+ cached: number;
25
+ running: number;
26
+ spentTokens: number;
27
+ budgetTokens: number;
28
+ }
18
29
  export interface WorkflowRunOptions {
19
30
  /** 脚本源码(调用方负责落盘)。 */
20
31
  code: string;
@@ -24,6 +35,10 @@ export interface WorkflowRunOptions {
24
35
  parentModel: ParentModelRef;
25
36
  signal?: AbortSignal;
26
37
  timeoutMs?: number;
38
+ /** 整个 run 的 token 上限;缺省 WORKFLOW_DEFAULT_BUDGET_TOKENS,<=0 不限。 */
39
+ budgetTokens?: number;
40
+ /** 每次子任务状态变化时回调(工具层节流后转成流式进度)。 */
41
+ onProgress?: (p: WorkflowProgress) => void;
27
42
  }
28
43
  /** 执行一个 workflow 脚本,返回给主对话的报告。不抛错——失败也进报告让模型自行修复。 */
29
44
  export declare function runWorkflow(opts: WorkflowRunOptions): Promise<WorkflowRunResult>;
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
4
  import vm from "node:vm";
4
5
  import { dirname, resolve } from "node:path";
5
6
  import { runPool, runSubagent, SUBAGENT_CONCURRENCY } from "../subagent.js";
@@ -11,6 +12,8 @@ export const WORKFLOW_TIMEOUT_MS = 30 * 60_000;
11
12
  const PER_TASK_TIMEOUT_MS = 15 * 60_000;
12
13
  /** 报告里最多保留多少行脚本 log 输出。 */
13
14
  const MAX_LOG_LINES = 60;
15
+ /** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
16
+ export const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20_000_000;
14
17
  /** JSONL 进度存档:一行一个已完成的子任务,断点续跑时按 key 跳过。 */
15
18
  class ProgressStore {
16
19
  path;
@@ -108,7 +111,36 @@ export async function runWorkflow(opts) {
108
111
  internal.abort(new Error(`整个工作流超时(${Math.round((opts.timeoutMs ?? WORKFLOW_TIMEOUT_MS) / 60_000)} 分钟)`));
109
112
  }, opts.timeoutMs ?? WORKFLOW_TIMEOUT_MS);
110
113
  const store = new ProgressStore(opts.progressPath);
111
- /** 注入沙箱的 subagent():字符串或 {task, model} 都行,返回最终输出文本。 */
114
+ const budgetTokens = opts.budgetTokens ?? WORKFLOW_DEFAULT_BUDGET_TOKENS;
115
+ let spentTokens = 0;
116
+ let running = 0;
117
+ const progress = { spawns: 0, ok: 0, failed: 0, cached: 0, running: 0, spentTokens: 0, budgetTokens };
118
+ const reportProgress = () => {
119
+ progress.spentTokens = spentTokens;
120
+ opts.onProgress?.({ ...progress });
121
+ };
122
+ /** git worktree 隔离:在 .u1s1/worktrees/ 下建临时工作树,返回其路径。 */
123
+ let worktreeSeq = 0;
124
+ function createTempWorktree() {
125
+ const dir = resolve(workflowsDir(), "../../.u1s1-worktrees", `wf-${Date.now()}-${++worktreeSeq}`);
126
+ execFileSync("git", ["worktree", "add", "--detach", dir], { stdio: "pipe" });
127
+ return dir;
128
+ }
129
+ function removeWorktree(dir) {
130
+ try {
131
+ execFileSync("git", ["worktree", "remove", "--force", dir], { stdio: "pipe" });
132
+ }
133
+ catch {
134
+ rmSync(dir, { recursive: true, force: true });
135
+ try {
136
+ execFileSync("git", ["worktree", "prune"], { stdio: "pipe" });
137
+ }
138
+ catch {
139
+ // 尽力清理即可
140
+ }
141
+ }
142
+ }
143
+ /** 注入沙箱的 subagent():字符串或 {task, model, worktree} 都行,返回最终输出文本。 */
112
144
  async function subagentImpl(input) {
113
145
  const o = typeof input === "string" ? { task: input } : (input ?? {});
114
146
  const task = typeof o.task === "string" ? o.task.trim() : "";
@@ -117,18 +149,30 @@ export async function runWorkflow(opts) {
117
149
  const model = typeof o.model === "string" && o.model ? o.model : undefined;
118
150
  if (internal.signal.aborted)
119
151
  throw new Error("工作流已中止,不再派发新子任务");
152
+ if (budgetTokens > 0 && spentTokens >= budgetTokens) {
153
+ throw new Error(`token 预算已用尽(已用 ${spentTokens.toLocaleString()} / 上限 ${budgetTokens.toLocaleString()}),不再派发新子任务`);
154
+ }
120
155
  const key = taskKey(task, model);
121
156
  if (opts.resume) {
122
157
  const prev = store.get(key);
123
158
  if (prev) {
124
159
  stats.cached++;
160
+ progress.cached = stats.cached;
161
+ reportProgress();
125
162
  if (prev.ok)
126
163
  return prev.text;
127
164
  throw new Error(prev.text);
128
165
  }
129
166
  }
130
167
  stats.spawns++;
168
+ progress.spawns = stats.spawns;
169
+ running++;
170
+ progress.running = running;
171
+ reportProgress();
131
172
  const t0 = Date.now();
173
+ let worktreeDir;
174
+ if (o.worktree === true)
175
+ worktreeDir = createTempWorktree();
132
176
  try {
133
177
  const out = await runSubagent({
134
178
  task,
@@ -136,17 +180,42 @@ export async function runWorkflow(opts) {
136
180
  parentModel: opts.parentModel,
137
181
  timeoutMs: PER_TASK_TIMEOUT_MS,
138
182
  signal: internal.signal,
183
+ cwd: worktreeDir,
184
+ noTools: o.noTools === true,
139
185
  });
140
186
  stats.ok++;
187
+ progress.ok = stats.ok;
188
+ spentTokens += out.usage.totalTokens;
141
189
  store.append({ key, task: task.slice(0, 120), ok: true, text: out.text, ms: Date.now() - t0 });
142
190
  return out.text;
143
191
  }
144
192
  catch (e) {
145
193
  stats.failed++;
194
+ progress.failed = stats.failed;
146
195
  const msg = e.message || "未知错误";
147
196
  store.append({ key, task: task.slice(0, 120), ok: false, text: msg, ms: Date.now() - t0 });
148
197
  throw new Error(msg);
149
198
  }
199
+ finally {
200
+ if (worktreeDir)
201
+ removeWorktree(worktreeDir);
202
+ running--;
203
+ progress.running = running;
204
+ reportProgress();
205
+ }
206
+ }
207
+ /** 注入沙箱的 judge():廉价无工具模型调用,严格回答 PASS/FAIL,转成布尔。 */
208
+ async function judgeImpl(question) {
209
+ if (typeof question !== "string" || !question.trim())
210
+ throw new Error("judge() 需要问题字符串");
211
+ const answer = await subagentImpl({
212
+ task: `回答以下判定问题。只允许回答一个词:PASS 或 FAIL,不要任何其他内容。\n\n判定问题:${question.trim()}`,
213
+ noTools: true,
214
+ });
215
+ const pass = /\bPASS\b/i.test(answer) && !/\bFAIL\b/i.test(answer);
216
+ if (!pass && !/\bFAIL\b/i.test(answer))
217
+ logs.push(`⚠ judge 回答无法解析,按 FAIL 处理:${answer.slice(0, 120)}`);
218
+ return pass;
150
219
  }
151
220
  /** barrier 扇出:全部并发(封顶排队),单个失败该项为 null,不拖垮整批。 */
152
221
  async function parallel(thunks) {
@@ -201,6 +270,7 @@ export async function runWorkflow(opts) {
201
270
  subagent: subagentImpl,
202
271
  parallel,
203
272
  pipeline,
273
+ judge: judgeImpl,
204
274
  log: pushLog,
205
275
  console: { log: pushLog, info: pushLog, warn: pushLog, error: pushLog },
206
276
  setTimeout: (fn, ms) => setTimeout(fn, Math.max(0, Math.min(Number(ms) || 0, 120_000))),
@@ -223,8 +293,9 @@ export async function runWorkflow(opts) {
223
293
  }
224
294
  const seconds = Math.round((Date.now() - started) / 1000);
225
295
  const aborted = internal.signal.aborted;
226
- const details = { ...stats, seconds, aborted };
227
- const lines = [`工作流结束,用时 ${seconds}s · 子任务 ✓${stats.ok} ✗${stats.failed}${stats.cached ? ` · 续跑命中 ${stats.cached}` : ""}${timedOut ? " · ⏱ 整体超时" : ""}${aborted && !timedOut ? " · 已中止" : ""}`, ""];
296
+ const budgetExhausted = budgetTokens > 0 && spentTokens >= budgetTokens;
297
+ const details = { ...stats, seconds, aborted, spentTokens, budgetExhausted };
298
+ const lines = [`工作流结束,用时 ${seconds}s · 子任务 ✓${stats.ok} ✗${stats.failed}${stats.cached ? ` · 续跑命中 ${stats.cached}` : ""} · 用 ${spentTokens.toLocaleString()} tokens${budgetTokens > 0 ? ` / 预算 ${budgetTokens.toLocaleString()}` : " (不限)"}${timedOut ? " · ⏱ 整体超时" : ""}${aborted && !timedOut ? " · 已中止" : ""}`, ""];
228
299
  if (completionValue !== undefined) {
229
300
  let shown;
230
301
  try {
@@ -9,4 +9,5 @@ export declare function createRunWorkflowTool(getParentModel: () => ParentModelR
9
9
  script_path: Type.TOptional<Type.TString>;
10
10
  resume: Type.TOptional<Type.TBoolean>;
11
11
  timeout_minutes: Type.TOptional<Type.TNumber>;
12
+ budget_tokens: Type.TOptional<Type.TNumber>;
12
13
  }>, unknown, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
@@ -4,7 +4,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { Type } from "typebox";
6
6
  import { compactResultRender, truncate } from "../tools.js";
7
- import { runWorkflow, saveWorkflowScript, validateScript, workflowsDir, WORKFLOW_TIMEOUT_MS, } from "./runner.js";
7
+ import { runWorkflow, saveWorkflowScript, validateScript, workflowsDir, WORKFLOW_DEFAULT_BUDGET_TOKENS, WORKFLOW_TIMEOUT_MS, } from "./runner.js";
8
8
  /**
9
9
  * run_workflow 工具:主 agent 把模型生成的编排脚本交给 WorkflowRunner 执行。
10
10
  * 脚本在 vm 沙箱里跑,只能用注入的 subagent/parallel/pipeline 原语。
@@ -38,6 +38,11 @@ export function createRunWorkflowTool(getParentModel) {
38
38
  minimum: 1,
39
39
  maximum: 180,
40
40
  })),
41
+ budget_tokens: Type.Optional(Type.Number({
42
+ description: `Total token ceiling for the whole run across all sub-agents (default ${WORKFLOW_DEFAULT_BUDGET_TOKENS}). Once exceeded, no new sub-agents are spawned; running ones finish. Use 0 for unlimited.`,
43
+ minimum: 0,
44
+ maximum: 1_000_000_000,
45
+ })),
41
46
  }),
42
47
  // 精简展示与 spawn_subagent 同款:收起一行摘要,ctrl+o 展开报告
43
48
  renderShell: "self",
@@ -53,7 +58,7 @@ export function createRunWorkflowTool(getParentModel) {
53
58
  : "🧭 工作流";
54
59
  return compactResultRender(result, options, theme, context, summary);
55
60
  },
56
- async execute(_toolCallId, params, signal) {
61
+ async execute(_toolCallId, params, signal, onUpdate) {
57
62
  let code = params.script?.trim();
58
63
  let scriptPath;
59
64
  if (code) {
@@ -76,6 +81,25 @@ export function createRunWorkflowTool(getParentModel) {
76
81
  throw new Error("script 和 script_path 至少填一个");
77
82
  }
78
83
  const progressPath = scriptPath.replace(/\.mjs$/, ".progress.jsonl");
84
+ // 流式进度:onProgress 高频触发,节流到 ≥2s 一次才推给 TUI
85
+ let lastPush = 0;
86
+ const onProgress = onUpdate
87
+ ? (p) => {
88
+ const now = Date.now();
89
+ if (now - lastPush < 2_000)
90
+ return;
91
+ lastPush = now;
92
+ onUpdate({
93
+ content: [
94
+ {
95
+ type: "text",
96
+ text: `🧭 子任务 ✓${p.ok} ✗${p.failed}${p.cached ? ` 缓存${p.cached}` : ""} · 在跑 ${p.running} · ${(p.spentTokens / 1000).toFixed(0)}k${p.budgetTokens > 0 ? ` / ${(p.budgetTokens / 1000).toFixed(0)}k tokens` : ""}`,
97
+ },
98
+ ],
99
+ details: p,
100
+ });
101
+ }
102
+ : undefined;
79
103
  const result = await runWorkflow({
80
104
  code: code ?? "",
81
105
  progressPath,
@@ -83,6 +107,8 @@ export function createRunWorkflowTool(getParentModel) {
83
107
  parentModel: getParentModel(),
84
108
  signal,
85
109
  timeoutMs: Math.min(180, params.timeout_minutes ?? WORKFLOW_TIMEOUT_MS / 60_000) * 60_000,
110
+ budgetTokens: params.budget_tokens,
111
+ onProgress,
86
112
  });
87
113
  return {
88
114
  content: [{ type: "text", text: truncate(result.report) }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {