@miphamai/cli 0.37.0 → 0.39.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.
Files changed (69) hide show
  1. package/bin/mipham.ts +25 -0
  2. package/package.json +1 -1
  3. package/src/agent/cross-session/discovery.ts +45 -1
  4. package/src/agent/cross-session/file-inbox.ts +15 -0
  5. package/src/agent/message-router.ts +22 -2
  6. package/src/agent/sub-agent.ts +4 -3
  7. package/src/config/defaults.ts +19 -1
  8. package/src/config/keys-manager.ts +11 -1
  9. package/src/config/loader.ts +20 -10
  10. package/src/core/context.ts +86 -56
  11. package/src/core/credential-masker/output-scrub.ts +5 -0
  12. package/src/core/crsi-sandbox.ts +19 -7
  13. package/src/core/engine.ts +69 -35
  14. package/src/core/instructions.ts +4 -42
  15. package/src/core/permission.ts +7 -1
  16. package/src/core/red-team.ts +20 -5
  17. package/src/core/self-critique.ts +3 -1
  18. package/src/core/session-log.ts +225 -0
  19. package/src/core/session-store.ts +119 -34
  20. package/src/core/workspace-trust.ts +25 -2
  21. package/src/daemon/auth.ts +9 -6
  22. package/src/daemon/cors.ts +24 -16
  23. package/src/daemon/rate-limiter.ts +4 -0
  24. package/src/daemon/server.ts +24 -3
  25. package/src/i18n-core/locales/en-US.json +4 -0
  26. package/src/i18n-core/locales/zh-CN.json +4 -0
  27. package/src/index.tsx +62 -30
  28. package/src/mcp/client.ts +56 -17
  29. package/src/mcp/http-transport.ts +2 -0
  30. package/src/mcp/oauth.ts +1 -1
  31. package/src/providers/llm-replay.ts +36 -0
  32. package/src/providers/llm.ts +16 -0
  33. package/src/providers/registry.ts +2 -1
  34. package/src/security/gate.ts +10 -0
  35. package/src/security/url.ts +37 -14
  36. package/src/shared/constants.ts +4 -486
  37. package/src/shared/types.ts +6 -437
  38. package/src/skills/fork-executor.ts +3 -1
  39. package/src/skills/loader.ts +2 -1
  40. package/src/skills/seam.ts +18 -0
  41. package/src/tools/agent/agent.ts +1 -0
  42. package/src/tools/agent/skill.ts +1 -0
  43. package/src/tools/exec/bash.ts +141 -129
  44. package/src/tools/exec/git.ts +17 -0
  45. package/src/tools/file/read.ts +73 -66
  46. package/src/tools/file/write.ts +1 -1
  47. package/src/tools/index.ts +54 -132
  48. package/src/tools/network/web-fetch.ts +41 -26
  49. package/src/tools/seam.ts +25 -0
  50. package/src/tools/validation.ts +91 -0
  51. package/src/ui/app.tsx +36 -0
  52. package/src/ui/commands.ts +27 -6
  53. package/src/ui/input.tsx +21 -0
  54. package/src/vajra/compose/assemble.ts +19 -0
  55. package/src/vajra/compose/bundle.ts +39 -0
  56. package/src/vajra/compose/dump.ts +5 -0
  57. package/src/vajra/compose/index.ts +4 -0
  58. package/src/vajra/compose/mount.ts +30 -0
  59. package/src/vajra/context.ts +183 -0
  60. package/src/vajra/events.ts +13 -0
  61. package/src/vajra/index.ts +4 -0
  62. package/src/vajra/leaf/plan-runner.ts +94 -0
  63. package/src/vajra/service.ts +15 -0
  64. package/src/workflow/primitives/agent.ts +3 -1
  65. package/src/workflow/runtime.ts +23 -15
  66. package/src/workflow/sandbox.ts +51 -9
  67. package/src/core/compaction-progress.ts +0 -205
  68. package/src/core/context-compact.ts +0 -124
  69. package/src/core/context-drain.ts +0 -113
@@ -11,6 +11,7 @@ import {
11
11
  import { join } from 'node:path'
12
12
  import { createHash } from 'node:crypto'
13
13
  import type { Message } from '../shared/types'
14
+ import { sanitizeSessionName, SessionLog, deriveMessages, messageToEvents } from './session-log'
14
15
 
15
16
  export interface SessionMetadata {
16
17
  name: string
@@ -46,21 +47,11 @@ export interface SessionIndexEntry {
46
47
  }
47
48
 
48
49
  function ensureDir(): void {
49
- mkdirSync(SESSIONS_DIR, { recursive: true })
50
+ mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 })
50
51
  }
51
52
 
52
53
  function filePath(name: string): string {
53
- // Sanitize name for filesystem
54
- const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_')
55
-
56
- // For very long names (>100 chars), use SHA-256 hash suffix to prevent collision
57
- if (safe.length > 100) {
58
- const hash = createHash('sha256').update(safe).digest('hex').slice(0, 16)
59
- const truncated = safe.slice(0, 80)
60
- return join(SESSIONS_DIR, `${truncated}-${hash}.jsonl`)
61
- }
62
-
63
- return join(SESSIONS_DIR, `${safe}.jsonl`)
54
+ return join(SESSIONS_DIR, `${sanitizeSessionName(name)}.jsonl`)
64
55
  }
65
56
 
66
57
  export class SessionStore {
@@ -90,7 +81,7 @@ export class SessionStore {
90
81
 
91
82
  // Atomic write: write to temp file, then rename (same-fs rename is atomic)
92
83
  const tmp = path + '.tmp'
93
- writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
84
+ writeFileSync(tmp, JSON.stringify(session) + '\n', { encoding: 'utf-8', mode: 0o600 })
94
85
  renameSync(tmp, path)
95
86
 
96
87
  // Incremental index update — only touch this session's entry
@@ -101,6 +92,69 @@ export class SessionStore {
101
92
  }
102
93
  }
103
94
 
95
+ /** 追加持久化一个 SessionLog(幂等:只写新事件)。缺失 session/start 时补一个。 */
96
+ static saveLog(
97
+ name: string,
98
+ log: SessionLog,
99
+ meta?: { provider?: string; model?: string; cwd?: string },
100
+ ): void {
101
+ ensureDir()
102
+ const events = log.events()
103
+ if (!events.some((e) => e.type === 'session/start')) {
104
+ log.append({ type: 'session/start', at: Date.now(), sessionId: name, ...meta })
105
+ }
106
+ log.save()
107
+ try {
108
+ const messages = deriveMessages(log.events())
109
+ SessionStore.updateIndexEntry(name, {
110
+ name,
111
+ createdAt: new Date().toISOString(),
112
+ updatedAt: new Date().toISOString(),
113
+ provider: meta?.provider || 'unknown',
114
+ model: meta?.model || 'unknown',
115
+ messageCount: messages.length,
116
+ cwd: meta?.cwd,
117
+ })
118
+ } catch {
119
+ // index 更新 best-effort
120
+ }
121
+ }
122
+
123
+ /** 从磁盘重开一个 SessionLog(不存在返回空 log)。旧格式快照自动迁移为事件日志。 */
124
+ static loadLog(name: string): SessionLog {
125
+ const path = filePath(name)
126
+ if (!existsSync(path)) return SessionLog.open(name)
127
+ try {
128
+ const raw = readFileSync(path, 'utf-8')
129
+ const parsed = JSON.parse(raw) as unknown
130
+ if (
131
+ parsed &&
132
+ typeof parsed === 'object' &&
133
+ 'metadata' in parsed &&
134
+ Array.isArray((parsed as { messages?: unknown }).messages)
135
+ ) {
136
+ // 旧格式快照 → 迁移为事件日志(重写文件,避免后续 append 混格式)
137
+ const old = parsed as StoredSession
138
+ const log = new SessionLog(name)
139
+ log.append({
140
+ type: 'session/start',
141
+ at: Date.parse(old.metadata.createdAt) || Date.now(),
142
+ sessionId: name,
143
+ provider: old.metadata.provider,
144
+ model: old.metadata.model,
145
+ cwd: old.metadata.cwd,
146
+ })
147
+ for (const m of old.messages) for (const e of messageToEvents(m)) log.append(e)
148
+ unlinkSync(path)
149
+ log.save()
150
+ return log
151
+ }
152
+ } catch {
153
+ // 非旧格式(多行 JSONL 或单事件)→ 走 open
154
+ }
155
+ return SessionLog.open(name)
156
+ }
157
+
104
158
  /**
105
159
  * Load a saved session. Returns null if not found or unparseable.
106
160
  */
@@ -110,14 +164,48 @@ export class SessionStore {
110
164
 
111
165
  try {
112
166
  const raw = readFileSync(path, 'utf-8')
113
- const session = JSON.parse(raw) as StoredSession
114
- // Validate structure
115
- if (!session.metadata || !Array.isArray(session.messages)) {
116
- return null
167
+ // 旧格式:单 JSON 对象 {metadata, messages}
168
+ const parsed = JSON.parse(raw) as unknown
169
+ if (
170
+ parsed &&
171
+ typeof parsed === 'object' &&
172
+ 'metadata' in parsed &&
173
+ Array.isArray((parsed as { messages?: unknown }).messages)
174
+ ) {
175
+ return parsed as StoredSession
117
176
  }
118
- return session
119
177
  } catch {
120
- return null
178
+ // 多行 JSONL → 新格式,走事件解析
179
+ }
180
+ return SessionStore.logToStoredSession(name, SessionLog.open(name))
181
+ }
182
+
183
+ private static logToStoredSession(name: string, log: SessionLog): StoredSession | null {
184
+ const events = log.events()
185
+ if (events.length === 0) return null
186
+ const start = events.find((e) => e.type === 'session/start') as
187
+ | {
188
+ type: 'session/start'
189
+ at: number
190
+ sessionId: string
191
+ provider?: string
192
+ model?: string
193
+ cwd?: string
194
+ }
195
+ | undefined
196
+ const messages = deriveMessages(events)
197
+ const stat = statSync(filePath(name))
198
+ return {
199
+ metadata: {
200
+ name,
201
+ createdAt: start?.at ? new Date(start.at).toISOString() : stat.mtime.toISOString(),
202
+ updatedAt: stat.mtime.toISOString(),
203
+ provider: start?.provider || 'unknown',
204
+ model: start?.model || 'unknown',
205
+ messageCount: messages.length,
206
+ cwd: start?.cwd,
207
+ },
208
+ messages,
121
209
  }
122
210
  }
123
211
 
@@ -128,20 +216,14 @@ export class SessionStore {
128
216
  ensureDir()
129
217
  try {
130
218
  const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.jsonl'))
131
-
132
219
  const sessions: SessionMetadata[] = []
133
220
  for (const file of files) {
134
- try {
135
- const raw = readFileSync(join(SESSIONS_DIR, file), 'utf-8')
136
- const session = JSON.parse(raw) as StoredSession
137
- if (session.metadata) {
138
- sessions.push(session.metadata)
139
- }
140
- } catch {
141
- // Skip corrupt files
221
+ const name = file.replace('.jsonl', '')
222
+ const session = SessionStore.load(name)
223
+ if (session?.metadata) {
224
+ sessions.push(session.metadata)
142
225
  }
143
226
  }
144
-
145
227
  sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
146
228
  return sessions
147
229
  } catch {
@@ -203,7 +285,7 @@ export class SessionStore {
203
285
  }
204
286
  }
205
287
 
206
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
288
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
207
289
  }
208
290
 
209
291
  /**
@@ -231,7 +313,7 @@ export class SessionStore {
231
313
  } else {
232
314
  index.push(entry)
233
315
  }
234
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
316
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
235
317
  }
236
318
 
237
319
  /**
@@ -240,7 +322,7 @@ export class SessionStore {
240
322
  */
241
323
  static saveSummary(name: string, summary: string, tags: string[]): void {
242
324
  ensureDir()
243
- mkdirSync(SUMMARIES_DIR, { recursive: true })
325
+ mkdirSync(SUMMARIES_DIR, { recursive: true, mode: 0o700 })
244
326
 
245
327
  const safeRaw = name.replace(/[^a-zA-Z0-9_-]/g, '_')
246
328
  const safe =
@@ -248,7 +330,10 @@ export class SessionStore {
248
330
  ? `${safeRaw.slice(0, 80)}-${createHash('sha256').update(safeRaw).digest('hex').slice(0, 16)}`
249
331
  : safeRaw
250
332
  const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
251
- writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, 'utf-8')
333
+ writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, {
334
+ encoding: 'utf-8',
335
+ mode: 0o600,
336
+ })
252
337
 
253
338
  // Update index entry — create minimal one if not present
254
339
  try {
@@ -270,7 +355,7 @@ export class SessionStore {
270
355
  tags,
271
356
  })
272
357
  }
273
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
358
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
274
359
  } catch {
275
360
  // Index write is best-effort
276
361
  }
@@ -25,6 +25,26 @@ function realPath(dir: string): string {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * True if a `.git` file or directory exists anywhere strictly between `dir` and
30
+ * its trusted ancestor `ancestor`. A `.git` marks the root of a separate
31
+ * repository — trust does not cross that boundary (nested-repo isolation).
32
+ */
33
+ function hasGitBoundary(dir: string, ancestor: string): boolean {
34
+ let cur = dir
35
+ // Case-insensitive termination matches isTrusted's `toLowerCase()` ancestor
36
+ // check — otherwise a path referenced with different casing than when it was
37
+ // trusted would walk past the ancestor and miss the boundary.
38
+ const targetLower = ancestor.toLowerCase()
39
+ while (cur.toLowerCase() !== targetLower && cur.length > ancestor.length) {
40
+ if (existsSync(join(cur, '.git'))) return true
41
+ const parent = dirname(cur)
42
+ if (parent === cur) break
43
+ cur = parent
44
+ }
45
+ return false
46
+ }
47
+
28
48
  /**
29
49
  * Manages the workspace trust store.
30
50
  *
@@ -82,8 +102,11 @@ export class WorkspaceTrust {
82
102
  const trustedLower = trusted.toLowerCase()
83
103
  // Exact match
84
104
  if (resolvedLower === trustedLower) return true
85
- // Ancestor match: trusted dir is a parent of the target
86
- if (resolvedLower.startsWith(trustedLower + '/')) return true
105
+ // Ancestor match: trusted dir is a parent of the target. Trust does not
106
+ // cross a nested git-repository boundary (vendored/submodule isolation).
107
+ if (resolvedLower.startsWith(trustedLower + '/') && !hasGitBoundary(resolved, trusted)) {
108
+ return true
109
+ }
87
110
  }
88
111
 
89
112
  return false
@@ -73,16 +73,19 @@ export function listTokens(tokenPath: string): string[] {
73
73
  * trusted and auth is bypassed. Auth enforcement activates when
74
74
  * MIPHAM_BIND=0.0.0.0 for remote access.
75
75
  */
76
- export function authMiddleware(request: Request, validToken: string): Response | null {
76
+ export function authMiddleware(
77
+ request: Request,
78
+ validToken: string,
79
+ peerAddress: string | undefined,
80
+ ): Response | null {
77
81
  // Allow health endpoint without auth
78
82
  const url = new URL(request.url)
79
83
  if (url.pathname === '/api/v1/health') return null
80
84
 
81
- // localhost requests skip auth
82
- const host = request.headers.get('host') || ''
83
- if (host.startsWith('127.0.0.1') || host.startsWith('localhost') || host.startsWith('[::1]')) {
84
- return null
85
- }
85
+ // Trust only actual loopback connections (socket IP), never the spoofable Host header.
86
+ const isLoopback =
87
+ peerAddress === '127.0.0.1' || peerAddress === '::1' || peerAddress === '::ffff:127.0.0.1'
88
+ if (isLoopback) return null
86
89
 
87
90
  const auth = request.headers.get('authorization')
88
91
  if (!auth || !auth.startsWith('Bearer ')) {
@@ -1,10 +1,11 @@
1
1
  // apps/cli/src/daemon/cors.ts
2
2
  //
3
3
  // CORS middleware for the daemon HTTP API.
4
- // Handles preflight OPTIONS requests and provides helpers for
5
- // adding CORS headers to responses from external origins.
4
+ // Only origins explicitly listed in MIPHAM_CORS_ORIGINS (comma-separated) are
5
+ // allowed cross-origin access. By default (empty) no external origin is allowed,
6
+ // so a malicious web page cannot read daemon responses cross-origin.
6
7
 
7
- const ALLOWED_HEADERS = 'Authorization, Content-Type'
8
+ const ALLOWED_HEADERS = 'Content-Type'
8
9
  const ALLOWED_METHODS = 'GET, POST, PATCH, DELETE, OPTIONS'
9
10
 
10
11
  /**
@@ -14,24 +15,31 @@ export function isLocalhostOrigin(origin: string): boolean {
14
15
  return origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('[::1]')
15
16
  }
16
17
 
18
+ function getAllowedOrigins(): string[] {
19
+ const raw = process.env.MIPHAM_CORS_ORIGINS || ''
20
+ return raw
21
+ .split(',')
22
+ .map((s) => s.trim())
23
+ .filter(Boolean)
24
+ }
25
+
26
+ function isAllowedOrigin(origin: string): boolean {
27
+ return getAllowedOrigins().includes(origin)
28
+ }
29
+
17
30
  /**
18
31
  * Handle CORS preflight (OPTIONS) requests.
19
32
  *
20
- * Returns a Response with appropriate CORS headers for preflight,
21
- * or null if the request does not need CORS handling (not a preflight,
22
- * or origin is localhost).
23
- *
24
- * For non-preflight requests with an external Origin, the caller should
25
- * use `addCorsHeaders` to attach CORS headers to the response.
33
+ * Returns a Response with CORS headers only for explicitly allow-listed external
34
+ * origins. Localhost origins need no CORS; unlisted external origins get no CORS
35
+ * headers (the browser blocks the cross-origin read).
26
36
  */
27
37
  export function corsMiddleware(request: Request): Response | null {
28
38
  const origin = request.headers.get('origin')
29
39
  if (!origin) return null
30
40
 
31
- // Skip localhost origins — no CORS needed
32
- if (isLocalhostOrigin(origin)) return null
41
+ if (isLocalhostOrigin(origin) || !isAllowedOrigin(origin)) return null
33
42
 
34
- // Handle preflight
35
43
  if (request.method === 'OPTIONS') {
36
44
  return new Response(null, {
37
45
  status: 204,
@@ -39,7 +47,7 @@ export function corsMiddleware(request: Request): Response | null {
39
47
  'Access-Control-Allow-Origin': origin,
40
48
  'Access-Control-Allow-Methods': ALLOWED_METHODS,
41
49
  'Access-Control-Allow-Headers': ALLOWED_HEADERS,
42
- 'Access-Control-Max-Age': '86400',
50
+ 'Access-Control-Max-Age': '3600',
43
51
  },
44
52
  })
45
53
  }
@@ -48,12 +56,12 @@ export function corsMiddleware(request: Request): Response | null {
48
56
  }
49
57
 
50
58
  /**
51
- * Add CORS headers to a response when the request has an external origin.
52
- * If the origin is localhost or absent, the response is returned unchanged.
59
+ * Add CORS headers to a response only for explicitly allow-listed external origins.
60
+ * Localhost / absent / unlisted origins return the response unchanged.
53
61
  */
54
62
  export function addCorsHeaders(response: Response, request: Request): Response {
55
63
  const origin = request.headers.get('origin')
56
- if (!origin || isLocalhostOrigin(origin)) return response
64
+ if (!origin || isLocalhostOrigin(origin) || !isAllowedOrigin(origin)) return response
57
65
 
58
66
  const headers = new Headers(response.headers)
59
67
  headers.set('Access-Control-Allow-Origin', origin)
@@ -30,6 +30,10 @@ export class RateLimiter {
30
30
  */
31
31
  check(key: string): { allowed: boolean; remaining: number; resetAt: number } {
32
32
  const now = Date.now()
33
+ // Bound the map size to prevent unbounded growth under a flood of distinct keys.
34
+ if (this.windows.size >= 100_000 && !this.windows.has(key)) {
35
+ this.cleanup()
36
+ }
33
37
  let entry = this.windows.get(key)
34
38
 
35
39
  if (!entry || now >= entry.resetAt) {
@@ -21,7 +21,7 @@ import { bootstrapProviders } from '../providers/bootstrap'
21
21
  import { createToolRegistry } from '../tools'
22
22
  import { PermissionSystem } from '../core/permission'
23
23
  import type { ProviderRegistry } from '../providers/registry'
24
- import type { ToolDefinition } from '../shared/types'
24
+ import type { ToolDefinition, PermissionMode } from '../shared/types'
25
25
 
26
26
  interface ServerConfig {
27
27
  db: DaemonDatabase
@@ -42,6 +42,27 @@ interface WsData {
42
42
  sessionId: string
43
43
  }
44
44
 
45
+ const DAEMON_PERMISSION_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>([
46
+ 'default',
47
+ 'acceptEdits',
48
+ 'plan',
49
+ 'auto',
50
+ 'dontAsk',
51
+ 'bypassPermissions',
52
+ ])
53
+
54
+ /**
55
+ * Resolve the daemon's permission mode from env, defaulting to least-privilege
56
+ * 'default'. Headless daemon sessions can never prompt, so 'ask'-level tools
57
+ * (Bash/Write/Edit) are blocked rather than auto-approved.
58
+ */
59
+ function resolveDaemonPermission(): PermissionMode {
60
+ const fromEnv = process.env.MIPHAM_DAEMON_PERMISSION
61
+ return fromEnv && DAEMON_PERMISSION_MODES.has(fromEnv as PermissionMode)
62
+ ? (fromEnv as PermissionMode)
63
+ : 'default'
64
+ }
65
+
45
66
  export function createServer(config: ServerConfig): Server<WsData> {
46
67
  const {
47
68
  db,
@@ -142,7 +163,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
142
163
  }
143
164
  }
144
165
 
145
- const permission = new PermissionSystem('bypassPermissions')
166
+ const permission = new PermissionSystem(resolveDaemonPermission())
146
167
  const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
147
168
  engine.setSessionId(sessionId)
148
169
  engineCache.set(sessionId, engine)
@@ -201,7 +222,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
201
222
  }
202
223
 
203
224
  // ── Auth check ──────────────────────────────────
204
- const authError = authMiddleware(req, token)
225
+ const authError = authMiddleware(req, token, server.requestIP(req)?.address)
205
226
  if (authError) return addCorsHeaders(authError, req)
206
227
 
207
228
  // Helper: create JSON response with CORS headers for external origins
@@ -682,6 +682,10 @@
682
682
  "count": "{shown} of {total}",
683
683
  "all": "all"
684
684
  },
685
+ "mention_hints": {
686
+ "label": "Sessions:",
687
+ "empty": "No active sessions"
688
+ },
685
689
  "picker": {
686
690
  "title": "Select Model",
687
691
  "nav_help": "←→ navigate · ↑↓ move · Enter select · Esc close",
@@ -682,6 +682,10 @@
682
682
  "count": "{shown} / {total}",
683
683
  "all": "全部"
684
684
  },
685
+ "mention_hints": {
686
+ "label": "会话:",
687
+ "empty": "无活跃会话"
688
+ },
685
689
  "picker": {
686
690
  "title": "选择模型",
687
691
  "nav_help": "←→ 导航 · ↑↓ 移动 · Enter 选择 · Esc 关闭",
package/src/index.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { join } from 'node:path'
2
+ import { join, basename } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import { existsSync } from 'node:fs'
5
5
  import { render } from 'ink'
@@ -16,6 +16,8 @@ import {
16
16
  heartbeatSession,
17
17
  unregisterSession,
18
18
  createSessionInfo,
19
+ ensureUniqueSessionName,
20
+ discoverSessions,
19
21
  } from './agent/cross-session/discovery'
20
22
  import { bootstrapProviders } from './providers/bootstrap'
21
23
  import { InstructionsLoader } from './core/instructions'
@@ -24,12 +26,16 @@ import { ContextManager } from './core/context'
24
26
  import { PrefixCacheTracker } from './core/context-token'
25
27
  import { QueryEngine } from './core/engine'
26
28
  import { ExperienceRuleEngine } from './core/rule-engine.js'
29
+ import { SessionLog } from './core/session-log'
27
30
  import { SessionStore } from './core/session-store'
28
31
  import type { PermissionLevel, MiphamConfig } from './shared/types'
29
32
  import { SkillsLoader } from './skills/loader'
30
33
  import { PluginManager } from './plugin/plugin-manager'
31
34
  import { loadPlugins } from './plugin/plugin-loader'
32
35
  import { createToolRegistry } from './tools'
36
+ import { Context } from './vajra'
37
+ import { mountSkills, SKILLS_KEY } from './skills/seam'
38
+ import { mountLlm, LLM_KEY } from './providers/llm'
33
39
  import { McpClient } from './mcp/client'
34
40
  import { registerMcpServerTools } from './mcp/registry'
35
41
  import { AgentRegistry } from './agent/agent-registry'
@@ -326,6 +332,16 @@ export async function runApp(options: RunOptions): Promise<void> {
326
332
  compactionThreshold: 0.9,
327
333
  contextWindow: adaptiveThresholds ? modelContextWindow : undefined,
328
334
  })
335
+ const sessionLog = new SessionLog(sessionName)
336
+ sessionLog.append({
337
+ type: 'session/start',
338
+ at: Date.now(),
339
+ sessionId: sessionName,
340
+ provider: defaultProvider,
341
+ model: defaultModel,
342
+ cwd: process.cwd(),
343
+ })
344
+ context.setLog(sessionLog)
329
345
  // Cache-aware microcompaction: track the provider's prompt-cache prefix.
330
346
  context.setCacheTracker(new PrefixCacheTracker())
331
347
 
@@ -333,19 +349,19 @@ export async function runApp(options: RunOptions): Promise<void> {
333
349
  getMemoryManager().setContextWindow(modelContextWindow)
334
350
 
335
351
  if (options.resume) {
336
- const saved = SessionStore.load(options.resume)
337
- if (saved) {
338
- // Restore working directory if saved
339
- if (saved.metadata.cwd && existsSync(saved.metadata.cwd)) {
352
+ const log = SessionStore.loadLog(options.resume)
353
+ const events = log.events()
354
+ if (events.length > 0) {
355
+ const start = events.find((e) => e.type === 'session/start') as
356
+ { type: 'session/start'; cwd?: string } | undefined
357
+ if (start?.cwd && existsSync(start.cwd)) {
340
358
  try {
341
- process.chdir(saved.metadata.cwd)
359
+ process.chdir(start.cwd)
342
360
  } catch {
343
- // cwd may no longer exist; silently continue
361
+ // cwd 可能已不存在
344
362
  }
345
363
  }
346
- for (const msg of saved.messages) {
347
- context.addMessage(msg)
348
- }
364
+ context.restoreLog(log)
349
365
  context.setSystemPrompt(instructions.buildSystemPrompt(config.permission as string))
350
366
  }
351
367
  }
@@ -421,8 +437,21 @@ export async function runApp(options: RunOptions): Promise<void> {
421
437
  context.setSystemPrompt(prompt)
422
438
  }
423
439
 
424
- // Create tool registry with all built-in tools
425
- const tools = createToolRegistry()
440
+ // Initialize credential masking pipeline (strategies: Full, Extract, JWT)
441
+ const { initializePipeline } = await import('./core/credential-masker/index')
442
+ initializePipeline()
443
+
444
+ // Load credential masking config and inject it into the tool seam
445
+ const credentialMaskingConfig = loadCredentialMaskingConfig()
446
+ const vajraContext = new Context()
447
+ vajraContext.provide('credentials', credentialMaskingConfig)
448
+
449
+ // Create tool registry with all built-in tools (mounted as Vajra services)
450
+ const tools = createToolRegistry(vajraContext)
451
+
452
+ // 绞杀收官:skills + llm 也挂载到同一 Vajra Context(tools 已接)
453
+ mountSkills(vajraContext, skillsLoader)
454
+ mountLlm(vajraContext, registry)
426
455
 
427
456
  // Connect MCP servers and register their tools into the tool registry.
428
457
  // Uses Promise.allSettled for parallel connection — failures are non-fatal.
@@ -469,23 +498,13 @@ export async function runApp(options: RunOptions): Promise<void> {
469
498
  engine.setHookEngine(hookEngine)
470
499
  engine.setArtifactServer(artifactServer)
471
500
  engine.setAgentViewManager(agentViewManager)
472
- engine.setSkillsLoader(skillsLoader)
501
+ engine.setSkills(vajraContext.get(SKILLS_KEY)!)
502
+ engine.setLlm(vajraContext.get(LLM_KEY)!)
473
503
 
474
504
  // Wire inference hooks (DLP) configuration
475
505
  const inferenceHookConfig = loadInferenceHookConfig()
476
506
  engine.setInferenceHookConfig(inferenceHookConfig)
477
507
 
478
- // Initialize credential masking pipeline (strategies: Full, Extract, JWT)
479
- const { initializePipeline } = await import('./core/credential-masker/index')
480
- initializePipeline()
481
-
482
- // Wire credential masking configuration into tools
483
- const credentialMaskingConfig = loadCredentialMaskingConfig()
484
- const { setCredentialMaskingConfigForRead } = await import('./tools/file/read')
485
- const { setCredentialMaskingConfigForBash } = await import('./tools/exec/bash')
486
- setCredentialMaskingConfigForRead(credentialMaskingConfig)
487
- setCredentialMaskingConfigForBash(credentialMaskingConfig)
488
-
489
508
  // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
490
509
  if (config.permission) {
491
510
  engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
@@ -515,9 +534,13 @@ export async function runApp(options: RunOptions): Promise<void> {
515
534
 
516
535
  // ── Cross-session messaging: register session, heartbeat, shutdown ──
517
536
  engine.setSessionId(sessionName)
537
+ // Human-readable, cross-session-addressable name. Defaults to the cwd basename
538
+ // so `@mipham-code` can address this session; uniqueness is enforced against
539
+ // other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
540
+ const defaultName = basename(process.cwd()) || 'session'
518
541
  const sessionInfo = createSessionInfo(
519
542
  sessionName,
520
- sessionName,
543
+ ensureUniqueSessionName(defaultName, discoverSessions()),
521
544
  process.cwd(),
522
545
  defaultProvider,
523
546
  defaultModel,
@@ -553,11 +576,20 @@ export async function runApp(options: RunOptions): Promise<void> {
553
576
  hookEngine.executeSessionEnd(sessionName).catch(() => {})
554
577
  artifactServer.stop()
555
578
  if (context.getMessageCount() > 0) {
556
- SessionStore.save(sessionName, context.getMessages(), {
557
- provider: defaultProvider,
558
- model: defaultModel,
559
- cwd: process.cwd(),
560
- })
579
+ const log = context.getLog()
580
+ if (log) {
581
+ SessionStore.saveLog(sessionName, log, {
582
+ provider: defaultProvider,
583
+ model: defaultModel,
584
+ cwd: process.cwd(),
585
+ })
586
+ } else {
587
+ SessionStore.save(sessionName, context.getMessages(), {
588
+ provider: defaultProvider,
589
+ model: defaultModel,
590
+ cwd: process.cwd(),
591
+ })
592
+ }
561
593
  }
562
594
  process.exit(0)
563
595
  }