@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.
- package/bin/mipham.ts +25 -0
- package/package.json +1 -1
- package/src/agent/cross-session/discovery.ts +45 -1
- package/src/agent/cross-session/file-inbox.ts +15 -0
- package/src/agent/message-router.ts +22 -2
- package/src/agent/sub-agent.ts +4 -3
- package/src/config/defaults.ts +19 -1
- package/src/config/keys-manager.ts +11 -1
- package/src/config/loader.ts +20 -10
- package/src/core/context.ts +86 -56
- package/src/core/credential-masker/output-scrub.ts +5 -0
- package/src/core/crsi-sandbox.ts +19 -7
- package/src/core/engine.ts +69 -35
- package/src/core/instructions.ts +4 -42
- package/src/core/permission.ts +7 -1
- package/src/core/red-team.ts +20 -5
- package/src/core/self-critique.ts +3 -1
- package/src/core/session-log.ts +225 -0
- package/src/core/session-store.ts +119 -34
- package/src/core/workspace-trust.ts +25 -2
- package/src/daemon/auth.ts +9 -6
- package/src/daemon/cors.ts +24 -16
- package/src/daemon/rate-limiter.ts +4 -0
- package/src/daemon/server.ts +24 -3
- package/src/i18n-core/locales/en-US.json +4 -0
- package/src/i18n-core/locales/zh-CN.json +4 -0
- package/src/index.tsx +62 -30
- package/src/mcp/client.ts +56 -17
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/oauth.ts +1 -1
- package/src/providers/llm-replay.ts +36 -0
- package/src/providers/llm.ts +16 -0
- package/src/providers/registry.ts +2 -1
- package/src/security/gate.ts +10 -0
- package/src/security/url.ts +37 -14
- package/src/shared/constants.ts +4 -486
- package/src/shared/types.ts +6 -437
- package/src/skills/fork-executor.ts +3 -1
- package/src/skills/loader.ts +2 -1
- package/src/skills/seam.ts +18 -0
- package/src/tools/agent/agent.ts +1 -0
- package/src/tools/agent/skill.ts +1 -0
- package/src/tools/exec/bash.ts +141 -129
- package/src/tools/exec/git.ts +17 -0
- package/src/tools/file/read.ts +73 -66
- package/src/tools/file/write.ts +1 -1
- package/src/tools/index.ts +54 -132
- package/src/tools/network/web-fetch.ts +41 -26
- package/src/tools/seam.ts +25 -0
- package/src/tools/validation.ts +91 -0
- package/src/ui/app.tsx +36 -0
- package/src/ui/commands.ts +27 -6
- package/src/ui/input.tsx +21 -0
- package/src/vajra/compose/assemble.ts +19 -0
- package/src/vajra/compose/bundle.ts +39 -0
- package/src/vajra/compose/dump.ts +5 -0
- package/src/vajra/compose/index.ts +4 -0
- package/src/vajra/compose/mount.ts +30 -0
- package/src/vajra/context.ts +183 -0
- package/src/vajra/events.ts +13 -0
- package/src/vajra/index.ts +4 -0
- package/src/vajra/leaf/plan-runner.ts +94 -0
- package/src/vajra/service.ts +15 -0
- package/src/workflow/primitives/agent.ts +3 -1
- package/src/workflow/runtime.ts +23 -15
- package/src/workflow/sandbox.ts +51 -9
- package/src/core/compaction-progress.ts +0 -205
- package/src/core/context-compact.ts +0 -124
- package/src/core/context-drain.ts +0 -113
package/src/tools/index.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import type { ToolDefinition
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
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 {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
withValidation(grepTool),
|
|
50
|
+
writeTool,
|
|
51
|
+
editTool,
|
|
52
|
+
globTool,
|
|
53
|
+
grepTool,
|
|
133
54
|
// Exec tools
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
withValidation(exitWorktreeTool),
|
|
55
|
+
gitTool,
|
|
56
|
+
taskTool,
|
|
57
|
+
enterWorktreeTool,
|
|
58
|
+
exitWorktreeTool,
|
|
139
59
|
// Agent tools
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
71
|
+
webFetchTool,
|
|
72
|
+
webSearchTool,
|
|
153
73
|
// System tools
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
74
|
+
configTool,
|
|
75
|
+
mcpTool,
|
|
76
|
+
toolSearchTool,
|
|
157
77
|
// Artifact tools
|
|
158
|
-
|
|
78
|
+
artifactTool,
|
|
159
79
|
// Computer Use tools
|
|
160
|
-
|
|
80
|
+
computerUseTool,
|
|
161
81
|
// Scheduling tools
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
82
|
+
scheduleWakeupTool,
|
|
83
|
+
cronCreateTool,
|
|
84
|
+
cronDeleteTool,
|
|
85
|
+
cronListTool,
|
|
166
86
|
]
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// SSRF defense:
|
|
213
|
-
const redirectError = validateUrl(
|
|
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)
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
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
|
+
}
|