@miphamai/cli 0.45.1 → 0.47.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.45.1",
3
+ "version": "0.47.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -41,6 +41,7 @@
41
41
  "test": "vitest run"
42
42
  },
43
43
  "dependencies": {
44
+ "@larksuiteoapi/node-sdk": "^1.73.0",
44
45
  "commander": "^13.1.0",
45
46
  "ink": "^5.2.1",
46
47
  "ink-text-input": "^6.0.0",
@@ -0,0 +1,79 @@
1
+ import { createFeishuApi } from './api.js'
2
+ import { createFeishuEventDispatcher } from './events.js'
3
+ import type { FeishuConfig, FeishuTextMessage } from './types.js'
4
+ import type { SessionManager } from '../session-manager'
5
+ import type { SessionWorker } from '../session-worker'
6
+ import type { RateLimiter } from '../rate-limiter'
7
+
8
+ export interface FeishuAdapterDeps {
9
+ sm: SessionManager
10
+ getOrCreateWorker: (sessionId: string) => SessionWorker | null
11
+ rateLimiter: RateLimiter
12
+ cwd: string
13
+ provider: string
14
+ model: string
15
+ }
16
+
17
+ export interface FeishuAdapter {
18
+ handleEvent(request: Request): Promise<Response>
19
+ isAllowed(openId: string): boolean
20
+ }
21
+
22
+ export function createFeishuAdapter(config: FeishuConfig, deps: FeishuAdapterDeps): FeishuAdapter {
23
+ const api = createFeishuApi(config)
24
+ const allowed = new Set(config.allowedOpenIds)
25
+
26
+ const onMessage = async (msg: FeishuTextMessage) => {
27
+ try {
28
+ if (!allowed.has(msg.openId)) return
29
+ if (!deps.rateLimiter.check(`feishu:${msg.openId}`).allowed) return
30
+
31
+ const session = deps.sm.getOrCreateByFeishuOpenId(
32
+ msg.openId,
33
+ deps.cwd,
34
+ deps.provider,
35
+ deps.model,
36
+ )
37
+ const worker = deps.getOrCreateWorker(session.id)
38
+ if (!worker) {
39
+ await api.sendText(msg.openId, '(会话初始化失败,请稍后重试)')
40
+ return
41
+ }
42
+ await worker.processPrompt(msg.text)
43
+ const result = worker.getLastAssistantContent()
44
+ await api.sendText(msg.openId, result ? result.slice(0, 4000) : '(无回复)')
45
+ } catch (err) {
46
+ console.error('[feishu] message handling failed:', err)
47
+ try {
48
+ await api.sendText(msg.openId, '(处理失败,请稍后重试)')
49
+ } catch {
50
+ /* 忽略回送失败,确保不 rethrow → Feishu 不重试 */
51
+ }
52
+ }
53
+ }
54
+
55
+ const dispatcher = createFeishuEventDispatcher(config, onMessage)
56
+
57
+ return {
58
+ isAllowed: (openId) => allowed.has(openId),
59
+ async handleEvent(request: Request): Promise<Response> {
60
+ let body: unknown = {}
61
+ try {
62
+ body = await request.json()
63
+ } catch {
64
+ /* 非 JSON body 容忍 */
65
+ }
66
+ // URL 验证:回显 challenge(未加密)
67
+ if (body && typeof body === 'object' && 'challenge' in (body as object)) {
68
+ return Response.json({ challenge: (body as { challenge: string }).challenge })
69
+ }
70
+ const headers: Record<string, string> = {}
71
+ request.headers.forEach((v, k) => (headers[k] = v))
72
+ const result = await dispatcher.invoke(body, headers)
73
+ if (!result.ok) {
74
+ return Response.json({ code: 1, msg: result.reason }, { status: 400 })
75
+ }
76
+ return Response.json({ code: 0 })
77
+ },
78
+ }
79
+ }
@@ -0,0 +1,23 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk'
2
+ import type { FeishuConfig } from './types.js'
3
+
4
+ export interface FeishuApi {
5
+ sendText(openId: string, text: string): Promise<void>
6
+ }
7
+
8
+ export function createFeishuApi(config: FeishuConfig): FeishuApi {
9
+ const client = new lark.Client({
10
+ appId: config.appId,
11
+ appSecret: config.appSecret,
12
+ appType: lark.AppType.SelfBuild,
13
+ domain: lark.Domain.Feishu,
14
+ })
15
+ return {
16
+ async sendText(openId: string, text: string): Promise<void> {
17
+ await client.im.message.create({
18
+ params: { receive_id_type: 'open_id' },
19
+ data: { receive_id: openId, msg_type: 'text', content: JSON.stringify({ text }) },
20
+ })
21
+ },
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import type { FeishuConfig } from './types.js'
2
+
3
+ export function parseFeishuEnv(): FeishuConfig | null {
4
+ const appId = process.env.FEISHU_APP_ID
5
+ const appSecret = process.env.FEISHU_APP_SECRET
6
+ const encryptKey = process.env.FEISHU_ENCRYPT_KEY || ''
7
+ const verificationToken = process.env.FEISHU_VERIFICATION_TOKEN || ''
8
+ // Fail-closed:encryptKey 必填。lark SDK 对 im.message.receive_v1 事件流的签名验证
9
+ // 只依赖 encryptKey(checkIsEventValidated 在空 key 时静默返回 true);
10
+ // verificationToken 仅用于 URL challenge 握手,不保护事件流,故保持可选。
11
+ if (!appId || !appSecret || !encryptKey) return null
12
+ return {
13
+ appId,
14
+ appSecret,
15
+ encryptKey,
16
+ verificationToken,
17
+ allowedOpenIds: (process.env.FEISHU_ALLOWED_OPEN_IDS || '')
18
+ .split(',')
19
+ .map((s) => s.trim())
20
+ .filter(Boolean),
21
+ }
22
+ }
@@ -0,0 +1,53 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk'
2
+ import type { FeishuConfig, FeishuTextMessage } from './types.js'
3
+
4
+ export type OnFeishuMessage = (msg: FeishuTextMessage) => Promise<void>
5
+
6
+ export type FeishuInvokeResult =
7
+ { ok: true } | { ok: false; reason: 'invalid_signature' | 'no_handler' | 'parse_failed' }
8
+
9
+ export interface FeishuEventDispatcher {
10
+ invoke(body: unknown, headers: Record<string, string>): Promise<FeishuInvokeResult>
11
+ }
12
+
13
+ export function createFeishuEventDispatcher(
14
+ config: FeishuConfig,
15
+ onMessage: OnFeishuMessage,
16
+ ): FeishuEventDispatcher {
17
+ const dispatcher = new lark.EventDispatcher({
18
+ encryptKey: config.encryptKey,
19
+ verificationToken: config.verificationToken,
20
+ }).register({
21
+ 'im.message.receive_v1': async (data: any): Promise<true> => {
22
+ const message = data?.message
23
+ if (message?.message_type !== 'text') return true
24
+ let text = ''
25
+ try {
26
+ text = JSON.parse(message.content).text ?? ''
27
+ } catch {
28
+ return true
29
+ }
30
+ const openId = data?.sender?.sender_id?.open_id
31
+ if (!openId || !text) return true
32
+ await onMessage({ chatId: message.chat_id, messageId: message.message_id, openId, text })
33
+ return true
34
+ },
35
+ })
36
+
37
+ return {
38
+ async invoke(body, headers): Promise<FeishuInvokeResult> {
39
+ const assigned = Object.assign(Object.create({ headers }), body)
40
+ let raw: unknown
41
+ try {
42
+ raw = await dispatcher.invoke(assigned)
43
+ } catch {
44
+ return { ok: false, reason: 'parse_failed' }
45
+ }
46
+ if (raw === undefined) return { ok: false, reason: 'invalid_signature' }
47
+ if (typeof raw === 'string' && raw.startsWith('no ')) {
48
+ return { ok: false, reason: 'no_handler' }
49
+ }
50
+ return { ok: true }
51
+ },
52
+ }
53
+ }
@@ -0,0 +1,14 @@
1
+ export interface FeishuConfig {
2
+ appId: string
3
+ appSecret: string
4
+ encryptKey: string
5
+ verificationToken: string
6
+ allowedOpenIds: string[]
7
+ }
8
+
9
+ export interface FeishuTextMessage {
10
+ chatId: string
11
+ messageId: string
12
+ openId: string
13
+ text: string
14
+ }
@@ -23,6 +23,9 @@ import { RateLimiter } from './rate-limiter'
23
23
  import { PACKAGE_VERSION } from '../shared/package-info'
24
24
  import type { DaemonStatus } from './types'
25
25
  import { logger } from './logger'
26
+ import { parseFeishuEnv } from './feishu/env.js'
27
+ import type { FeishuConfig } from './feishu/types.js'
28
+ import { loadConfig } from '../config/loader'
26
29
 
27
30
  const HOME = homedir()
28
31
  const MIPHAM_HOME = join(HOME, '.mipham')
@@ -163,6 +166,21 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
163
166
  const rateLimiter = new RateLimiter(100, 60_000)
164
167
  activeRateLimiter = rateLimiter
165
168
 
169
+ // Feishu remote-control adapter(env 未配置时跳过;配置则解析 session 默认 provider/model)
170
+ const feishuConfig = parseFeishuEnv()
171
+ let feishu: { config: FeishuConfig; cwd: string; provider: string; model: string } | undefined
172
+ if (feishuConfig) {
173
+ const cfg = loadConfig()
174
+ // 取第一个非 upcoming(即非「待上线」)的 provider 及其首个 model
175
+ const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
176
+ feishu = {
177
+ config: feishuConfig,
178
+ cwd: process.env.FEISHU_CWD || process.cwd(),
179
+ provider: provider?.id ?? 'anthropic',
180
+ model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
181
+ }
182
+ }
183
+
166
184
  // Start HTTP server (Bun.serve starts listening immediately)
167
185
  const server = createServer({
168
186
  db,
@@ -177,6 +195,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
177
195
  goalManager,
178
196
  scheduleManager,
179
197
  rateLimiter,
198
+ feishu,
180
199
  })
181
200
  activeServer = server
182
201
 
@@ -23,6 +23,8 @@ import { createToolRegistry } from '../tools'
23
23
  import { PermissionSystem } from '../core/permission'
24
24
  import type { ProviderRegistry } from '../providers/registry'
25
25
  import type { ToolDefinition, PermissionMode } from '../shared/types'
26
+ import { createFeishuAdapter } from './feishu/adapter.js'
27
+ import type { FeishuConfig } from './feishu/types.js'
26
28
 
27
29
  interface ServerConfig {
28
30
  db: DaemonDatabase
@@ -37,6 +39,7 @@ interface ServerConfig {
37
39
  goalManager: GoalManager
38
40
  scheduleManager: ScheduleManager
39
41
  rateLimiter: RateLimiter
42
+ feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
40
43
  }
41
44
 
42
45
  interface WsData {
@@ -78,6 +81,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
78
81
  goalManager,
79
82
  scheduleManager,
80
83
  rateLimiter,
84
+ feishu,
81
85
  } = config
82
86
 
83
87
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
@@ -198,6 +202,18 @@ export function createServer(config: ServerConfig): Server<WsData> {
198
202
  }
199
203
  }
200
204
 
205
+ // ── Feishu remote-control adapter(独立签名验证,复用同一 worker 池)──
206
+ const feishuAdapter = feishu
207
+ ? createFeishuAdapter(feishu.config, {
208
+ sm,
209
+ getOrCreateWorker,
210
+ rateLimiter,
211
+ cwd: feishu.cwd,
212
+ provider: feishu.provider,
213
+ model: feishu.model,
214
+ })
215
+ : undefined
216
+
201
217
  const server = Bun.serve<WsData>({
202
218
  port,
203
219
  hostname,
@@ -211,6 +227,11 @@ export function createServer(config: ServerConfig): Server<WsData> {
211
227
  const method = req.method
212
228
  logger.info('request', { method, path })
213
229
 
230
+ // ── Feishu event callback(独立签名验证,不经过 daemon Bearer 鉴权)──
231
+ if (feishuAdapter && method === 'POST' && path === '/feishu/event') {
232
+ return await feishuAdapter.handleEvent(req)
233
+ }
234
+
214
235
  // ── Rate limiting (skip health endpoint) ──────────
215
236
  if (path !== '/api/v1/health') {
216
237
  const ip = server.requestIP(req)?.address || 'unknown'
@@ -23,6 +23,18 @@ export class SessionManager {
23
23
  return this.db.listSessions(status)
24
24
  }
25
25
 
26
+ getOrCreateByFeishuOpenId(
27
+ openId: string,
28
+ cwd: string,
29
+ provider: string,
30
+ model: string,
31
+ ): DaemonSession {
32
+ const name = `feishu-${openId}`
33
+ const existing = this.db.listSessions().find((s) => s.name === name && s.status !== 'closed')
34
+ if (existing) return existing
35
+ return this.db.createSession({ name, cwd, provider, model })
36
+ }
37
+
26
38
  closeSession(id: string): void {
27
39
  this.db.closeSession(id)
28
40
  for (const cb of this.closeCallbacks) {
@@ -253,6 +253,11 @@ export class SessionWorker {
253
253
  return this.session.id
254
254
  }
255
255
 
256
+ /** Get the last assistant text content from the underlying engine. */
257
+ getLastAssistantContent(): string | undefined {
258
+ return this.engine.getLastAssistantContent()
259
+ }
260
+
256
261
  // ── Persistence ────────────────────────────────────────────────────────
257
262
 
258
263
  /**
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.45.1' as const
12
+ export const PACKAGE_VERSION = '0.47.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -1,6 +1,6 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
2
 
3
- interface Task {
3
+ export interface Task {
4
4
  id: string
5
5
  subject: string
6
6
  description: string
@@ -20,6 +20,11 @@ interface Task {
20
20
  const tasks = new Map<string, Task>()
21
21
  let taskCounter = 0
22
22
 
23
+ /** Read accessor for the session task store — returns all non-deleted tasks. */
24
+ export function getTasks(): Task[] {
25
+ return Array.from(tasks.values()).filter((t) => t.status !== 'deleted')
26
+ }
27
+
23
28
  function formatTask(t: Task): string {
24
29
  const meta = t.metadata ? ` ${JSON.stringify(t.metadata)}` : ''
25
30
  const blocks = t.blocks.length ? ` blocks:[${t.blocks.join(',')}]` : ''
package/src/ui/app.tsx CHANGED
@@ -20,6 +20,7 @@ import { AgentFooter, type AgentEntry } from './agent-footer'
20
20
  import { AgentViewDashboard } from '../agent-view/dashboard'
21
21
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
22
22
  import { WorkflowProgress } from './workflow-progress.js'
23
+ import { GoalProgress } from './goal-progress.js'
23
24
  import {
24
25
  getCommand,
25
26
  looksLikeSlashCommand,
@@ -828,6 +829,17 @@ export function App({
828
829
  {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
829
830
  <WorkflowProgress />
830
831
 
832
+ {/* Goal progress — live goal + subtasks + done/total · elapsed · tokens (auto-hides when no goal) */}
833
+ <GoalProgress
834
+ goal={goalText}
835
+ getTokens={() => {
836
+ const tracker = engine.getUsageTracker()
837
+ if (!tracker) return 0
838
+ if ('totalApiTokens' in tracker) return tracker.totalApiTokens
839
+ return Object.values(tracker.getStats()).reduce((a, b) => a + b, 0)
840
+ }}
841
+ />
842
+
831
843
  {/* Agent View Dashboard — Ctrl+G overlay (replaces chat + input) */}
832
844
  {agentViewOpen && agentViewManager ? (
833
845
  <AgentViewDashboard
@@ -935,11 +947,6 @@ export function App({
935
947
 
936
948
  {/* Status line — Claude Code style */}
937
949
  <Box marginTop={1} flexDirection="column">
938
- {goalText && (
939
- <Box>
940
- <Text color="green">🎯 Goal: {goalText}</Text>
941
- </Box>
942
- )}
943
950
  <Box flexDirection="row">
944
951
  <Text color={PERMISSION_COLORS[permissionMode]}>
945
952
  ⏵⏵ {PERMISSION_LABELS[permissionMode]}
@@ -0,0 +1,137 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { getTasks } from '../tools/exec/task.js'
4
+ import type { Task } from '../tools/exec/task.js'
5
+
6
+ export interface GoalProgressProps {
7
+ goal: string
8
+ /** Read current session token total; defaults to 0. */
9
+ getTokens?: () => number
10
+ /** Read current tasks; defaults to the real task store. */
11
+ listTasks?: () => Task[]
12
+ }
13
+
14
+ export interface TaskSummary {
15
+ total: number
16
+ done: number
17
+ inProgress: number
18
+ pending: number
19
+ failed: number
20
+ }
21
+
22
+ /** Count tasks by status, ignoring deleted. */
23
+ export function summarizeTasks(tasks: Task[]): TaskSummary {
24
+ const summary: TaskSummary = { total: 0, done: 0, inProgress: 0, pending: 0, failed: 0 }
25
+ for (const t of tasks) {
26
+ if (t.status === 'deleted') continue
27
+ summary.total++
28
+ if (t.status === 'completed') summary.done++
29
+ else if (t.status === 'in_progress') summary.inProgress++
30
+ else if (t.status === 'pending') summary.pending++
31
+ else if (t.status === 'failed') summary.failed++
32
+ }
33
+ return summary
34
+ }
35
+
36
+ /** Format a token count: raw under 1000, `1.2K` style above. */
37
+ export function formatTokens(n: number): string {
38
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
39
+ return String(n)
40
+ }
41
+
42
+ function formatElapsed(ms: number): string {
43
+ const s = Math.floor(ms / 1000)
44
+ if (s < 60) return `${s}s`
45
+ const m = Math.floor(s / 60)
46
+ return `${m}m ${s % 60}s`
47
+ }
48
+
49
+ function taskIcon(status: Task['status']): { icon: string; color?: string; dim?: boolean } {
50
+ switch (status) {
51
+ case 'completed':
52
+ return { icon: '✓', color: 'green' }
53
+ case 'in_progress':
54
+ return { icon: '⏳', color: 'yellow' }
55
+ case 'failed':
56
+ return { icon: '✗', color: 'red' }
57
+ default:
58
+ return { icon: '📋', dim: true }
59
+ }
60
+ }
61
+
62
+ /** Live goal progress panel — mirrors ZCode's goal view: title + subtasks + done/total · elapsed · tokens. */
63
+ export function GoalProgress({ goal, getTokens, listTasks }: GoalProgressProps) {
64
+ const [tasks, setTasks] = useState<Task[]>([])
65
+ const [tokens, setTokens] = useState(0)
66
+ const [elapsedMs, setElapsedMs] = useState(0)
67
+
68
+ // Hold latest callbacks in refs so the interval doesn't churn on re-render.
69
+ const listTasksRef = useRef(listTasks ?? getTasks)
70
+ const getTokensRef = useRef(getTokens ?? (() => 0))
71
+ listTasksRef.current = listTasks ?? getTasks
72
+ getTokensRef.current = getTokens ?? (() => 0)
73
+
74
+ useEffect(() => {
75
+ if (!goal) {
76
+ setTasks([])
77
+ setTokens(0)
78
+ setElapsedMs(0)
79
+ return
80
+ }
81
+ const start = Date.now()
82
+ const tick = () => {
83
+ setTasks(listTasksRef.current())
84
+ setTokens(getTokensRef.current())
85
+ setElapsedMs(Date.now() - start)
86
+ }
87
+ tick()
88
+ const timer = setInterval(tick, 1000)
89
+ return () => clearInterval(timer)
90
+ }, [goal])
91
+
92
+ if (!goal) return null
93
+
94
+ const summary = summarizeTasks(tasks)
95
+ const visibleTasks = tasks.filter((t) => t.status !== 'deleted')
96
+
97
+ return (
98
+ <Box
99
+ flexDirection="column"
100
+ borderStyle="round"
101
+ borderColor="cyan"
102
+ paddingX={1}
103
+ marginBottom={1}
104
+ >
105
+ <Text bold color="green">
106
+ 🎯 Goal: {goal}
107
+ </Text>
108
+
109
+ {visibleTasks.length === 0 ? (
110
+ <Text dimColor> (no subtasks yet)</Text>
111
+ ) : (
112
+ visibleTasks.map((t) => {
113
+ const meta = taskIcon(t.status)
114
+ return (
115
+ <Box key={t.id} marginLeft={1}>
116
+ <Text color={meta.color} dimColor={meta.dim}>
117
+ {meta.icon} {t.subject}
118
+ </Text>
119
+ </Box>
120
+ )
121
+ })
122
+ )}
123
+
124
+ <Box marginTop={1}>
125
+ <Text dimColor>
126
+ ═══ {summary.done}/{summary.total} done
127
+ {summary.inProgress > 0 && ` · ${summary.inProgress} in progress`}
128
+ {summary.failed > 0 && ` · ${summary.failed} failed`}
129
+ {' · '}
130
+ {formatElapsed(elapsedMs)}
131
+ {tokens > 0 && ` · ${formatTokens(tokens)} tokens`}
132
+ {' ═══'}
133
+ </Text>
134
+ </Box>
135
+ </Box>
136
+ )
137
+ }