@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyun/bylane",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Frontend development harness for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { cpSync, mkdirSync, symlinkSync, existsSync, readdirSync, copyFileSync, renameSync, readFileSync } from 'fs'
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'), label: 'Commands' },
17
- { src: join(ROOT, 'hooks'), dest: join(CLAUDE_DIR, 'hooks'), label: '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
 
@@ -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
- let startTime = Date.now()
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
- const clockInterval = setInterval(() => {
11
- header.update({
12
- time: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
13
- elapsed: `${Math.floor((Date.now() - startTime) / 1000)}s`
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
- pipeline.update(states)
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()
@@ -23,7 +23,7 @@ export function createLayout() {
23
23
  left: 0,
24
24
  width: '100%',
25
25
  height: 1,
26
- content: ' [q]종료 [p]일시정지 [c]작업취소 [Tab]포커스 [j/k]로그스크롤 [?]도움말',
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
- const newLines = []
25
+ let changed = false
24
26
  for (const state of Object.values(states)) {
25
- for (const entry of (state.log ?? []).slice(-5)) {
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
- newLines.push(` ${ts} {cyan-fg}${state.agent}{/cyan-fg}`)
28
- newLines.push(` > ${entry.msg}`)
32
+ lines.push(` ${ts} {cyan-fg}${state.agent}{/cyan-fg}`)
33
+ lines.push(` > ${entry.msg}`)
34
+ changed = true
29
35
  }
30
36
  }
31
- const all = [...lines, ...newLines].slice(-50)
32
- lines.length = 0
33
- lines.push(...all)
34
- box.setContent(lines.join('\n'))
35
- box.scrollTo(lines.length)
36
- screen.render()
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 queuePath = '.bylane/queue.json'
20
- let queue = []
21
- if (existsSync(queuePath)) {
22
- try {
23
- queue = JSON.parse(readFileSync(queuePath, 'utf8'))
24
- } catch {
25
- queue = []
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
- box.setContent([header, ...rows].join('\n'))
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
  }