@k1e1n04/mav 0.1.23 → 0.1.25

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.
@@ -0,0 +1,131 @@
1
+ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { randomUUID } from 'node:crypto'
5
+
6
+ export interface HookInjectionResult {
7
+ /** Injected startup args */
8
+ args: string[]
9
+ /** Temp file paths to delete on agent exit */
10
+ hookFiles: string[]
11
+ }
12
+
13
+ interface BuildHookArgsOptions {
14
+ /** Agent working directory (for gemini-cli) */
15
+ cwd?: string
16
+ }
17
+
18
+ /**
19
+ * Injects a hook into the startup args for the given agent type and returns the result.
20
+ * Generated temp files are listed in hookFiles.
21
+ * Call cleanupHookFiles() after the agent exits.
22
+ */
23
+ export function buildHookArgs(
24
+ agentType: string,
25
+ baseArgs: string[],
26
+ hookCommand: string,
27
+ options: BuildHookArgsOptions = {},
28
+ ): HookInjectionResult {
29
+ switch (agentType) {
30
+ case 'claude-code':
31
+ return buildClaudeCodeHook(baseArgs, hookCommand)
32
+ case 'gemini-cli':
33
+ return buildGeminiHook(baseArgs, hookCommand, options.cwd)
34
+ case 'codex':
35
+ return buildCodexHook(baseArgs, hookCommand)
36
+ default:
37
+ return { args: baseArgs, hookFiles: [] }
38
+ }
39
+ }
40
+
41
+ function buildClaudeCodeHook(baseArgs: string[], hookCommand: string): HookInjectionResult {
42
+ const settings = {
43
+ hooks: {
44
+ PostToolUse: [
45
+ {
46
+ matcher: '',
47
+ hooks: [{ type: 'command', command: hookCommand }],
48
+ },
49
+ ],
50
+ },
51
+ }
52
+ return {
53
+ args: [...baseArgs, '--settings', JSON.stringify(settings)],
54
+ hookFiles: [],
55
+ }
56
+ }
57
+
58
+ const MAV_MARKER = '_mavGenerated'
59
+
60
+ function buildGeminiHook(
61
+ baseArgs: string[],
62
+ hookCommand: string,
63
+ cwd?: string,
64
+ ): HookInjectionResult {
65
+ const projectDir = cwd ?? process.cwd()
66
+ const geminiDir = join(projectDir, '.gemini')
67
+ const settingsPath = join(geminiDir, 'settings.local.json')
68
+
69
+ // If the file exists and was NOT written by mav, skip to protect user's config
70
+ if (existsSync(settingsPath)) {
71
+ try {
72
+ const existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>
73
+ if (!existing[MAV_MARKER]) {
74
+ // User-owned file — do not overwrite
75
+ return { args: baseArgs, hookFiles: [] }
76
+ }
77
+ } catch {
78
+ // Unreadable/invalid JSON — treat as user-owned and skip
79
+ return { args: baseArgs, hookFiles: [] }
80
+ }
81
+ // File was written by mav (stale from crash) — overwrite below
82
+ }
83
+
84
+ const settings = {
85
+ [MAV_MARKER]: true,
86
+ hooks: {
87
+ AfterTool: [
88
+ { matcher: '', hooks: [{ type: 'command', command: hookCommand }] },
89
+ ],
90
+ },
91
+ }
92
+
93
+ mkdirSync(geminiDir, { recursive: true })
94
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
95
+
96
+ return { args: baseArgs, hookFiles: [settingsPath] }
97
+ }
98
+
99
+ function buildCodexHook(baseArgs: string[], hookCommand: string): HookInjectionResult {
100
+ const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex')
101
+ mkdirSync(codexHome, { recursive: true })
102
+ const profileName = `mav-hook-${randomUUID()}`
103
+ const tomlPath = join(codexHome, `${profileName}.config.toml`)
104
+
105
+ const tomlContent = [
106
+ '[hooks.post_tool_use]',
107
+ `cmd = ${JSON.stringify(hookCommand)}`,
108
+ 'timeout_seconds = 10',
109
+ ].join('\n')
110
+
111
+ writeFileSync(tomlPath, tomlContent)
112
+
113
+ return {
114
+ args: [...baseArgs, '--profile-v2', profileName],
115
+ hookFiles: [tomlPath],
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Deletes temp files generated by buildHookArgs.
121
+ * Call this when the agent process exits.
122
+ */
123
+ export function cleanupHookFiles(hookFiles: string[]): void {
124
+ for (const f of hookFiles) {
125
+ try {
126
+ if (existsSync(f)) unlinkSync(f)
127
+ } catch {
128
+ // Ignore deletion failures (file may already be gone)
129
+ }
130
+ }
131
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { homedir } from 'node:os'
1
+ import { homedir, tmpdir } from 'node:os'
2
2
  import { join, dirname } from 'node:path'
3
3
  import { loadConfig } from './config.js'
4
4
  import { clearCurrentSessionState, saveCurrentSessionState } from './current-session.js'
@@ -7,6 +7,8 @@ import { SessionManager } from './session-manager.js'
7
7
  import { App } from './ui/app.js'
8
8
  import type { AgentSession } from './agent.js'
9
9
  import { resolveSessionArgs } from './agent-launch.js'
10
+ import { createServer } from './ipc-server.js'
11
+ import { buildHookArgs } from './hook-injector.js'
10
12
 
11
13
  export interface StartOptions {
12
14
  configPath?: string
@@ -31,8 +33,16 @@ export function start(options: StartOptions = {}): SessionManager {
31
33
 
32
34
  const savedState = loadState(statePath)
33
35
 
36
+ // Start IPC server for cwd tracking (non-blocking)
37
+ const socketPath = join(tmpdir(), `mav-${process.pid}.sock`)
38
+ const ipcServer = createServer(socketPath)
39
+ ipcServer.listen().catch((err: unknown) => {
40
+ const msg = err instanceof Error ? err.message : String(err)
41
+ process.stderr.write(`mav: IPC server failed to start (${msg}), cwd tracking disabled\n`)
42
+ })
43
+
34
44
  const manager = new SessionManager()
35
- const app = new App(manager, statePath, undefined, config.agents)
45
+ const app = new App(manager, statePath, undefined, config.agents, socketPath)
36
46
  const publishSelectedSession = () => {
37
47
  const session = manager.selectedSession
38
48
  if (!session) {
@@ -64,11 +74,20 @@ export function start(options: StartOptions = {}): SessionManager {
64
74
  )
65
75
 
66
76
  const restoredCwd = savedSession?.cwd
67
- const session = manager.addSession({
68
- ...agentConfig,
69
- args,
70
- ...(restoredCwd != null && { cwd: restoredCwd }),
71
- }) as AgentSession & { sessionId?: string }
77
+ const hookCmd = `mav report cwd "$(pwd)"`
78
+ let hookedArgs = args
79
+ let hookFiles: string[] = []
80
+ try {
81
+ const hookResult = buildHookArgs(agentConfig.type, args, hookCmd, { cwd: restoredCwd })
82
+ hookedArgs = hookResult.args
83
+ hookFiles = hookResult.hookFiles
84
+ } catch {
85
+ // hook file I/O failed — start agent without hooks
86
+ }
87
+ const session = manager.addSession(
88
+ { ...agentConfig, args: hookedArgs, ...(restoredCwd != null && { cwd: restoredCwd }) },
89
+ { socketPath, hookFiles },
90
+ ) as AgentSession & { sessionId?: string }
72
91
  session.baseArgs = agentConfig.args
73
92
  configSessionIds.add(session.id)
74
93
 
@@ -89,12 +108,20 @@ export function start(options: StartOptions = {}): SessionManager {
89
108
  savedSession.sessionId,
90
109
  true,
91
110
  )
92
- const session = manager.addSession({
93
- type: rc.type,
94
- cmd: rc.cmd,
95
- args,
96
- ...(savedSession.cwd != null && { cwd: savedSession.cwd }),
97
- }) as AgentSession & { sessionId?: string }
111
+ const hookCmd = `mav report cwd "$(pwd)"`
112
+ let hookedArgs = args
113
+ let hookFiles: string[] = []
114
+ try {
115
+ const hookResult = buildHookArgs(rc.type, args, hookCmd, { cwd: savedSession.cwd })
116
+ hookedArgs = hookResult.args
117
+ hookFiles = hookResult.hookFiles
118
+ } catch {
119
+ // hook file I/O failed — start agent without hooks
120
+ }
121
+ const session = manager.addSession(
122
+ { type: rc.type, cmd: rc.cmd, args: hookedArgs, ...(savedSession.cwd != null && { cwd: savedSession.cwd }) },
123
+ { socketPath, hookFiles },
124
+ ) as AgentSession & { sessionId?: string }
98
125
  session.baseArgs = rc.args
99
126
  if (newSessionId != null) {
100
127
  session.sessionId = newSessionId
@@ -126,14 +153,28 @@ export function start(options: StartOptions = {}): SessionManager {
126
153
  publishSelectedSession()
127
154
  })
128
155
 
156
+ manager.on('name', (sessionId: string) => {
157
+ if (manager.selectedSession?.id !== sessionId) {
158
+ return
159
+ }
160
+ publishSelectedSession()
161
+ })
162
+
129
163
  publishSelectedSession()
130
164
 
165
+ ipcServer.onMessage((msg) => {
166
+ if (msg.type === 'cwd') {
167
+ const session = manager.sessions.find((s) => s.id === msg.sessionId)
168
+ session?.notifyCwd(msg.path)
169
+ }
170
+ })
171
+
131
172
  // q/Ctrl+C 以外の終了(ウィンドウ閉じ等)でも state を保存する
132
173
  const saveOnExit = () => {
133
174
  try { saveState(statePath, manager) } catch { /* ignore */ }
134
175
  }
135
- process.once('SIGTERM', () => { saveOnExit(); process.exit(0) })
136
- process.once('SIGHUP', () => { saveOnExit(); process.exit(0) })
176
+ process.once('SIGTERM', () => { ipcServer.close(); saveOnExit(); process.exit(0) })
177
+ process.once('SIGHUP', () => { ipcServer.close(); saveOnExit(); process.exit(0) })
137
178
 
138
179
  app.start()
139
180
  return manager
@@ -0,0 +1,72 @@
1
+ import { createServer as netCreateServer, Server, Socket } from 'node:net'
2
+ import { existsSync, unlinkSync } from 'node:fs'
3
+
4
+ export interface IpcMessage {
5
+ type: string
6
+ sessionId: string
7
+ path: string
8
+ }
9
+
10
+ type MessageHandler = (msg: IpcMessage) => void
11
+
12
+ export interface IpcServer {
13
+ listen(): Promise<void>
14
+ onMessage(handler: MessageHandler): void
15
+ close(): void
16
+ }
17
+
18
+ export function createServer(socketPath: string): IpcServer {
19
+ const handlers: MessageHandler[] = []
20
+ let server: Server | null = null
21
+
22
+ return {
23
+ onMessage(handler) {
24
+ handlers.push(handler)
25
+ },
26
+
27
+ listen() {
28
+ return new Promise((resolve, reject) => {
29
+ if (existsSync(socketPath)) {
30
+ unlinkSync(socketPath)
31
+ }
32
+
33
+ server = netCreateServer((socket: Socket) => {
34
+ let buf = ''
35
+ socket.on('error', () => {}) // absorb socket errors (e.g. ECONNRESET)
36
+ socket.on('data', (chunk) => {
37
+ buf += chunk.toString()
38
+ const lines = buf.split('\n')
39
+ buf = lines.pop() ?? ''
40
+ for (const line of lines) {
41
+ if (!line.trim()) continue
42
+ try {
43
+ const msg = JSON.parse(line) as IpcMessage
44
+ if (msg.type && msg.sessionId && typeof msg.path === 'string') {
45
+ handlers.forEach((h) => h(msg))
46
+ }
47
+ } catch {
48
+ // ignore
49
+ }
50
+ }
51
+ })
52
+ })
53
+
54
+ server.once('error', reject)
55
+ server.listen(socketPath, () => {
56
+ server!.removeListener('error', reject)
57
+ server!.on('error', () => {}) // absorb post-bind server errors
58
+ resolve()
59
+ })
60
+ })
61
+ },
62
+
63
+ close() {
64
+ const closingServer = server
65
+ server = null
66
+ if (existsSync(socketPath)) {
67
+ try { unlinkSync(socketPath) } catch { /* ignore */ }
68
+ }
69
+ closingServer?.close()
70
+ },
71
+ }
72
+ }
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import { AgentSession } from './agent.js'
3
3
  import type { AgentConfig } from './config.js'
4
+ import type { IpcContext } from './agent.js'
4
5
  import type { MavState } from './state.js'
5
6
 
6
7
  type SessionListeners = {
@@ -20,8 +21,8 @@ export class SessionManager extends EventEmitter {
20
21
  this.emit('selection', this.selectedSession)
21
22
  }
22
23
 
23
- addSession(config: AgentConfig, cols = 80, rows = 24): AgentSession {
24
- const session = new AgentSession(config, cols, rows)
24
+ addSession(config: AgentConfig, ipcContext?: IpcContext, cols = 80, rows = 24): AgentSession {
25
+ const session = new AgentSession(config, cols, rows, ipcContext)
25
26
 
26
27
  const onData = (chunk: string) => { this.emit('data', session.id, chunk) }
27
28
  const onExit = (code: number) => { this.emit('exit', session.id, code) }
package/src/ui/app.ts CHANGED
@@ -22,6 +22,7 @@ export class App {
22
22
  statePath: string,
23
23
  terminal = new TerminalUI(),
24
24
  agentConfigs: AgentConfig[] = [],
25
+ socketPath?: string,
25
26
  ) {
26
27
  this.manager = manager
27
28
  this.statePath = statePath
@@ -31,7 +32,7 @@ export class App {
31
32
  if (session) {
32
33
  this.switchToDetail(session)
33
34
  }
34
- }, agentConfigs)
35
+ }, agentConfigs, socketPath)
35
36
  this.detailUI = new DetailUI(terminal, () => {
36
37
  if (this.mode === 'detail') {
37
38
  this.switchToOverview()
@@ -2,12 +2,14 @@ import type { SessionManager } from '../session-manager.js'
2
2
  import type { AgentSession } from '../agent.js'
3
3
  import type { AgentConfig } from '../config.js'
4
4
  import { getAgentDefaults, resolveSessionArgs } from '../agent-launch.js'
5
+ import { buildHookArgs } from '../hook-injector.js'
5
6
  import { completePath } from './path-completion.js'
6
7
  import type { KeyInfo, TerminalUI } from './terminal.js'
7
8
 
8
9
  type PromptState =
9
10
  | { mode: 'agent'; selectedIndex: number }
10
11
  | { mode: 'cwd'; agentType: string; value: string; candidates: string[] }
12
+ | { mode: 'rename'; sessionId: string; value: string }
11
13
  | { mode: 'error'; message: string }
12
14
  | null
13
15
 
@@ -32,12 +34,13 @@ export class OverviewUI {
32
34
  { status: 'error', label: 'Failed' },
33
35
  ] as const
34
36
 
35
- private static readonly AGENT_TYPES = ['claude-code', 'codex', 'gemini-cli', 'copilot'] as const
37
+ private static readonly AGENT_TYPES = ['claude-code', 'codex', 'gemini-cli', 'copilot', 'cursor', 'opencode', 'antigravity-cli'] as const
36
38
 
37
39
  private terminal: TerminalUI
38
40
  private manager: SessionManager
39
41
  private onSessionCreated?: (session: SessionManager['selectedSession']) => void
40
42
  private agentConfigs: AgentConfig[]
43
+ private socketPath: string | undefined
41
44
  private promptState: PromptState = null
42
45
  private displaySessionIds: string[] = []
43
46
  private visible = false
@@ -47,11 +50,13 @@ export class OverviewUI {
47
50
  manager: SessionManager,
48
51
  onSessionCreated?: (session: SessionManager['selectedSession']) => void,
49
52
  agentConfigs: AgentConfig[] = [],
53
+ socketPath?: string,
50
54
  ) {
51
55
  this.terminal = terminal
52
56
  this.manager = manager
53
57
  this.onSessionCreated = onSessionCreated
54
58
  this.agentConfigs = agentConfigs
59
+ this.socketPath = socketPath
55
60
 
56
61
  this.syncList()
57
62
 
@@ -93,6 +98,10 @@ export class OverviewUI {
93
98
  this.handleCwdPromptKeypress(key)
94
99
  return
95
100
  }
101
+ if (this.promptState?.mode === 'rename') {
102
+ this.handleRenamePromptKeypress(key)
103
+ return
104
+ }
96
105
  if (this.promptState?.mode === 'error') {
97
106
  if (key.name === 'enter' || key.name === 'return' || key.name === 'escape' || str === 'q') {
98
107
  this.promptState = null
@@ -116,6 +125,11 @@ export class OverviewUI {
116
125
  return
117
126
  }
118
127
 
128
+ if (str === 'e') {
129
+ this.showRenamePrompt()
130
+ return
131
+ }
132
+
119
133
  if (str === 'd' || (key.ctrl && key.name === 'x')) {
120
134
  const session = this.manager.selectedSession
121
135
  if (session) {
@@ -202,12 +216,62 @@ export class OverviewUI {
202
216
  }
203
217
  }
204
218
 
219
+ private handleRenamePromptKeypress(key: KeyInfo): void {
220
+ const state = this.promptState
221
+ if (!state || state.mode !== 'rename') return
222
+
223
+ if (key.name === 'escape') {
224
+ this.promptState = null
225
+ this.render()
226
+ return
227
+ }
228
+
229
+ if (key.name === 'enter' || key.name === 'return') {
230
+ const session = this.manager.sessions.find((candidate) => candidate.id === state.sessionId)
231
+ const value = state.value.trim()
232
+ this.promptState = null
233
+ if (session && value.length >= 3) {
234
+ if ('setDisplayName' in session && typeof session.setDisplayName === 'function') {
235
+ session.setDisplayName(value)
236
+ } else {
237
+ session.displayName = value
238
+ }
239
+ }
240
+ this.syncList()
241
+ this.render()
242
+ return
243
+ }
244
+
245
+ if (key.name === 'backspace') {
246
+ state.value = state.value.slice(0, -1)
247
+ this.render()
248
+ return
249
+ }
250
+
251
+ if (!key.ctrl && !key.meta && key.sequence?.length === 1) {
252
+ state.value += key.sequence
253
+ this.render()
254
+ }
255
+ }
256
+
205
257
  private showAddPrompt(): void {
206
258
  if (this.promptState) return
207
259
  this.promptState = { mode: 'agent', selectedIndex: 0 }
208
260
  this.render()
209
261
  }
210
262
 
263
+ private showRenamePrompt(): void {
264
+ if (this.promptState) return
265
+ const session = this.manager.selectedSession
266
+ if (!session) return
267
+ this.promptState = {
268
+ mode: 'rename',
269
+ sessionId: session.id,
270
+ value: session.displayName ?? session.id,
271
+ }
272
+ this.render()
273
+ }
274
+
211
275
  private spawnAgent(agentType: string, cwd: string): void {
212
276
  const config = this.agentConfigs.find((candidate) => candidate.type === agentType)
213
277
  const defaults = config ?? {
@@ -215,12 +279,27 @@ export class OverviewUI {
215
279
  ...getAgentDefaults(agentType),
216
280
  }
217
281
  const { args, newSessionId } = resolveSessionArgs(agentType, defaults.args, undefined, false)
282
+
283
+ let hookedArgs = args
284
+ let hookFiles: string[] = []
285
+ if (this.socketPath) {
286
+ try {
287
+ const hookCmd = `mav report cwd "$(pwd)"`
288
+ const hookResult = buildHookArgs(agentType, args, hookCmd, { cwd })
289
+ hookedArgs = hookResult.args
290
+ hookFiles = hookResult.hookFiles
291
+ } catch {
292
+ // hook injection failed — start agent without hooks
293
+ }
294
+ }
295
+
296
+ const ipcContext = this.socketPath ? { socketPath: this.socketPath, hookFiles } : undefined
218
297
  const session = this.manager.addSession({
219
298
  type: agentType,
220
299
  cmd: defaults.cmd,
221
- args,
300
+ args: hookedArgs,
222
301
  cwd,
223
- }) as AgentSession & { sessionId?: string }
302
+ }, ipcContext) as AgentSession & { sessionId?: string }
224
303
  session.baseArgs = defaults.args
225
304
  if (newSessionId != null) {
226
305
  session.sessionId = newSessionId
@@ -550,7 +629,7 @@ export class OverviewUI {
550
629
  }
551
630
 
552
631
  lines.push(this.color(this.makeRule('controls', width), OverviewUI.ANSI.dim))
553
- lines.push(...this.wrapPlainText('↑/↓ or j/k move Enter detail Ctrl+] back n new d delete q quit', width))
632
+ lines.push(...this.wrapPlainText('↑/↓ or j/k move Enter detail Ctrl+] back n new e rename d delete q quit', width))
554
633
  return this.finalizeLines(lines)
555
634
  }
556
635
 
@@ -586,6 +665,14 @@ export class OverviewUI {
586
665
  return this.finalizeLines(lines)
587
666
  }
588
667
 
668
+ if (state.mode === 'rename') {
669
+ lines.push(this.fitPlain('rename session', width))
670
+ lines.push(...this.wrapPlainText(state.value, width))
671
+ lines.push('')
672
+ lines.push(...this.wrapPlainText('Enter: confirm Esc: cancel', width))
673
+ return this.finalizeLines(lines)
674
+ }
675
+
589
676
  lines.push(this.fitPlain('Error', width))
590
677
  for (const line of state.message.split('\n')) {
591
678
  lines.push(this.fitPlain(line, width))