@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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
- const raw = readFileSync(join(ACTIVE_SESSIONS_DIR, file), 'utf-8')
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
- const senderSession = createSessionInfo(from, from)
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()
@@ -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 messages) {
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
@@ -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. Not caught — security gap
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
- 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
@@ -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'
@@ -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
- sessionName,
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
- // Start the transport (transport-specific), then perform the handshake.
171
- if (transport instanceof HttpTransport) {
172
- await transport.start(config.url ?? '', config.headers, config.env)
173
- } else {
174
- await transport.start(config.command ?? '', config.args ?? [], config.env)
175
- }
176
- const initResult: InitializeResult = await protocol.initialize()
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
- connection.status = 'connected'
179
- connection.serverInfo = initResult.serverInfo
188
+ connection.status = 'connected'
189
+ connection.serverInfo = initResult.serverInfo
180
190
 
181
- // Wire tools-changed notification
182
- protocol.on('tools-changed', async () => {
183
- await this.onToolsChanged(config.name)
184
- })
191
+ // Wire tools-changed notification
192
+ protocol.on('tools-changed', async () => {
193
+ await this.onToolsChanged(config.name)
194
+ })
185
195
 
186
- // Discover tools
187
- if (initResult.capabilities.tools) {
188
- connection.tools = await protocol.listTools()
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.
@@ -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)
@@ -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) => {
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
  }