@k1e1n04/mav 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/mav.ts +31 -0
  4. package/dist/bin/mav.d.ts +2 -0
  5. package/dist/bin/mav.js +24 -0
  6. package/dist/bin/mav.js.map +1 -0
  7. package/dist/src/agent-launch.d.ts +15 -0
  8. package/dist/src/agent-launch.js +36 -0
  9. package/dist/src/agent-launch.js.map +1 -0
  10. package/dist/src/agent.d.ts +42 -0
  11. package/dist/src/agent.js +209 -0
  12. package/dist/src/agent.js.map +1 -0
  13. package/dist/src/config.d.ts +11 -0
  14. package/dist/src/config.js +32 -0
  15. package/dist/src/config.js.map +1 -0
  16. package/dist/src/current-session.d.ts +15 -0
  17. package/dist/src/current-session.js +17 -0
  18. package/dist/src/current-session.js.map +1 -0
  19. package/dist/src/index.d.ts +6 -0
  20. package/dist/src/index.js +90 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/process-cwd.d.ts +1 -0
  23. package/dist/src/process-cwd.js +24 -0
  24. package/dist/src/process-cwd.js.map +1 -0
  25. package/dist/src/session-manager.d.ts +16 -0
  26. package/dist/src/session-manager.js +102 -0
  27. package/dist/src/session-manager.js.map +1 -0
  28. package/dist/src/state.d.ts +19 -0
  29. package/dist/src/state.js +30 -0
  30. package/dist/src/state.js.map +1 -0
  31. package/dist/src/ui/app.d.ts +15 -0
  32. package/dist/src/ui/app.js +109 -0
  33. package/dist/src/ui/app.js.map +1 -0
  34. package/dist/src/ui/detail.d.ts +28 -0
  35. package/dist/src/ui/detail.js +137 -0
  36. package/dist/src/ui/detail.js.map +1 -0
  37. package/dist/src/ui/overview.d.ts +24 -0
  38. package/dist/src/ui/overview.js +267 -0
  39. package/dist/src/ui/overview.js.map +1 -0
  40. package/lua/mav/follow.lua +37 -0
  41. package/lua/mav/init.lua +92 -0
  42. package/lua/mav/state.lua +22 -0
  43. package/package.json +54 -0
  44. package/plugin/mav.lua +24 -0
  45. package/src/agent-launch.ts +43 -0
  46. package/src/agent.ts +243 -0
  47. package/src/config.ts +51 -0
  48. package/src/current-session.ts +34 -0
  49. package/src/index.ts +121 -0
  50. package/src/process-cwd.ts +31 -0
  51. package/src/session-manager.ts +122 -0
  52. package/src/state.ts +49 -0
  53. package/src/types/neo-blessed.d.ts +5 -0
  54. package/src/ui/app.ts +121 -0
  55. package/src/ui/detail.ts +164 -0
  56. package/src/ui/overview.ts +306 -0
@@ -0,0 +1,43 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ export const DEFAULT_CMDS: Record<string, { cmd: string; args: string[]; resumeArgs?: string[] }> = {
4
+ 'claude-code': { cmd: 'claude', args: [] },
5
+ 'codex': { cmd: 'codex', args: [] },
6
+ 'gemini-cli': { cmd: 'gemini', args: [] },
7
+ 'copilot': { cmd: 'copilot', args: [] },
8
+ }
9
+
10
+ export function getAgentDefaults(type: string): { cmd: string; args: string[]; resumeArgs?: string[] } {
11
+ return DEFAULT_CMDS[type] ?? { cmd: type, args: [] }
12
+ }
13
+
14
+ /** ツールごとのセッション管理方法を返す */
15
+ export function resolveSessionArgs(
16
+ type: string,
17
+ baseArgs: string[],
18
+ savedSessionId: string | undefined,
19
+ hasSavedState: boolean,
20
+ ): { args: string[]; newSessionId?: string } {
21
+ switch (type) {
22
+ case 'claude-code':
23
+ case 'gemini-cli': {
24
+ if (savedSessionId) {
25
+ return { args: [...baseArgs, '--resume', savedSessionId] }
26
+ }
27
+ const id = randomUUID()
28
+ return { args: [...baseArgs, '--session-id', id], newSessionId: id }
29
+ }
30
+ case 'copilot': {
31
+ const id = savedSessionId ?? randomUUID()
32
+ return { args: [...baseArgs, '--session-id', id], newSessionId: savedSessionId ? undefined : id }
33
+ }
34
+ case 'codex': {
35
+ if (hasSavedState) {
36
+ return { args: ['resume', '--last'] }
37
+ }
38
+ return { args: baseArgs }
39
+ }
40
+ default:
41
+ return { args: baseArgs }
42
+ }
43
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,243 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import * as pty from 'node-pty'
3
+ import type { AgentConfig } from './config.js'
4
+ import { getProcessCwd } from './process-cwd.js'
5
+
6
+ export type SessionStatus = 'running' | 'idle' | 'done' | 'error'
7
+
8
+ const counters: Record<string, number> = {}
9
+
10
+ export class AgentSession extends EventEmitter {
11
+ private static readonly IDLE_TIMEOUT_MS = 1500
12
+ private static readonly DISPLAY_NAME_MAX_LENGTH = 25
13
+ private static readonly DISPLAY_NAME_MIN_LENGTH = 3
14
+ private static readonly CWD_POLL_INTERVAL_MS = 1000
15
+
16
+ readonly id: string
17
+ readonly type: string
18
+ readonly cmd: string
19
+ cwd: string
20
+ displayName: string
21
+ baseArgs: string[] = []
22
+ status: SessionStatus = 'running'
23
+ logBuffer: string[] = []
24
+ lastPrompt: string = ''
25
+ sessionId?: string
26
+
27
+ private ptyProcess: pty.IPty | undefined
28
+ private exited = false
29
+ private idleTimer: ReturnType<typeof setTimeout> | null = null
30
+ private cwdPollTimer: ReturnType<typeof setInterval> | null = null
31
+ private displayNameLocked = false
32
+ private initialInputBuffer = ''
33
+
34
+ constructor(config: AgentConfig, cols: number, rows: number) {
35
+ super()
36
+ counters[config.type] = (counters[config.type] ?? 0) + 1
37
+ this.id = `${config.type}#${counters[config.type]}`
38
+ this.type = config.type
39
+ this.cmd = config.cmd
40
+ this.cwd = config.cwd ?? process.cwd()
41
+ this.displayName = `${config.type} ${counters[config.type]}`
42
+
43
+ let proc: pty.IPty
44
+ try {
45
+ proc = pty.spawn(config.cmd, config.args, {
46
+ name: 'xterm-256color',
47
+ cols,
48
+ rows,
49
+ cwd: this.cwd,
50
+ env: process.env as Record<string, string>,
51
+ })
52
+ } catch (err) {
53
+ const msg = err instanceof Error ? err.message : String(err)
54
+ this.exited = true
55
+ this.status = 'error'
56
+ this.logBuffer.push(`Error: failed to spawn '${config.cmd}': ${msg}\r\n`)
57
+ process.nextTick(() => this.emit('exit', 1))
58
+ return
59
+ }
60
+
61
+ this.ptyProcess = proc
62
+
63
+ this.ptyProcess.onData((data) => {
64
+ this.updateCwdFromOutput(data)
65
+ this.appendLog(data)
66
+ this.setStatus('running')
67
+ this.scheduleIdleTimer()
68
+ this.emit('data', data)
69
+ })
70
+
71
+ this.ptyProcess.onExit(({ exitCode }) => {
72
+ this.clearIdleTimer()
73
+ this.clearCwdPollTimer()
74
+ this.setStatus(exitCode === 0 ? 'done' : 'error')
75
+ this.exited = true
76
+ this.ptyProcess = undefined
77
+ this.emit('exit', exitCode)
78
+ })
79
+
80
+ this.startCwdPolling()
81
+ }
82
+
83
+ /** 保存されたdisplayNameを復元し、以降の入力で上書きされないようにロックする */
84
+ restoreDisplayName(name: string): void {
85
+ this.displayName = name
86
+ this.displayNameLocked = true
87
+ this.emit('name', name)
88
+ }
89
+
90
+ write(data: string): void {
91
+ if (this.exited) return
92
+ this.updateDisplayNameFromInput(data)
93
+ this.ptyProcess?.write(data)
94
+ }
95
+
96
+ kill(): void {
97
+ if (this.exited) return
98
+ this.clearIdleTimer()
99
+ this.clearCwdPollTimer()
100
+ this.ptyProcess?.kill()
101
+ }
102
+
103
+ resize(cols: number, rows: number): void {
104
+ if (this.exited) return
105
+ this.ptyProcess?.resize(cols, rows)
106
+ }
107
+
108
+ private appendLog(chunk: string): void {
109
+ this.logBuffer.push(chunk)
110
+ if (this.logBuffer.length > 500) {
111
+ this.logBuffer.splice(0, this.logBuffer.length - 500)
112
+ }
113
+ }
114
+
115
+ private setStatus(nextStatus: SessionStatus): void {
116
+ if (this.status === nextStatus) {
117
+ return
118
+ }
119
+ this.status = nextStatus
120
+ this.emit('status', nextStatus)
121
+ }
122
+
123
+ private scheduleIdleTimer(): void {
124
+ if (this.exited) return
125
+ this.clearIdleTimer()
126
+ this.idleTimer = setTimeout(() => {
127
+ this.idleTimer = null
128
+ if (this.exited) return
129
+ this.setStatus('idle')
130
+ }, AgentSession.IDLE_TIMEOUT_MS)
131
+ }
132
+
133
+ private clearIdleTimer(): void {
134
+ if (!this.idleTimer) {
135
+ return
136
+ }
137
+ clearTimeout(this.idleTimer)
138
+ this.idleTimer = null
139
+ }
140
+
141
+ private startCwdPolling(): void {
142
+ if (this.cwdPollTimer || !this.ptyProcess) {
143
+ return
144
+ }
145
+
146
+ this.cwdPollTimer = setInterval(() => {
147
+ if (this.exited || !this.ptyProcess) {
148
+ return
149
+ }
150
+
151
+ this.updateCwd(getProcessCwd(this.ptyProcess.pid))
152
+ }, AgentSession.CWD_POLL_INTERVAL_MS)
153
+ }
154
+
155
+ private clearCwdPollTimer(): void {
156
+ if (!this.cwdPollTimer) {
157
+ return
158
+ }
159
+
160
+ clearInterval(this.cwdPollTimer)
161
+ this.cwdPollTimer = null
162
+ }
163
+
164
+ private updateDisplayNameFromInput(data: string): void {
165
+ if (this.displayNameLocked) {
166
+ return
167
+ }
168
+
169
+ this.initialInputBuffer += data
170
+
171
+ // DCS/APC/PM/SOS シーケンス(Warp など一部ターミナルがstdinに送る)を除去してから
172
+ // ユーザー入力の改行を探す。これらのシーケンスは CR/LF を含むことがあり、
173
+ // そのまま処理すると表示名がターミナル固有の文字列で汚染される。
174
+ const cleaned = this.initialInputBuffer.replace(/\x1b[P_^X][^\x1b]*(?:\x1b\\)?/g, '')
175
+
176
+ const newlineIndex = cleaned.search(/\r|\n/)
177
+ if (newlineIndex === -1) {
178
+ return
179
+ }
180
+
181
+ const firstLine = cleaned.slice(0, newlineIndex)
182
+ this.initialInputBuffer = ''
183
+
184
+ const normalized = AgentSession.normalizeDisplayName(firstLine)
185
+ if (normalized.length < AgentSession.DISPLAY_NAME_MIN_LENGTH) {
186
+ return
187
+ }
188
+
189
+ this.displayNameLocked = true
190
+ this.displayName = normalized
191
+ this.emit('name', normalized)
192
+ }
193
+
194
+ private updateCwdFromOutput(data: string): void {
195
+ this.updateCwd(AgentSession.extractOsc7Cwd(data))
196
+ }
197
+
198
+ private updateCwd(nextCwd: string | null): void {
199
+ if (!nextCwd || nextCwd === this.cwd) {
200
+ return
201
+ }
202
+
203
+ this.cwd = nextCwd
204
+ this.emit('cwd', nextCwd)
205
+ }
206
+
207
+ private static extractOsc7Cwd(data: string): string | null {
208
+ const matches = [...data.matchAll(/\x1b\]7;([^\x07\x1b]+)(?:\x07|\x1b\\)/g)]
209
+ const rawUrl = matches.at(-1)?.[1]
210
+ if (!rawUrl) {
211
+ return null
212
+ }
213
+
214
+ try {
215
+ const url = new URL(rawUrl)
216
+ if (url.protocol !== 'file:') {
217
+ return null
218
+ }
219
+
220
+ return decodeURIComponent(url.pathname || '')
221
+ } catch {
222
+ return null
223
+ }
224
+ }
225
+
226
+ private static normalizeDisplayName(input: string): string {
227
+ const withoutAnsi = input
228
+ .replace(/\x1b[P_^X][^\x1b]*(?:\x1b\\)?/g, '') // DCS, APC, PM, SOS(複数文字シーケンス)
229
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
230
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
231
+ .replace(/\x1b[@-Z\\-_]/g, '')
232
+ const collapsed = withoutAnsi
233
+ .replace(/[\x00-\x1f\x7f]+/g, ' ')
234
+ .replace(/\s+/g, ' ')
235
+ .trim()
236
+
237
+ if (collapsed.length <= AgentSession.DISPLAY_NAME_MAX_LENGTH) {
238
+ return collapsed
239
+ }
240
+
241
+ return `${collapsed.slice(0, AgentSession.DISPLAY_NAME_MAX_LENGTH - 3).trimEnd()}...`
242
+ }
243
+ }
package/src/config.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { load as yamlLoad } from 'js-yaml'
3
+ import { getAgentDefaults } from './agent-launch.js'
4
+
5
+ export interface AgentConfig {
6
+ type: string
7
+ cmd: string
8
+ args: string[]
9
+ cwd?: string
10
+ resumeArgs?: string[]
11
+ }
12
+
13
+ export interface MavConfig {
14
+ agents: AgentConfig[]
15
+ }
16
+
17
+ const DEFAULT_CONFIG: MavConfig = {
18
+ agents: [],
19
+ }
20
+
21
+ export function loadConfig(configPath: string): MavConfig {
22
+ if (!existsSync(configPath)) {
23
+ return DEFAULT_CONFIG
24
+ }
25
+
26
+ const raw = readFileSync(configPath, 'utf-8')
27
+ const parsed = yamlLoad(raw) as { agents?: Array<{ type: string; cmd?: string; args?: string[] }> }
28
+
29
+ if (!parsed?.agents || !Array.isArray(parsed.agents)) {
30
+ return DEFAULT_CONFIG
31
+ }
32
+
33
+ const agents: AgentConfig[] = parsed.agents
34
+ .filter((a): a is { type: string; cmd?: string; args?: string[]; resumeArgs?: string[] } =>
35
+ typeof a?.type === 'string' && a.type.length > 0
36
+ )
37
+ .map((a) => {
38
+ const defaults = getAgentDefaults(a.type)
39
+ const cmd = typeof a.cmd === 'string' && a.cmd.trim().length > 0
40
+ ? a.cmd
41
+ : defaults.cmd
42
+ return {
43
+ type: a.type,
44
+ cmd,
45
+ args: Array.isArray(a.args) ? a.args : defaults.args,
46
+ resumeArgs: Array.isArray(a.resumeArgs) ? a.resumeArgs : defaults.resumeArgs,
47
+ }
48
+ })
49
+
50
+ return { agents }
51
+ }
@@ -0,0 +1,34 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ export interface CurrentSessionSnapshot {
5
+ id: string
6
+ type: string
7
+ displayName: string
8
+ cwd: string
9
+ }
10
+
11
+ export interface CurrentSessionState {
12
+ sessionId: string
13
+ agentType: string
14
+ displayName: string
15
+ cwd: string
16
+ updatedAt: string
17
+ }
18
+
19
+ export function saveCurrentSessionState(path: string, session: CurrentSessionSnapshot): void {
20
+ const state: CurrentSessionState = {
21
+ sessionId: session.id,
22
+ agentType: session.type,
23
+ displayName: session.displayName,
24
+ cwd: session.cwd,
25
+ updatedAt: new Date().toISOString(),
26
+ }
27
+
28
+ mkdirSync(dirname(path), { recursive: true })
29
+ writeFileSync(path, JSON.stringify(state), 'utf-8')
30
+ }
31
+
32
+ export function clearCurrentSessionState(path: string): void {
33
+ rmSync(path, { force: true })
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, dirname } from 'node:path'
3
+ import { loadConfig } from './config.js'
4
+ import { clearCurrentSessionState, saveCurrentSessionState } from './current-session.js'
5
+ import { loadState, saveState } from './state.js'
6
+ import { SessionManager } from './session-manager.js'
7
+ import { App } from './ui/app.js'
8
+ import type { AgentSession } from './agent.js'
9
+ import { resolveSessionArgs } from './agent-launch.js'
10
+
11
+ export interface StartOptions {
12
+ configPath?: string
13
+ agentType?: string
14
+ }
15
+
16
+ export function start(options: StartOptions = {}): SessionManager {
17
+ const configPath =
18
+ options.configPath ?? join(homedir(), '.config', 'mav', 'config.yaml')
19
+ const statePath = join(dirname(configPath), 'state.json')
20
+ const currentSessionPath = join(homedir(), '.local', 'state', 'mav', 'current-session.json')
21
+ const config = loadConfig(configPath)
22
+
23
+ const agentsToStart = options.agentType
24
+ ? config.agents.filter((a) => a.type === options.agentType)
25
+ : config.agents
26
+
27
+ if (agentsToStart.length === 0 && options.agentType) {
28
+ console.error(`No agents found for type: ${options.agentType}`)
29
+ process.exit(1)
30
+ }
31
+
32
+ const savedState = loadState(statePath)
33
+
34
+ const manager = new SessionManager()
35
+ const app = new App(manager, statePath)
36
+ const publishSelectedSession = () => {
37
+ const session = manager.selectedSession
38
+ if (!session) {
39
+ clearCurrentSessionState(currentSessionPath)
40
+ return
41
+ }
42
+
43
+ saveCurrentSessionState(currentSessionPath, {
44
+ id: session.id,
45
+ type: session.type,
46
+ displayName: session.displayName,
47
+ cwd: session.cwd,
48
+ })
49
+ }
50
+
51
+ const typeCounters: Record<string, number> = {}
52
+ const configSessionIds = new Set<string>()
53
+
54
+ for (const agentConfig of agentsToStart) {
55
+ typeCounters[agentConfig.type] = (typeCounters[agentConfig.type] ?? 0) + 1
56
+ const predictedId = `${agentConfig.type}#${typeCounters[agentConfig.type]}`
57
+ const savedSession = savedState?.sessions[predictedId]
58
+
59
+ const { args, newSessionId } = resolveSessionArgs(
60
+ agentConfig.type,
61
+ agentConfig.args,
62
+ savedSession?.sessionId,
63
+ savedSession != null,
64
+ )
65
+
66
+ const session = manager.addSession({ ...agentConfig, args }) as AgentSession & { sessionId?: string }
67
+ session.baseArgs = agentConfig.args
68
+ configSessionIds.add(session.id)
69
+
70
+ if (newSessionId != null) {
71
+ session.sessionId = newSessionId
72
+ }
73
+ }
74
+
75
+ // configに定義されていないが保存済みセッション(動的追加分)を再作成する
76
+ if (savedState) {
77
+ for (const [stateId, savedSession] of Object.entries(savedState.sessions)) {
78
+ if (configSessionIds.has(stateId) || !savedSession.agentBase) continue
79
+
80
+ const rc = savedSession.agentBase
81
+ const { args, newSessionId } = resolveSessionArgs(
82
+ rc.type,
83
+ rc.args,
84
+ savedSession.sessionId,
85
+ true,
86
+ )
87
+ const session = manager.addSession({ type: rc.type, cmd: rc.cmd, args }) as AgentSession & { sessionId?: string }
88
+ session.baseArgs = rc.args
89
+ if (newSessionId != null) {
90
+ session.sessionId = newSessionId
91
+ }
92
+ }
93
+ }
94
+
95
+ if (savedState) {
96
+ manager.restoreLogBuffers(savedState)
97
+ }
98
+
99
+ manager.on('selection', () => {
100
+ publishSelectedSession()
101
+ })
102
+
103
+ manager.on('cwd', (sessionId: string) => {
104
+ if (manager.selectedSession?.id !== sessionId) {
105
+ return
106
+ }
107
+ publishSelectedSession()
108
+ })
109
+
110
+ publishSelectedSession()
111
+
112
+ // q/Ctrl+C 以外の終了(ウィンドウ閉じ等)でも state を保存する
113
+ const saveOnExit = () => {
114
+ try { saveState(statePath, manager) } catch { /* ignore */ }
115
+ }
116
+ process.once('SIGTERM', () => { saveOnExit(); process.exit(0) })
117
+ process.once('SIGHUP', () => { saveOnExit(); process.exit(0) })
118
+
119
+ app.start()
120
+ return manager
121
+ }
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { readlinkSync } from 'node:fs'
3
+
4
+ export function getProcessCwd(pid: number, platform: NodeJS.Platform = process.platform): string | null {
5
+ if (!Number.isInteger(pid) || pid <= 0) {
6
+ return null
7
+ }
8
+
9
+ try {
10
+ if (platform === 'linux') {
11
+ return readlinkSync(`/proc/${pid}/cwd`)
12
+ }
13
+
14
+ if (platform === 'darwin') {
15
+ const output = execFileSync(
16
+ 'lsof',
17
+ ['-a', '-d', 'cwd', '-p', String(pid), '-Fn'],
18
+ { encoding: 'utf8' },
19
+ )
20
+ const cwdLine = output
21
+ .split('\n')
22
+ .find((line) => line.startsWith('n/') || line === 'n/')
23
+
24
+ return cwdLine ? cwdLine.slice(1) : null
25
+ }
26
+ } catch {
27
+ return null
28
+ }
29
+
30
+ return null
31
+ }
@@ -0,0 +1,122 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { AgentSession } from './agent.js'
3
+ import type { AgentConfig } from './config.js'
4
+ import type { MavState } from './state.js'
5
+
6
+ type SessionListeners = {
7
+ onData: (chunk: string) => void
8
+ onExit: (code: number) => void
9
+ onStatus: (status: string) => void
10
+ onName: (name: string) => void
11
+ onCwd: (cwd: string) => void
12
+ }
13
+
14
+ export class SessionManager extends EventEmitter {
15
+ sessions: AgentSession[] = []
16
+ selectedIndex: number = -1
17
+ private sessionListeners = new Map<string, SessionListeners>()
18
+
19
+ private emitSelection(): void {
20
+ this.emit('selection', this.selectedSession)
21
+ }
22
+
23
+ addSession(config: AgentConfig, cols = 80, rows = 24): AgentSession {
24
+ const session = new AgentSession(config, cols, rows)
25
+
26
+ const onData = (chunk: string) => { this.emit('data', session.id, chunk) }
27
+ const onExit = (code: number) => { this.emit('exit', session.id, code) }
28
+ const onStatus = (status: string) => { this.emit('status', session.id, status) }
29
+ const onName = (name: string) => { this.emit('name', session.id, name) }
30
+ const onCwd = (cwd: string) => { this.emit('cwd', session.id, cwd) }
31
+
32
+ session.on('data', onData)
33
+ session.on('exit', onExit)
34
+ session.on('status', onStatus)
35
+ session.on('name', onName)
36
+ session.on('cwd', onCwd)
37
+ this.sessionListeners.set(session.id, { onData, onExit, onStatus, onName, onCwd })
38
+
39
+ this.sessions.push(session)
40
+
41
+ if (this.selectedIndex === -1) {
42
+ this.selectedIndex = 0
43
+ this.emitSelection()
44
+ }
45
+
46
+ return session
47
+ }
48
+
49
+ removeSession(id: string): void {
50
+ const idx = this.sessions.findIndex((s) => s.id === id)
51
+ if (idx === -1) return
52
+
53
+ const session = this.sessions[idx]!
54
+ const ls = this.sessionListeners.get(id)
55
+ if (ls) {
56
+ session.off('data', ls.onData)
57
+ session.off('exit', ls.onExit)
58
+ session.off('status', ls.onStatus)
59
+ session.off('name', ls.onName)
60
+ session.off('cwd', ls.onCwd)
61
+ this.sessionListeners.delete(id)
62
+ }
63
+ session.kill()
64
+ this.sessions.splice(idx, 1)
65
+
66
+ if (this.sessions.length === 0) {
67
+ this.selectedIndex = -1
68
+ } else if (idx < this.selectedIndex) {
69
+ this.selectedIndex -= 1
70
+ } else if (this.selectedIndex >= this.sessions.length) {
71
+ this.selectedIndex = this.sessions.length - 1
72
+ }
73
+
74
+ this.emitSelection()
75
+ }
76
+
77
+ selectSession(index: number): void {
78
+ if (index < 0 || index >= this.sessions.length) return
79
+ this.selectedIndex = index
80
+ this.emitSelection()
81
+ }
82
+
83
+ get selectedSession(): AgentSession | null {
84
+ if (this.selectedIndex === -1) return null
85
+ return this.sessions[this.selectedIndex] ?? null
86
+ }
87
+
88
+ restoreLogBuffers(state: MavState): void {
89
+ for (const session of this.sessions) {
90
+ const saved = state.sessions[session.id]
91
+ if (saved) {
92
+ session.logBuffer = Array.isArray(saved.logBuffer)
93
+ ? saved.logBuffer.filter((chunk): chunk is string => typeof chunk === 'string')
94
+ : []
95
+ if (saved.sessionId != null) {
96
+ session.sessionId = saved.sessionId
97
+ }
98
+ if (saved.displayName) {
99
+ session.restoreDisplayName(saved.displayName)
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ killAll(): void {
106
+ for (const session of this.sessions) {
107
+ const ls = this.sessionListeners.get(session.id)
108
+ if (ls) {
109
+ session.off('data', ls.onData)
110
+ session.off('exit', ls.onExit)
111
+ session.off('status', ls.onStatus)
112
+ session.off('name', ls.onName)
113
+ session.off('cwd', ls.onCwd)
114
+ }
115
+ session.kill()
116
+ }
117
+ this.sessions = []
118
+ this.sessionListeners.clear()
119
+ this.selectedIndex = -1
120
+ this.emitSelection()
121
+ }
122
+ }
package/src/state.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import type { SessionManager } from './session-manager.js'
4
+ import type { SessionStatus } from './agent.js'
5
+
6
+ export interface SessionState {
7
+ logBuffer: string[]
8
+ status: SessionStatus
9
+ sessionId?: string
10
+ displayName?: string
11
+ /** configに定義されていない動的セッションを再起動時に復元するための情報 */
12
+ agentBase?: {
13
+ type: string
14
+ cmd: string
15
+ args: string[]
16
+ }
17
+ }
18
+
19
+ export interface MavState {
20
+ sessions: Record<string, SessionState>
21
+ }
22
+
23
+ export function saveState(path: string, manager: SessionManager): void {
24
+ const state: MavState = { sessions: {} }
25
+ for (const session of manager.sessions) {
26
+ state.sessions[session.id] = {
27
+ logBuffer: [...session.logBuffer],
28
+ status: session.status,
29
+ ...(session.sessionId != null && { sessionId: session.sessionId }),
30
+ displayName: session.displayName,
31
+ agentBase: {
32
+ type: session.type,
33
+ cmd: session.cmd,
34
+ args: [...session.baseArgs],
35
+ },
36
+ }
37
+ }
38
+ mkdirSync(dirname(path), { recursive: true })
39
+ writeFileSync(path, JSON.stringify(state), 'utf-8')
40
+ }
41
+
42
+ export function loadState(path: string): MavState | null {
43
+ try {
44
+ const raw = readFileSync(path, 'utf-8')
45
+ return JSON.parse(raw) as MavState
46
+ } catch {
47
+ return null
48
+ }
49
+ }
@@ -0,0 +1,5 @@
1
+ declare module 'neo-blessed' {
2
+ export * from 'blessed'
3
+ declare const _default: typeof import('blessed')
4
+ export { _default as default }
5
+ }