@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.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/mav.ts +31 -0
- package/dist/bin/mav.d.ts +2 -0
- package/dist/bin/mav.js +24 -0
- package/dist/bin/mav.js.map +1 -0
- package/dist/src/agent-launch.d.ts +15 -0
- package/dist/src/agent-launch.js +36 -0
- package/dist/src/agent-launch.js.map +1 -0
- package/dist/src/agent.d.ts +42 -0
- package/dist/src/agent.js +209 -0
- package/dist/src/agent.js.map +1 -0
- package/dist/src/config.d.ts +11 -0
- package/dist/src/config.js +32 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/current-session.d.ts +15 -0
- package/dist/src/current-session.js +17 -0
- package/dist/src/current-session.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +90 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/process-cwd.d.ts +1 -0
- package/dist/src/process-cwd.js +24 -0
- package/dist/src/process-cwd.js.map +1 -0
- package/dist/src/session-manager.d.ts +16 -0
- package/dist/src/session-manager.js +102 -0
- package/dist/src/session-manager.js.map +1 -0
- package/dist/src/state.d.ts +19 -0
- package/dist/src/state.js +30 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/ui/app.d.ts +15 -0
- package/dist/src/ui/app.js +109 -0
- package/dist/src/ui/app.js.map +1 -0
- package/dist/src/ui/detail.d.ts +28 -0
- package/dist/src/ui/detail.js +137 -0
- package/dist/src/ui/detail.js.map +1 -0
- package/dist/src/ui/overview.d.ts +24 -0
- package/dist/src/ui/overview.js +267 -0
- package/dist/src/ui/overview.js.map +1 -0
- package/lua/mav/follow.lua +37 -0
- package/lua/mav/init.lua +92 -0
- package/lua/mav/state.lua +22 -0
- package/package.json +54 -0
- package/plugin/mav.lua +24 -0
- package/src/agent-launch.ts +43 -0
- package/src/agent.ts +243 -0
- package/src/config.ts +51 -0
- package/src/current-session.ts +34 -0
- package/src/index.ts +121 -0
- package/src/process-cwd.ts +31 -0
- package/src/session-manager.ts +122 -0
- package/src/state.ts +49 -0
- package/src/types/neo-blessed.d.ts +5 -0
- package/src/ui/app.ts +121 -0
- package/src/ui/detail.ts +164 -0
- 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
|
+
}
|