@miphamai/cli 0.37.0 → 0.38.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 +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/core/engine.ts +19 -2
- package/src/core/red-team.ts +16 -1
- package/src/core/workspace-trust.ts +25 -2
- package/src/i18n-core/locales/en-US.json +4 -0
- package/src/i18n-core/locales/zh-CN.json +4 -0
- package/src/index.tsx +8 -2
- package/src/mcp/client.ts +56 -17
- package/src/tools/exec/bash.ts +1 -1
- package/src/ui/app.tsx +36 -0
- package/src/ui/commands.ts +10 -1
- package/src/ui/input.tsx +21 -0
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
existsSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
unlinkSync,
|
|
8
|
+
statSync,
|
|
8
9
|
} from 'node:fs'
|
|
9
10
|
import { join } from 'node:path'
|
|
10
11
|
import { homedir, hostname } from 'node:os'
|
|
@@ -13,6 +14,9 @@ import type { SessionInfo } from '../../shared/types'
|
|
|
13
14
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
14
15
|
const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
|
|
15
16
|
|
|
17
|
+
/** A session whose heartbeat file hasn't been touched in this long is dead. */
|
|
18
|
+
const STALE_SESSION_TTL_MS = 10 * 60 * 1000 // 10 min — heartbeat is 30s
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
21
|
* Register the current session as active.
|
|
18
22
|
* Writes a SessionInfo JSON file + updates mtime for heartbeat.
|
|
@@ -56,11 +60,19 @@ export function discoverSessions(): SessionInfo[] {
|
|
|
56
60
|
|
|
57
61
|
try {
|
|
58
62
|
const files = readdirSync(ACTIVE_SESSIONS_DIR).filter((f) => f.endsWith('.json'))
|
|
63
|
+
const now = Date.now()
|
|
59
64
|
|
|
60
65
|
const sessions: SessionInfo[] = []
|
|
61
66
|
for (const file of files) {
|
|
67
|
+
const filePath = join(ACTIVE_SESSIONS_DIR, file)
|
|
62
68
|
try {
|
|
63
|
-
|
|
69
|
+
// Prune stale session files (crashed without unregistering). A live
|
|
70
|
+
// session's 30s heartbeat keeps mtime fresh; 10 min of silence = dead.
|
|
71
|
+
if (now - statSync(filePath).mtimeMs > STALE_SESSION_TTL_MS) {
|
|
72
|
+
unlinkSync(filePath)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
64
76
|
const info = JSON.parse(raw) as SessionInfo
|
|
65
77
|
sessions.push(info)
|
|
66
78
|
} catch {
|
|
@@ -97,3 +109,35 @@ export function createSessionInfo(
|
|
|
97
109
|
model,
|
|
98
110
|
}
|
|
99
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Return a unique session name among active sessions. If `preferred` is taken
|
|
115
|
+
* by another session (excluding `selfId`), append `-2`, `-3`, … until free.
|
|
116
|
+
* Pure with respect to `existing` — no filesystem access, so it's unit-testable.
|
|
117
|
+
*/
|
|
118
|
+
export function ensureUniqueSessionName(
|
|
119
|
+
preferred: string,
|
|
120
|
+
existing: SessionInfo[],
|
|
121
|
+
selfId?: string,
|
|
122
|
+
): string {
|
|
123
|
+
const taken = new Set(existing.filter((s) => s.id !== selfId).map((s) => s.name))
|
|
124
|
+
if (!taken.has(preferred)) return preferred
|
|
125
|
+
let i = 2
|
|
126
|
+
while (taken.has(`${preferred}-${i}`)) i++
|
|
127
|
+
return `${preferred}-${i}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rename an active session in the registry. Enforces uniqueness against other
|
|
132
|
+
* live sessions (excluding self). Returns null if the session isn't registered.
|
|
133
|
+
*/
|
|
134
|
+
export function renameActiveSession(sessionId: string, newName: string): string | null {
|
|
135
|
+
const filePath = join(ACTIVE_SESSIONS_DIR, `${sessionId}.json`)
|
|
136
|
+
if (!existsSync(filePath)) return null
|
|
137
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
138
|
+
const info = JSON.parse(raw) as SessionInfo
|
|
139
|
+
const others = discoverSessions().filter((s) => s.id !== sessionId)
|
|
140
|
+
info.name = ensureUniqueSessionName(newName, others)
|
|
141
|
+
writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
|
|
142
|
+
return info.name
|
|
143
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
unlinkSync,
|
|
7
7
|
existsSync,
|
|
8
8
|
renameSync,
|
|
9
|
+
lstatSync,
|
|
9
10
|
} from 'node:fs'
|
|
10
11
|
import { join } from 'node:path'
|
|
11
12
|
import { homedir } from 'node:os'
|
|
@@ -16,6 +17,15 @@ import type { SessionInfo } from '../../shared/types'
|
|
|
16
17
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
17
18
|
const INBOX_DIR = join(MIPHAM_HOME, 'inbox')
|
|
18
19
|
|
|
20
|
+
/** True if a path exists and is a symbolic link (symlink-attack guard). */
|
|
21
|
+
function isSymlink(path: string): boolean {
|
|
22
|
+
try {
|
|
23
|
+
return lstatSync(path).isSymbolicLink()
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* Filesystem-based cross-session transport.
|
|
21
31
|
*
|
|
@@ -43,6 +53,9 @@ export class FileInboxTransport implements CrossSessionTransport {
|
|
|
43
53
|
const inboxDir = join(INBOX_DIR, toSessionId)
|
|
44
54
|
this.ensureDir(inboxDir)
|
|
45
55
|
|
|
56
|
+
// Symlink guard: refuse to write through a symlinked inbox directory.
|
|
57
|
+
if (isSymlink(INBOX_DIR) || isSymlink(inboxDir)) return false
|
|
58
|
+
|
|
46
59
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
47
60
|
const filename = `${timestamp}-${msg.id}.json`
|
|
48
61
|
const filePath = join(inboxDir, filename)
|
|
@@ -71,6 +84,8 @@ export class FileInboxTransport implements CrossSessionTransport {
|
|
|
71
84
|
this.ensureInboxRoot()
|
|
72
85
|
const inboxDir = join(INBOX_DIR, sessionId)
|
|
73
86
|
if (!existsSync(inboxDir)) return []
|
|
87
|
+
// Symlink guard: refuse to read through a symlinked inbox directory.
|
|
88
|
+
if (isSymlink(INBOX_DIR) || isSymlink(inboxDir)) return []
|
|
74
89
|
|
|
75
90
|
try {
|
|
76
91
|
const files = readdirSync(inboxDir)
|
|
@@ -40,6 +40,24 @@ export function resolveRecipientSession(sessions: SessionInfo[], to: string): Se
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export interface MentionParse {
|
|
44
|
+
name: string
|
|
45
|
+
message: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a user-input `@name message` mention. Returns null when the input does
|
|
50
|
+
* not start with an @mention. The message may be empty — callers decide whether
|
|
51
|
+
* an empty body is valid. Used by the input bar to route a direct cross-session
|
|
52
|
+
* message without going through the AI.
|
|
53
|
+
*/
|
|
54
|
+
export function parseMention(input: string): MentionParse | null {
|
|
55
|
+
const trimmed = input.trim()
|
|
56
|
+
const m = /^@(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed)
|
|
57
|
+
if (!m) return null
|
|
58
|
+
return { name: m[1]!, message: (m[2] ?? '').trim() }
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
/**
|
|
44
62
|
* MessageRouter decides how to deliver a message based on the recipient.
|
|
45
63
|
*
|
|
@@ -72,8 +90,10 @@ export class MessageRouter {
|
|
|
72
90
|
}
|
|
73
91
|
const targetSession = resolution.session!
|
|
74
92
|
|
|
75
|
-
// Get sender info
|
|
76
|
-
|
|
93
|
+
// Get sender info — resolve the human-readable name when the sender is a
|
|
94
|
+
// registered session, else fall back to the raw id (e.g. non-session senders).
|
|
95
|
+
const senderName = discoverSessions().find((s) => s.id === from)?.name ?? from
|
|
96
|
+
const senderSession = createSessionInfo(from, senderName)
|
|
77
97
|
|
|
78
98
|
try {
|
|
79
99
|
const transport = getFileInboxTransport()
|
package/src/core/engine.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { SelfCritique } from './self-critique.js'
|
|
|
35
35
|
import { UsageTracker } from './usage-tracker'
|
|
36
36
|
import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
|
|
37
37
|
import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
|
|
38
|
-
import { getMessageBus } from '../agent/message-bus'
|
|
38
|
+
import { getMessageBus, type AgentMessage } from '../agent/message-bus'
|
|
39
39
|
import { createT } from '../i18n-core/t'
|
|
40
40
|
import enUS from '../i18n-core/locales/en-US.json'
|
|
41
41
|
import zhCN from '../i18n-core/locales/zh-CN.json'
|
|
@@ -47,6 +47,20 @@ const bundles: Record<string, TranslationMap> = {
|
|
|
47
47
|
}
|
|
48
48
|
const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Drop inbound messages whose timestamp is older than the dialog-expiry TTL.
|
|
52
|
+
* A stale message's approval dialog is no longer relevant, so it's discarded
|
|
53
|
+
* before forwarding. Pure — unit-testable without touching the inbox.
|
|
54
|
+
*/
|
|
55
|
+
export function filterExpiredMessages(
|
|
56
|
+
messages: AgentMessage[],
|
|
57
|
+
dialogExpirySeconds: number,
|
|
58
|
+
now = Date.now(),
|
|
59
|
+
): AgentMessage[] {
|
|
60
|
+
const maxAgeMs = dialogExpirySeconds * 1000
|
|
61
|
+
return messages.filter((m) => now - new Date(m.timestamp).getTime() <= maxAgeMs)
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
export class QueryEngine {
|
|
51
65
|
private hookEngine?: HookEngine
|
|
52
66
|
private artifactServer?: ArtifactServer
|
|
@@ -131,8 +145,11 @@ export class QueryEngine {
|
|
|
131
145
|
const transport = getFileInboxTransport()
|
|
132
146
|
const messages = await transport.poll(this.sessionId)
|
|
133
147
|
if (messages.length > 0) {
|
|
148
|
+
// dialogExpiry: discard messages older than the approval-dialog TTL
|
|
149
|
+
const fresh = filterExpiredMessages(messages, this.crossSessionConfig.dialogExpiry)
|
|
150
|
+
if (fresh.length === 0) return
|
|
134
151
|
const bus = getMessageBus()
|
|
135
|
-
for (const msg of
|
|
152
|
+
for (const msg of fresh) {
|
|
136
153
|
// P2-1: Trigger Notification hook for cross-session messages
|
|
137
154
|
if (this.hookEngine) {
|
|
138
155
|
this.hookEngine
|
package/src/core/red-team.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { ConstitutionLoader } from './constitution-loader.js'
|
|
20
20
|
import type { PreFlightChecker } from './preflight-checker.js'
|
|
21
21
|
import type { ErrorSignatureDB } from './error-signature-db.js'
|
|
22
|
+
import { isBlocked } from '../tools/exec/bash'
|
|
22
23
|
|
|
23
24
|
// ── Types ──
|
|
24
25
|
|
|
@@ -262,7 +263,21 @@ export class RedTeam {
|
|
|
262
263
|
}
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
// 3.
|
|
266
|
+
// 3. Check the real Bash tool's isBlocked — ground-truth defense, not just
|
|
267
|
+
// the constitution/preflight abstraction. Catches e.g. `git push --force`.
|
|
268
|
+
if (attack.toolName === 'Bash' && typeof attack.params.command === 'string') {
|
|
269
|
+
const blockedReason = isBlocked(attack.params.command)
|
|
270
|
+
if (blockedReason) {
|
|
271
|
+
return {
|
|
272
|
+
attack,
|
|
273
|
+
blocked: true,
|
|
274
|
+
caughtBy: 'bash-tool',
|
|
275
|
+
message: blockedReason,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 4. Not caught — security gap
|
|
266
281
|
return {
|
|
267
282
|
attack,
|
|
268
283
|
blocked: false,
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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",
|
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'
|
|
@@ -515,9 +517,13 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
515
517
|
|
|
516
518
|
// ── Cross-session messaging: register session, heartbeat, shutdown ──
|
|
517
519
|
engine.setSessionId(sessionName)
|
|
520
|
+
// Human-readable, cross-session-addressable name. Defaults to the cwd basename
|
|
521
|
+
// so `@mipham-code` can address this session; uniqueness is enforced against
|
|
522
|
+
// other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
|
|
523
|
+
const defaultName = basename(process.cwd()) || 'session'
|
|
518
524
|
const sessionInfo = createSessionInfo(
|
|
519
525
|
sessionName,
|
|
520
|
-
|
|
526
|
+
ensureUniqueSessionName(defaultName, discoverSessions()),
|
|
521
527
|
process.cwd(),
|
|
522
528
|
defaultProvider,
|
|
523
529
|
defaultModel,
|
package/src/mcp/client.ts
CHANGED
|
@@ -22,6 +22,15 @@ const bundles: Record<string, TranslationMap> = {
|
|
|
22
22
|
}
|
|
23
23
|
const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
|
|
24
24
|
|
|
25
|
+
// Connect-handshake timeout: a hung MCP server should fail startup fast rather
|
|
26
|
+
// than block on the per-request 60s timeout. Overridable via env for tests.
|
|
27
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
|
28
|
+
|
|
29
|
+
function connectTimeoutMs(): number {
|
|
30
|
+
const env = Number(process.env.MIPHAM_MCP_CONNECT_TIMEOUT_MS)
|
|
31
|
+
return Number.isFinite(env) && env > 0 ? env : DEFAULT_CONNECT_TIMEOUT_MS
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
interface ActiveConnection {
|
|
26
35
|
config: McpServerConfig
|
|
27
36
|
transport: Transport
|
|
@@ -167,33 +176,63 @@ export class McpClient {
|
|
|
167
176
|
this.connections.set(config.name, connection)
|
|
168
177
|
|
|
169
178
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
await this.withConnectTimeout(config.name, async () => {
|
|
180
|
+
// Start the transport (transport-specific), then perform the handshake.
|
|
181
|
+
if (transport instanceof HttpTransport) {
|
|
182
|
+
await transport.start(config.url ?? '', config.headers, config.env)
|
|
183
|
+
} else {
|
|
184
|
+
await transport.start(config.command ?? '', config.args ?? [], config.env)
|
|
185
|
+
}
|
|
186
|
+
const initResult: InitializeResult = await protocol.initialize()
|
|
177
187
|
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
connection.status = 'connected'
|
|
189
|
+
connection.serverInfo = initResult.serverInfo
|
|
180
190
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
191
|
+
// Wire tools-changed notification
|
|
192
|
+
protocol.on('tools-changed', async () => {
|
|
193
|
+
await this.onToolsChanged(config.name)
|
|
194
|
+
})
|
|
185
195
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
// Discover tools
|
|
197
|
+
if (initResult.capabilities.tools) {
|
|
198
|
+
connection.tools = await protocol.listTools()
|
|
199
|
+
}
|
|
200
|
+
})
|
|
190
201
|
} catch (err) {
|
|
191
202
|
connection.status = 'error'
|
|
192
203
|
connection.error = String(err)
|
|
204
|
+
try {
|
|
205
|
+
await transport.close()
|
|
206
|
+
} catch {
|
|
207
|
+
// Best-effort cleanup on a failed connect
|
|
208
|
+
}
|
|
193
209
|
throw err
|
|
194
210
|
}
|
|
195
211
|
}
|
|
196
212
|
|
|
213
|
+
/** Race the connect handshake against a timeout so a hung server fails fast. */
|
|
214
|
+
private async withConnectTimeout<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
215
|
+
const timeoutMs = connectTimeoutMs()
|
|
216
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
217
|
+
// Swallow a late rejection: if the timeout wins, transport.close() in the
|
|
218
|
+
// caller's catch makes fn()'s pending request reject after we've moved on.
|
|
219
|
+
const work = fn()
|
|
220
|
+
work.catch(() => {})
|
|
221
|
+
try {
|
|
222
|
+
return await Promise.race([
|
|
223
|
+
work,
|
|
224
|
+
new Promise<never>((_, reject) => {
|
|
225
|
+
timer = setTimeout(
|
|
226
|
+
() => reject(new Error(`MCP connect timed out for "${name}" after ${timeoutMs}ms`)),
|
|
227
|
+
timeoutMs,
|
|
228
|
+
)
|
|
229
|
+
}),
|
|
230
|
+
])
|
|
231
|
+
} finally {
|
|
232
|
+
if (timer) clearTimeout(timer)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
197
236
|
/**
|
|
198
237
|
* Disconnect an MCP server and return the names of its registered tools
|
|
199
238
|
* so the caller can unregister them from the central tool registry.
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -104,7 +104,7 @@ function normalizeEscapes(command: string): string {
|
|
|
104
104
|
)
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function isBlocked(command: string): string | null {
|
|
107
|
+
export function isBlocked(command: string): string | null {
|
|
108
108
|
// Normalize ANSI-C escape sequences for defense-in-depth
|
|
109
109
|
const normalized = normalizeEscapes(command)
|
|
110
110
|
// P0-1: Also sanitize for permission check (strips invisible chars, normalizes homoglyphs)
|
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) => {
|
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
|
}
|