axmap-cli 1.6.0 → 1.8.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/src/view.mts ADDED
@@ -0,0 +1,115 @@
1
+ /** Read-only terminal view of observed sessions, claims, mail and events. */
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { bridgeHome } from './bridge.mjs'
5
+ import { createMonitor, fitTerminal, terminalText } from './monitor.mjs'
6
+ import type { MonitorSnapshot } from './monitor.mjs'
7
+
8
+ interface ViewMail { to: string; from: string; topic: string; text: string }
9
+ export interface ViewSnapshot extends MonitorSnapshot { mail: ViewMail[]; mailIssues: string[] }
10
+ const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
11
+
12
+ export function createView(cwd: string, options: Parameters<typeof createMonitor>[1] = {}): () => Promise<ViewSnapshot> {
13
+ const collect = createMonitor(cwd, options)
14
+ return async () => {
15
+ const snapshot = await collect(), mail: ViewMail[] = [], mailIssues: string[] = []
16
+ if (snapshot.issues.some(issue => issue.startsWith('세션 확인:'))) mailIssues.push('받은 편지함: 세션 목록을 확인하지 못함')
17
+ // Reading does not create mailboxes, expire records, collect messages or wake a model.
18
+ for (const session of snapshot.sessions.slice(0, 100)) {
19
+ if (!uuid.test(session.id)) { mailIssues.push('받은 편지함: 세션 ID 확인 필요'); continue }
20
+ const file = path.join(bridgeHome(), 'mailboxes', `${session.id}.json`)
21
+ try {
22
+ if (fs.statSync(file).size > 1024 * 1024) throw new Error('파일 크기 초과')
23
+ const box: unknown = JSON.parse(fs.readFileSync(file, 'utf8'))
24
+ if (!box || typeof box !== 'object' || (box as { version?: unknown }).version !== 1 || !Array.isArray((box as { messages?: unknown }).messages)) throw new Error('형식 오류')
25
+ for (const m of (box as { messages: unknown[] }).messages) {
26
+ if (!m || typeof m !== 'object') throw new Error('메시지 형식 오류')
27
+ const entry = m as Record<string, unknown>
28
+ if (entry.repo !== snapshot.repo || entry.state !== 'queued') continue
29
+ if (entry.to !== session.id || typeof entry.from !== 'string' || typeof entry.text !== 'string' || typeof entry.expiresAt !== 'string' || !Number.isFinite(Date.parse(entry.expiresAt))) throw new Error('메시지 형식 오류')
30
+ if (Date.parse(entry.expiresAt) <= Date.now()) continue
31
+ mail.push({ to: session.id, from: entry.from, topic: typeof entry.topic === 'string' ? entry.topic : '', text: entry.text })
32
+ }
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') mailIssues.push(`받은 편지함 ${session.id.slice(0, 8)}: 읽기 실패`)
35
+ }
36
+ }
37
+ if (snapshot.sessions.length > 100) mailIssues.push('받은 편지함: 앞의 세션 100개만 확인')
38
+ return { ...snapshot, mail, mailIssues }
39
+ }
40
+ }
41
+
42
+ const labels: Record<string, string> = { idle: '대기', running: '작업 중', closed: '종료', awaiting_user: '승인 대기', unknown: '상태 미확인', starting: '시작 중', settling: '대기 전환 중' }
43
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
44
+ function cells(value: string): number {
45
+ return [...segmenter.segment(value)].reduce((n, { segment }) => {
46
+ const cp = segment.codePointAt(0) || 0
47
+ return n + (/\p{Extended_Pictographic}/u.test(segment) || cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff || cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0x20000) ? 2 : /^\p{Mark}+$/u.test(segment) ? 0 : 1)
48
+ }, 0)
49
+ }
50
+ function pad(value: unknown, width: number): string {
51
+ const text = fitTerminal(value, Math.max(0, width))
52
+ return text + ' '.repeat(Math.max(0, width - cells(text)))
53
+ }
54
+
55
+ export function renderView(snapshot: MonitorSnapshot, { columns = 100, rows = 36, color = false, frame = 0 } = {}): string {
56
+ const width = Math.max(1, Math.floor(columns)), height = Math.max(1, Math.floor(rows)), lines: string[] = []
57
+ if (width < 20) return [fitTerminal('axMap · 화면이 좁습니다', width), fitTerminal('q 종료', width)].slice(0, height).join('\n')
58
+ const view = snapshot as Partial<ViewSnapshot>
59
+ const palette = {
60
+ text: '194;197;205', muted: '137;140;150', border: '78;82;94', accent: '172;158;199',
61
+ claude: '207;157;134', codex: '143;174;214', agy: '139;187;165', warning: '212;171;125',
62
+ }
63
+ type Tone = keyof typeof palette
64
+ const paint = (text: string, tone: Tone) => color ? `\x1b[38;2;${palette[tone]}m${text}\x1b[0m` : text
65
+ const line = (text: string, tone: Tone = 'text') => lines.push(paint(fitTerminal(text, width), tone))
66
+ const paintBoxLine = (text: string, index: number, last: number, tone: Tone, colorStatus = false) => {
67
+ if (index === 0 || index === last) return paint(text, 'border')
68
+ return paint(text.slice(0, 1), 'border') + paint(text.slice(1, -1), index === 1 || colorStatus && index === 3 ? tone : 'text') + paint(text.slice(-1), 'border')
69
+ }
70
+ const box = (title: string, content: string[], w: number): string[] => {
71
+ const n = Math.max(1, w - 2)
72
+ return [`┌${'─'.repeat(n)}┐`, ...[title, ...content].map(s => `│${pad(' ' + terminalText(s), n)}│`), `└${'─'.repeat(n)}┘`]
73
+ }
74
+ line('axMap VIEW · 실제 로컬 관측', 'accent')
75
+ line(`${snapshot.cwd} · ${snapshot.checkedAt}`, 'muted')
76
+ const sessions = [...snapshot.sessions].sort((a, b) => Number(b.connection === 'connected') - Number(a.connection === 'connected')).slice(0, 3)
77
+ const cols = width >= 90 ? 3 : width >= 58 ? 2 : 1
78
+ const cardWidth = Math.max(3, Math.floor((width - (cols - 1) * 2) / cols))
79
+ const cards = sessions.map(s => {
80
+ const active = snapshot.requests.some(r => r.to === s.id && ['sending', 'submitted'].includes(r.state))
81
+ const connection: Record<string, string> = { closed: '연결 종료', unreachable: '연결 불가', unverified: '연결 미확인' }
82
+ const state = s.connection === 'connected' ? labels[s.state] || '상태 미확인' : connection[s.connection] || '연결 미확인'
83
+ return box(`${s.provider.toUpperCase()} · ${s.id.slice(0, 8)}`, [s.role || '역할 미확인', `${active ? ['◐', '◓', '◑', '◒'][frame % 4] : '○'} ${state}`, s.model || '모델 미확인'], cardWidth)
84
+ })
85
+ for (let start = 0; start < cards.length; start += cols) {
86
+ const group = cards.slice(start, start + cols)
87
+ for (let i = 0; i < group[0]!.length; i++) lines.push(group.map((card, k) => {
88
+ const provider = sessions[start + k]!.provider.toLowerCase()
89
+ const tone: Tone = provider === 'codex' ? 'codex' : provider === 'agy' ? 'agy' : provider === 'claude' ? 'claude' : 'accent'
90
+ return paintBoxLine(card[i]!, i, card.length - 1, tone, true)
91
+ }).join(' '))
92
+ }
93
+ if (!sessions.length) line('등록된 세션 없음 · bridge 연결 후 표시', 'muted')
94
+ const claims = snapshot.claims.flatMap(c => c.paths.map(p => `${terminalText(c.agent)} · ${terminalText(p)}`))
95
+ const mails = view.mail?.map(m => `${terminalText(m.from)} → ${m.to.slice(0, 8)} · ${terminalText(m.topic || m.text)}`) || []
96
+ const left = [`LEDGER · 현재 선점 ${snapshot.claims.length}건`, ...(claims.slice(0, 4).length ? claims.slice(0, 4) : ['현재 선점 없음'])]
97
+ const right = [`MAILBOX · ${view.mailIssues?.length ? '일부 확인 실패' : `대기 ${view.mail?.length ?? 0}건`}`, ...(mails.slice(0, 4).length ? mails.slice(0, 4) : [view.mailIssues?.length ? '대기 건수 미확인' : '대기 메시지 없음'])]
98
+ if (width >= 58) {
99
+ const lw = Math.floor((width - 2) / 2), rw = width - lw - 2, count = Math.max(left.length, right.length)
100
+ const a = box(left[0]!, Array.from({ length: count - 1 }, (_, i) => left[i + 1] || ''), lw)
101
+ const b = box(right[0]!, Array.from({ length: count - 1 }, (_, i) => right[i + 1] || ''), rw)
102
+ a.forEach((v, i) => lines.push(paintBoxLine(v, i, a.length - 1, 'accent') + ' ' + paintBoxLine(b[i]!, i, b.length - 1, 'accent')))
103
+ } else {
104
+ const a = box(left[0]!, left.slice(1), Math.max(3, width))
105
+ const b = box(right[0]!, right.slice(1), Math.max(3, width))
106
+ lines.push(...a.map((x, i) => paintBoxLine(x, i, a.length - 1, 'accent')))
107
+ lines.push(...b.map((x, i) => paintBoxLine(x, i, b.length - 1, 'accent')))
108
+ }
109
+ line('── 최근 관측 이벤트 ──', 'muted')
110
+ for (const e of snapshot.events.slice(0, 4)) line(`${terminalText(e.at.slice(11, 19))} ${terminalText(e.label)}`)
111
+ if (!snapshot.events.length) line('기록된 이벤트 없음', 'muted')
112
+ for (const issue of [...snapshot.issues, ...(view.mailIssues || [])].slice(0, 2)) line(`확인 필요: ${issue}`, 'warning')
113
+ const footer = paint(fitTerminal('q / Esc 종료 · 작업은 계속됨 · 모델 호출 없음', width), 'muted')
114
+ return [...lines.slice(0, Math.max(0, height - 1)), footer].join('\n')
115
+ }
package/tools/bridge.mjs CHANGED
@@ -8,6 +8,7 @@ import { runDialogue, startBackgroundDialogue, dialogueResult, stopDialogue } fr
8
8
  import { sendTerminal } from '../src/terminal-bridge.mjs'
9
9
  import { sessionStatus, formatSessionStatus } from '../src/session-status.mjs'
10
10
  import { attachConsole } from '../src/external-console.mjs'
11
+ import { postMail, collectMail, flushMail } from '../src/session-mailbox.mjs'
11
12
 
12
13
  const args = process.argv.slice(2)
13
14
  const [command] = args
@@ -15,14 +16,31 @@ const option = (name) => { const i = args.indexOf(`--${name}`); return i < 0 ? u
15
16
  const output = (value) => console.log(JSON.stringify(value, null, 2))
16
17
  try {
17
18
  switch (command) {
19
+ case 'message': {
20
+ const r = postMail({ to: option('to'), cwd: process.cwd(), text: fs.readFileSync(0, 'utf8'),
21
+ priority: option('priority') || 'boundary', reason: option('reason'), topic: option('topic'), key: option('key'),
22
+ ttlSeconds: Number(option('ttl-seconds') || 3600) })
23
+ output(r)
24
+ if (r.delivery && !['submitted', 'empty'].includes(r.delivery.state)) process.exitCode = 2
25
+ break
26
+ }
27
+ case 'mailbox': output(collectMail({ to: option('to'), cwd: process.cwd(), at: option('at'), take: args.includes('--take'), all: args.includes('--all') })); break
28
+ case 'flush': {
29
+ const r = flushMail({ to: option('to'), cwd: process.cwd(), at: option('at') }); output(r)
30
+ if (['unknown', 'deferred'].includes(r.state)) process.exitCode = 2
31
+ break
32
+ }
18
33
  case 'attach': output(attachConsole({ pid: Number(option('pid')), cwd: process.cwd() })); break
34
+ case 'connect':
19
35
  case 'serve': {
20
36
  const provider = args[1]
21
37
  if (!['codex', 'agy'].includes(provider)) throw new Error('axmap bridge serve codex|agy를 사용하세요.')
22
38
  const binary = resolveBin(provider)
23
39
  if (!binary) throw new Error(`${provider} 실행 파일을 찾지 못했습니다.`)
24
40
  const [executable, ...prefix] = provider === 'codex' ? codexInvocation(binary) : [binary]
41
+ if (command === 'connect' && (!option('thread') || !option('socket'))) throw new Error('bridge connect codex --thread <기존 UUID> --socket <로컬 경로>가 필요합니다.')
25
42
  const session = await startManagedSession({ provider, executable, prefix, model: option('model'), role: option('role'),
43
+ ...(command === 'connect' ? { existingThreadId: option('thread'), socketPath: option('socket'), observeActive: args.includes('--observe') } : {}),
26
44
  maxTokens: Number(option('max-tokens') || 100000), onEvent: output })
27
45
  output({ event: 'connected', ...session, exited: undefined, close: undefined })
28
46
  process.once('SIGINT', session.close); process.once('SIGTERM', session.close)
@@ -41,6 +59,7 @@ try {
41
59
  break
42
60
  }
43
61
  case 'dialogue': output(dialogueResult(args[1])); break
62
+ case 'notice': output(await sendTerminal(option('to'), { action: 'notice', text: fs.readFileSync(0, 'utf8') })); break
44
63
  case 'stop': output(stopDialogue(args[1])); break
45
64
  case 'answer': output(await sendTerminal(option('to'), { action: 'answer', id: option('request'), decision: option('decision') })); break
46
65
  case 'install':
@@ -78,6 +97,9 @@ try {
78
97
  console.log(`axMap 로컬 세션 연결 — 관리형 Codex/agy와 제한된 자동 대화
79
98
  axmap bridge serve codex|agy [--model 이름] [--role 역할] [--max-tokens 100000]
80
99
  명시적으로 새 관리형 CLI를 시작합니다. 기존 창에 임의로 붙지 않습니다.
100
+ axmap bridge connect codex --thread <기존 UUID> --socket <로컬 socket> [--role 역할] [--observe]
101
+ 실행 중인 공식 Codex daemon의 기존 idle 대화에 연결합니다. 새 모델/대화를 만들지 않습니다.
102
+ --observe는 active 대화에도 관측만 연결하며 idle 전환 전에는 새 요청을 거절합니다.
81
103
  axmap bridge chat --left <UUID> --right <UUID> [--max-turns 4] [--max-tokens 50000]
82
104
  stdin 작업을 자동 중계합니다. 기본 메시지 한도 2000자, 대기 중 모델 호출 없음.
83
105
  axmap bridge answer --to <UUID> --request <ID> --decision accept|decline|cancel
@@ -88,9 +110,19 @@ axmap bridge stop <대화 UUID> 추가 중계 중단 (이
88
110
  axmap bridge install --experimental --executable <agy 절대 경로> 연결 훅 실험
89
111
  axmap bridge uninstall 추가한 연결 훅 해제
90
112
  axmap bridge sessions 이 repo에 연결된 세션
113
+ axmap bridge notice --to <UUID> stdin 안내를 기존 Codex 문맥에 기록 (모델 호출 없음)
91
114
  axmap bridge attach --pid <PID> 이미 실행한 Windows agy 콘솔을 현재 repo에 연결
92
115
  axmap bridge status [--json] 로컬 연결·작업·승인 대기·종료 상태 조회 (모델 호출 없음)
93
116
  axmap bridge send --to <세션 UUID> [--wait-ms 30000] 본문은 stdin. 전달 후 지정 시간까지 로컬에서 회신 대기
117
+ send는 실행용 저수준 명령입니다. 일반 협업 알림에는 message를 사용하세요.
118
+ axmap bridge message --to <UUID> [--topic <갱신 주제>] [--key <중복 방지 키>] [--ttl-seconds 3600]
119
+ stdin 일반 메시지를 큐에 저장합니다. 모델 호출·새 턴 없음. 같은 발신자의 대기 중 topic만 최신으로 합칩니다.
120
+ axmap bridge message --to <UUID> --priority immediate --reason stop|data-loss|work-invalidated
121
+ 긴급 사유를 보존하고 즉시 전달을 시도합니다. busy면 deferred, unknown이면 재전송 금지.
122
+ axmap bridge mailbox --to <UUID> [--all]
123
+ 읽기만 합니다. --take --at checkpoint로 작업 경계에서 묶음 회수 (모델 호출 없음).
124
+ axmap bridge flush --to <UUID> --at task-complete|test-complete|experiment-complete|checkpoint
125
+ 필요할 때만 idle 수신자에게 큐를 한 요청으로 전달. 자동 감시·중단·재전송 없음.
94
126
  axmap bridge result <요청 UUID> [--wait-ms 30000] 제출/완료/불명 상태와 결과. 대기 중 모델 호출 없음
95
127
  관리형 stdio: Codex App Server / agy stream-json. PTY와 agentapi는 별도 실험 경로입니다.`)
96
128
  break
@@ -3,14 +3,22 @@ import fs from 'node:fs'
3
3
  import { runPipelineOperation } from '../src/pipeline-mcp.mjs'
4
4
  import { errorMessage } from '../src/session-types.mjs'
5
5
  import { installPipelineStatusline, pipelineStatusline } from '../src/pipeline-statusline.mjs'
6
+ import { runMonitor } from '../src/monitor-cli.mjs'
7
+ import { startPipelineWeb } from '../src/pipeline-web.mjs'
6
8
  const output = value => process.stdout.write(JSON.stringify(value) + '\n')
7
9
  let cancelled = false
8
10
  process.on('message', message => { if (message?.type === 'cancel-observation') cancelled = true })
9
11
  try {
10
12
  const action = process.argv[2]
11
13
  const option = name => { const at = process.argv.indexOf(`--${name}`); return at < 0 ? undefined : process.argv[at + 1] }
12
- if (!action || action === '--help' || action === '-h') {
13
- console.log('axmap pipeline install-statusline --provider claude|agy\naxmap pipeline uninstall-statusline --provider claude|agy\n현재 CLI의 상태줄에 axMap 진행을 표시합니다. 기존 명령은 보존하며 새 창은 열지 않습니다.\n대화 안 패널은 MCP ax_pipeline, 진행 알림은 ax_pipeline_watch를 사용하세요.')
14
+ if (process.argv.includes('--web')) {
15
+ const web = await startPipelineWeb(option('cwd') || process.cwd(), Number(option('port') || 0))
16
+ console.log(JSON.stringify({ url: web.url, mode: 'read-only-local-pipeline' }))
17
+ const close = () => { void web.close() }; process.once('SIGINT', close); process.once('SIGTERM', close)
18
+ } else if (!action || action === 'view' || action.startsWith('--') && !['--help', '-h'].includes(action)) {
19
+ await runMonitor(['--graph', ...process.argv.slice(action === 'view' ? 3 : 2)])
20
+ } else if (action === '--help' || action === '-h') {
21
+ console.log('axmap pipeline [view] [--cwd <repo>] [--once | --json]\naxmap pipeline --web [--cwd <repo>] [--port <local-port>]\n세션 그래프 · 역할 · 실제 요청/응답 · 토큰 · 이벤트를 표시합니다. TTY에서는 실시간 갱신합니다.\naxmap pipeline install-statusline --provider claude|agy\naxmap pipeline uninstall-statusline --provider claude|agy\n상태줄 설치는 기존 명령을 보존하며 새 창은 열지 않습니다.\n대화 안 패널은 MCP ax_pipeline, 진행 알림은 ax_pipeline_watch를 사용하세요.')
14
22
  } else if (action === 'install-statusline' || action === 'uninstall-statusline') {
15
23
  console.log(JSON.stringify(installPipelineStatusline(option('provider'), option('home'), action === 'uninstall-statusline'), null, 2))
16
24
  } else if (action === 'statusline') {
@@ -40,7 +40,9 @@ if (files.length === 0) {
40
40
  // Windows ConPTY owns native console workers. Exercise it after other test
41
41
  // processes have exited so temporary console teardown cannot race the suite's
42
42
  // many concurrent subprocess fixtures. Both batches remain mandatory.
43
- const nativeNames = new Set(['terminal-bridge.test.mjs', 'monitor-cli.test.mjs'])
43
+ // The lock test also asserts an elapsed-time ceiling, so concurrent fixtures
44
+ // can make it fail even when the lock retry path was never entered.
45
+ const nativeNames = new Set(['terminal-bridge.test.mjs', 'monitor-cli.test.mjs', 'view.test.mjs', 'ledgerlock.test.mjs'])
44
46
  const native = process.platform === 'win32' ? files.filter(f => nativeNames.has(path.basename(f))) : []
45
47
  const ordinary = files.filter(f => !native.includes(f))
46
48
  for (const batch of [ordinary, ...native.map(f => [f])].filter(batch => batch.length)) {
package/tools/view.mjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runMonitor } from '../src/monitor-cli.mjs'
3
+ import { errorMessage } from '../src/session-types.mjs'
4
+
5
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
6
+ console.log('axmap view [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n실제 세션·선점·대기 메시지·최근 이벤트를 터미널에서 표시합니다. q / Esc / Ctrl+C 종료.')
7
+ } else {
8
+ try { await runMonitor(['--view', ...process.argv.slice(2)]) }
9
+ catch (e) { console.error(`axMap view: ${errorMessage(e)}`); process.exitCode = 1 }
10
+ }