@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
@@ -1,15 +1,13 @@
1
- import type { ToolDefinition, ToolResult } from '../shared/index.ts'
2
- import { sanitizeParams } from '../shared/sanitize'
3
- import { createT } from '../i18n-core/t'
4
- import enUS from '../i18n-core/locales/en-US.json'
5
- import zhCN from '../i18n-core/locales/zh-CN.json'
6
- import type { TranslationMap } from '../i18n-core/types'
7
- import { readTool } from './file/read'
1
+ import type { ToolDefinition } from '../shared/index.ts'
2
+ import { Context } from '../vajra'
3
+ import { withValidation } from './validation'
4
+ import { toolService, collectTools } from './seam'
5
+ import { readToolService } from './file/read'
8
6
  import { writeTool } from './file/write'
9
7
  import { editTool } from './file/edit'
10
8
  import { globTool } from './file/glob'
11
9
  import { grepTool } from './file/grep'
12
- import { bashTool } from './exec/bash'
10
+ import { bashToolService } from './exec/bash'
13
11
  import { gitTool } from './exec/git'
14
12
  import { taskTool } from './exec/task'
15
13
  import { enterWorktreeTool } from './exec/enter-worktree'
@@ -33,141 +31,65 @@ import { listAgentsTool } from './agent/list-agents'
33
31
  import { computerUseTool } from './computer/computer-use'
34
32
  import { scheduleWakeupTool } from './scheduling/schedule-wakeup.js'
35
33
  import { cronCreateTool, cronDeleteTool, cronListTool } from './scheduling/cron.js'
34
+ import { DISABLED_CREDENTIAL_MASKING_CONFIG } from '../config/defaults'
36
35
 
37
- const bundles: Record<string, TranslationMap> = {
38
- 'en-US': enUS as TranslationMap,
39
- 'zh-CN': zhCN as TranslationMap,
36
+ function defaultToolContext(): Context {
37
+ const ctx = new Context()
38
+ // 掩码中立默认:无参调用(daemon/workflow)保持 Read/Bash 挂载,但不启用掩码,
39
+ // 对齐 pre-seam 行为(那些路径从不调用 setter)。显式开启掩码走 index.tsx 的加载配置。
40
+ ctx.provide('credentials', DISABLED_CREDENTIAL_MASKING_CONFIG)
41
+ return ctx
40
42
  }
41
- const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
42
43
 
43
- /**
44
- * Validate tool parameters against the tool's JSON Schema definition.
45
- * Returns an array of error messages (empty = valid).
46
- */
47
- function validateParams(
48
- schema: Record<string, unknown>,
49
- params: Record<string, unknown>,
50
- ): string[] {
51
- const errors: string[] = []
52
-
53
- // Check required fields
54
- const required = schema.required as string[] | undefined
55
- if (required) {
56
- for (const field of required) {
57
- if (params[field] === undefined || params[field] === null) {
58
- errors.push(t('errors.missing_param', { param: field }))
59
- }
60
- }
61
- }
62
-
63
- // Check types for provided fields
64
- const properties = schema.properties as
65
- Record<string, { type: string; enum?: string[] }> | undefined
66
- if (properties) {
67
- for (const [key, def] of Object.entries(properties)) {
68
- const value = params[key]
69
- if (value === undefined || value === null) continue // already caught by required check
70
-
71
- switch (def.type) {
72
- case 'string':
73
- if (typeof value !== 'string') errors.push(t('errors.type_string', { key }))
74
- else if (def.enum && !def.enum.includes(value)) {
75
- errors.push(t('errors.type_enum', { key, values: def.enum.join(', ') }))
76
- }
77
- break
78
- case 'integer':
79
- case 'number':
80
- if (typeof value !== 'number') errors.push(t('errors.type_number', { key }))
81
- break
82
- case 'boolean':
83
- if (typeof value !== 'boolean') errors.push(t('errors.type_boolean', { key }))
84
- break
85
- case 'object':
86
- if (typeof value !== 'object' || Array.isArray(value)) {
87
- errors.push(t('errors.type_object', { key }))
88
- }
89
- break
90
- case 'array':
91
- if (!Array.isArray(value)) errors.push(t('errors.type_array', { key }))
92
- break
93
- }
94
- }
95
- }
96
-
97
- return errors
98
- }
99
-
100
- /**
101
- * Wrap a tool's execute with parameter validation.
102
- */
103
- function withValidation(tool: ToolDefinition): ToolDefinition {
104
- const schema = tool.parameters as Record<string, unknown>
105
- if (!schema || !schema.properties) return tool // no schema to validate against
106
-
107
- return {
108
- ...tool,
109
- async execute(params, ctx): Promise<ToolResult> {
110
- // Sanitize dangerous Unicode from all inputs
111
- const cleanParams = sanitizeParams(params)
112
- const errors = validateParams(schema, cleanParams)
113
- if (errors.length > 0) {
114
- return {
115
- success: false,
116
- content: '',
117
- error: t('errors.invalid_params', { errors: errors.join('; ') }),
118
- }
119
- }
120
- return tool.execute(cleanParams, ctx)
121
- },
122
- }
123
- }
124
-
125
- export function createToolRegistry(): Map<string, ToolDefinition> {
126
- const tools: ToolDefinition[] = [
44
+ export function createToolRegistry(
45
+ ctx: Context = defaultToolContext(),
46
+ ): Map<string, ToolDefinition> {
47
+ // 普通工具:包 withValidation 后作为 Service 挂载
48
+ const plainTools: ToolDefinition[] = [
127
49
  // File tools
128
- withValidation(readTool),
129
- withValidation(writeTool),
130
- withValidation(editTool),
131
- withValidation(globTool),
132
- withValidation(grepTool),
50
+ writeTool,
51
+ editTool,
52
+ globTool,
53
+ grepTool,
133
54
  // Exec tools
134
- withValidation(bashTool),
135
- withValidation(gitTool),
136
- withValidation(taskTool),
137
- withValidation(enterWorktreeTool),
138
- withValidation(exitWorktreeTool),
55
+ gitTool,
56
+ taskTool,
57
+ enterWorktreeTool,
58
+ exitWorktreeTool,
139
59
  // Agent tools
140
- withValidation(agentTool),
141
- withValidation(skillTool),
142
- withValidation(planTool),
143
- withValidation(enterPlanModeTool),
144
- withValidation(exitPlanModeTool),
145
- withValidation(memoryTool),
146
- withValidation(workflowTool),
147
- withValidation(reportFindingsTool),
148
- withValidation(sendMessageTool),
149
- withValidation(listAgentsTool),
60
+ agentTool,
61
+ skillTool,
62
+ planTool,
63
+ enterPlanModeTool,
64
+ exitPlanModeTool,
65
+ memoryTool,
66
+ workflowTool,
67
+ reportFindingsTool,
68
+ sendMessageTool,
69
+ listAgentsTool,
150
70
  // Network tools
151
- withValidation(webFetchTool),
152
- withValidation(webSearchTool),
71
+ webFetchTool,
72
+ webSearchTool,
153
73
  // System tools
154
- withValidation(configTool),
155
- withValidation(mcpTool),
156
- withValidation(toolSearchTool),
74
+ configTool,
75
+ mcpTool,
76
+ toolSearchTool,
157
77
  // Artifact tools
158
- withValidation(artifactTool),
78
+ artifactTool,
159
79
  // Computer Use tools
160
- withValidation(computerUseTool),
80
+ computerUseTool,
161
81
  // Scheduling tools
162
- withValidation(scheduleWakeupTool),
163
- withValidation(cronCreateTool),
164
- withValidation(cronDeleteTool),
165
- withValidation(cronListTool),
82
+ scheduleWakeupTool,
83
+ cronCreateTool,
84
+ cronDeleteTool,
85
+ cronListTool,
166
86
  ]
167
-
168
- const map = new Map<string, ToolDefinition>()
169
- for (const tool of tools) {
170
- map.set(tool.name, tool)
87
+ for (const tool of plainTools) {
88
+ ctx.mount(toolService(withValidation(tool)))
171
89
  }
172
- return map
90
+ // 注入工具(credentials 依赖):read + bash
91
+ ctx.mount(readToolService)
92
+ ctx.mount(bashToolService)
93
+
94
+ return collectTools(ctx)
173
95
  }
@@ -177,7 +177,7 @@ export const webFetchTool: ToolDefinition = {
177
177
  }
178
178
 
179
179
  // SSRF protection: validate URL before fetching
180
- const validationError = validateUrl(url)
180
+ const validationError = await validateUrl(url)
181
181
  if (validationError) {
182
182
  return { success: false, content: '', error: validationError }
183
183
  }
@@ -186,40 +186,55 @@ export const webFetchTool: ToolDefinition = {
186
186
  const controller = new AbortController()
187
187
  const timer = setTimeout(() => controller.abort(), 30_000) // 30s timeout
188
188
 
189
- const response = await fetch(url, {
190
- headers: {
191
- 'User-Agent': 'Mipham-Code/0.24.0',
192
- Accept: 'text/html,application/xhtml+xml,*/*',
193
- },
194
- redirect: 'follow',
195
- signal: controller.signal,
196
- })
197
-
198
- clearTimeout(timer)
199
-
200
- // Cross-host redirect reporting
201
- if (response.url) {
202
- const finalHost = new URL(response.url).hostname
203
- const originalHost = new URL(url).hostname
204
- if (finalHost !== originalHost) {
205
- return {
206
- success: true,
207
- content: `Redirected to: ${response.url}\n\nFetch from this URL directly to retrieve content.`,
208
- metadata: { redirected: true, originalUrl: url, finalUrl: response.url },
209
- }
210
- }
211
-
212
- // SSRF defense: re-validate the resolved URL after redirects
213
- const redirectError = validateUrl(response.url)
189
+ // Manual redirect handling: follow same-host redirects one hop at a time,
190
+ // validating the target BEFORE each request (closes the SSRF-via-redirect gap).
191
+ let currentUrl = url
192
+ const originalHost = new URL(url).hostname
193
+ let response!: Response
194
+
195
+ for (let hop = 0; hop <= 5; hop++) {
196
+ response = await fetch(currentUrl, {
197
+ headers: {
198
+ 'User-Agent': 'Mipham-Code/0.24.0',
199
+ Accept: 'text/html,application/xhtml+xml,*/*',
200
+ },
201
+ redirect: 'manual',
202
+ signal: controller.signal,
203
+ })
204
+
205
+ // Not a redirect (or no Location) → proceed with this response
206
+ if (response.status < 300 || response.status >= 400) break
207
+ const location = response.headers.get('location')
208
+ if (!location || hop === 5) break
209
+
210
+ const nextUrl = new URL(location, currentUrl).toString()
211
+
212
+ // SSRF defense: validate the redirect target BEFORE following
213
+ const redirectError = await validateUrl(nextUrl)
214
214
  if (redirectError) {
215
+ clearTimeout(timer)
215
216
  return {
216
217
  success: false,
217
218
  content: '',
218
219
  error: `Redirect blocked: ${redirectError}`,
219
220
  }
220
221
  }
222
+
223
+ // Cross-host redirects are returned to the caller (tool contract)
224
+ if (new URL(nextUrl).hostname !== originalHost) {
225
+ clearTimeout(timer)
226
+ return {
227
+ success: true,
228
+ content: `Redirected to: ${nextUrl}\n\nFetch from this URL directly to retrieve content.`,
229
+ metadata: { redirected: true, originalUrl: url, finalUrl: nextUrl },
230
+ }
231
+ }
232
+
233
+ currentUrl = nextUrl
221
234
  }
222
235
 
236
+ clearTimeout(timer)
237
+
223
238
  // Determine content type; only convert HTML to markdown
224
239
  const contentType = response.headers.get('content-type') || ''
225
240
  const isHtml = contentType.includes('text/html') || contentType.includes('application/xhtml')
@@ -0,0 +1,25 @@
1
+ import type { Context, Service } from '../vajra'
2
+ import type { ToolDefinition } from '../shared'
3
+
4
+ /** 工具服务键前缀。一个已挂载工具以 `tool:<name>` 注册于 Context。 */
5
+ export const toolKey = (name: string): string => `tool:${name}`
6
+
7
+ /** 把普通 ToolDefinition 包装成 Vajra Service(无注入依赖)。 */
8
+ export function toolService(tool: ToolDefinition): Service {
9
+ return {
10
+ apply(ctx) {
11
+ ctx.provide(toolKey(tool.name), tool)
12
+ },
13
+ }
14
+ }
15
+
16
+ /** 从 Context 已挂载的工具服务派生 name → definition 的 Map(engine 消费)。 */
17
+ export function collectTools(ctx: Context): Map<string, ToolDefinition> {
18
+ const map = new Map<string, ToolDefinition>()
19
+ for (const key of ctx.keysRecursive()) {
20
+ if (!key.startsWith('tool:')) continue
21
+ const tool = ctx.get<ToolDefinition>(key)
22
+ if (tool) map.set(tool.name, tool)
23
+ }
24
+ return map
25
+ }
@@ -0,0 +1,91 @@
1
+ import { sanitizeParams } from '../shared/sanitize'
2
+ import { createT } from '../i18n-core/t'
3
+ import enUS from '../i18n-core/locales/en-US.json'
4
+ import zhCN from '../i18n-core/locales/zh-CN.json'
5
+ import type { TranslationMap } from '../i18n-core/types'
6
+ import type { ToolDefinition, ToolResult } from '../shared'
7
+
8
+ const bundles: Record<string, TranslationMap> = {
9
+ 'en-US': enUS as TranslationMap,
10
+ 'zh-CN': zhCN as TranslationMap,
11
+ }
12
+ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
13
+
14
+ /**
15
+ * Validate tool parameters against the tool's JSON Schema definition.
16
+ * Returns an array of error messages (empty = valid).
17
+ */
18
+ export function validateParams(
19
+ schema: Record<string, unknown>,
20
+ params: Record<string, unknown>,
21
+ ): string[] {
22
+ const errors: string[] = []
23
+
24
+ const required = schema.required as string[] | undefined
25
+ if (required) {
26
+ for (const field of required) {
27
+ if (params[field] === undefined || params[field] === null) {
28
+ errors.push(t('errors.missing_param', { param: field }))
29
+ }
30
+ }
31
+ }
32
+
33
+ const properties = schema.properties as
34
+ Record<string, { type: string; enum?: string[] }> | undefined
35
+ if (properties) {
36
+ for (const [key, def] of Object.entries(properties)) {
37
+ const value = params[key]
38
+ if (value === undefined || value === null) continue
39
+
40
+ switch (def.type) {
41
+ case 'string':
42
+ if (typeof value !== 'string') errors.push(t('errors.type_string', { key }))
43
+ else if (def.enum && !def.enum.includes(value)) {
44
+ errors.push(t('errors.type_enum', { key, values: def.enum.join(', ') }))
45
+ }
46
+ break
47
+ case 'integer':
48
+ case 'number':
49
+ if (typeof value !== 'number') errors.push(t('errors.type_number', { key }))
50
+ break
51
+ case 'boolean':
52
+ if (typeof value !== 'boolean') errors.push(t('errors.type_boolean', { key }))
53
+ break
54
+ case 'object':
55
+ if (typeof value !== 'object' || Array.isArray(value)) {
56
+ errors.push(t('errors.type_object', { key }))
57
+ }
58
+ break
59
+ case 'array':
60
+ if (!Array.isArray(value)) errors.push(t('errors.type_array', { key }))
61
+ break
62
+ }
63
+ }
64
+ }
65
+
66
+ return errors
67
+ }
68
+
69
+ /**
70
+ * Wrap a tool's execute with parameter validation.
71
+ */
72
+ export function withValidation(tool: ToolDefinition): ToolDefinition {
73
+ const schema = tool.parameters as Record<string, unknown>
74
+ if (!schema || !schema.properties) return tool
75
+
76
+ return {
77
+ ...tool,
78
+ async execute(params, ctx): Promise<ToolResult> {
79
+ const cleanParams = sanitizeParams(params)
80
+ const errors = validateParams(schema, cleanParams)
81
+ if (errors.length > 0) {
82
+ return {
83
+ success: false,
84
+ content: '',
85
+ error: t('errors.invalid_params', { errors: errors.join('; ') }),
86
+ }
87
+ }
88
+ return tool.execute(cleanParams, ctx)
89
+ },
90
+ }
91
+ }
package/src/ui/app.tsx CHANGED
@@ -11,6 +11,8 @@ import { setPreference } from '../config/preferences'
11
11
  import { saveProviderApiKey } from '../config/loader'
12
12
  import { AgentRegistry } from '../agent/agent-registry'
13
13
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
14
+ import { getMessageRouter, parseMention, resolveRecipientSession } from '../agent/message-router'
15
+ import { discoverSessions } from '../agent/cross-session/discovery'
14
16
  import { ChatPanel } from './chat'
15
17
  import { InputBar } from './input'
16
18
  import { ModelPicker } from './picker'
@@ -325,6 +327,40 @@ export function App({
325
327
  async (input: string) => {
326
328
  if (!input.trim()) return
327
329
 
330
+ // ── @mention: direct cross-session message — only when the name resolves
331
+ // to a live session; otherwise fall through to normal AI processing so a
332
+ // leading `@word` (e.g. "@bob please review") isn't hijacked as a send. ──
333
+ const mention = parseMention(input)
334
+ if (mention && resolveRecipientSession(discoverSessions(), mention.name).session) {
335
+ if (!mention.message) {
336
+ setMessages((prev) => [
337
+ ...prev,
338
+ { role: 'user', content: input },
339
+ { role: 'system', content: 'Usage: @session-name <message>' },
340
+ ])
341
+ return
342
+ }
343
+ const mentionSummary =
344
+ mention.message.length > 50 ? mention.message.slice(0, 47) + '...' : mention.message
345
+ const result = await getMessageRouter().route(
346
+ sessionId || 'main',
347
+ mention.name,
348
+ mentionSummary,
349
+ mention.message,
350
+ )
351
+ setMessages((prev) => [
352
+ ...prev,
353
+ { role: 'user', content: input },
354
+ {
355
+ role: 'system',
356
+ content: result.success
357
+ ? `── Message sent to ${mention.name} ──${result.messageId ? `\nID: ${result.messageId}` : ''}`
358
+ : `❌ Failed to send to ${mention.name}: ${result.error}`,
359
+ },
360
+ ])
361
+ return
362
+ }
363
+
328
364
  // ── Slash command dispatch ──
329
365
  if (looksLikeSlashCommand(input)) {
330
366
  const { command, args } = parseSlashCommand(input)
@@ -12,6 +12,7 @@ import type { Message } from '../shared/types.js'
12
12
  import { McpClient } from '../mcp/client'
13
13
  import { NPM_UPDATE_COMMAND, PACKAGE_VERSION } from '../shared/index.ts'
14
14
  import { getPreference } from '../config/preferences'
15
+ import { loadCrossSessionConfig } from '../config/loader'
15
16
  import { stripIndent } from './strip-indent.js'
16
17
  import { createT } from '../i18n-core/t'
17
18
  import type { TranslationMap } from '../i18n-core/types'
@@ -43,6 +44,7 @@ import { keysCmd } from '../commands/keys'
43
44
  import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
44
45
  import { execSync } from 'node:child_process'
45
46
  import { OLLAMA_PRESET_MODELS } from '../shared/constants'
47
+ import { renameActiveSession } from '../agent/cross-session/discovery'
46
48
 
47
49
  export interface CommandContext {
48
50
  engine: QueryEngine
@@ -517,12 +519,16 @@ const switchCmd: CommandHandler = (ctx, args) => {
517
519
  const configCmd: CommandHandler = (ctx) => {
518
520
  const t = resolveT(ctx)
519
521
  const c = ctx.config
522
+ const xs = loadCrossSessionConfig(process.cwd())
520
523
  const lines = [
521
524
  `version: ${c.version}`,
522
525
  `defaultProvider: ${c.defaultProvider}`,
523
526
  `defaultModel: ${c.defaultModel}`,
524
527
  `permission: ${c.permission}`,
525
528
  `providers: ${c.providers.length} configured`,
529
+ '',
530
+ `crossSessionInbound: ${xs.crossSessionInbound}`,
531
+ `dialogExpiry: ${xs.dialogExpiry}s`,
526
532
  ]
527
533
  return {
528
534
  content: `${t('commands.config.title')}\n${lines.join('\n')}\n\n${t('commands.config.edit')}`,
@@ -1588,7 +1594,10 @@ const renameCmd: CommandHandler = (ctx, args) => {
1588
1594
  return { content: t('commands.rename.usage') }
1589
1595
  }
1590
1596
  ctx.setSessionTitle(name.trim())
1591
- return { content: t('commands.rename.confirmed', { name: name.trim() }) }
1597
+ // Persist to the cross-session registry so `@name` can address this session.
1598
+ // Returns the (possibly uniquified) final name — surface that to the user.
1599
+ const finalName = renameActiveSession(ctx.sessionId, name.trim())
1600
+ return { content: t('commands.rename.confirmed', { name: finalName ?? name.trim() }) }
1592
1601
  }
1593
1602
 
1594
1603
  const goalCmd: CommandHandler = (ctx, args) => {
@@ -2831,11 +2840,20 @@ const cdCmd: CommandHandler = async (ctx, args) => {
2831
2840
  const { SessionStore } = await import('../core/session-store')
2832
2841
  const saved = SessionStore.load(ctx.sessionId)
2833
2842
  if (saved) {
2834
- SessionStore.save(ctx.sessionId, saved.messages, {
2835
- provider: saved.metadata.provider,
2836
- model: saved.metadata.model,
2837
- cwd: resolved,
2838
- })
2843
+ const log = ctx.engine.getContext().getLog()
2844
+ if (log) {
2845
+ SessionStore.saveLog(ctx.sessionId, log, {
2846
+ provider: saved.metadata.provider,
2847
+ model: saved.metadata.model,
2848
+ cwd: resolved,
2849
+ })
2850
+ } else {
2851
+ SessionStore.save(ctx.sessionId, saved.messages, {
2852
+ provider: saved.metadata.provider,
2853
+ model: saved.metadata.model,
2854
+ cwd: resolved,
2855
+ })
2856
+ }
2839
2857
  }
2840
2858
  } catch {
2841
2859
  /* session persistence is best-effort */
@@ -4040,6 +4058,9 @@ const forkCmd: CommandHandler = async (ctx, args) => {
4040
4058
  ctx.engine.getRegistry(),
4041
4059
  ctx.engine.getTools(),
4042
4060
  ctx.engine.getPermission(),
4061
+ undefined,
4062
+ undefined,
4063
+ ctx.engine.getLlm(),
4043
4064
  )
4044
4065
  const parentContext = ctx.engine.getContext()
4045
4066
  const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {
package/src/ui/input.tsx CHANGED
@@ -5,6 +5,7 @@ import { VimMotionEngine, type VimMode } from './vim-motions.js'
5
5
  import { getCommandList } from './commands.js'
6
6
  import { CommandPicker } from './command-picker.js'
7
7
  import { useI18n } from '../i18n-context'
8
+ import { discoverSessions } from '../agent/cross-session/discovery'
8
9
 
9
10
  interface InputBarProps {
10
11
  onSubmit: (input: string) => void
@@ -107,6 +108,15 @@ export function InputBar({
107
108
  return allCommands.filter((c) => c.name.toLowerCase().includes(filter)).slice(0, 8)
108
109
  }, [value, allCommands])
109
110
 
111
+ // ── @mention hints (cross-session recipients) ──
112
+ const mentionHints = useMemo(() => {
113
+ if (!value.startsWith('@')) return []
114
+ const filter = value.slice(1).toLowerCase()
115
+ const names = discoverSessions().map((s) => s.name)
116
+ if (!filter) return names.slice(0, 12)
117
+ return names.filter((n) => n.toLowerCase().includes(filter)).slice(0, 8)
118
+ }, [value])
119
+
110
120
  // Rotate gerunds while loading — use tRef to avoid re-running when i18n context re-renders
111
121
  useEffect(() => {
112
122
  if (!isLoading) return
@@ -448,6 +458,17 @@ export function InputBar({
448
458
  </Text>
449
459
  </Box>
450
460
  )}
461
+ {/* @mention hints — active sessions (cross-session messaging) */}
462
+ {mentionHints.length > 0 && vimMode === 'insert' && !pickerActive && (
463
+ <Box marginTop={1} flexDirection="column" gap={1}>
464
+ <Text dimColor>{t('ui.mention_hints.label')} </Text>
465
+ {mentionHints.map((name) => (
466
+ <Text key={name} color="green">
467
+ {name}
468
+ </Text>
469
+ ))}
470
+ </Box>
471
+ )}
451
472
  </Box>
452
473
  )
453
474
  }
@@ -0,0 +1,19 @@
1
+ import type { Profile, Bundle, BundleLine } from './bundle'
2
+
3
+ export function assemble(profile: Profile, resolveBundle: (name: string) => Bundle): BundleLine[] {
4
+ // 浅拷贝每行,使 patch 不回写共享的 bundle 行对象(不同 profile 共享同一 bundle 时补丁隔离)
5
+ const lines = profile.bundles.flatMap((name) =>
6
+ resolveBundle(name).lines.map((l) => ({ ...l, config: { ...l.config } })),
7
+ )
8
+ if (profile.patch) {
9
+ for (const [id, partial] of Object.entries(profile.patch)) {
10
+ const target = lines.find((l) => l.id === id)
11
+ if (target) {
12
+ Object.assign(target, partial)
13
+ } else {
14
+ lines.push({ id, kind: partial.kind ?? 'tool', config: partial.config ?? {} })
15
+ }
16
+ }
17
+ }
18
+ return lines
19
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { parse as parseYaml } from 'yaml'
3
+
4
+ export type BundleLine = {
5
+ id: string
6
+ kind: 'tool' | 'provider' | 'skill' | 'service'
7
+ config: Record<string, unknown>
8
+ }
9
+ export type Bundle = { name: string; lines: BundleLine[] }
10
+ export type Profile = {
11
+ name: string
12
+ bundles: string[]
13
+ patch?: Record<string, Partial<BundleLine>>
14
+ }
15
+
16
+ export function loadBundle(path: string): Bundle {
17
+ const raw = readFileSync(path, 'utf-8')
18
+ const data = parseYaml(raw) as { name?: unknown; lines?: unknown }
19
+ if (data.lines !== undefined && !Array.isArray(data.lines)) {
20
+ throw new Error(`bundle "${path}": "lines" must be an array`)
21
+ }
22
+ return {
23
+ name: typeof data.name === 'string' ? data.name : path,
24
+ lines: (data.lines ?? []) as BundleLine[],
25
+ }
26
+ }
27
+
28
+ export function loadProfile(path: string): Profile {
29
+ const raw = readFileSync(path, 'utf-8')
30
+ const data = parseYaml(raw) as { name?: unknown; bundles?: unknown; patch?: unknown }
31
+ if (data.bundles !== undefined && !Array.isArray(data.bundles)) {
32
+ throw new Error(`profile "${path}": "bundles" must be an array`)
33
+ }
34
+ return {
35
+ name: typeof data.name === 'string' ? data.name : path,
36
+ bundles: (data.bundles ?? []) as string[],
37
+ patch: data.patch as Profile['patch'] | undefined,
38
+ }
39
+ }
@@ -0,0 +1,5 @@
1
+ import type { BundleLine } from './bundle'
2
+
3
+ export function dumpConfig(lines: BundleLine[]): string {
4
+ return lines.map((l) => `${l.id}\t${l.kind}\t${JSON.stringify(l.config)}`).join('\n')
5
+ }
@@ -0,0 +1,4 @@
1
+ export * from './bundle'
2
+ export * from './assemble'
3
+ export * from './dump'
4
+ export * from './mount'