@elyun/bylane 1.12.0 → 1.14.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/hooks/bylane-agent-tracker.js +84 -0
- package/package.json +1 -1
- package/src/cli.js +49 -3
- package/src/monitor/index.js +41 -8
- package/src/monitor/layout.js +1 -1
- package/src/monitor/panels/log.js +16 -10
- package/src/monitor/panels/pipeline.js +21 -1
- package/src/monitor/panels/queue.js +23 -13
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bylane-agent-tracker.js
|
|
4
|
+
* PreToolUse / PostToolUse 훅 — Agent 도구 호출을 추적하고 취소 플래그를 검사한다.
|
|
5
|
+
*
|
|
6
|
+
* 등록 방법 (npx @elyun/bylane 이 자동 등록):
|
|
7
|
+
* settings.json > hooks > PreToolUse : node .../bylane-agent-tracker.js pre
|
|
8
|
+
* settings.json > hooks > PostToolUse : node .../bylane-agent-tracker.js post
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
|
|
14
|
+
const hookType = process.argv[2] ?? 'pre' // 'pre' | 'post'
|
|
15
|
+
const STATE_DIR = '.bylane/state'
|
|
16
|
+
const SUBAGENTS_FILE = join(STATE_DIR, 'subagents.json')
|
|
17
|
+
const CANCEL_FILE = join(STATE_DIR, 'cancel.json')
|
|
18
|
+
|
|
19
|
+
let input
|
|
20
|
+
try {
|
|
21
|
+
input = JSON.parse(readFileSync('/dev/stdin', 'utf8'))
|
|
22
|
+
} catch {
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { session_id, tool_name, tool_input, tool_result } = input
|
|
27
|
+
|
|
28
|
+
// Agent 도구 호출만 처리
|
|
29
|
+
if (tool_name !== 'Agent') process.exit(0)
|
|
30
|
+
|
|
31
|
+
function readSubagents() {
|
|
32
|
+
if (!existsSync(SUBAGENTS_FILE)) return { active: [], recent: [] }
|
|
33
|
+
try { return JSON.parse(readFileSync(SUBAGENTS_FILE, 'utf8')) } catch { return { active: [], recent: [] } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeSubagents(data) {
|
|
37
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
38
|
+
writeFileSync(SUBAGENTS_FILE, JSON.stringify(data, null, 2))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (hookType === 'pre') {
|
|
42
|
+
// 취소 플래그 확인 → 있으면 에이전트 실행 차단
|
|
43
|
+
if (existsSync(CANCEL_FILE)) {
|
|
44
|
+
const out = JSON.stringify({
|
|
45
|
+
decision: 'block',
|
|
46
|
+
reason: '사용자가 에이전트 실행을 취소했습니다. (byLane monitor에서 [c] 키로 설정됨)'
|
|
47
|
+
})
|
|
48
|
+
process.stdout.write(out)
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 신규 하위 에이전트 기록
|
|
53
|
+
const data = readSubagents()
|
|
54
|
+
const entry = {
|
|
55
|
+
id: `${session_id}-${Date.now()}`,
|
|
56
|
+
sessionId: session_id,
|
|
57
|
+
subagentType: tool_input?.subagent_type ?? 'general-purpose',
|
|
58
|
+
prompt: (tool_input?.prompt ?? '').slice(0, 120),
|
|
59
|
+
status: 'running',
|
|
60
|
+
startedAt: new Date().toISOString()
|
|
61
|
+
}
|
|
62
|
+
data.active.push(entry)
|
|
63
|
+
// active는 최대 20개 유지
|
|
64
|
+
if (data.active.length > 20) data.active.shift()
|
|
65
|
+
writeSubagents(data)
|
|
66
|
+
|
|
67
|
+
} else if (hookType === 'post') {
|
|
68
|
+
// 완료 처리 — active → recent 이동
|
|
69
|
+
const data = readSubagents()
|
|
70
|
+
const idx = [...data.active].reverse()
|
|
71
|
+
.findIndex(a => a.sessionId === session_id && a.status === 'running')
|
|
72
|
+
|
|
73
|
+
if (idx !== -1) {
|
|
74
|
+
const realIdx = data.active.length - 1 - idx
|
|
75
|
+
const agent = { ...data.active[realIdx], status: 'completed', completedAt: new Date().toISOString() }
|
|
76
|
+
data.active.splice(realIdx, 1)
|
|
77
|
+
data.recent.unshift(agent)
|
|
78
|
+
// recent는 최대 10개 유지
|
|
79
|
+
if (data.recent.length > 10) data.recent.pop()
|
|
80
|
+
writeSubagents(data)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.exit(0)
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, symlinkSync, existsSync, readdirSync, copyFileSync, renameSync, readFileSync, writeFileSync } from 'fs'
|
|
3
3
|
import { join, dirname } from 'path'
|
|
4
4
|
import { fileURLToPath } from 'url'
|
|
5
5
|
import { homedir } from 'os'
|
|
@@ -13,8 +13,8 @@ const command = args[0] || 'install'
|
|
|
13
13
|
const useSymlink = args.includes('--symlink')
|
|
14
14
|
|
|
15
15
|
const TARGETS = [
|
|
16
|
-
{ src: join(ROOT, 'commands'), dest: join(CLAUDE_DIR, 'commands'),
|
|
17
|
-
{ src: join(ROOT, 'hooks'), dest: join(CLAUDE_DIR, 'hooks'),
|
|
16
|
+
{ src: join(ROOT, 'commands'), dest: join(CLAUDE_DIR, 'commands'), label: 'Commands' },
|
|
17
|
+
{ src: join(ROOT, 'hooks'), dest: join(CLAUDE_DIR, 'hooks'), label: 'Hooks' },
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
function backupAndCopy(src, dest, file, label) {
|
|
@@ -38,6 +38,49 @@ function backupAndCopy(src, dest, file, label) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function registerHooks() {
|
|
42
|
+
const settingsPath = join(CLAUDE_DIR, 'settings.json')
|
|
43
|
+
let settings = {}
|
|
44
|
+
if (existsSync(settingsPath)) {
|
|
45
|
+
try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')) } catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hookScript = join(CLAUDE_DIR, 'hooks', 'bylane-agent-tracker.js')
|
|
49
|
+
settings.hooks = settings.hooks ?? {}
|
|
50
|
+
|
|
51
|
+
// PreToolUse
|
|
52
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse ?? []
|
|
53
|
+
const preExists = settings.hooks.PreToolUse.some(h =>
|
|
54
|
+
h.hooks?.some(hh => hh.command?.includes('bylane-agent-tracker'))
|
|
55
|
+
)
|
|
56
|
+
if (!preExists) {
|
|
57
|
+
settings.hooks.PreToolUse.push({
|
|
58
|
+
matcher: 'Agent',
|
|
59
|
+
hooks: [{ type: 'command', command: `node ${hookScript} pre` }]
|
|
60
|
+
})
|
|
61
|
+
console.log(' + Hook: PreToolUse/Agent → bylane-agent-tracker')
|
|
62
|
+
} else {
|
|
63
|
+
console.log(' = Hook: PreToolUse/Agent (이미 등록됨)')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// PostToolUse
|
|
67
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse ?? []
|
|
68
|
+
const postExists = settings.hooks.PostToolUse.some(h =>
|
|
69
|
+
h.hooks?.some(hh => hh.command?.includes('bylane-agent-tracker'))
|
|
70
|
+
)
|
|
71
|
+
if (!postExists) {
|
|
72
|
+
settings.hooks.PostToolUse.push({
|
|
73
|
+
matcher: 'Agent',
|
|
74
|
+
hooks: [{ type: 'command', command: `node ${hookScript} post` }]
|
|
75
|
+
})
|
|
76
|
+
console.log(' + Hook: PostToolUse/Agent → bylane-agent-tracker')
|
|
77
|
+
} else {
|
|
78
|
+
console.log(' = Hook: PostToolUse/Agent (이미 등록됨)')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
function install() {
|
|
42
85
|
console.log('\n byLane 설치 중...\n')
|
|
43
86
|
|
|
@@ -63,6 +106,9 @@ function install() {
|
|
|
63
106
|
}
|
|
64
107
|
}
|
|
65
108
|
|
|
109
|
+
console.log('')
|
|
110
|
+
registerHooks()
|
|
111
|
+
|
|
66
112
|
console.log(`
|
|
67
113
|
byLane 설치 완료!
|
|
68
114
|
|
package/src/monitor/index.js
CHANGED
|
@@ -1,26 +1,59 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createLayout } from './layout.js'
|
|
3
3
|
import { createPoller } from './poller.js'
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs'
|
|
5
|
+
import { join } from 'path'
|
|
4
6
|
|
|
5
7
|
const { screen, header, pipeline, log, queue, status, onCleanup } = createLayout()
|
|
6
8
|
const poller = createPoller()
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
const startTime = Date.now()
|
|
11
|
+
const STATE_DIR = '.bylane/state'
|
|
12
|
+
const SUBAGENTS_FILE = join(STATE_DIR, 'subagents.json')
|
|
13
|
+
const CANCEL_FILE = join(STATE_DIR, 'cancel.json')
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
})
|
|
15
|
-
}, 1000)
|
|
15
|
+
function readSubagents() {
|
|
16
|
+
if (!existsSync(SUBAGENTS_FILE)) return { active: [], recent: [] }
|
|
17
|
+
try { return JSON.parse(readFileSync(SUBAGENTS_FILE, 'utf8')) } catch { return { active: [], recent: [] } }
|
|
18
|
+
}
|
|
16
19
|
|
|
17
20
|
onCleanup(() => poller.stop())
|
|
18
21
|
|
|
19
22
|
poller.onChange((states) => {
|
|
20
|
-
|
|
23
|
+
const active = Object.values(states).find(s => s.status === 'in_progress')
|
|
24
|
+
const workflowTitle = active ? (active.currentTask ?? active.agent) : 'Idle'
|
|
25
|
+
|
|
26
|
+
header.update({
|
|
27
|
+
workflowTitle,
|
|
28
|
+
time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
|
|
29
|
+
elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
|
|
30
|
+
})
|
|
31
|
+
pipeline.update(states, readSubagents())
|
|
21
32
|
log.update(states)
|
|
22
33
|
queue.update()
|
|
23
34
|
status.update()
|
|
24
35
|
})
|
|
25
36
|
|
|
37
|
+
// 'c' — 하위 에이전트 취소 플래그 토글
|
|
38
|
+
const cancelLabel = () => existsSync(CANCEL_FILE) ? '[취소 활성]' : ''
|
|
39
|
+
screen.key('c', () => {
|
|
40
|
+
if (existsSync(CANCEL_FILE)) {
|
|
41
|
+
unlinkSync(CANCEL_FILE)
|
|
42
|
+
header.update({
|
|
43
|
+
workflowTitle: '에이전트 취소 해제',
|
|
44
|
+
time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
|
|
45
|
+
elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
|
|
46
|
+
})
|
|
47
|
+
} else {
|
|
48
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
49
|
+
writeFileSync(CANCEL_FILE, JSON.stringify({ cancelledAt: new Date().toISOString() }))
|
|
50
|
+
header.update({
|
|
51
|
+
workflowTitle: '!! 에이전트 취소 요청됨 !!',
|
|
52
|
+
time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
|
|
53
|
+
elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
screen.render()
|
|
57
|
+
})
|
|
58
|
+
|
|
26
59
|
screen.render()
|
package/src/monitor/layout.js
CHANGED
|
@@ -23,7 +23,7 @@ export function createLayout() {
|
|
|
23
23
|
left: 0,
|
|
24
24
|
width: '100%',
|
|
25
25
|
height: 1,
|
|
26
|
-
content: ' [q]종료 [
|
|
26
|
+
content: ' [q]종료 [c]에이전트취소토글 [Tab]포커스 [j/k]로그스크롤',
|
|
27
27
|
style: { fg: 'black', bg: 'cyan' }
|
|
28
28
|
})
|
|
29
29
|
screen.append(footer)
|
|
@@ -16,24 +16,30 @@ export function createLogPanel(screen) {
|
|
|
16
16
|
vi: true
|
|
17
17
|
})
|
|
18
18
|
screen.append(box)
|
|
19
|
+
|
|
19
20
|
const lines = []
|
|
21
|
+
const seen = new Set()
|
|
20
22
|
|
|
21
23
|
return {
|
|
22
24
|
update(states) {
|
|
23
|
-
|
|
25
|
+
let changed = false
|
|
24
26
|
for (const state of Object.values(states)) {
|
|
25
|
-
for (const entry of (state.log ?? [])
|
|
27
|
+
for (const entry of (state.log ?? [])) {
|
|
28
|
+
const key = `${state.agent}:${entry.ts}`
|
|
29
|
+
if (seen.has(key)) continue
|
|
30
|
+
seen.add(key)
|
|
26
31
|
const ts = new Date(entry.ts).toLocaleTimeString('ko-KR', { hour12: false })
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
lines.push(` ${ts} {cyan-fg}${state.agent}{/cyan-fg}`)
|
|
33
|
+
lines.push(` > ${entry.msg}`)
|
|
34
|
+
changed = true
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
if (changed) {
|
|
38
|
+
if (lines.length > 200) lines.splice(0, lines.length - 200)
|
|
39
|
+
box.setContent(lines.join('\n'))
|
|
40
|
+
box.scrollTo(lines.length)
|
|
41
|
+
screen.render()
|
|
42
|
+
}
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
}
|
|
@@ -27,7 +27,7 @@ export function createPipelinePanel(screen) {
|
|
|
27
27
|
screen.append(box)
|
|
28
28
|
|
|
29
29
|
return {
|
|
30
|
-
update(states) {
|
|
30
|
+
update(states, subagents = { active: [], recent: [] }) {
|
|
31
31
|
const lines = AGENTS.map(name => {
|
|
32
32
|
const s = states[name]
|
|
33
33
|
if (!s) return ` ${STATUS_ICON.idle} ${name.padEnd(16)} 대기`
|
|
@@ -40,9 +40,29 @@ export function createPipelinePanel(screen) {
|
|
|
40
40
|
: ''
|
|
41
41
|
return ` ${icon} ${name.padEnd(16)} ${elapsed.padEnd(6)} ${bar}`
|
|
42
42
|
})
|
|
43
|
+
|
|
43
44
|
const retries = states['orchestrator']?.retries ?? 0
|
|
44
45
|
const maxRetries = states['orchestrator']?.maxRetries ?? 3
|
|
45
46
|
lines.push('', ` Retries: ${retries}/${maxRetries}`)
|
|
47
|
+
|
|
48
|
+
// 하위 에이전트 섹션
|
|
49
|
+
lines.push('', ' SUBAGENTS')
|
|
50
|
+
if (subagents.active.length === 0) {
|
|
51
|
+
lines.push(' 실행 중 없음')
|
|
52
|
+
} else {
|
|
53
|
+
for (const a of subagents.active) {
|
|
54
|
+
const elapsed = `${Math.floor((Date.now() - new Date(a.startedAt)) / 1000)}s`
|
|
55
|
+
const type = (a.subagentType ?? 'general').padEnd(14)
|
|
56
|
+
const prompt = a.prompt.length > 28
|
|
57
|
+
? a.prompt.slice(0, 28) + '...'
|
|
58
|
+
: a.prompt.padEnd(31)
|
|
59
|
+
lines.push(` [>] ${type} ${elapsed.padEnd(6)} ${prompt}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (subagents.recent.length > 0) {
|
|
63
|
+
lines.push(` 최근 완료: ${subagents.recent.length}건`)
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
box.setContent(lines.join('\n'))
|
|
47
67
|
screen.render()
|
|
48
68
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import blessed from 'blessed'
|
|
2
2
|
import { existsSync, readFileSync } from 'fs'
|
|
3
3
|
|
|
4
|
+
function readQueue(path) {
|
|
5
|
+
if (!existsSync(path)) return []
|
|
6
|
+
try {
|
|
7
|
+
const data = JSON.parse(readFileSync(path, 'utf8'))
|
|
8
|
+
return data.queue ?? []
|
|
9
|
+
} catch {
|
|
10
|
+
return []
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
export function createQueuePanel(screen) {
|
|
5
15
|
const box = blessed.box({
|
|
6
16
|
top: '60%',
|
|
@@ -16,20 +26,20 @@ export function createQueuePanel(screen) {
|
|
|
16
26
|
|
|
17
27
|
return {
|
|
18
28
|
update() {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const header = ` ${'#'.padEnd(3)} ${'TYPE'.padEnd(12)} ${'TARGET'.padEnd(10)} STATUS`
|
|
29
|
-
const rows = queue.slice(0, 8).map((item, i) =>
|
|
30
|
-
` ${String(i + 1).padEnd(3)} ${(item.type ?? '').padEnd(12)} ${(item.target ?? '').padEnd(10)} ${item.status ?? ''}`
|
|
29
|
+
const reviewItems = readQueue('.bylane/state/review-queue.json')
|
|
30
|
+
.map(p => ({ type: 'review', target: `PR #${p.number}`, status: p.status ?? '' }))
|
|
31
|
+
const respondItems = readQueue('.bylane/state/respond-queue.json')
|
|
32
|
+
.map(p => ({ type: 'respond', target: `PR #${p.number}`, status: p.status ?? '' }))
|
|
33
|
+
|
|
34
|
+
const all = [...reviewItems, ...respondItems]
|
|
35
|
+
const header = ` ${'#'.padEnd(3)} ${'TYPE'.padEnd(10)} ${'TARGET'.padEnd(10)} STATUS`
|
|
36
|
+
const rows = all.slice(0, 8).map((item, i) =>
|
|
37
|
+
` ${String(i + 1).padEnd(3)} ${item.type.padEnd(10)} ${item.target.padEnd(10)} ${item.status}`
|
|
31
38
|
)
|
|
32
|
-
|
|
39
|
+
const content = all.length === 0
|
|
40
|
+
? [header, ' 대기 중인 항목 없음']
|
|
41
|
+
: [header, ...rows]
|
|
42
|
+
box.setContent(content.join('\n'))
|
|
33
43
|
screen.render()
|
|
34
44
|
}
|
|
35
45
|
}
|