@miphamai/cli 0.65.0 → 0.68.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/bin/mipham.ts CHANGED
@@ -1070,7 +1070,14 @@ async function runInitCLI(): Promise<boolean> {
1070
1070
  const targetDir = rawTarget ? resolve(rawTarget.replace(/^~/, homedir())) : process.cwd()
1071
1071
 
1072
1072
  const gitInit = !args.includes('--no-git')
1073
- const result = scaffoldProject(targetDir, { gitInit })
1073
+ const full = args.includes('--full')
1074
+ const licenseArg = args.find((a) => a.startsWith('--license='))
1075
+ const licenseVal = licenseArg?.split('=')[1]
1076
+ const license =
1077
+ licenseVal === 'mit' || licenseVal === 'apache' || licenseVal === 'proprietary'
1078
+ ? licenseVal
1079
+ : undefined
1080
+ const result = scaffoldProject(targetDir, { gitInit, full, license })
1074
1081
 
1075
1082
  const lines: string[] = ['', `✅ Mipham Code 项目已初始化:${targetDir}`, '']
1076
1083
  if (result.created.length > 0) {
@@ -1160,6 +1167,9 @@ async function main() {
1160
1167
  Usage:
1161
1168
  mipham Launch interactive CLI
1162
1169
  mipham init [dir] Scaffold a project (CLAUDE.md/MIPHAM.md/README.md)
1170
+ --full Full scaffold: 8-section CLAUDE.md + 7 .md + .github templates
1171
+ --license=<type> mit | apache | proprietary (default: proprietary)
1172
+ --no-git Skip git init
1163
1173
  mipham update Update to the latest version
1164
1174
  mipham upgrade Same as 'mipham update'
1165
1175
  mipham daemon <cmd> Daemon lifecycle (start, stop, status, restart)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.65.0",
3
+ "version": "0.68.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -20,6 +20,8 @@ export interface BackgroundTask {
20
20
  status: BackgroundTaskStatus
21
21
  kind: BackgroundTaskKind
22
22
  startedAt: Date
23
+ /** Cumulative token usage (input + output) reported by the running task. */
24
+ tokensUsed: number
23
25
  completedAt?: Date
24
26
  result?: string
25
27
  error?: string
@@ -99,6 +101,7 @@ export class BackgroundAgentRegistry {
99
101
  status: 'running',
100
102
  kind,
101
103
  startedAt: now,
104
+ tokensUsed: 0,
102
105
  abortController: new AbortController(),
103
106
  }
104
107
 
@@ -177,6 +180,15 @@ export class BackgroundAgentRegistry {
177
180
  return this.list().filter((t) => t.status === 'running')
178
181
  }
179
182
 
183
+ /**
184
+ * Update the cumulative token usage reported by a running task.
185
+ * No-op if the task no longer exists.
186
+ */
187
+ updateTokenUsage(id: string, tokensUsed: number): void {
188
+ const task = this.tasks.get(id)
189
+ if (task) task.tokensUsed = tokensUsed
190
+ }
191
+
180
192
  /**
181
193
  * Stop a running task by aborting its controller.
182
194
  * Returns true if the task was running and was stopped.
@@ -73,8 +73,13 @@ export class SubAgent {
73
73
  const bgRegistry = getBackgroundAgentRegistry()
74
74
 
75
75
  const taskId = bgRegistry.spawn(description, agentType, async (signal) => {
76
- // Run the synchronous execution inside the background executor
77
- return this.runExecution(prompt, options, signal)
76
+ // Run the synchronous execution inside the background executor, reporting
77
+ // cumulative token usage back to the registry for live footer display.
78
+ const opts: SubAgentOptions = {
79
+ ...options,
80
+ onTokenUsage: (total) => bgRegistry.updateTokenUsage(taskId, total),
81
+ }
82
+ return this.runExecution(prompt, opts, signal)
78
83
  })
79
84
 
80
85
  // Register completion callback for hook firing
@@ -323,6 +328,7 @@ export class SubAgent {
323
328
 
324
329
  let currentMessages = messages
325
330
  let currentSystemPrompt = systemPrompt
331
+ let totalTokens = 0
326
332
 
327
333
  try {
328
334
  for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) {
@@ -366,6 +372,10 @@ export class SubAgent {
366
372
  if (chunk.type === 'stop') {
367
373
  break
368
374
  }
375
+ if (chunk.type === 'usage') {
376
+ totalTokens += (chunk.inputTokens ?? 0) + (chunk.outputTokens ?? 0)
377
+ if (options.onTokenUsage) options.onTokenUsage(totalTokens)
378
+ }
369
379
  }
370
380
 
371
381
  chunks.push(turnText)
@@ -46,6 +46,8 @@ export interface SubAgentOptions {
46
46
  runInBackground?: boolean
47
47
  /** Optional callback for streaming progress chunks during background execution. */
48
48
  onProgress?: (chunk: string) => void
49
+ /** Optional callback reporting cumulative token usage (input + output) from the API stream. */
50
+ onTokenUsage?: (totalTokens: number) => void
49
51
  /** When set, tool executions use this path as cwd (git worktree isolation). */
50
52
  worktreePath?: string
51
53
  /** Seed the sub-agent with a parent conversation prefix (e.g., fork inheritance). */
@@ -1,7 +1,22 @@
1
+ import { realpathSync } from 'node:fs'
1
2
  import type { CredentialMaskingConfig } from '../../shared/index.ts'
2
3
  import { matchCredentialFile } from './matcher'
3
4
  import { CREDENTIAL_SENTINEL } from './types'
4
5
 
6
+ /**
7
+ * Resolve a path through symlinks before deny-rule matching, so a symlink whose
8
+ * name is innocuous but whose target is a sensitive file is still masked —
9
+ * mirroring Read, which matches on the canonical path. Falls back to the raw
10
+ * path when the target doesn't exist (e.g. synthetic paths in tests).
11
+ */
12
+ function resolveForMatch(p: string): string {
13
+ try {
14
+ return realpathSync(p)
15
+ } catch {
16
+ return p
17
+ }
18
+ }
19
+
5
20
  /**
6
21
  * Mask grep/search output: any match line that belongs to a credential file
7
22
  * (e.g. `.env`, `.aws/credentials`) has its content replaced with the
@@ -31,7 +46,9 @@ export function maskSearchOutput(
31
46
  const m = line.match(/^(.+?):(\d+):(.*)$/)
32
47
  if (m) {
33
48
  out.push(
34
- matchCredentialFile(m[1]!, config) ? `${m[1]}:${m[2]}:${CREDENTIAL_SENTINEL}` : line,
49
+ matchCredentialFile(resolveForMatch(m[1]!), config)
50
+ ? `${m[1]}:${m[2]}:${CREDENTIAL_SENTINEL}`
51
+ : line,
35
52
  )
36
53
  } else {
37
54
  out.push(line)
@@ -49,7 +66,7 @@ export function maskSearchOutput(
49
66
  out.push(line) // blank separator — pass through, keep current file
50
67
  continue
51
68
  }
52
- currentMasked = !!matchCredentialFile(line, config)
69
+ currentMasked = !!matchCredentialFile(resolveForMatch(line), config)
53
70
  out.push(line)
54
71
  }
55
72
 
@@ -68,6 +85,6 @@ export function maskGlobOutput(paths: string, config?: CredentialMaskingConfig):
68
85
  if (!config?.enabled) return paths
69
86
  return paths
70
87
  .split('\n')
71
- .map((p) => (matchCredentialFile(p, config) ? CREDENTIAL_SENTINEL : p))
88
+ .map((p) => (matchCredentialFile(resolveForMatch(p), config) ? CREDENTIAL_SENTINEL : p))
72
89
  .join('\n')
73
90
  }
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * Mipham Code — Project Scaffold
3
3
  *
4
- * `mipham init` 生成规范的项目文档(CLAUDE.md + MIPHAM.md + README.md),
5
- * 避免用户「冷建立文件」。模板遵循 One Mipham Corporation 规范。
4
+ * `mipham init` 生成规范的项目文档,避免用户「冷建立文件」。
5
+ *
6
+ * 分级:
7
+ * - 基础(默认):CLAUDE.md(简版)+ MIPHAM.md + README.md
8
+ * - 完整(--full):八章 CLAUDE.md + SECURITY/CONTRIBUTING/CODE_OF_CONDUCT/
9
+ * DEVELOPMENT/TRADEMARKS/CHANGELOG/LICENSE + .github 四件套
10
+ *
11
+ * 模板遵循 One Mipham Corporation 规范。
6
12
  */
7
13
  import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'
8
14
  import { join, basename } from 'node:path'
@@ -14,6 +20,23 @@ export interface ScaffoldResult {
14
20
  gitInitialized: boolean
15
21
  }
16
22
 
23
+ export type LicenseType = 'proprietary' | 'mit' | 'apache'
24
+
25
+ export interface ScaffoldOptions {
26
+ /** 是否执行 git init(默认 false,由 CLI 传入 true) */
27
+ gitInit?: boolean
28
+ /** 完整脚手架(生成完整 .md 体系 + .github 模板) */
29
+ full?: boolean
30
+ /** 许可证类型,默认 proprietary */
31
+ license?: LicenseType
32
+ }
33
+
34
+ const COPYRIGHT = '© 2026 One Mipham Corporation(北京华安麦逄科技有限公司)— All Rights Reserved.'
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // 基础模板(mipham init)
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
17
40
  export function renderMiphamMd(projectName: string): string {
18
41
  return `---
19
42
  model: mipham-code
@@ -111,6 +134,405 @@ export function renderReadmeMd(projectName: string): string {
111
134
  `
112
135
  }
113
136
 
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+ // 完整模板(mipham init --full)
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+
141
+ export function renderFullClaudeMd(projectName: string): string {
142
+ return `# CLAUDE.md
143
+
144
+ > **项目**: ${projectName}
145
+ > **版本**: 0.1.0
146
+ > **初始化**: \`mipham init --full\`
147
+ > **公司**: One Mipham Corporation | 品牌: MiphamAI
148
+ > **维护人**: One Mipham Corporation 技术委员会
149
+ > **审核周期**: 每季度复审一次
150
+
151
+ This file provides guidance to Claude Code when working with code in this repository.
152
+
153
+ ---
154
+
155
+ ## 一、项目概述
156
+
157
+ [简要描述项目目的和定位]
158
+
159
+ | 模块 | 目录 | 框架 | 语言 |
160
+ |------|------|------|------|
161
+ | [模块名] | \`[目录]\` | [框架] | [语言] |
162
+
163
+ ## 二、AI 编码协作原则
164
+
165
+ **原则 1: 编码前先思考 — Think Before Coding**
166
+ - 不确定时必须提问,不得自行假设;存在多种解读时呈现所有选项
167
+ - 有更简单方案时必须指出;有理由时应推回不合理需求
168
+
169
+ **原则 2: 简洁优先 — Simplicity First**
170
+ - 只写解决问题所需的最小代码,不写未要求的功能
171
+ - 不添加未被要求的"灵活性"或"可配置性"
172
+
173
+ **原则 3: 精准修改 — Surgical Changes**
174
+ - 只改被要求修改的内容;匹配现有代码风格
175
+ - 发现无关 dead code 时只提出来,不自行删除
176
+
177
+ **原则 4: 目标驱动执行 — Goal-Driven Execution**
178
+ - 将任务转化为可验证的成功标准;多步骤任务先陈述简要计划
179
+
180
+ ## 三、合规与安全(强制性)
181
+
182
+ - 用户数据:传输层 TLS 1.3,存储层 AES-256-GCM(母公司底线)
183
+ - 禁止在代码、日志、配置文件或提交历史中硬编码凭据、API 密钥、令牌
184
+ - PII 脱敏后进入开发/测试环境
185
+ - 面向用户的 AI 功能上线前通过 prompt injection 与对抗攻击测试
186
+
187
+ ## 四、代码质量标准
188
+
189
+ - 配置自动化 lint + 格式化工具,通过 CI 强制执行
190
+ - 新代码通过测试后合并;至少一名资深工程师 review
191
+ - 禁止直接提交主分支:feature branch + PR 流程
192
+ - 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/)
193
+
194
+ ## 五、技术架构原则
195
+
196
+ - 服务间通信通过定义良好的 API,禁止跨服务直接数据库访问
197
+ - 关键业务路径可观测性:结构化日志 + 指标 + 追踪
198
+ - 基础设施即代码(IaC)
199
+
200
+ ## 六、项目结构约定
201
+
202
+ \`\`\`
203
+ ${projectName}/
204
+ ├── CLAUDE.md # AI 编码规范
205
+ ├── SECURITY.md # 安全策略
206
+ ├── README.md # 项目说明
207
+ ├── LICENSE # 许可证
208
+ ├── CHANGELOG.md # 版本变更日志
209
+ ├── .github/ # PR/Issue 模板 + CODEOWNERS
210
+ └── src/ # [源代码目录]
211
+ \`\`\`
212
+
213
+ ## 七、技术决策框架
214
+
215
+ 按优先级评估技术选型:成熟度与社区支持 → 安全合规 → 团队能力匹配 → 技术栈互操作性 → 许可证与供应商风险。
216
+
217
+ ## 八、代码评审检查清单
218
+
219
+ - [ ] 无硬编码凭据、密钥或 Token
220
+ - [ ] 敏感数据已加密/脱敏
221
+ - [ ] 新功能有对应测试
222
+ - [ ] 提交信息符合 Conventional Commits
223
+
224
+ ---
225
+
226
+ ### 修订历史
227
+
228
+ | 版本 | 日期 | 变更内容 | 维护人 |
229
+ |------|------|---------|--------|
230
+ | 0.1.0 | 2026-08-30 | \`mipham init --full\` 初始化 | 技术委员会 |
231
+
232
+ ---
233
+
234
+ > **版权**: ${COPYRIGHT}
235
+ `
236
+ }
237
+
238
+ export function renderSecurityMd(projectName: string): string {
239
+ return `# Security Policy — ${projectName}
240
+
241
+ ## 加密要求
242
+
243
+ - 传输层 TLS 1.3,存储层 AES-256-GCM(母公司底线)
244
+ - 禁止在代码、日志、配置或提交历史中硬编码凭据、API 密钥、令牌
245
+
246
+ ## 依赖安全
247
+
248
+ - 第三方依赖引入前通过许可证合规检查(严禁 copyleft/GPL)
249
+ - 定期升级依赖,响应 Dependabot 告警
250
+
251
+ ## AI 安全
252
+
253
+ - 面向用户的 AI 功能上线前通过 prompt injection 测试、对抗攻击红队评估
254
+ - LLM System Prompt 版本管理,变更记录原因和审批人
255
+
256
+ ## 报告漏洞
257
+
258
+ 安全漏洞请勿公开提交 issue,私密报告至维护人(见 README)。
259
+ `
260
+ }
261
+
262
+ export function renderContributingMd(projectName: string, license: LicenseType): string {
263
+ const isOpen = license !== 'proprietary'
264
+ const header = isOpen
265
+ ? `# Contributing to ${projectName}
266
+
267
+ 感谢你对 **${projectName}** 的关注。本项目采用 **${license === 'apache' ? 'Apache 2.0' : 'MIT'}** 开源许可。
268
+ `
269
+ : `# Contributing to ${projectName}
270
+
271
+ **${projectName} 是专有闭源商业软件**(见 LICENSE)。本文档仅面向**内部团队成员和授权贡献者**,不接受外部 PR。
272
+ `
273
+
274
+ return `${header}
275
+ ## 分支与提交
276
+
277
+ - 分支命名:\`feat/xxx\`、\`fix/xxx\`、\`docs/xxx\`、\`chore/xxx\`
278
+ - 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/)
279
+ - \`main\` 受保护,所有变更走 PR + 至少一名资深工程师 review
280
+
281
+ ## Pull Request 指南
282
+
283
+ 请说明:
284
+ - 改了什么、为什么
285
+ - 如何测试
286
+ - UI 变更附截图
287
+ - 涉及环境变量或数据库迁移时,说明兼容性影响
288
+
289
+ ## 安全
290
+
291
+ - 禁止硬编码凭据、密钥或 Token
292
+ - 安全漏洞按 SECURITY.md 渠道私密报告
293
+
294
+ ## 许可证
295
+
296
+ ${isOpen ? `贡献将被纳入项目许可证之下(见 LICENSE)。` : '未经书面授权,禁止复制、修改、分发或使用(见 LICENSE)。'}
297
+ `
298
+ }
299
+
300
+ export function renderCodeOfConductMd(projectName: string): string {
301
+ return `# Code of Conduct — ${projectName}
302
+
303
+ ## 期望行为
304
+
305
+ - 清晰、尊重地沟通
306
+ - 聚焦想法、代码与设计,而非个人
307
+ - 除非确证,否则假设他人善意
308
+ - 保持讨论相关且有建设性
309
+
310
+ ## 不可接受的行为
311
+
312
+ - 任何形式的骚扰、歧视或恐吓
313
+ - 人身攻击、侮辱或贬损性言论
314
+ - 未经许可发布他人隐私信息
315
+
316
+ ## 举报
317
+
318
+ 如遇违反本准则的行为,通过 README.md 中的渠道联系维护者。所有举报严肃、保密处理。
319
+ `
320
+ }
321
+
322
+ export function renderDevelopmentMd(projectName: string): string {
323
+ return `# ${projectName} — 开发指南
324
+
325
+ ## 前置条件
326
+
327
+ | 工具 | 版本 | 说明 |
328
+ |------|------|------|
329
+ | [工具] | [版本] | [说明] |
330
+
331
+ ## 快速开始
332
+
333
+ \`\`\`bash
334
+ # 1. 安装依赖
335
+ # 2. 配置环境
336
+ # 3. 运行
337
+ \`\`\`
338
+
339
+ ## 常用命令
340
+
341
+ \`\`\`bash
342
+ # 构建 / 测试 / lint
343
+ \`\`\`
344
+
345
+ ## 环境变量
346
+
347
+ | 变量 | 必填 | 说明 |
348
+ |------|------|------|
349
+ | [变量名] | [是/否] | [说明] |
350
+
351
+ ## 测试
352
+
353
+ \`\`\`bash
354
+ # 运行测试
355
+ \`\`\`
356
+ `
357
+ }
358
+
359
+ export function renderTrademarksMd(projectName: string): string {
360
+ return `# ${projectName} 商标与品牌政策
361
+
362
+ 本文件规范 **${projectName}** 商标与品牌资产的使用(名称、Logo、视觉识别)。独立于代码许可证。
363
+
364
+ > 说明:本政策仅为清晰起见,不构成法律意见。
365
+
366
+ ## 允许的使用
367
+
368
+ - 讨论官方未修改项目时如实提及"${projectName}"
369
+ - 用名称链接到本仓库
370
+
371
+ ## 禁止的使用(未经书面许可)
372
+
373
+ - 以暗示背书、赞助或官方身份的方式使用商标
374
+ - 修改、重绘、裁剪 Logo 或衍生品牌资产
375
+ - 注册或使用易混淆的名称 / 域名 / 社交账号
376
+
377
+ ## 商业许可与授权
378
+
379
+ 如需白标、OEM、企业部署,联系:\`legal@onemipham.com\`
380
+ `
381
+ }
382
+
383
+ export function renderChangelogMd(projectName: string): string {
384
+ return `# Changelog
385
+
386
+ All notable changes to ${projectName} will be documented in this file.
387
+
388
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
389
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
390
+
391
+ ## [0.1.0] — 2026-08-30
392
+
393
+ ### Added
394
+ - 初始版本(\`mipham init --full\` 脚手架)
395
+ `
396
+ }
397
+
398
+ export function renderLicenseMd(license: LicenseType): string {
399
+ if (license === 'mit') {
400
+ return `MIT License
401
+
402
+ Copyright (c) 2026 One Mipham Corporation(北京华安麦逄科技有限公司)
403
+
404
+ Permission is hereby granted, free of charge, to any person obtaining a copy
405
+ of this software and associated documentation files (the "Software"), to deal
406
+ in the Software without restriction, including without limitation the rights
407
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
408
+ copies of the Software, and to permit persons to whom the Software is
409
+ furnished to do so, subject to the following conditions:
410
+
411
+ The above copyright notice and this permission notice shall be included in all
412
+ copies or substantial portions of the Software.
413
+
414
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
415
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
416
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
417
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
418
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
419
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
420
+ SOFTWARE.
421
+ `
422
+ }
423
+ if (license === 'apache') {
424
+ return `Apache License 2.0
425
+
426
+ Copyright (c) 2026 One Mipham Corporation(北京华安麦逄科技有限公司)
427
+
428
+ Licensed under the Apache License, Version 2.0 (the "License");
429
+ you may not use this file except in compliance with the License.
430
+ You may obtain a copy of the License at:
431
+
432
+ http://www.apache.org/licenses/LICENSE-2.0
433
+
434
+ Unless required by applicable law or agreed to in writing, software
435
+ distributed under the License is distributed on an "AS IS" BASIS,
436
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
437
+ See the License for the specific language governing permissions and
438
+ limitations under the License.
439
+ `
440
+ }
441
+ return `${projectLicenseProprietary()}
442
+ `
443
+ }
444
+
445
+ function projectLicenseProprietary(): string {
446
+ return `Copyright (c) 2026 One Mipham Corporation(北京华安麦逄科技有限公司)
447
+ All rights reserved.
448
+
449
+ Proprietary Software — Closed Source Commercial Software
450
+
451
+ This software and its source code are the proprietary property of
452
+ One Mipham Corporation. Reproduction, modification, distribution,
453
+ or use of this software in any form is prohibited without explicit
454
+ written authorization.
455
+ `
456
+ }
457
+
458
+ export function renderPullRequestTemplate(): string {
459
+ return `## 变更说明
460
+ <!-- 改了什么、为什么 -->
461
+
462
+ ## 变更类型
463
+ - [ ] feat 新功能
464
+ - [ ] fix 修复
465
+ - [ ] docs 文档
466
+ - [ ] chore 维护
467
+
468
+ ## 如何测试
469
+ <!-- 验证步骤与命令 -->
470
+
471
+ ## 截图
472
+ <!-- UI 变更附截图 -->
473
+
474
+ ## 兼容性影响
475
+ <!-- 环境变量、数据库迁移或破坏性变更 -->
476
+ `
477
+ }
478
+
479
+ export function renderBugReportTemplate(): string {
480
+ return `---
481
+ name: Bug 报告
482
+ about: 报告一个 bug
483
+ title: "[Bug] "
484
+ labels: bug
485
+ assignees: ""
486
+ ---
487
+
488
+ ## 描述
489
+ <!-- 清晰描述 bug -->
490
+
491
+ ## 复现步骤
492
+ 1.
493
+ 2.
494
+ 3.
495
+
496
+ ## 期望行为
497
+
498
+ ## 实际行为
499
+
500
+ ## 环境
501
+ - 平台 / 系统:
502
+ - 版本:
503
+ - 日志 / 截图:
504
+ `
505
+ }
506
+
507
+ export function renderFeatureRequestTemplate(): string {
508
+ return `---
509
+ name: 功能请求
510
+ about: 建议一个新功能
511
+ title: "[Feature] "
512
+ labels: enhancement
513
+ assignees: ""
514
+ ---
515
+
516
+ ## 问题背景
517
+ <!-- 这个功能解决什么问题 -->
518
+
519
+ ## 期望方案
520
+ <!-- 描述你期望的功能 -->
521
+
522
+ ## 备选方案
523
+ <!-- 考虑过的其他方案 -->
524
+ `
525
+ }
526
+
527
+ export function renderCodeowners(owner: string): string {
528
+ return `* @${owner.replace(/^@/, '')}
529
+ `
530
+ }
531
+
532
+ // ─────────────────────────────────────────────────────────────────────────────
533
+ // 脚手架
534
+ // ─────────────────────────────────────────────────────────────────────────────
535
+
114
536
  /** 检测目录是否为「愣建文件夹」——无任何条目(忽略 .DS_Store)。目录不存在则视为非空。 */
115
537
  export function isEmptyProject(dir: string): boolean {
116
538
  try {
@@ -121,16 +543,45 @@ export function isEmptyProject(dir: string): boolean {
121
543
  }
122
544
  }
123
545
 
124
- export function scaffoldProject(targetDir: string, opts?: { gitInit?: boolean }): ScaffoldResult {
125
- mkdirSync(targetDir, { recursive: true })
546
+ interface FileSpec {
547
+ path: string
548
+ content: string
549
+ }
126
550
 
127
- const name = basename(targetDir) || 'my-project'
128
- const files = [
129
- { path: 'CLAUDE.md', content: renderClaudeMd(name) },
551
+ function buildFileSpecs(name: string, opts?: ScaffoldOptions): FileSpec[] {
552
+ const full = opts?.full === true
553
+ const license: LicenseType = opts?.license ?? 'proprietary'
554
+
555
+ const specs: FileSpec[] = [
556
+ { path: 'CLAUDE.md', content: full ? renderFullClaudeMd(name) : renderClaudeMd(name) },
130
557
  { path: 'MIPHAM.md', content: renderMiphamMd(name) },
131
558
  { path: 'README.md', content: renderReadmeMd(name) },
132
559
  ]
133
560
 
561
+ if (!full) return specs
562
+
563
+ return [
564
+ ...specs,
565
+ { path: 'SECURITY.md', content: renderSecurityMd(name) },
566
+ { path: 'CONTRIBUTING.md', content: renderContributingMd(name, license) },
567
+ { path: 'CODE_OF_CONDUCT.md', content: renderCodeOfConductMd(name) },
568
+ { path: 'DEVELOPMENT.md', content: renderDevelopmentMd(name) },
569
+ { path: 'TRADEMARKS.md', content: renderTrademarksMd(name) },
570
+ { path: 'CHANGELOG.md', content: renderChangelogMd(name) },
571
+ { path: 'LICENSE', content: renderLicenseMd(license) },
572
+ { path: '.github/pull_request_template.md', content: renderPullRequestTemplate() },
573
+ { path: '.github/ISSUE_TEMPLATE/bug_report.md', content: renderBugReportTemplate() },
574
+ { path: '.github/ISSUE_TEMPLATE/feature_request.md', content: renderFeatureRequestTemplate() },
575
+ { path: '.github/CODEOWNERS', content: renderCodeowners('sarvadaya') },
576
+ ]
577
+ }
578
+
579
+ export function scaffoldProject(targetDir: string, opts?: ScaffoldOptions): ScaffoldResult {
580
+ mkdirSync(targetDir, { recursive: true })
581
+
582
+ const name = basename(targetDir) || 'my-project'
583
+ const files = buildFileSpecs(name, opts)
584
+
134
585
  const created: string[] = []
135
586
  const skipped: string[] = []
136
587
 
@@ -139,6 +590,7 @@ export function scaffoldProject(targetDir: string, opts?: { gitInit?: boolean })
139
590
  if (existsSync(full)) {
140
591
  skipped.push(f.path)
141
592
  } else {
593
+ mkdirSync(join(full, '..'), { recursive: true })
142
594
  writeFileSync(full, f.content, 'utf-8')
143
595
  created.push(f.path)
144
596
  }
package/src/index.tsx CHANGED
@@ -314,7 +314,7 @@ export async function runApp(options: RunOptions): Promise<void> {
314
314
  const { isEmptyProject } = await import('./core/project-scaffold')
315
315
  if (isEmptyProject(process.cwd())) {
316
316
  console.log(
317
- '\n💡 空目录。运行 `mipham init` 生成规范项目文档(CLAUDE.md / MIPHAM.md / README.md),或直接开始对话。\n',
317
+ '\n💡 空目录。运行 `mipham init` 生成基础文档,或 `mipham init --full` 生成完整规范(八章 CLAUDE.md + 7 个 .md + .github 模板),或直接开始对话。\n',
318
318
  )
319
319
  }
320
320
 
@@ -0,0 +1,52 @@
1
+ import { openSync, writeFileSync, closeSync, constants } from 'node:fs'
2
+
3
+ /**
4
+ * O_NOFOLLOW is POSIX-only; on Windows it is undefined. Fall back to 0 (a
5
+ * no-op) — Windows symlinks are governed by different semantics, and
6
+ * `resolveSafe` still rejects UNC/device-namespace paths and workspace
7
+ * escapes before any file is opened.
8
+ */
9
+ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0
10
+
11
+ /**
12
+ * Open a file with O_NOFOLLOW so a symlink swapped in after path resolution
13
+ * (a TOCTOU race) fails with ELOOP instead of being silently followed.
14
+ *
15
+ * `resolveSafe` resolves symlinks at check time, but the returned canonical
16
+ * path is a *string*; re-opening it by path re-resolves the filesystem. If a
17
+ * concurrent process replaces the final component with a symlink between the
18
+ * check and the open, O_NOFOLLOW makes the open fail closed.
19
+ *
20
+ * Callers combine with their own flags (O_RDONLY / O_WRONLY / O_CREAT /
21
+ * O_TRUNC). Throws ELOOP when the path is a symlink.
22
+ */
23
+ export function openNoFollow(path: string, flags: number, mode?: number): number {
24
+ return openSync(path, flags | O_NOFOLLOW, mode)
25
+ }
26
+
27
+ /** True if `err` is ELOOP — a symlink was encountered where O_NOFOLLOW forbade following it. */
28
+ export function isSymlinkLoop(err: unknown): boolean {
29
+ return (err as NodeJS.ErrnoException)?.code === 'ELOOP'
30
+ }
31
+
32
+ /**
33
+ * Open a file O_WRONLY|O_NOFOLLOW, write `content`, and close. Used by Write
34
+ * and Edit so a symlink swapped in after `resolveSafe` is rejected rather than
35
+ * followed (writing through it would clobber an out-of-workspace target).
36
+ *
37
+ * `flags` should include O_WRONLY|O_TRUNC (and O_CREAT for Write). Throws
38
+ * ELOOP when the path is a symlink.
39
+ */
40
+ export function writeFileNoFollow(
41
+ path: string,
42
+ content: string,
43
+ flags: number,
44
+ mode = 0o666,
45
+ ): void {
46
+ const fd = openSync(path, flags | O_NOFOLLOW, mode)
47
+ try {
48
+ writeFileSync(fd, content, 'utf-8')
49
+ } finally {
50
+ closeSync(fd)
51
+ }
52
+ }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.65.0' as const
12
+ export const PACKAGE_VERSION = '0.68.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -75,6 +75,60 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
75
75
  return null
76
76
  }
77
77
 
78
+ /**
79
+ * Split a git command string into argv tokens, honoring shell-style quoting
80
+ * (single quotes, double quotes, and backslash escapes). Unlike a naive
81
+ * `split(/\s+/)`, this keeps `-m "multi word message"` as a single argument
82
+ * instead of splitting it into separate tokens.
83
+ */
84
+ export function splitCommand(command: string): string[] {
85
+ const tokens: string[] = []
86
+ let current = ''
87
+ let inSingle = false
88
+ let inDouble = false
89
+
90
+ for (let i = 0; i < command.length; i++) {
91
+ const ch = command[i]!
92
+
93
+ if (inSingle) {
94
+ if (ch === "'") inSingle = false
95
+ else current += ch
96
+ continue
97
+ }
98
+
99
+ if (inDouble) {
100
+ if (ch === '"') inDouble = false
101
+ else if (ch === '\\' && i + 1 < command.length && '$`"\\'.includes(command[i + 1]!)) {
102
+ current += command[++i]!
103
+ } else {
104
+ current += ch
105
+ }
106
+ continue
107
+ }
108
+
109
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
110
+ if (current !== '') {
111
+ tokens.push(current)
112
+ current = ''
113
+ }
114
+ continue
115
+ }
116
+
117
+ if (ch === "'") {
118
+ inSingle = true
119
+ } else if (ch === '"') {
120
+ inDouble = true
121
+ } else if (ch === '\\' && i + 1 < command.length) {
122
+ current += command[++i]!
123
+ } else {
124
+ current += ch
125
+ }
126
+ }
127
+
128
+ if (current !== '') tokens.push(current)
129
+ return tokens
130
+ }
131
+
78
132
  export const gitTool: ToolDefinition = {
79
133
  name: 'Git',
80
134
  description: 'Execute git commands. Dangerous operations (force push, hard reset) are blocked.',
@@ -115,7 +169,7 @@ export const gitTool: ToolDefinition = {
115
169
  }
116
170
 
117
171
  try {
118
- const proc = Bun.spawn(['git', ...command.split(/\s+/)], {
172
+ const proc = Bun.spawn(['git', ...splitCommand(command)], {
119
173
  cwd: ctx.cwd,
120
174
  stdout: 'pipe',
121
175
  stderr: 'pipe',
@@ -1,6 +1,7 @@
1
- import { readFileSync, writeFileSync } from 'node:fs'
1
+ import { readFileSync, closeSync, constants } from 'node:fs'
2
2
  import type { ToolDefinition } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
+ import { openNoFollow, writeFileNoFollow, isSymlinkLoop } from '../../security/fd'
4
5
 
5
6
  /**
6
7
  * Characters considered part of an identifier in source code.
@@ -89,7 +90,22 @@ export const editTool: ToolDefinition = {
89
90
  }
90
91
  }
91
92
 
92
- const content = readFileSync(filePath, 'utf-8')
93
+ // O_NOFOLLOW: fail closed on a symlink swapped in after resolveSafe (TOCTOU).
94
+ let rfd: number
95
+ try {
96
+ rfd = openNoFollow(filePath, constants.O_RDONLY)
97
+ } catch (err) {
98
+ if (isSymlinkLoop(err)) {
99
+ return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
100
+ }
101
+ throw err
102
+ }
103
+ let content: string
104
+ try {
105
+ content = readFileSync(rfd, 'utf-8')
106
+ } finally {
107
+ closeSync(rfd)
108
+ }
93
109
 
94
110
  // ── Read tracking: mark as read before editing ──
95
111
  ctx.readFiles?.add(filePath)
@@ -114,28 +130,43 @@ export const editTool: ToolDefinition = {
114
130
  }
115
131
  }
116
132
 
133
+ let result: string
134
+ let count: number
117
135
  if (replaceAll) {
118
136
  // Build result by splicing at each match (in reverse so indices stay valid)
119
- let result = content
137
+ result = content
120
138
  for (let i = matches.length - 1; i >= 0; i--) {
121
139
  const idx = matches[i]!
122
140
  result = result.slice(0, idx) + newStr + result.slice(idx + oldStr.length)
123
141
  }
124
- writeFileSync(filePath, result, 'utf-8')
125
- return { success: true, content: `Replaced ${matches.length} occurrences in ${filePath}` }
142
+ count = matches.length
143
+ } else {
144
+ if (matches.length > 1) {
145
+ return {
146
+ success: false,
147
+ content: '',
148
+ error: 'old_string is not unique in file. Use replace_all or make it more specific.',
149
+ }
150
+ }
151
+
152
+ const idx = matches[0]!
153
+ result = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length)
154
+ count = 1
126
155
  }
127
156
 
128
- if (matches.length > 1) {
129
- return {
130
- success: false,
131
- content: '',
132
- error: 'old_string is not unique in file. Use replace_all or make it more specific.',
157
+ // O_NOFOLLOW: reject a symlink swapped in after the read (TOCTOU) instead of
158
+ // writing through it to an out-of-workspace target.
159
+ try {
160
+ writeFileNoFollow(filePath, result, constants.O_WRONLY | constants.O_TRUNC)
161
+ } catch (err) {
162
+ if (isSymlinkLoop(err)) {
163
+ return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
133
164
  }
165
+ throw err
166
+ }
167
+ return {
168
+ success: true,
169
+ content: `Replaced ${count} occurrence${count === 1 ? '' : 's'} in ${filePath}`,
134
170
  }
135
-
136
- const idx = matches[0]!
137
- const updated = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length)
138
- writeFileSync(filePath, updated, 'utf-8')
139
- return { success: true, content: `Replaced 1 occurrence in ${filePath}` }
140
171
  },
141
172
  }
@@ -90,8 +90,22 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
90
90
  // rg not installed → fall through to grep
91
91
  }
92
92
 
93
- // 2. fallback to plain grep
94
- const grepArgs = ['grep', '-rn', pattern, searchPath]
93
+ // 2. fallback: `find -type f -exec grep -Hn {} +`
94
+ // `-type f` skips symlinks (file and dir) so the fallback can't follow
95
+ // one out of the workspace — macOS BSD `grep -r` follows symlinks and
96
+ // would leak target contents, unlike ripgrep's default no-follow.
97
+ const grepArgs = [
98
+ 'find',
99
+ searchPath,
100
+ '-type',
101
+ 'f',
102
+ '-exec',
103
+ 'grep',
104
+ '-Hn',
105
+ pattern,
106
+ '{}',
107
+ '+',
108
+ ]
95
109
  try {
96
110
  const { stdout, timedOut, exitCode } = await runSearch(grepArgs, ctx.cwd, GREP_TIMEOUT_MS)
97
111
  if (timedOut) {
@@ -1,6 +1,7 @@
1
- import { readFileSync, existsSync, statSync } from 'node:fs'
1
+ import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
2
2
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
+ import { openNoFollow, isSymlinkLoop } from '../../security/fd'
4
5
  import type { Service } from '../../vajra'
5
6
  import { toolKey } from '../seam'
6
7
  import { withValidation } from '../validation'
@@ -23,25 +24,44 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
23
24
  },
24
25
  async execute(params, ctx) {
25
26
  const filePath = resolveSafe(ctx.cwd, params.file_path as string)
26
- if (!existsSync(filePath)) {
27
- return { success: false, content: '', error: `File not found: ${filePath}` }
28
- }
29
- const stat = statSync(filePath)
30
- if (stat.isDirectory()) {
31
- return { success: false, content: '', error: `Path is a directory: ${filePath}` }
27
+
28
+ // O_NOFOLLOW: fail closed if the path was swapped to a symlink after
29
+ // resolveSafe (TOCTOU) — never follow it to read outside the workspace.
30
+ let fd: number
31
+ try {
32
+ fd = openNoFollow(filePath, constants.O_RDONLY)
33
+ } catch (err) {
34
+ if (isSymlinkLoop(err)) {
35
+ return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
36
+ }
37
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
38
+ return { success: false, content: '', error: `File not found: ${filePath}` }
39
+ }
40
+ throw err
32
41
  }
33
- // Prevent OOM on single-line files (e.g. 500MB JSON blob)
34
- const MAX_FILE_SIZE = 50_000_000 // 50 MB
35
- if (stat.size > MAX_FILE_SIZE) {
36
- return {
37
- success: false,
38
- content: '',
39
- error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
42
+
43
+ let content: string
44
+ try {
45
+ const stat = fstatSync(fd)
46
+ if (stat.isDirectory()) {
47
+ return { success: false, content: '', error: `Path is a directory: ${filePath}` }
48
+ }
49
+ // Prevent OOM on single-line files (e.g. 500MB JSON blob)
50
+ const MAX_FILE_SIZE = 50_000_000 // 50 MB
51
+ if (stat.size > MAX_FILE_SIZE) {
52
+ return {
53
+ success: false,
54
+ content: '',
55
+ error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
56
+ }
40
57
  }
58
+ content = readFileSync(fd, 'utf-8')
59
+ } finally {
60
+ closeSync(fd)
41
61
  }
62
+
42
63
  const offset = (params.offset as number) || 0
43
64
  const limit = (params.limit as number) || 2000
44
- const content = readFileSync(filePath, 'utf-8')
45
65
 
46
66
  // ── Read tracking: mark file as read for Write tool safety ──
47
67
  ctx.readFiles?.add(filePath)
@@ -1,7 +1,8 @@
1
- import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
1
+ import { mkdirSync, existsSync, constants } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
3
  import type { ToolDefinition } from '../../shared/index.ts'
4
4
  import { resolveSafe } from '../../security/path'
5
+ import { writeFileNoFollow, isSymlinkLoop } from '../../security/fd'
5
6
 
6
7
  export const writeTool: ToolDefinition = {
7
8
  name: 'Write',
@@ -42,7 +43,25 @@ export const writeTool: ToolDefinition = {
42
43
  }
43
44
 
44
45
  mkdirSync(dirname(filePath), { recursive: true })
45
- writeFileSync(filePath, content, 'utf-8')
46
+
47
+ // O_NOFOLLOW: reject a symlink swapped in after resolveSafe (TOCTOU) —
48
+ // writing through it would clobber an out-of-workspace target.
49
+ try {
50
+ writeFileNoFollow(
51
+ filePath,
52
+ content,
53
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
54
+ )
55
+ } catch (err) {
56
+ if (isSymlinkLoop(err)) {
57
+ return {
58
+ success: false,
59
+ content: '',
60
+ error: `Refusing to write through a symbolic link: ${filePath}`,
61
+ }
62
+ }
63
+ throw err
64
+ }
46
65
 
47
66
  // Track as read since we just wrote it (future writes allowed)
48
67
  ctx.readFiles?.add(filePath)
@@ -101,6 +101,9 @@ function rotatingGerund(tick: number): string {
101
101
  return GERUNDS[Math.floor(tick / 2) % GERUNDS.length]!
102
102
  }
103
103
 
104
+ /** Max agent lines to render before collapsing the rest into "↓ N more". */
105
+ const MAX_AGENT_LINES = 5
106
+
104
107
  export function AgentFooter({ agents, tick, activeTool, agentProgress }: AgentFooterProps) {
105
108
  const { t } = useI18n()
106
109
  const hasAgents = agents.length > 0
@@ -118,6 +121,7 @@ export function AgentFooter({ agents, tick, activeTool, agentProgress }: AgentFo
118
121
  {' '}[{activeTool!.name} {activeTool!.detail.slice(0, 60)}
119
122
  {animatedDots(tick)}]
120
123
  </Text>
124
+ <Box flexGrow={1} />
121
125
  <Text dimColor>
122
126
  {' '}
123
127
  {formatElapsed(Math.floor((Date.now() - activeTool!.startTime) / 1000))}
@@ -131,6 +135,7 @@ export function AgentFooter({ agents, tick, activeTool, agentProgress }: AgentFo
131
135
  <Text color="#FFA500">
132
136
  {' '}✻ {rotatingGerund(tick)}…
133
137
  </Text>
138
+ <Box flexGrow={1} />
134
139
  <Text dimColor>
135
140
  {' ('}
136
141
  {formatElapsed(Math.floor((Date.now() - agentProgress!.startTime) / 1000))}
@@ -142,8 +147,8 @@ export function AgentFooter({ agents, tick, activeTool, agentProgress }: AgentFo
142
147
  </Box>
143
148
  )}
144
149
 
145
- {/* Agent status lines */}
146
- {agents.map((agent) => {
150
+ {/* Agent status lines — collapse to MAX_AGENT_LINES + "↓ N more" */}
151
+ {agents.slice(0, MAX_AGENT_LINES).map((agent) => {
147
152
  const elapsed = Math.floor((Date.now() - agent.startTime) / 1000)
148
153
  const isRunning = agent.status === 'running'
149
154
  const symbol = isRunning ? '◯' : '◼'
@@ -163,7 +168,8 @@ export function AgentFooter({ agents, tick, activeTool, agentProgress }: AgentFo
163
168
  {agent.name}
164
169
  </Text>
165
170
  <Text dimColor> {desc}</Text>
166
- {/* Spacer pushes stats to the right — Ink handles this naturally */}
171
+ {/* Spacer pushes elapsed + tokens to the right edge */}
172
+ <Box flexGrow={1} />
167
173
  <Text dimColor>
168
174
  {' '}
169
175
  {isRunning ? formatElapsed(elapsed) : t('ui.agent.finished')}
@@ -177,6 +183,11 @@ export function AgentFooter({ agents, tick, activeTool, agentProgress }: AgentFo
177
183
  </Box>
178
184
  )
179
185
  })}
186
+ {agents.length > MAX_AGENT_LINES && (
187
+ <Text dimColor>
188
+ {' '}↓ {agents.length - MAX_AGENT_LINES} more
189
+ </Text>
190
+ )}
180
191
  </Box>
181
192
  )
182
193
  }
package/src/ui/app.tsx CHANGED
@@ -270,7 +270,23 @@ export function App({
270
270
  agentProgressRef.current !== null ||
271
271
  activeToolRef.current !== null
272
272
  if (!hasRunning) return
273
- const i = setInterval(() => setAgentTick((t) => t + 1), 1000)
273
+ const i = setInterval(() => {
274
+ setAgentTick((t) => t + 1)
275
+ // Refresh live token counts for running background agents.
276
+ setRunningAgents((prev) => {
277
+ const bg = getBackgroundAgentRegistry()
278
+ let changed = false
279
+ const next = { ...prev }
280
+ for (const task of bg.list()) {
281
+ const cur = next[task.id]
282
+ if (cur && cur.status === 'running' && cur.tokensUsed !== task.tokensUsed) {
283
+ next[task.id] = { ...cur, tokensUsed: task.tokensUsed }
284
+ changed = true
285
+ }
286
+ }
287
+ return changed ? next : prev
288
+ })
289
+ }, 1000)
274
290
  return () => clearInterval(i)
275
291
  }, [runningAgents, agentProgress, activeTool])
276
292
 
@@ -282,14 +298,14 @@ export function App({
282
298
  setRunningAgents((prev) => {
283
299
  const next: Record<string, AgentEntry> = {}
284
300
  for (const task of all) {
285
- // Preserve existing entry if it has more data (e.g. tokens accumulated during streaming)
301
+ // Preserve existing entry for metadata; tokens come from the registry task.
286
302
  const existing = prev[task.id]
287
303
  next[task.id] = {
288
304
  id: task.id,
289
305
  name: existing?.name || task.agentType.charAt(0).toUpperCase() + task.agentType.slice(1),
290
306
  description: existing?.description || task.description,
291
307
  startTime: existing?.startTime || task.startedAt.getTime(),
292
- tokensUsed: existing?.tokensUsed || 0,
308
+ tokensUsed: task.tokensUsed ?? 0,
293
309
  status: task.status === 'running' ? 'running' : 'completed',
294
310
  }
295
311
  }