@miphamai/cli 0.81.7 → 0.81.9

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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/bin/mipham.ts +35 -1
  3. package/package.json +1 -1
  4. package/src/agent/message-bus.ts +10 -3
  5. package/src/agent/sub-agent.ts +60 -12
  6. package/src/agent/types.ts +14 -1
  7. package/src/artifacts/manifest.ts +90 -34
  8. package/src/artifacts/paths.ts +19 -0
  9. package/src/artifacts/server.ts +48 -8
  10. package/src/config/credential-crypto.ts +28 -5
  11. package/src/config/defaults.ts +18 -10
  12. package/src/config/keys-manager.ts +14 -9
  13. package/src/config/loader.ts +202 -63
  14. package/src/config/preferences.ts +5 -2
  15. package/src/core/credential-masker/output-scrub.ts +16 -2
  16. package/src/core/cron-poller.ts +30 -6
  17. package/src/core/engine.ts +7 -2
  18. package/src/core/hooks-executor.ts +30 -2
  19. package/src/core/hooks.ts +51 -4
  20. package/src/core/paths.ts +44 -1
  21. package/src/core/permission-config.ts +146 -14
  22. package/src/core/permission-rules.ts +157 -6
  23. package/src/core/permission.ts +81 -13
  24. package/src/core/rules-loader.ts +35 -5
  25. package/src/core/session-log.ts +49 -2
  26. package/src/core/session-store.ts +11 -1
  27. package/src/core/workspace-trust.ts +42 -4
  28. package/src/daemon/auth.ts +15 -14
  29. package/src/daemon/engine-capabilities.ts +12 -2
  30. package/src/daemon/remote-engine.ts +9 -4
  31. package/src/daemon/server.ts +29 -1
  32. package/src/daemon/session-worker.ts +15 -0
  33. package/src/i18n-core/locales/en-US.json +12 -8
  34. package/src/i18n-core/locales/zh-CN.json +12 -8
  35. package/src/index.tsx +47 -19
  36. package/src/mcp/client.ts +24 -0
  37. package/src/mcp/http-transport.ts +35 -3
  38. package/src/plugin/plugin-manager.ts +30 -8
  39. package/src/providers/anthropic.ts +74 -13
  40. package/src/providers/openai-compat.ts +14 -1
  41. package/src/security/gate.ts +18 -0
  42. package/src/security/path.ts +25 -2
  43. package/src/shared/arg-validation.ts +37 -2
  44. package/src/shared/atomic-write.ts +28 -5
  45. package/src/shared/package-info.ts +1 -1
  46. package/src/shared/sanitize.ts +27 -2
  47. package/src/shared/types.ts +17 -0
  48. package/src/shared/update.ts +22 -5
  49. package/src/tools/agent/agent.ts +3 -0
  50. package/src/tools/artifact/artifact.ts +14 -4
  51. package/src/tools/exec/bash.ts +146 -24
  52. package/src/tools/exec/enter-worktree.ts +9 -3
  53. package/src/tools/exec/exit-worktree.ts +6 -3
  54. package/src/tools/exec/git.ts +83 -3
  55. package/src/tools/file/glob.ts +19 -3
  56. package/src/tools/file/grep.ts +70 -16
  57. package/src/tools/file/read.ts +151 -45
  58. package/src/tools/index.ts +12 -4
  59. package/src/tools/scheduling/cron.ts +34 -5
  60. package/src/tools/system/config.ts +6 -2
  61. package/src/ui/app.tsx +47 -11
  62. package/src/ui/commands.ts +187 -41
  63. package/src/workflow/primitives/agent.ts +4 -0
  64. package/src/artifacts/versioning.ts +0 -127
@@ -26,14 +26,31 @@ export interface CronJob {
26
26
  createdAt: string
27
27
  nextFire: string
28
28
  lastFired: string | null
29
+ /**
30
+ * The directory this job belongs to. The store is global (`~/.mipham/cron/`)
31
+ * and every session's poller reads all of it, so without this a job created in
32
+ * project A gets executed by whichever session in project B happens to be
33
+ * running — its prompt expands against the wrong codebase.
34
+ *
35
+ * Optional because files written before this field existed genuinely lack it;
36
+ * the poller treats a missing `cwd` as "any directory" so those keep firing.
37
+ */
38
+ cwd?: string
39
+ /** Session that created the job. Informational — the poller keys on `cwd`. */
40
+ sessionId?: string
29
41
  }
30
42
 
31
43
  function jobPath(id: string): string {
32
44
  return join(CRON_DIR, `${id}.json`)
33
45
  }
34
46
 
35
- function generateId(cron: string, prompt: string): string {
36
- return createHash('sha256').update(`${cron}:${prompt}`).digest('hex').slice(0, 12)
47
+ /**
48
+ * Job id includes `cwd`: keyed on `cron:prompt` alone, creating the same schedule
49
+ * in two directories wrote to the same file and the second silently replaced the
50
+ * first.
51
+ */
52
+ function generateId(cron: string, prompt: string, cwd: string): string {
53
+ return createHash('sha256').update(`${cwd}:${cron}:${prompt}`).digest('hex').slice(0, 12)
37
54
  }
38
55
 
39
56
  /**
@@ -101,11 +118,11 @@ export const cronCreateTool: ToolDefinition = {
101
118
  },
102
119
  required: ['cron', 'prompt'],
103
120
  },
104
- async execute(params, _ctx) {
121
+ async execute(params, ctx) {
105
122
  const cron = params.cron as string
106
123
  const prompt = params.prompt as string
107
124
  const recurring = params.recurring !== false
108
- const id = generateId(cron, prompt)
125
+ const id = generateId(cron, prompt, ctx.cwd)
109
126
 
110
127
  const now = new Date()
111
128
  const job: CronJob = {
@@ -116,6 +133,8 @@ export const cronCreateTool: ToolDefinition = {
116
133
  createdAt: now.toISOString(),
117
134
  nextFire: computeNextFire(cron, now),
118
135
  lastFired: null,
136
+ cwd: ctx.cwd,
137
+ sessionId: ctx.sessionId,
119
138
  }
120
139
 
121
140
  writeJob(job)
@@ -127,6 +146,7 @@ export const cronCreateTool: ToolDefinition = {
127
146
  `Created ${type} cron job.\n` +
128
147
  `ID: ${id}\n` +
129
148
  `Schedule: ${cron}\n` +
149
+ `Scoped to: ${ctx.cwd}\n` +
130
150
  `Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`,
131
151
  }
132
152
  },
@@ -182,11 +202,20 @@ export const cronListTool: ToolDefinition = {
182
202
  const lines = [`── Scheduled Cron Jobs (${jobs.length}) ──`, '']
183
203
  for (const j of jobs) {
184
204
  const type = j.recurring ? 'recurring' : 'one-shot'
185
- lines.push(`${j.id} ${j.cron} ${type}`)
205
+ // 目录要看得见:任务被限定在建立它的那个目录,从别的项目 `/schedule` 列出来时
206
+ // 用户得能看出它为什么不在自己这里跑。**没有 cwd 的旧文件恰恰相反** —— 它在任何
207
+ // 目录都会执行,而这里是唯一能看见那件事的地方,所以必须显式标出来(留空等于
208
+ // 把「无归属」显示成「和别的任务一样」)。
209
+ lines.push(`${j.id} ${j.cron} ${type} ${j.cwd ? `[${j.cwd}]` : '[无归属]'}`)
186
210
  lines.push(` ${j.prompt.slice(0, 100)}`)
187
211
  lines.push('')
188
212
  }
189
213
 
214
+ if (jobs.some((j) => !j.cwd)) {
215
+ lines.push('⚠️ [无归属] 是加 cwd 字段之前写的任务:没有目录可判,任何项目里都会执行。')
216
+ lines.push(' 消除办法:在目标目录里用 CronCreate 重建,或让 Mipham 用 CronDelete 删掉。')
217
+ }
218
+
190
219
  return { success: true, content: lines.join('\n') }
191
220
  },
192
221
  }
@@ -1,7 +1,8 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
1
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import { parse as parseYaml, stringify } from 'yaml'
5
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
5
6
  import type { ToolDefinition } from '../../shared/index.ts'
6
7
 
7
8
  const MIPHAM_HOME = join(homedir(), '.mipham')
@@ -60,7 +61,10 @@ export const configTool: ToolDefinition = {
60
61
  obj = obj[k] as Record<string, unknown>
61
62
  }
62
63
  obj[keys[keys.length - 1]!] = params.value
63
- writeFileSync(USER_CONFIG, stringify(config), 'utf-8')
64
+ // 原子写 + 0o600:这是整份 read-modify-write,裸 writeFileSync 原地截断 ——
65
+ // 崩在写中途就留下半截 YAML,而权限由 umask 决定(典型 0644),比同一份配置的
66
+ // 另一个写者 saveProviderApiKey(loader.ts,0600 原子写)更松。
67
+ atomicWriteFileSync(USER_CONFIG, stringify(config), { mode: 0o600 })
64
68
  return { success: true, content: `Set ${key} = ${params.value}` }
65
69
  }
66
70
 
package/src/ui/app.tsx CHANGED
@@ -114,9 +114,46 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
114
114
  bypassPermissions: 'red',
115
115
  }
116
116
 
117
+ /** 页脚读模式所需的最小面 —— `QueryEngine` 与 `RemoteEngine` 都满足。 */
118
+ export type PermissionSource = {
119
+ setMode(mode: PermissionMode): void
120
+ getMode(): PermissionMode
121
+ }
122
+
123
+ /**
124
+ * 页脚那一行是**执行的镜像,不是本地猜的值**。
125
+ *
126
+ * 组织级限制(`maxAllowedMode` / `forbiddenModes`)会在 `setMode` 里**静默改写**你请求的
127
+ * 那一档,所以页脚存「请求值」就会报出一个引擎不会给你的权限 —— 说放行、实际审批。
128
+ * 初始值与每次 Shift+Tab 都从这里回读。
129
+ */
130
+ export function livePermissionMode(permission: PermissionSource): PermissionMode {
131
+ return permission.getMode()
132
+ }
133
+
134
+ /** 走一档 Shift+Tab 循环,然后**回读**真正生效的模式(上限可能把这一档压回去)。 */
135
+ export function cyclePermissionMode(
136
+ permission: PermissionSource,
137
+ current: PermissionMode,
138
+ ): PermissionMode {
139
+ const idx = PERMISSION_MODES.indexOf(current)
140
+ const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
141
+ permission.setMode(next)
142
+ return permission.getMode()
143
+ }
144
+
145
+ /** 预览截断按 UTF-16 码元计数,落点若正好夹在一个代理对中间,切完就留下
146
+ * **半个** emoji(终端渲染成 U+FFFD)。落点是高代理时后退一个码元即可。 */
147
+ export function truncateForDisplay(text: string, max: number): string {
148
+ if (text.length <= max) return text
149
+ const last = text.charCodeAt(max - 1)
150
+ const end = last >= 0xd800 && last <= 0xdbff ? max - 1 : max
151
+ return text.slice(0, end)
152
+ }
153
+
117
154
  /** Format a tool's input parameters into a compact one-line detail string.
118
155
  * Tool display names follow Claude Code convention: Write/Edit → Update. */
119
- function formatToolDetail(name: string, input: Record<string, unknown>): string {
156
+ export function formatToolDetail(name: string, input: Record<string, unknown>): string {
120
157
  switch (name) {
121
158
  case 'Bash':
122
159
  return sanitizeForDisplay((input.command as string) || '')
@@ -125,13 +162,13 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
125
162
  case 'Write':
126
163
  return (input.file_path as string) || '' // displayed as "Update" in chat
127
164
  case 'Edit':
128
- return `${(input.file_path as string) || ''}: ${((input.old_string as string) || '').slice(0, 60)}` // displayed as "Update" in chat
165
+ return `${(input.file_path as string) || ''}: ${truncateForDisplay((input.old_string as string) || '', 60)}` // displayed as "Update" in chat
129
166
  case 'Grep':
130
167
  return (input.pattern as string) || ''
131
168
  case 'Glob':
132
169
  return (input.pattern as string) || ''
133
170
  case 'Agent':
134
- return `${(input.subagent_type as string) || 'general'}, "${((input.description as string) || (input.prompt as string) || '').slice(0, 80)}"`
171
+ return `${(input.subagent_type as string) || 'general'}, "${truncateForDisplay((input.description as string) || (input.prompt as string) || '', 80)}"`
135
172
  case 'WebSearch':
136
173
  return (input.query as string) || ''
137
174
  case 'WebFetch':
@@ -139,7 +176,7 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
139
176
  case 'Task':
140
177
  return `"${(input.subject as string) || ''}"`
141
178
  default:
142
- return JSON.stringify(input).slice(0, 80)
179
+ return truncateForDisplay(JSON.stringify(input), 80)
143
180
  }
144
181
  }
145
182
 
@@ -251,7 +288,11 @@ export function App({
251
288
  const [focusMode, setFocusMode] = useState(false)
252
289
  const [_ultracodeMode, setUltracodeMode] = useState(false)
253
290
  const [goalText, setGoalText] = useState('')
254
- const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
291
+ // 初始值取自 live 权限系统 —— 启动时 config 的模式可能已被组织级限制钳到别处
292
+ // (页脚写死 'default' 时会与执行不一致,且偏差方向是「报得比实际宽」)。
293
+ const [permissionMode, setPermissionMode] = useState<PermissionMode>(() =>
294
+ livePermissionMode(engine.getPermission()),
295
+ )
255
296
  const abortRef = useRef<AbortController | null>(null)
256
297
  // Monotonic turn id — lets a stale turn's finally() skip resetting shared UI
257
298
  // state (isLoading/abortRef/progress) after a newer turn has already started.
@@ -1239,12 +1280,7 @@ export function App({
1239
1280
  })
1240
1281
  }}
1241
1282
  onCyclePermission={() => {
1242
- setPermissionMode((prev) => {
1243
- const idx = PERMISSION_MODES.indexOf(prev)
1244
- const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
1245
- engine.getPermission().setMode(next)
1246
- return next
1247
- })
1283
+ setPermissionMode((prev) => cyclePermissionMode(engine.getPermission(), prev))
1248
1284
  }}
1249
1285
  onCancel={() => {
1250
1286
  if (abortRef.current) {
@@ -13,6 +13,7 @@ import type { PluginManager } from '../plugin/plugin-manager'
13
13
  import type { Message } from '../shared/types.js'
14
14
  import type { UpdateStatus } from '../shared/update'
15
15
  import { McpClient } from '../mcp/client'
16
+ import { unregisterMcpServerTools } from '../mcp/registry'
16
17
  import { buildCapabilityReport } from '../core/capability-inventory'
17
18
  import { InstructionsLoader } from '../core/instructions'
18
19
  import { findDerivableSections, DERIVABLE_HINTS } from '../core/claude-md-audit'
@@ -56,6 +57,7 @@ import {
56
57
  import { NPM_UPDATE_COMMAND, PACKAGE_VERSION, COAUTHOR_TRAILER } from '../shared/index.ts'
57
58
  import { getPreference } from '../config/preferences'
58
59
  import { loadCrossSessionConfig, tryRestoreFromBackup } from '../config/loader'
60
+ import { getWorkspaceTrust } from '../core/workspace-trust'
59
61
  import { getMemoryManager } from '../core/memory/memory-loader'
60
62
  import {
61
63
  resolveTelemetry,
@@ -363,14 +365,18 @@ const contextCmd: CommandHandler = (ctx) => {
363
365
  const tokens = c.getEstimatedTokens()
364
366
  const msgs = c.getMessages()
365
367
  const systemPromptLen = c.getSystemPrompt().length
368
+ // 窗口与阈值一律问引擎:模型注册表里 1M/256K/128K/32K 都有,写死 200K 会
369
+ // 让 1M 模型显示约 5 倍偏高的百分比,并把压缩点说成 90%(实际 95%)。
370
+ const maxTokens = c.getMaxTokens()
371
+ const threshold = c.getCompactionThreshold()
366
372
  return {
367
373
  content: stripIndent`
368
374
  ${t('commands.context.title')}
369
375
  ${t('commands.context.messages')} ${msgs.length}
370
- ${t('commands.context.estimated_tokens')} ${tokens.toLocaleString()} / 200,000
371
- ${t('commands.context.usage_pct')} ${((tokens / 200_000) * 100).toFixed(1)}%
376
+ ${t('commands.context.estimated_tokens')} ${tokens.toLocaleString()} / ${maxTokens.toLocaleString()}
377
+ ${t('commands.context.usage_pct')} ${((tokens / maxTokens) * 100).toFixed(1)}%
372
378
  ${t('commands.context.system_prompt')} ${systemPromptLen.toLocaleString()} chars (~${Math.ceil(systemPromptLen / 4).toLocaleString()} tokens)
373
- ${t('commands.context.compaction')} at 90% (${(200_000 * 0.9).toLocaleString()} tokens)
379
+ ${t('commands.context.compaction')} at ${(threshold * 100).toFixed(0)}% (${Math.round(maxTokens * threshold).toLocaleString()} tokens)
374
380
  `,
375
381
  }
376
382
  }
@@ -395,7 +401,7 @@ const statusCmd: CommandHandler = async (ctx) => {
395
401
  ${t('commands.status.provider')} ${ctx.providerId}
396
402
  ${t('commands.status.model')} ${ctx.modelId}
397
403
  ${t('commands.status.messages')} ${c.getMessages().length}
398
- ${t('commands.status.tokens')} ~${c.getEstimatedTokens().toLocaleString()} / 200,000
404
+ ${t('commands.status.tokens')} ~${c.getEstimatedTokens().toLocaleString()} / ${c.getMaxTokens().toLocaleString()}
399
405
  ${t('commands.status.tools')} ${tools.size} ${t('commands.status.loaded')}
400
406
  ${t('commands.status.permission')} ${ctx.config.permission}
401
407
 
@@ -412,16 +418,18 @@ const statusCmd: CommandHandler = async (ctx) => {
412
418
 
413
419
  const costCmd: CommandHandler = (ctx) => {
414
420
  const t = resolveT(ctx)
415
- const tokens = ctx.engine.getContext().getEstimatedTokens()
416
- const cacheStatus = ctx.engine.getContext().getCacheStatus()
421
+ const c = ctx.engine.getContext()
422
+ const tokens = c.getEstimatedTokens()
423
+ const cacheStatus = c.getCacheStatus()
417
424
  const cachedTokens = cacheStatus.cachedTokens
418
425
  const hitRatio = tokens > 0 ? Math.min(1, cachedTokens / tokens) : 0
419
426
  const uncachedTokens = Math.max(0, tokens - cachedTokens)
427
+ const maxTokens = c.getMaxTokens()
420
428
  return {
421
429
  content: stripIndent`
422
430
  ${t('commands.context_tokens.title')}
423
- ${t('commands.context_tokens.context_tokens')} ~${tokens.toLocaleString()} / 200,000
424
- ${t('commands.context_tokens.usage')} ${((tokens / 200_000) * 100).toFixed(1)}%
431
+ ${t('commands.context_tokens.context_tokens')} ~${tokens.toLocaleString()} / ${maxTokens.toLocaleString()}
432
+ ${t('commands.context_tokens.usage')} ${((tokens / maxTokens) * 100).toFixed(1)}%
425
433
  ${t('commands.context_tokens.prompt_cache', {
426
434
  cached: cachedTokens.toLocaleString(),
427
435
  ratio: (hitRatio * 100).toFixed(1),
@@ -2257,7 +2265,7 @@ const usageCmd: CommandHandler = (ctx) => {
2257
2265
  const c = ctx.engine.getContext()
2258
2266
  const estTokens = c.getEstimatedTokens()
2259
2267
  const msgs = c.getMessages()
2260
- const maxTokens = 200_000
2268
+ const maxTokens = c.getMaxTokens()
2261
2269
  const pct = ((estTokens / maxTokens) * 100).toFixed(1)
2262
2270
 
2263
2271
  const tracker = ctx.engine.getUsageTracker()
@@ -3133,9 +3141,10 @@ const doctorCmd: CommandHandler = async (ctx) => {
3133
3141
  const c = ctx.engine.getContext()
3134
3142
  const msgs = c.getMessages()
3135
3143
  const tokens = c.getEstimatedTokens()
3144
+ const maxTokens = c.getMaxTokens()
3136
3145
  lines.push(`Messages ${msgs.length}`)
3137
3146
  lines.push(
3138
- `Tokens ~${tokens.toLocaleString()} / 200,000 (${((tokens / 200_000) * 100).toFixed(1)}%)`,
3147
+ `Tokens ~${tokens.toLocaleString()} / ${maxTokens.toLocaleString()} (${((tokens / maxTokens) * 100).toFixed(1)}%)`,
3139
3148
  )
3140
3149
  lines.push(`Checkpoints ${c.getCheckpoints().length}`)
3141
3150
 
@@ -3637,13 +3646,16 @@ const statsCmd: CommandHandler = (ctx) => {
3637
3646
  assistant: String(assistantMsgs),
3638
3647
  system: String(systemMsgs),
3639
3648
  }),
3640
- t('commands.stats.tokens', { tokens: tokens.toLocaleString() }),
3649
+ t('commands.stats.tokens', {
3650
+ tokens: tokens.toLocaleString(),
3651
+ max: c.getMaxTokens().toLocaleString(),
3652
+ }),
3641
3653
  t('commands.stats.tools', { count: String(tools.size) }),
3642
3654
  t('commands.stats.provider', { provider: ctx.providerId }),
3643
3655
  t('commands.stats.model', { model: ctx.modelId }),
3644
3656
  t('commands.stats.permission', { permission: ctx.config.permission }),
3645
3657
  '',
3646
- t('commands.stats.usage', { pct: ((tokens / 200_000) * 100).toFixed(1) }),
3658
+ t('commands.stats.usage', { pct: ((tokens / c.getMaxTokens()) * 100).toFixed(1) }),
3647
3659
  ]
3648
3660
 
3649
3661
  // ── CRSI & SIS extensions ──
@@ -3780,18 +3792,42 @@ const cdCmd: CommandHandler = async (ctx, args) => {
3780
3792
  const hooksCmd: CommandHandler = async (ctx) => {
3781
3793
  const t = resolveT(ctx)
3782
3794
  const { loadSettingsJson } = await import('../config/loader')
3783
- const settingsJson = loadSettingsJson(process.cwd())
3784
-
3785
- const hooks = settingsJson.hooks as Record<
3786
- string,
3787
- Array<{ matcher?: string; hooks: Array<{ type: string; command?: string }> }>
3788
- >
3795
+ const cwd = process.cwd()
3796
+ // This command *displays* the configured list, so it asks for the project file
3797
+ // explicitly — the loader's default drops project hooks (they are
3798
+ // repository-controlled code execution) and would hide hooks that really do
3799
+ // exist here.
3800
+ //
3801
+ // Two calls, one per source: the merged list cannot say which entry came from
3802
+ // which file, and the two are not governed alike — only the project file's
3803
+ // entries are gated on workspace trust.
3804
+ const userHooks = loadSettingsJson(cwd).hooks
3805
+ const projectHooks = loadSettingsJson(cwd, { includeProjectHooks: true }).projectHooks ?? {}
3806
+
3807
+ // Listing them is not the same claim as their running. Project hooks are
3808
+ // gated, and this command renders them even when the gate is shut — so the
3809
+ // display has to name the source and say which entries will not run.
3810
+ const gated = !getWorkspaceTrust().isTrusted(cwd)
3811
+
3812
+ type Entry = { matcher?: string; hooks: Array<{ type: string; command?: string }> }
3813
+ const sources: Array<{ project: boolean; hooks: Record<string, Entry[]> }> = [
3814
+ { project: true, hooks: projectHooks as Record<string, Entry[]> },
3815
+ { project: false, hooks: userHooks as Record<string, Entry[]> },
3816
+ ]
3789
3817
 
3790
- const configured = Object.entries(hooks).filter(
3791
- ([, entries]) => Array.isArray(entries) && entries.length > 0,
3792
- )
3818
+ // Event → its entries, project first — the order the merged list produced, so
3819
+ // a trusted workspace's output is unchanged by the split.
3820
+ const byEvent = new Map<string, Array<{ entry: Entry; project: boolean }>>()
3821
+ for (const { project, hooks } of sources) {
3822
+ for (const [eventName, entries] of Object.entries(hooks)) {
3823
+ if (!Array.isArray(entries) || entries.length === 0) continue
3824
+ const bucket = byEvent.get(eventName) ?? []
3825
+ for (const entry of entries) bucket.push({ entry, project })
3826
+ byEvent.set(eventName, bucket)
3827
+ }
3828
+ }
3793
3829
 
3794
- if (configured.length === 0) {
3830
+ if (byEvent.size === 0) {
3795
3831
  return { content: t('commands.hooks.no_hooks') }
3796
3832
  }
3797
3833
 
@@ -3803,13 +3839,15 @@ const hooksCmd: CommandHandler = async (ctx) => {
3803
3839
  ]
3804
3840
 
3805
3841
  let count = 0
3806
- for (const [eventName, entries] of configured) {
3807
- for (const entry of entries) {
3842
+ for (const [eventName, bucket] of byEvent) {
3843
+ for (const { entry, project } of bucket) {
3808
3844
  for (const hook of entry.hooks) {
3809
3845
  count++
3810
3846
  const matcher = entry.matcher || '*'
3811
3847
  const cmd = hook.type === 'command' && hook.command ? hook.command : hook.type
3812
- lines.push(` ${eventName} [${matcher}] → ${cmd}`)
3848
+ const source = t(project ? 'commands.hooks.source_project' : 'commands.hooks.source_user')
3849
+ const suffix = project && gated ? ` ${t('commands.hooks.gated')}` : ''
3850
+ lines.push(` [${source}] ${eventName} [${matcher}] → ${cmd}${suffix}`)
3813
3851
  }
3814
3852
  }
3815
3853
  }
@@ -4092,7 +4130,12 @@ const resumeLastCmd: CommandHandler = async (ctx) => {
4092
4130
  loaded: String(messages.length),
4093
4131
  total: String(session.messages.length),
4094
4132
  })
4095
- : t('commands.resume.restored_full_footer', { loaded: String(messages.length) }),
4133
+ : t('commands.resume.restored_full_footer', {
4134
+ loaded: String(messages.length),
4135
+ // 文案里的 `{name}` 必须真给:`t()` 把没给的占位符替换成**空串**
4136
+ // (`t.ts` 的 `params[k] ?? ''`),漏掉就静默印出 `--resume ""`。
4137
+ name: latest.name,
4138
+ }),
4096
4139
  ].join('\n'),
4097
4140
  forwardedMessages: messages,
4098
4141
  resumeWarning: true,
@@ -4319,6 +4362,16 @@ const upgradeCmd: CommandHandler = async (ctx) => {
4319
4362
 
4320
4363
  const update = checkForUpdates()
4321
4364
 
4365
+ // The registry was never reached. "Already up to date" would be a claim we
4366
+ // cannot support: `checkForUpdates` leaves `latest === current` on failure, so
4367
+ // the old code printed "✓ Already up to date (v0.81.8 → v0.81.8)" while
4368
+ // offline and gave no hint that a check had even been attempted.
4369
+ // (`mipham update` on the CLI path already says "Could not determine latest
4370
+ // version" — this is the TUI's equivalent.)
4371
+ if (!update.checked) {
4372
+ return { content: t('commands.upgrade.check_failed', { current: update.current }) }
4373
+ }
4374
+
4322
4375
  if (!update.available) {
4323
4376
  return {
4324
4377
  content: t('commands.upgrade.uptodate', { current: update.current, latest: update.latest }),
@@ -4688,18 +4741,51 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
4688
4741
  if (sub === 'disconnect') {
4689
4742
  const name = args[1]
4690
4743
  if (!name) return { content: 'Usage: /mcp disconnect <server-name>' }
4691
- const tools = client.disconnect(name)
4744
+ client.disconnect(name)
4745
+ // Dropping the connection is not dropping the tools. They live in the
4746
+ // engine's registry — the map the model actually calls — and closing the
4747
+ // transport leaves every one of them registered and selectable, failing
4748
+ // only at call time. Count what the registry actually gave up, not what the
4749
+ // connection used to hold: those are different objects and only one of them
4750
+ // is what "removed" refers to.
4751
+ const toolsMap = ctx.engine.getTools()
4752
+ const before = toolsMap.size
4753
+ unregisterMcpServerTools(name, toolsMap)
4754
+ const removed = before - toolsMap.size
4692
4755
  return {
4693
4756
  content: [
4694
4757
  `── MCP Disconnect: ${name} ──`,
4695
4758
  '',
4696
- tools.length > 0
4697
- ? `Disconnected. ${tools.length} tool(s) removed.`
4759
+ removed > 0
4760
+ ? `Disconnected. ${removed} tool(s) removed.`
4698
4761
  : 'Disconnected (no tools were registered).',
4699
4762
  ].join('\n'),
4700
4763
  }
4701
4764
  }
4702
4765
 
4766
+ // /mcp reconnect <name>
4767
+ if (sub === 'reconnect') {
4768
+ const name = args[1]
4769
+ if (!name) return { content: 'Usage: /mcp reconnect <server-name>' }
4770
+ try {
4771
+ await client.reconnect(name)
4772
+ const count = client.getTools(name).length
4773
+ return {
4774
+ content: `── MCP Reconnect: ${name} ──\n\nReconnected — ${count} tool(s) rediscovered.`,
4775
+ }
4776
+ } catch (err) {
4777
+ return {
4778
+ content: [
4779
+ `── MCP Reconnect: ${name} ──`,
4780
+ '',
4781
+ `Failed: ${String(err)}`,
4782
+ '',
4783
+ `The server stayed disconnected. Check that it is reachable, then retry — or use /mcp connect ${name}.`,
4784
+ ].join('\n'),
4785
+ }
4786
+ }
4787
+ }
4788
+
4703
4789
  // /mcp reload
4704
4790
  if (sub === 'reload') {
4705
4791
  return {
@@ -4749,6 +4835,7 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
4749
4835
  lines.push('── Commands ──')
4750
4836
  lines.push(' /mcp connect <name> Connect to a server (OAuth or stdio)')
4751
4837
  lines.push(' /mcp disconnect <name> Disconnect from a server')
4838
+ lines.push(' /mcp reconnect <name> Reconnect a dropped server (with backoff)')
4752
4839
  lines.push(' /mcp reload Disconnect all and reconnect')
4753
4840
  lines.push('')
4754
4841
  lines.push('── Protocol ──')
@@ -4994,7 +5081,7 @@ Or use the Agent tool in a conversation to launch a sub-agent.`,
4994
5081
  return { content: lines.join('\n') }
4995
5082
  }
4996
5083
 
4997
- const bgCmd: CommandHandler = (ctx, args) => {
5084
+ const bgCmd: CommandHandler = async (ctx, args) => {
4998
5085
  const prompt = args.join(' ')
4999
5086
  if (!prompt.trim()) {
5000
5087
  return {
@@ -5023,6 +5110,49 @@ Background agents appear in the Agent View dashboard (/agents).`,
5023
5110
 
5024
5111
  agentViewManager.addMessage(session.id, { role: 'user', content: prompt })
5025
5112
  agentViewManager.updateStatus(session.id, 'working')
5113
+ session.kind = 'unattended'
5114
+
5115
+ // A session row is not a task. Without the spawn below this command only ever
5116
+ // wrote a dashboard entry and reported success — the prompt was never handed
5117
+ // to a model, and the row sat at `working` forever.
5118
+ const bgReg = (await import('../agent/background-registry')).getBackgroundAgentRegistry()
5119
+ bgReg.spawn(
5120
+ prompt,
5121
+ 'general',
5122
+ async (_signal) => {
5123
+ const { SubAgent } = await import('../agent/sub-agent')
5124
+ const sa = new SubAgent(
5125
+ ctx.engine.getRegistry(),
5126
+ ctx.engine.getTools(),
5127
+ ctx.engine.getPermission(),
5128
+ undefined,
5129
+ undefined,
5130
+ ctx.engine.getLlm(),
5131
+ )
5132
+ try {
5133
+ // The prompt came from the user typing `/bg …`, so it stays unframed —
5134
+ // unlike a workflow script's computed prompt.
5135
+ const result = await sa.execute(prompt, 'bg: ' + prompt.slice(0, 60), { type: 'general' })
5136
+ agentViewManager.addMessage(session.id, {
5137
+ role: 'assistant',
5138
+ content: result || '(no output)',
5139
+ })
5140
+ agentViewManager.updateStatus(session.id, 'completed')
5141
+ return result
5142
+ } catch (err) {
5143
+ // Leave the row honest on failure too — a session stuck at `working`
5144
+ // after its executor died is the same lie as never spawning at all.
5145
+ const message = err instanceof Error ? err.message : String(err)
5146
+ agentViewManager.addMessage(session.id, {
5147
+ role: 'assistant',
5148
+ content: `Background agent failed: ${message}`,
5149
+ })
5150
+ agentViewManager.updateStatus(session.id, 'failed')
5151
+ throw err
5152
+ }
5153
+ },
5154
+ 'unattended',
5155
+ )
5026
5156
 
5027
5157
  return {
5028
5158
  content: `✓ Background agent spawned: ${session.id}
@@ -5191,10 +5321,23 @@ const artifactOpenCmd: CommandHandler = async (ctx, args) => {
5191
5321
  return { content: 'Usage: /artifact open <name>\nExample: /artifact open dashboard' }
5192
5322
  }
5193
5323
 
5194
- const sessionId = 'session-1' // default session
5195
- const port = 9876
5196
- const ext = name.endsWith('.svg') ? '' : '.html'
5197
- const url = `http://localhost:${port}/${sessionId}/${name}${ext}`
5324
+ const { readManifest } = await import('../artifacts/manifest')
5325
+ const { artifactsRoot } = await import('../artifacts/paths')
5326
+ const manifest = readManifest(artifactsRoot(process.cwd()))
5327
+
5328
+ // 先找本会话,再退到任意会话:文件在磁盘上跨会话留存,而 `open` 是用户手敲的。
5329
+ // 原先写死 `session-1` + 端口 9876,工具回报的却是真会话 id 和**实际**端口
5330
+ // (服务端遇到端口占用会自增),所以这条命令从构造上就打不开任何东西。
5331
+ const entry =
5332
+ manifest.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId) ??
5333
+ manifest.artifacts.find((a) => a.name === name)
5334
+
5335
+ if (!entry) {
5336
+ return { content: `✗ No artifact named "${name}". Run /artifact list to see them.` }
5337
+ }
5338
+
5339
+ // 用工具落盘时记下的 URL —— 那就是它印给用户的同一个坐标,两边不该各算一次。
5340
+ const url = entry.url
5198
5341
 
5199
5342
  try {
5200
5343
  await openBrowser(url)
@@ -5204,13 +5347,14 @@ const artifactOpenCmd: CommandHandler = async (ctx, args) => {
5204
5347
  }
5205
5348
  }
5206
5349
 
5207
- const artifactListCmd: CommandHandler = async (_ctx, _args) => {
5208
- const { getSessionArtifacts } = await import('../artifacts/manifest')
5209
- const { join } = await import('node:path')
5210
- const { ARTIFACTS_DIR } = await import('../shared/constants')
5350
+ const artifactListCmd: CommandHandler = async (ctx, _args) => {
5351
+ const { readManifest, getSessionArtifacts } = await import('../artifacts/manifest')
5352
+ const { artifactsRoot } = await import('../artifacts/paths')
5211
5353
 
5212
- const dir = join(process.cwd(), ARTIFACTS_DIR)
5213
- const entries = getSessionArtifacts(dir, 'session-1')
5354
+ const dir = artifactsRoot(process.cwd())
5355
+ // 会话 id 取自真实上下文,不是写死的 'session-1' —— 工具落盘时写的是真 id,
5356
+ // 写死的那一支必然过滤出空列表。
5357
+ const entries = getSessionArtifacts(dir, ctx.sessionId)
5214
5358
 
5215
5359
  if (entries.length === 0) {
5216
5360
  return {
@@ -5226,7 +5370,9 @@ const artifactListCmd: CommandHandler = async (_ctx, _args) => {
5226
5370
  )
5227
5371
  }
5228
5372
  lines.push('', ` ${entries.length} artifact(s) — /artifact open <name> to view`)
5229
- lines.push(` Gallery: http://localhost:9876`)
5373
+ // 画廊端口同样不写死:用最近一次落盘记下的那个(服务端会因端口占用自增)。
5374
+ const { port } = readManifest(dir)
5375
+ if (port) lines.push(` Gallery: http://localhost:${port}`)
5230
5376
 
5231
5377
  return { content: lines.join('\n') }
5232
5378
  }
@@ -108,6 +108,10 @@ export async function workflowAgent(
108
108
  modelOverride: opts.model,
109
109
  allowedTools: undefined, // use all tools by default
110
110
  worktreePath,
111
+ // The prompt is computed by the script, not typed by the user — say so,
112
+ // so text the script relayed from a file or another agent cannot pass
113
+ // as the user's own opening instruction.
114
+ promptOrigin: 'script',
111
115
  })
112
116
 
113
117
  lastResult = textResult