@miphamai/cli 0.59.0 → 0.60.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.59.0",
3
+ "version": "0.60.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -8,7 +8,7 @@ function ensureDir(): void {
8
8
  if (!existsSync(AUTOLOOP_DIR)) mkdirSync(AUTOLOOP_DIR, { recursive: true })
9
9
  }
10
10
 
11
- interface AutoloopJournal {
11
+ export interface AutoloopJournal {
12
12
  sessionId: string
13
13
  prompt: string
14
14
  status: 'active' | 'completed' | 'stopped'
@@ -16,6 +16,9 @@ interface AutoloopJournal {
16
16
  startedAt: string
17
17
  lastIteration?: string
18
18
  logs: string[]
19
+ startTokens?: number
20
+ totalTokens: number
21
+ maxIterations: number
19
22
  }
20
23
 
21
24
  function journalPath(sessionId: string): string {
@@ -23,7 +26,11 @@ function journalPath(sessionId: string): string {
23
26
  }
24
27
 
25
28
  /** Create or reset an autonomous loop journal. */
26
- export function createAutoloopJournal(sessionId: string, prompt: string): AutoloopJournal {
29
+ export function createAutoloopJournal(
30
+ sessionId: string,
31
+ prompt: string,
32
+ startTokens = 0,
33
+ ): AutoloopJournal {
27
34
  ensureDir()
28
35
  const journal: AutoloopJournal = {
29
36
  sessionId,
@@ -32,11 +39,22 @@ export function createAutoloopJournal(sessionId: string, prompt: string): Autolo
32
39
  iterations: 0,
33
40
  startedAt: new Date().toISOString(),
34
41
  logs: [],
42
+ startTokens,
43
+ totalTokens: 0,
44
+ maxIterations: 100,
35
45
  }
36
46
  writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
37
47
  return journal
38
48
  }
39
49
 
50
+ /** Accumulate token usage into the loop journal. */
51
+ export function recordLoopTokens(sessionId: string, delta: number): void {
52
+ const journal = readAutoloopJournal(sessionId)
53
+ if (!journal) return
54
+ journal.totalTokens += delta
55
+ writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
56
+ }
57
+
40
58
  /** Read the journal for an autonomous loop. */
41
59
  export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
42
60
  const path = journalPath(sessionId)
@@ -48,6 +66,19 @@ export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
48
66
  }
49
67
  }
50
68
 
69
+ /** End-of-loop-turn accounting: log the iteration, record the token delta, and
70
+ * stop the loop if it has reached its max-iteration guard (spec §七). */
71
+ export function recordLoopTurn(sessionId: string, summary: string, delta: number): void {
72
+ const journal = readAutoloopJournal(sessionId)
73
+ if (!journal || journal.status !== 'active') return
74
+ logAutoloopIteration(sessionId, summary)
75
+ recordLoopTokens(sessionId, delta)
76
+ const updated = readAutoloopJournal(sessionId)
77
+ if (updated && updated.iterations >= updated.maxIterations) {
78
+ completeAutoloopJournal(sessionId, 'stopped')
79
+ }
80
+ }
81
+
51
82
  /** Log an iteration in the autonomous loop journal. */
52
83
  export function logAutoloopIteration(sessionId: string, summary: string): void {
53
84
  const journal = readAutoloopJournal(sessionId)
@@ -85,6 +116,15 @@ export function listActiveAutoloops(): AutoloopJournal[] {
85
116
  return journals
86
117
  }
87
118
 
119
+ /** Format active loop journals as one-line rows for the /usage panel. */
120
+ export function formatLoopRows(journals: AutoloopJournal[]): string[] {
121
+ return journals.map((j) => {
122
+ const per = j.iterations > 0 ? Math.round(j.totalTokens / j.iterations) : 0
123
+ const last = j.lastIteration ? new Date(j.lastIteration).toLocaleString() : 'N/A'
124
+ return `${j.sessionId.slice(-8)} 🔄 ${j.iterations} iterations · ${j.totalTokens} tokens · ${per} /run · last ${last}`
125
+ })
126
+ }
127
+
88
128
  /** Get the autonomous loop status message. */
89
129
  export function getAutoloopStatus(sessionId: string): string {
90
130
  const journal = readAutoloopJournal(sessionId)
@@ -13,6 +13,9 @@ export const DEFAULT_CONFIG: MiphamConfig = {
13
13
  defaultProvider: 'deepseek',
14
14
  defaultModel: 'deepseek-v4-pro',
15
15
  permission: 'default',
16
+ showThinking: 'off',
17
+ showSchedulingNotices: false,
18
+ showCommandPicker: false,
16
19
  // Org 级权限限制(可选):forbiddenModes 禁指定模式 / maxAllowedMode 封顶层级;
17
20
  // 请求被禁模式时 fail-closed 降级(如 forbiddenModes:['bypassPermissions'])。
18
21
  // permissionRestrictions: { forbiddenModes: ['bypassPermissions'] },
@@ -38,6 +38,7 @@ import { UsageTracker } from './usage-tracker'
38
38
  import { getMetrics } from './metrics'
39
39
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
40
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
41
+ import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
41
42
  import { accumulateGraftSavings } from '../shared/graft-savings'
42
43
  import { getMessageBus, type AgentMessage } from '../agent/message-bus'
43
44
  import { createT } from '../i18n-core/t'
@@ -101,6 +102,12 @@ export class QueryEngine {
101
102
  /** 注入的 LLM chat 缝。未设置时回退 this.registry(strangler-fig)。 */
102
103
  private llm?: Llm
103
104
 
105
+ /** Pending /loop re-invocation prompts, enqueued by the ScheduleWakeup timer. */
106
+ private wakeupQueue: Array<{ prompt: string; noop: boolean }> = []
107
+
108
+ /** Callback fired whenever a wakeup is enqueued (the ScheduleWakeup timer fired). */
109
+ private onWakeupEnqueued: (() => void) | null = null
110
+
104
111
  constructor(
105
112
  private registry: ProviderRegistry,
106
113
  private context: ContextManager,
@@ -109,6 +116,36 @@ export class QueryEngine {
109
116
  ruleEngine?: ExperienceRuleEngine,
110
117
  ) {
111
118
  this.ruleEngine = ruleEngine
119
+ // ScheduleWakeup 的 timer 到期后,把 loop prompt 交回本引擎 re-invoke。
120
+ registerWakeupHandler((_sessionId, prompt, noop) => this.enqueueWakeup(prompt, noop))
121
+ }
122
+
123
+ /** Enqueue a loop prompt for later re-invocation (keep-latest — drops older wakeups). */
124
+ enqueueWakeup(prompt: string, noop = false): void {
125
+ // 队列只保留最新(丢弃旧唤醒,防堆积)
126
+ this.wakeupQueue = [{ prompt, noop }]
127
+ this.onWakeupEnqueued?.()
128
+ }
129
+
130
+ /** Register a callback fired whenever a wakeup is enqueued (the ScheduleWakeup
131
+ * timer fired). The app subscribes to drain the queue while idle. */
132
+ setOnWakeupEnqueued(cb: (() => void) | null): void {
133
+ this.onWakeupEnqueued = cb
134
+ }
135
+
136
+ /** Empty the wakeup queue (stops re-invocation at the max-iteration guard). */
137
+ clearWakeupQueue(): void {
138
+ this.wakeupQueue = []
139
+ }
140
+
141
+ /** Pop the next pending wakeup prompt, or null when the queue is empty. */
142
+ dequeueWakeup(): { prompt: string; noop: boolean } | null {
143
+ return this.wakeupQueue.shift() ?? null
144
+ }
145
+
146
+ /** Whether a wakeup prompt is pending re-invocation. */
147
+ hasPendingWakeup(): boolean {
148
+ return this.wakeupQueue.length > 0
112
149
  }
113
150
 
114
151
  /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
@@ -486,8 +523,11 @@ export class QueryEngine {
486
523
  yield chunk
487
524
 
488
525
  if (chunk.type === 'error') {
526
+ // #23: client-generated errors are system lines, not model output —
527
+ // store as `system` so resume renders an error line (⚠) rather than
528
+ // attributing the failure to the assistant.
489
529
  this.context.addMessage({
490
- role: 'assistant',
530
+ role: 'system',
491
531
  content: t('errors.model_error', { error: chunk.error ?? 'Unknown error' }),
492
532
  })
493
533
  return
@@ -964,7 +1004,7 @@ export class QueryEngine {
964
1004
  // P1-4: Increment consecutive block counter; if limit exceeded,
965
1005
  // tell the model to move on instead of retrying.
966
1006
  const limitExceeded = this.permission.incrementBlockCounter()
967
- const baseError = t('errors.tool_not_allowed', { name })
1007
+ const baseError = this.buildDenialError(name, tool, params)
968
1008
  const moveOnHint = limitExceeded
969
1009
  ? '\n(Consecutive block limit reached. Please try a different approach or ask the user for guidance.)'
970
1010
  : ''
@@ -1297,6 +1337,29 @@ export class QueryEngine {
1297
1337
  return this.tools
1298
1338
  }
1299
1339
 
1340
+ /**
1341
+ * Build a rich permission-denial error naming the mode (level), the setting
1342
+ * (rule/level) that caused the denial, and the correct fix (#52).
1343
+ */
1344
+ private buildDenialError(
1345
+ name: string,
1346
+ tool: ToolDefinition,
1347
+ params: Record<string, unknown>,
1348
+ ): string {
1349
+ const { reason, rulePattern } = this.permission.explainDenial(tool, params)
1350
+ const mode = this.permission.getMode()
1351
+ switch (reason) {
1352
+ case 'deny-rule':
1353
+ return t('errors.tool_denied_deny_rule', { name, pattern: rulePattern ?? '?' })
1354
+ case 'ask-rule':
1355
+ return t('errors.tool_denied_ask_rule', { name, pattern: rulePattern ?? '?' })
1356
+ default:
1357
+ // mode-baseline / tool-default / legacy-rule / system-default — a mode
1358
+ // switch (or /permissions) resolves it.
1359
+ return t('errors.tool_denied_mode', { name, mode })
1360
+ }
1361
+ }
1362
+
1300
1363
  getRuleEngine(): ExperienceRuleEngine | undefined {
1301
1364
  return this.ruleEngine
1302
1365
  }
@@ -62,6 +62,18 @@ function isVerificationCommand(input: Record<string, unknown>): boolean {
62
62
 
63
63
  const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
64
64
 
65
+ /**
66
+ * Why a tool resolved to 'ask' — for rich denial errors (#52).
67
+ * Mirrors the resolution chain order in `check()` (first match wins).
68
+ */
69
+ export type PermissionDenialReason =
70
+ | 'deny-rule' // org deny rule — overrides mode, cannot be switched away
71
+ | 'ask-rule' // explicit ask rule — still requires approval under any mode
72
+ | 'legacy-rule' // legacy exact-name rule (setRule)
73
+ | 'mode-baseline' // mode-specific default (acceptEdits/plan) → ask
74
+ | 'tool-default' // tool.permission === 'ask'
75
+ | 'system-default' // no rule, no tool permission → fallback ask
76
+
65
77
  export class PermissionSystem {
66
78
  private allowRules: PermissionRuleEntry[] = []
67
79
  private denyRules: PermissionRuleEntry[] = []
@@ -344,6 +356,39 @@ export class PermissionSystem {
344
356
  return this.check(tool, input) === 'bypass'
345
357
  }
346
358
 
359
+ /**
360
+ * Explain WHY a tool resolves to 'ask' (rich denial errors — #52).
361
+ *
362
+ * Only call after `check()` returns 'ask'; mirrors its resolution order
363
+ * (deny → ask → legacy → mode → tool → system). Returns the matched
364
+ * rule pattern when the denial came from a deny/ask rule.
365
+ */
366
+ explainDenial(
367
+ tool: ToolDefinition,
368
+ input: Record<string, unknown>,
369
+ ): { reason: PermissionDenialReason; rulePattern?: string } {
370
+ for (const rule of this.denyRules) {
371
+ if (this.ruleMatches(rule, tool, input)) {
372
+ return { reason: 'deny-rule', rulePattern: rule.pattern }
373
+ }
374
+ }
375
+ for (const rule of this.askRules) {
376
+ if (this.ruleMatches(rule, tool, input)) {
377
+ return { reason: 'ask-rule', rulePattern: rule.pattern }
378
+ }
379
+ }
380
+ if (this.legacyRules.has(tool.name)) {
381
+ return { reason: 'legacy-rule' }
382
+ }
383
+ if (this.modeBaseline(tool, input) !== 'mode-baseline') {
384
+ return { reason: 'mode-baseline' }
385
+ }
386
+ if (tool.permission) {
387
+ return { reason: 'tool-default' }
388
+ }
389
+ return { reason: 'system-default' }
390
+ }
391
+
347
392
  // ── Helpers ──
348
393
 
349
394
  private ruleMatches(
@@ -364,6 +364,7 @@
364
364
  "provider_line": "Provider: {provider}",
365
365
  "model_line": "Model: {model}",
366
366
  "tool_section": "── Per-Tool ──",
367
+ "loops_header": "── Autonomous Loops ──",
367
368
  "hint": "Use /context for detailed stats, /compact to free space."
368
369
  },
369
370
  "reload_skills": {
@@ -940,6 +941,9 @@
940
941
  },
941
942
  "errors": {
942
943
  "tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed. Press Shift+Tab to switch permission mode, or run /permissions.",
944
+ "tool_denied_deny_rule": "Tool \"{name}\" blocked by a deny rule (\"{pattern}\"). Deny rules override permission mode — try a different approach.",
945
+ "tool_denied_ask_rule": "Tool \"{name}\" requires approval (ask rule: \"{pattern}\"). Approve when prompted, or adjust the rule via /permissions.",
946
+ "tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or run /permissions.",
943
947
  "tool_blocked": "Tool \"{name}\" blocked by hook",
944
948
  "user_input_blocked": "User input blocked by hook.",
945
949
  "dlp_blocked": "Request blocked by DLP policy.",
@@ -364,6 +364,7 @@
364
364
  "provider_line": "提供商: {provider}",
365
365
  "model_line": "模型: {model}",
366
366
  "tool_section": "── 按工具 ──",
367
+ "loops_header": "── 自动循环 ──",
367
368
  "hint": "使用 /context 查看详细统计,/compact 释放空间。"
368
369
  },
369
370
  "reload_skills": {
@@ -940,6 +941,9 @@
940
941
  },
941
942
  "errors": {
942
943
  "tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。按 Shift+Tab 切换权限模式,或运行 /permissions。",
944
+ "tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式。",
945
+ "tool_denied_ask_rule": "工具 \"{name}\" 需要批准(ask 规则:\"{pattern}\")。请在提示时批准,或通过 /permissions 调整规则。",
946
+ "tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或运行 /permissions。",
943
947
  "tool_blocked": "工具 \"{name}\" 被钩子拦截",
944
948
  "user_input_blocked": "用户输入被钩子拦截。",
945
949
  "dlp_blocked": "请求被 DLP 策略阻止。",
@@ -21,7 +21,7 @@ export class OpenAICompatProvider implements ProviderInstance {
21
21
  model: req.model,
22
22
  messages: this.convertMessages(req.messages, req.systemPrompt),
23
23
  stream: true,
24
- max_tokens: req.maxTokens,
24
+ max_tokens: req.maxTokens || 8192,
25
25
  temperature: req.temperature,
26
26
  tools: req.tools?.map((t) => ({ type: 'function', function: t })),
27
27
  }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.59.0' as const
12
+ export const PACKAGE_VERSION = '0.60.1' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -135,6 +135,17 @@ export interface MiphamConfig {
135
135
  permissionRestrictions?: PermissionRestrictions
136
136
  /** User-defined permission rules (allow/deny patterns), wired into the runtime PermissionSystem. */
137
137
  permissionRules?: { allow?: string[]; deny?: string[] }
138
+ /**
139
+ * How to render the model's reasoning/thinking before the answer:
140
+ * - `off` → hide entirely (default — clean output)
141
+ * - `minimal` → content-free "thinking…" indicator
142
+ * - `full` → last 200 chars of the actual thinking text
143
+ */
144
+ showThinking?: 'off' | 'minimal' | 'full'
145
+ /** When false, hide the `⏰ Wakeup scheduled` scheduling confirmation notices. Default false. */
146
+ showSchedulingNotices?: boolean
147
+ /** When false, disable the slash-command picker auto-popup on `/`. Default false. */
148
+ showCommandPicker?: boolean
138
149
  providers: ProviderConfig[]
139
150
  skills?: { paths: string[]; mcpServers: McpServerConfig[] }
140
151
  marketplace?: {
@@ -34,6 +34,12 @@ export function launchApp(appName: string): { success: boolean; message: string
34
34
  }
35
35
  return { success: true, message: `Launched: ${appName}` }
36
36
  } catch (err) {
37
- return { success: false, message: `Failed to launch ${appName}: ${String(err)}` }
37
+ // #57: on macOS an `open -a` failure is often missing Automation permission —
38
+ // point the user at the fix instead of a raw error.
39
+ const macHint =
40
+ process.platform === 'darwin'
41
+ ? ' On macOS, grant Automation permission (System Settings → Privacy & Security → Automation) if prompted.'
42
+ : ''
43
+ return { success: false, message: `Failed to launch ${appName}: ${String(err)}${macHint}` }
38
44
  }
39
45
  }
@@ -52,6 +52,12 @@ $bitmap.Dispose()
52
52
  } catch {
53
53
  // ignore cleanup failures
54
54
  }
55
- return { success: false, error: `Screenshot failed: ${String(err)}` }
55
+ // #57: on macOS a screencapture failure is almost always missing Screen
56
+ // Recording permission — point the user at the fix instead of a raw error.
57
+ const macHint =
58
+ process.platform === 'darwin'
59
+ ? '\n(macOS: grant Screen Recording permission in System Settings → Privacy & Security → Screen Recording)'
60
+ : ''
61
+ return { success: false, error: `Screenshot failed: ${String(err)}${macHint}` }
56
62
  }
57
63
  }
@@ -6,6 +6,15 @@ import type { ToolDefinition } from '../../shared/index.ts'
6
6
  */
7
7
  const activeTimers = new Map<string, ReturnType<typeof setTimeout>>()
8
8
 
9
+ let wakeupHandler: ((sessionId: string, prompt: string, noop?: boolean) => void) | null = null
10
+
11
+ /** 引擎在启动时注入——timer 到期后回调,把 loop prompt 交回引擎 re-invoke。 */
12
+ export function registerWakeupHandler(
13
+ fn: (sessionId: string, prompt: string, noop?: boolean) => void,
14
+ ): void {
15
+ wakeupHandler = fn
16
+ }
17
+
9
18
  export const scheduleWakeupTool: ToolDefinition = {
10
19
  name: 'ScheduleWakeup',
11
20
  description:
@@ -31,6 +40,10 @@ export const scheduleWakeupTool: ToolDefinition = {
31
40
  type: 'boolean',
32
41
  description: 'Set to true to end the dynamic loop immediately.',
33
42
  },
43
+ noop: {
44
+ type: 'boolean',
45
+ description: 'true = 本轮无事可报(仅用于 UI 折叠空闲提示,不改变 re-invoke 语义)',
46
+ },
34
47
  },
35
48
  required: [],
36
49
  },
@@ -58,6 +71,7 @@ export const scheduleWakeupTool: ToolDefinition = {
58
71
  const delaySeconds = params.delaySeconds as number
59
72
  const prompt = (params.prompt as string) || ''
60
73
  const reason = (params.reason as string) || 'scheduled wakeup'
74
+ const noop = params.noop === true
61
75
 
62
76
  if (!delaySeconds || typeof delaySeconds !== 'number') {
63
77
  return {
@@ -86,6 +100,7 @@ export const scheduleWakeupTool: ToolDefinition = {
86
100
  const timerKey = `${sessionId}:wakeup`
87
101
  const timeoutId = setTimeout(() => {
88
102
  activeTimers.delete(timerKey)
103
+ wakeupHandler?.(sessionId, prompt, noop)
89
104
  }, delaySeconds * 1000)
90
105
 
91
106
  activeTimers.set(timerKey, timeoutId)