axmap-cli 1.4.0 → 1.6.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 (46) hide show
  1. package/README.ko.md +36 -0
  2. package/README.md +36 -0
  3. package/bin/axmap.mjs +6 -0
  4. package/mcp/README.md +6 -0
  5. package/mcp/pipeline.html +157 -0
  6. package/mcp/server.mjs +130 -20
  7. package/package.json +11 -3
  8. package/src/agentcli.mjs +17 -14
  9. package/src/agentcli.mts +19 -0
  10. package/src/bridge.mjs +299 -210
  11. package/src/bridge.mts +281 -0
  12. package/src/external-console.mjs +159 -0
  13. package/src/external-console.mts +128 -0
  14. package/src/managed-session.mjs +263 -169
  15. package/src/managed-session.mts +198 -0
  16. package/src/monitor-cli.mjs +87 -0
  17. package/src/monitor-cli.mts +64 -0
  18. package/src/monitor.mjs +240 -0
  19. package/src/monitor.mts +195 -0
  20. package/src/node-pty.d.ts +13 -0
  21. package/src/pipeline-mcp.mjs +109 -0
  22. package/src/pipeline-mcp.mts +103 -0
  23. package/src/pipeline-statusline.mjs +135 -0
  24. package/src/pipeline-statusline.mts +106 -0
  25. package/src/session-dialogue.mjs +168 -116
  26. package/src/session-dialogue.mts +136 -0
  27. package/src/session-status.mjs +54 -0
  28. package/src/session-status.mts +50 -0
  29. package/src/session-types.mjs +7 -0
  30. package/src/session-types.mts +118 -0
  31. package/src/session-usage.mjs +33 -28
  32. package/src/session-usage.mts +39 -0
  33. package/src/terminal-bridge.mjs +294 -209
  34. package/src/terminal-bridge.mts +235 -0
  35. package/src/windows-console.mjs +42 -0
  36. package/src/windows-console.mts +29 -0
  37. package/tools/bench-console.mjs +70 -0
  38. package/tools/bridge.mjs +20 -6
  39. package/tools/build.mjs +39 -0
  40. package/tools/monitor.mjs +5 -0
  41. package/tools/pipeline.mjs +26 -0
  42. package/tools/run-tests.mjs +3 -2
  43. package/tools/run.mjs +6 -2
  44. package/tools/setup.mjs +1 -1
  45. package/tools/windows-console.cs +116 -0
  46. package/tools/windows-console.ps1 +91 -0
@@ -0,0 +1,195 @@
1
+ /** Local observation only: no model submission, Git fetch, ledger bootstrap or approval. */
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { bridgeHome, repoKey, requestResult } from './bridge.mjs'
5
+ import { sessionStatus } from './session-status.mjs'
6
+ import { errorCode, errorMessage } from './session-types.mjs'
7
+ import type { RequestRecord, SessionSnapshot, DialogueRecord } from './session-types.mjs'
8
+
9
+ export interface MonitorClaim { agent: string; session?: string | null; task?: string | null; intent?: string | null; paths: string[]; expiresAt: number }
10
+ export interface MonitorEvent { id: string; at: string; label: string; tone: 'info' | 'success' | 'warning' }
11
+ export interface MonitorSnapshot {
12
+ cwd: string; repo: string; checkedAt: string; sessions: SessionSnapshot[]; requests: RequestRecord[]
13
+ dialogues: DialogueRecord[]; claims: MonitorClaim[]; events: MonitorEvent[]; issues: string[]
14
+ }
15
+ const uuidFile = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/i
16
+ const pending = new Set(['sending', 'submitted', 'unknown'])
17
+ const requestLabels: Record<string, string> = { sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' }
18
+ const sessionLabels: Record<string, string> = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '사용자 승인 필요', settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' }
19
+ const dialogueLabels: Record<string, string> = { queued: '접수', running: '중계 중', stopping: '중단 대기', completed: '완료', failed: '실패', cancelled: '중단', token_limit: '토큰 한도', unknown: '확인 필요', usage_unavailable: '사용량 미확인', message_limit: '메시지 한도', invalid_response: '응답 형식 확인 필요', turn_limit: '횟수 한도' }
20
+
21
+ function records(directory: string, issues: string[], label: string, uuidOnly = true): unknown[] {
22
+ try {
23
+ const files = fs.readdirSync(directory).filter(name => uuidOnly ? uuidFile.test(name) : name.endsWith('.json'))
24
+ .map(name => ({ name, stat: fs.statSync(path.join(directory, name)) }))
25
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
26
+ if (files.length > 500) issues.push(`${label}: 최근 500개 기록만 표시합니다.`)
27
+ const result: unknown[] = []
28
+ for (const { name, stat } of files.slice(0, 500)) {
29
+ if (stat.size > 1024 * 1024) { issues.push(`${label}: 큰 기록을 건너뛰었습니다 (${name}).`); continue }
30
+ try { result.push(JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8')) as unknown) }
31
+ catch { issues.push(`${label}: 읽을 수 없는 기록 (${name}).`) }
32
+ }
33
+ return result
34
+ } catch (e) { if (errorCode(e) !== 'ENOENT') issues.push(`${label}: ${errorMessage(e)}`); return [] }
35
+ }
36
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
37
+ const time = (v: unknown) => typeof v === 'string' ? Date.parse(v) || 0 : 0
38
+
39
+ export function createMonitor(cwd: string, options: {
40
+ probe?: (cwd: string) => Promise<SessionSnapshot[]>
41
+ resolve?: (id: string, cwd: string) => RequestRecord
42
+ now?: () => number
43
+ } = {}): () => Promise<MonitorSnapshot> {
44
+ const workspace = path.resolve(cwd), repo = repoKey(workspace)
45
+ const probe = options.probe || sessionStatus, resolve = options.resolve || requestResult, now = options.now || Date.now
46
+ const observed = new Map<string, string>(), events = new Map<string, MonitorEvent>()
47
+ const event = (id: string, at: string, label: string, tone: MonitorEvent['tone'] = 'info') => events.set(id, { id, at, label, tone })
48
+ return async () => {
49
+ const checkedAt = new Date(now()).toISOString(), issues: string[] = []
50
+ let sessions: SessionSnapshot[] = []
51
+ try { sessions = await probe(workspace) } catch (e) { issues.push(`세션 확인: ${errorMessage(e)}`) }
52
+ const requests: RequestRecord[] = []
53
+ for (const raw of records(path.join(bridgeHome(), 'requests'), issues, '요청')) {
54
+ if (!object(raw) || raw.repo !== repo) continue
55
+ if (typeof raw.id !== 'string' || typeof raw.to !== 'string' || typeof raw.prompt !== 'string' || typeof raw.from !== 'string' || typeof raw.createdAt !== 'string' || !Number.isFinite(Date.parse(raw.createdAt)) || typeof raw.state !== 'string' || !requestLabels[raw.state]) { issues.push('형식이 올바르지 않은 요청 기록이 있습니다.'); continue }
56
+ requests.push(raw as unknown as RequestRecord)
57
+ }
58
+ requests.sort((a, b) => time(b.createdAt) - time(a.createdAt))
59
+ // Refresh at most one pending request per session, never submit or retry it.
60
+ const refreshed = new Set<string>()
61
+ for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
62
+ const request = requests[index]!
63
+ if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to)) continue
64
+ refreshed.add(request.to)
65
+ try { requests[index] = resolve(request.id, workspace) }
66
+ catch (e) { issues.push(`요청 ${request.id.slice(0, 8)}: ${errorMessage(e)}`) }
67
+ }
68
+ for (const request of requests) {
69
+ const key = `request:${request.id}`, previous = observed.get(key)
70
+ event(`${key}:created`, request.createdAt, `요청 접수 · ${request.from} → ${request.to.slice(0, 8)}`)
71
+ if (request.completedAt) event(`${key}:completed`, request.completedAt, `완료 확인 · ${request.to.slice(0, 8)}`, 'success')
72
+ if (previous !== undefined && previous !== request.state && !(request.state === 'completed' && request.completedAt)) event(`${key}:${request.state}`, checkedAt,
73
+ `${requestLabels[request.state]} · ${request.to.slice(0, 8)}`, request.state === 'completed' ? 'success' : request.state === 'submitted' ? 'info' : 'warning')
74
+ observed.set(key, request.state)
75
+ }
76
+ for (const session of sessions) {
77
+ const key = `session:${session.id}`, value = `${session.connection}:${session.state}`, previous = observed.get(key)
78
+ if (previous !== undefined && previous !== value) event(`${key}:${value}`, checkedAt,
79
+ `${session.provider} ${session.id.slice(0, 8)} · ${sessionLabels[session.state]}`, session.state === 'awaiting_user' || session.connection === 'unreachable' ? 'warning' : 'info')
80
+ observed.set(key, value)
81
+ }
82
+ const dialogues: DialogueRecord[] = []
83
+ for (const raw of records(path.join(bridgeHome(), 'dialogues'), issues, '대화')) {
84
+ if (!object(raw) || raw.repo !== repo) continue
85
+ if (typeof raw.id !== 'string' || typeof raw.state !== 'string' || !dialogueLabels[raw.state] || !Array.isArray(raw.turns)) { issues.push('형식이 올바르지 않은 대화 기록이 있습니다.'); continue }
86
+ dialogues.push(raw as unknown as DialogueRecord)
87
+ }
88
+ dialogues.sort((a, b) => time(b.createdAt) - time(a.createdAt))
89
+ const claims: MonitorClaim[] = []
90
+ const ledger = path.join(path.dirname(repo), '.axmap', 'ledger', 'claims')
91
+ for (const raw of records(ledger, issues, '선점', false)) {
92
+ if (!object(raw) || typeof raw.agent !== 'string' || !Array.isArray(raw.paths) || !raw.paths.every(p => typeof p === 'string') || typeof raw.since !== 'string' || typeof raw.ttlMs !== 'number') { issues.push('형식이 올바르지 않은 선점 기록이 있습니다.'); continue }
93
+ const expiresAt = time(raw.since) + raw.ttlMs
94
+ if (!Number.isFinite(expiresAt) || expiresAt <= now()) continue
95
+ claims.push({ agent: raw.agent, paths: raw.paths as string[], expiresAt,
96
+ session: typeof raw.session === 'string' ? raw.session : null, task: typeof raw.task === 'string' ? raw.task : null, intent: typeof raw.intent === 'string' ? raw.intent : null })
97
+ }
98
+ const recent = [...events.values()].sort((a, b) => time(b.at) - time(a.at)).slice(0, 40)
99
+ events.clear(); for (const entry of recent) events.set(entry.id, entry)
100
+ return { cwd: workspace, repo, checkedAt, sessions, requests: requests.slice(0, 50), dialogues: dialogues.slice(0, 20), claims, events: recent, issues }
101
+ }
102
+ }
103
+
104
+ /** Strip untrusted terminal controls, including OSC, CSI and bidi overrides. */
105
+ export function terminalText(value: unknown): string {
106
+ return String(value ?? '').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
107
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').replace(/\s+/g, ' ').trim()
108
+ }
109
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
110
+ function cellWidth(text: string): number {
111
+ if (/\p{Extended_Pictographic}/u.test(text)) return 2
112
+ const cp = text.codePointAt(0) || 0
113
+ if (/^\p{Mark}+$/u.test(text)) return 0
114
+ return cp >= 0x1100 && (cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
115
+ cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
116
+ cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0xffe0 && cp <= 0xffe6 || cp >= 0x20000) ? 2 : 1
117
+ }
118
+ export function fitTerminal(value: unknown, width: number): string {
119
+ const text = terminalText(value), parts = [...segmenter.segment(text)].map(s => s.segment)
120
+ if (parts.reduce((n, c) => n + cellWidth(c), 0) <= width) return text
121
+ let out = '', used = 0
122
+ for (const part of parts) { const next = cellWidth(part); if (used + next > width - 1) break; out += part; used += next }
123
+ return width > 0 ? out + '…' : ''
124
+ }
125
+ const elapsed = (start: string, end: number): string => {
126
+ const seconds = Math.max(0, Math.floor((end - time(start)) / 1000))
127
+ return seconds < 60 ? `${seconds}초` : `${Math.floor(seconds / 60)}분 ${seconds % 60}초`
128
+ }
129
+ const clock = (value: string) => new Date(value).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
130
+
131
+ export function renderMonitor(snapshot: MonitorSnapshot, { columns = 100, rows = 36, color = false, now = Date.now(), frame = 0 } = {}): string {
132
+ const width = Math.max(1, Math.floor(columns)), height = Math.max(1, Math.floor(rows)), lines: string[] = []
133
+ const paint = (text: string, tone: 'info' | 'success' | 'warning' | 'muted' = 'info') => color ? `\x1b[${{ info: 36, success: 32, warning: 33, muted: 90 }[tone]}m${text}\x1b[0m` : text
134
+ const add = (text: string, tone?: Parameters<typeof paint>[1]) => {
135
+ const indent = Math.min(text.length - text.trimStart().length, 4, width)
136
+ lines.push(paint(' '.repeat(indent) + fitTerminal(text, width - indent), tone))
137
+ }
138
+ const heading = (text: string) => add(`── ${text} ${'─'.repeat(Math.max(0, width - 4 - [...text].reduce((n, c) => n + cellWidth(c), 0)))}`, 'muted')
139
+ const active = snapshot.requests.filter(r => pending.has(r.state)), attention = snapshot.sessions.filter(s => s.state === 'awaiting_user').length
140
+ add(`axMap LIVE · 진행 확인 ${active.length}개${attention ? ` · 승인 대기 ${attention}` : ''}`, attention ? 'warning' : 'info')
141
+ add(snapshot.cwd, 'muted')
142
+ add(`관측 ${clock(snapshot.checkedAt)} · 확인된 정보만 표시 · 모델 호출 없음`, 'muted')
143
+ if (width < 56 || height < 16) {
144
+ add('터미널을 넓히면 파이프라인과 최근 이벤트가 표시됩니다.', 'muted')
145
+ for (const s of snapshot.sessions.slice(0, Math.max(0, height - 7))) add(`${s.provider} ${s.id.slice(0, 8)} · ${sessionLabels[s.state]}`)
146
+ for (const r of active.slice(0, 2)) add(`${r.id.slice(0, 8)} · ${requestLabels[r.state]}`, 'warning')
147
+ } else {
148
+ heading('에이전트')
149
+ if (!snapshot.sessions.length) add('연결된 세션 기록이 없습니다. axmap bridge attach / serve로 연결하세요.', 'muted')
150
+ for (const session of snapshot.sessions.slice(0, Math.min(5, Math.max(1, Math.floor((height - 14) / 5))))) {
151
+ const state = sessionLabels[session.state], connection = { connected: '연결됨', closed: '종료됨', unreachable: '연결 확인 불가', unverified: '확인 미지원' }[session.connection]
152
+ const usage = session.usage?.totalTokens == null ? '토큰 미측정' : `처리 ${session.usage.totalTokens.toLocaleString()} 토큰`
153
+ add(`${session.state === 'idle' ? '○' : session.state === 'running' ? '●' : '◇'} ${session.provider.toUpperCase()} ${session.id.slice(0, 8)} ${state} · ${connection} · ${usage}`, session.state === 'awaiting_user' || session.connection === 'unreachable' ? 'warning' : 'info')
154
+ if (session.role) add(` 역할 ${session.role}`, 'muted')
155
+ if (session.pendingApproval) {
156
+ const detail = session.pendingApproval.command || session.pendingApproval.commandLine || session.pendingApproval.reason
157
+ if (typeof detail === 'string') add(` 승인 요청 ${detail}`, 'warning')
158
+ }
159
+ }
160
+ heading('요청 파이프라인')
161
+ const selected = [...active, ...snapshot.requests.filter(r => !pending.has(r.state))].slice(0, height >= 32 ? 3 : 1)
162
+ if (!selected.length) { add('새 요청을 기다리는 중', 'muted'); add('별도 터미널이나 AI에서 요청을 보내면 여기에 흐름이 나타납니다.', 'muted') }
163
+ for (const request of selected) {
164
+ const session = snapshot.sessions.find(s => s.id === request.to)
165
+ const target = `${session?.provider || '세션'} ${request.to.slice(0, 8)}`
166
+ const tone = request.state === 'completed' ? 'success' : ['unknown', 'failed', 'superseded'].includes(request.state) ? 'warning' : 'info'
167
+ add(`${request.from} → ${target} · ${requestLabels[request.state]} · ${elapsed(request.createdAt, request.completedAt ? time(request.completedAt) : now)}`, tone)
168
+ const spin = ['◐', '◓', '◑', '◒'][frame % 4]
169
+ const stages = request.state === 'completed' ? '● 접수 ── ● 전달 ── ● 응답 ── ● 완료 확인' :
170
+ request.state === 'submitted' ? `● 접수 ── ● 전달 ── ${spin} 응답 대기 ── ○ 완료 확인` :
171
+ request.state === 'sending' ? `● 접수 ── ${spin} 전달 중 ── ○ 응답 ── ○ 완료 확인` :
172
+ request.state === 'unknown' ? '● 접수 ── ? 전달 확인 필요 ── ○ 완료 미확정' : '● 접수 ── ! 진행 중단 ── ○ 완료 미확정'
173
+ add(` ${stages}`, tone)
174
+ add(` 작업 ${request.prompt}`, 'muted')
175
+ if (request.error) add(` 사유 ${request.error}`, 'warning')
176
+ }
177
+ const dialogue = snapshot.dialogues.find(d => ['queued', 'running', 'stopping'].includes(d.state))
178
+ if (dialogue) {
179
+ const used = dialogue.usage?.totalTokens ?? dialogue.knownUsage?.totalTokens
180
+ add(`자동 대화 ${dialogueLabels[dialogue.state]} · ${dialogue.turns.length}/${dialogue.limits?.maxTurns ?? '?'}회 · 처리 ${used == null ? '미확인' : used.toLocaleString()} 토큰${dialogue.usagePending ? ' (측정 대기 포함)' : ''}`)
181
+ }
182
+ if (snapshot.claims.length && lines.length < height - 6) {
183
+ heading('작업 선점')
184
+ for (const claim of snapshot.claims.slice(0, 2)) add(`${claim.agent} · ${claim.intent || claim.task || '작업 중'} · ${claim.paths.slice(0, 2).join(', ')}${claim.paths.length > 2 ? ` 외 ${claim.paths.length - 2}개` : ''}`, 'muted')
185
+ }
186
+ if (lines.length < height - 4) {
187
+ heading('최근 이벤트')
188
+ if (!snapshot.events.length) add('관측된 이벤트가 없습니다.', 'muted')
189
+ for (const event of snapshot.events.slice(0, Math.min(5, height - lines.length - 3))) add(`${clock(event.at)} ${event.label}`, event.tone)
190
+ }
191
+ }
192
+ const warnings = snapshot.issues.slice(0, Math.min(2, height - 1)).map(issue => paint(fitTerminal(`! ${issue}`, width), 'warning'))
193
+ const footer = paint(fitTerminal('q / Esc 종료 · 모니터를 닫아도 진행 중인 작업은 계속됩니다.', width), 'muted')
194
+ return [...lines.slice(0, height - 1 - warnings.length), ...warnings, footer].join('\n')
195
+ }
@@ -0,0 +1,13 @@
1
+ /// <reference path="../node_modules/node-pty/node-pty.d.ts" />
2
+ // The prebuilt package declares its original name; the runtime uses an npm alias.
3
+ declare module 'node-pty' {
4
+ export * from '@lydell/node-pty'
5
+ }
6
+ // Preserve the bridge's existing Windows configuration during migration;
7
+ // the package's declarations omit these legacy options.
8
+ declare module '@lydell/node-pty' {
9
+ interface IWindowsPtyForkOptions {
10
+ useConpty?: boolean
11
+ useConptyDll?: boolean
12
+ }
13
+ }
@@ -0,0 +1,109 @@
1
+ // Generated from src/pipeline-mcp.mts. Edit the .mts source and run npm run build.
2
+ /** Portable MCP observation: structured snapshots, plain text and progress share one source. */
3
+ import { createMonitor, terminalText, fitTerminal } from './monitor.mjs';
4
+ import { dispatchRequest, requestResult } from './bridge.mjs';
5
+ const labels = { sending: '전달 중', submitted: '응답 대기', completed: '완료 확인', unknown: '전송 확인 필요', failed: '실패', superseded: '다른 입력으로 대체됨' };
6
+ const pending = new Set(['sending', 'submitted', 'unknown']);
7
+ export function pipelineView(snapshot) {
8
+ return {
9
+ schemaVersion: 1, type: 'pipeline', cwd: snapshot.cwd, checkedAt: snapshot.checkedAt,
10
+ sessions: snapshot.sessions.map(s => ({ id: s.id, provider: s.provider, state: s.state, connection: s.connection,
11
+ role: fitTerminal(s.role, 120), usage: s.usage ?? null, pendingApproval: !!s.pendingApproval })),
12
+ requests: snapshot.requests.map(r => ({ id: r.id, to: r.to, from: terminalText(r.from), state: r.state,
13
+ task: fitTerminal(r.prompt, 200), createdAt: r.createdAt, completedAt: r.completedAt, error: fitTerminal(r.error, 240) })),
14
+ dialogues: snapshot.dialogues.map(d => ({ id: d.id, state: d.state, turns: d.turns.length,
15
+ maxTurns: d.limits?.maxTurns, usage: d.usage ?? null, usagePending: d.usagePending })),
16
+ claims: snapshot.claims.slice(0, 10).map(c => ({ agent: terminalText(c.agent), task: fitTerminal(c.intent || c.task, 160),
17
+ paths: c.paths.slice(0, 6).map(terminalText), pathCount: c.paths.length })),
18
+ events: snapshot.events.slice(0, 15).map(e => ({ ...e, label: terminalText(e.label) })),
19
+ issues: snapshot.issues.map(terminalText),
20
+ };
21
+ }
22
+ export function pipelineText(view) {
23
+ const active = view.requests.filter(r => pending.has(r.state));
24
+ const lines = [`axMap · 진행 확인 ${active.length}개 · ${view.sessions.length}개 세션`, `저장소: ${terminalText(view.cwd)}`];
25
+ for (const session of view.sessions.slice(0, 5))
26
+ lines.push(`${session.provider} ${session.id.slice(0, 8)} · ${session.pendingApproval ? '사용자 승인 필요' : session.state} · ${session.connection}`);
27
+ const selected = [...active, ...view.requests.filter(r => !pending.has(r.state))].slice(0, 3);
28
+ for (const request of selected) {
29
+ lines.push(`${request.from} → ${request.to.slice(0, 8)} · ${labels[request.state] || request.state}`);
30
+ lines.push(request.state === 'completed' ? '접수 ✓ → 전달 ✓ → 응답 ✓ → 완료 확인 ✓' :
31
+ request.state === 'submitted' ? '접수 ✓ → 전달 ✓ → 응답 대기 → 완료 미확정' :
32
+ request.state === 'unknown' ? '접수 ✓ → 전달 여부 확인 필요 → 완료 미확정' : `접수 → ${labels[request.state] || request.state} → 완료 미확정`);
33
+ lines.push(`작업: ${request.task}`);
34
+ if (request.error)
35
+ lines.push(`사유: ${request.error}`);
36
+ }
37
+ if (!selected.length)
38
+ lines.push('새 요청을 기다리는 중');
39
+ for (const issue of view.issues.slice(0, 2))
40
+ lines.push(`확인 필요: ${issue}`);
41
+ return lines.join('\n');
42
+ }
43
+ function integer(value, fallback, min, max, name) {
44
+ const result = value === undefined ? fallback : value;
45
+ if (typeof result !== 'number' || !Number.isInteger(result) || result < min || result > max)
46
+ throw new Error(`${name}: ${min}~${max} 정수여야 합니다.`);
47
+ return result;
48
+ }
49
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
50
+ const textResult = (text, structuredContent, isError = false) => ({ content: [{ type: 'text', text }], structuredContent, isError });
51
+ export async function runPipelineOperation(action, args, emit = () => { }, cancelled = () => false) {
52
+ const cwd = process.cwd(), progress = args.__progress === true;
53
+ if (action === 'snapshot' || action === 'watch') {
54
+ const duration = integer(args.duration_ms, action === 'watch' ? 5000 : 0, 0, 25000, 'duration_ms');
55
+ const interval = integer(args.interval_ms, 1000, 500, 5000, 'interval_ms');
56
+ const collect = createMonitor(cwd), deadline = Date.now() + (progress ? duration : 0);
57
+ let view;
58
+ do {
59
+ view = pipelineView(await collect());
60
+ if (progress) {
61
+ const active = view.requests.filter(r => pending.has(r.state));
62
+ emit(active.length ? active.slice(0, 3).map(r => `${r.id.slice(0, 8)} ${labels[r.state]}`).join(' · ') : 'axMap · 진행 중인 요청 없음');
63
+ }
64
+ if (cancelled() || Date.now() >= deadline)
65
+ break;
66
+ await delay(Math.min(interval, Math.max(0, deadline - Date.now())));
67
+ if (cancelled())
68
+ break;
69
+ } while (true);
70
+ const note = action === 'watch' && !progress ? '\n클라이언트가 progressToken을 제공하지 않아 현재 상태를 한 번 반환했습니다.' : '';
71
+ return textResult(pipelineText(view) + note + (cancelled() ? '\n관측 대기를 중단했습니다. 이미 전달한 작업은 계속 실행될 수 있습니다.' : ''), view, cancelled());
72
+ }
73
+ if (action === 'dispatch' || action === 'result') {
74
+ // Validate the wait budget before any irreversible submission.
75
+ const waitMs = integer(args.wait_ms, 0, 0, 30000, 'wait-ms');
76
+ let request;
77
+ if (action === 'dispatch') {
78
+ if (typeof args.to !== 'string' || typeof args.prompt !== 'string')
79
+ throw new Error('to와 prompt 문자열이 필요합니다.');
80
+ if (progress)
81
+ emit('axMap · 대상 세션과 입력 상태 확인');
82
+ request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap' });
83
+ }
84
+ else {
85
+ if (typeof args.id !== 'string')
86
+ throw new Error('요청 UUID가 필요합니다.');
87
+ request = requestResult(args.id, cwd);
88
+ }
89
+ const deadline = Date.now() + waitMs;
90
+ let lastMessage = '', lastAt = 0;
91
+ do {
92
+ const message = `axMap · ${request.id.slice(0, 8)} · ${labels[request.state] || request.state}`;
93
+ if (progress && (message !== lastMessage || Date.now() - lastAt >= 1000)) {
94
+ emit(message);
95
+ lastMessage = message;
96
+ lastAt = Date.now();
97
+ }
98
+ if (cancelled() || request.state !== 'submitted' || Date.now() >= deadline)
99
+ break;
100
+ await delay(Math.min(200, Math.max(0, deadline - Date.now())));
101
+ if (cancelled())
102
+ break;
103
+ request = requestResult(request.id, cwd);
104
+ } while (true);
105
+ return textResult(JSON.stringify(request, null, 2), { schemaVersion: 1, type: 'request', request,
106
+ observationCancelled: cancelled() }, action === 'dispatch' && request.state === 'unknown');
107
+ }
108
+ throw new Error(`지원하지 않는 파이프라인 동작: ${action}`);
109
+ }
@@ -0,0 +1,103 @@
1
+ /** Portable MCP observation: structured snapshots, plain text and progress share one source. */
2
+ import { createMonitor, terminalText, fitTerminal } from './monitor.mjs'
3
+ import { dispatchRequest, requestResult } from './bridge.mjs'
4
+ import type { MonitorSnapshot } from './monitor.mjs'
5
+ import type { RequestRecord } from './session-types.mjs'
6
+
7
+ export interface PipelineOutput {
8
+ content: Array<{ type: 'text'; text: string }>
9
+ structuredContent?: Record<string, unknown>
10
+ isError: boolean
11
+ }
12
+ export type PipelineEmit = (message: string) => void
13
+ const labels: Record<string, string> = { sending: '전달 중', submitted: '응답 대기', completed: '완료 확인', unknown: '전송 확인 필요', failed: '실패', superseded: '다른 입력으로 대체됨' }
14
+ const pending = new Set(['sending', 'submitted', 'unknown'])
15
+
16
+ export function pipelineView(snapshot: MonitorSnapshot) {
17
+ return {
18
+ schemaVersion: 1, type: 'pipeline', cwd: snapshot.cwd, checkedAt: snapshot.checkedAt,
19
+ sessions: snapshot.sessions.map(s => ({ id: s.id, provider: s.provider, state: s.state, connection: s.connection,
20
+ role: fitTerminal(s.role, 120), usage: s.usage ?? null, pendingApproval: !!s.pendingApproval })),
21
+ requests: snapshot.requests.map(r => ({ id: r.id, to: r.to, from: terminalText(r.from), state: r.state,
22
+ task: fitTerminal(r.prompt, 200), createdAt: r.createdAt, completedAt: r.completedAt, error: fitTerminal(r.error, 240) })),
23
+ dialogues: snapshot.dialogues.map(d => ({ id: d.id, state: d.state, turns: d.turns.length,
24
+ maxTurns: d.limits?.maxTurns, usage: d.usage ?? null, usagePending: d.usagePending })),
25
+ claims: snapshot.claims.slice(0, 10).map(c => ({ agent: terminalText(c.agent), task: fitTerminal(c.intent || c.task, 160),
26
+ paths: c.paths.slice(0, 6).map(terminalText), pathCount: c.paths.length })),
27
+ events: snapshot.events.slice(0, 15).map(e => ({ ...e, label: terminalText(e.label) })),
28
+ issues: snapshot.issues.map(terminalText),
29
+ }
30
+ }
31
+ export type PipelineView = ReturnType<typeof pipelineView>
32
+ export function pipelineText(view: PipelineView): string {
33
+ const active = view.requests.filter(r => pending.has(r.state))
34
+ const lines = [`axMap · 진행 확인 ${active.length}개 · ${view.sessions.length}개 세션`, `저장소: ${terminalText(view.cwd)}`]
35
+ for (const session of view.sessions.slice(0, 5)) lines.push(`${session.provider} ${session.id.slice(0, 8)} · ${session.pendingApproval ? '사용자 승인 필요' : session.state} · ${session.connection}`)
36
+ const selected = [...active, ...view.requests.filter(r => !pending.has(r.state))].slice(0, 3)
37
+ for (const request of selected) {
38
+ lines.push(`${request.from} → ${request.to.slice(0, 8)} · ${labels[request.state] || request.state}`)
39
+ lines.push(request.state === 'completed' ? '접수 ✓ → 전달 ✓ → 응답 ✓ → 완료 확인 ✓' :
40
+ request.state === 'submitted' ? '접수 ✓ → 전달 ✓ → 응답 대기 → 완료 미확정' :
41
+ request.state === 'unknown' ? '접수 ✓ → 전달 여부 확인 필요 → 완료 미확정' : `접수 → ${labels[request.state] || request.state} → 완료 미확정`)
42
+ lines.push(`작업: ${request.task}`)
43
+ if (request.error) lines.push(`사유: ${request.error}`)
44
+ }
45
+ if (!selected.length) lines.push('새 요청을 기다리는 중')
46
+ for (const issue of view.issues.slice(0, 2)) lines.push(`확인 필요: ${issue}`)
47
+ return lines.join('\n')
48
+ }
49
+ function integer(value: unknown, fallback: number, min: number, max: number, name: string): number {
50
+ const result = value === undefined ? fallback : value
51
+ if (typeof result !== 'number' || !Number.isInteger(result) || result < min || result > max) throw new Error(`${name}: ${min}~${max} 정수여야 합니다.`)
52
+ return result
53
+ }
54
+ const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
55
+ const textResult = (text: string, structuredContent?: Record<string, unknown>, isError = false): PipelineOutput => ({ content: [{ type: 'text', text }], structuredContent, isError })
56
+
57
+ export async function runPipelineOperation(action: string, args: Record<string, unknown>, emit: PipelineEmit = () => {}, cancelled: () => boolean = () => false): Promise<PipelineOutput> {
58
+ const cwd = process.cwd(), progress = args.__progress === true
59
+ if (action === 'snapshot' || action === 'watch') {
60
+ const duration = integer(args.duration_ms, action === 'watch' ? 5000 : 0, 0, 25000, 'duration_ms')
61
+ const interval = integer(args.interval_ms, 1000, 500, 5000, 'interval_ms')
62
+ const collect = createMonitor(cwd), deadline = Date.now() + (progress ? duration : 0)
63
+ let view: PipelineView
64
+ do {
65
+ view = pipelineView(await collect())
66
+ if (progress) {
67
+ const active = view.requests.filter(r => pending.has(r.state))
68
+ emit(active.length ? active.slice(0, 3).map(r => `${r.id.slice(0, 8)} ${labels[r.state]}`).join(' · ') : 'axMap · 진행 중인 요청 없음')
69
+ }
70
+ if (cancelled() || Date.now() >= deadline) break
71
+ await delay(Math.min(interval, Math.max(0, deadline - Date.now())))
72
+ if (cancelled()) break
73
+ } while (true)
74
+ const note = action === 'watch' && !progress ? '\n클라이언트가 progressToken을 제공하지 않아 현재 상태를 한 번 반환했습니다.' : ''
75
+ return textResult(pipelineText(view) + note + (cancelled() ? '\n관측 대기를 중단했습니다. 이미 전달한 작업은 계속 실행될 수 있습니다.' : ''), view, cancelled())
76
+ }
77
+ if (action === 'dispatch' || action === 'result') {
78
+ // Validate the wait budget before any irreversible submission.
79
+ const waitMs = integer(args.wait_ms, 0, 0, 30000, 'wait-ms')
80
+ let request: RequestRecord
81
+ if (action === 'dispatch') {
82
+ if (typeof args.to !== 'string' || typeof args.prompt !== 'string') throw new Error('to와 prompt 문자열이 필요합니다.')
83
+ if (progress) emit('axMap · 대상 세션과 입력 상태 확인')
84
+ request = dispatchRequest({ to: args.to, prompt: args.prompt, cwd, from: process.env.AXMAP_AGENT || 'axmap' })
85
+ } else {
86
+ if (typeof args.id !== 'string') throw new Error('요청 UUID가 필요합니다.')
87
+ request = requestResult(args.id, cwd)
88
+ }
89
+ const deadline = Date.now() + waitMs
90
+ let lastMessage = '', lastAt = 0
91
+ do {
92
+ const message = `axMap · ${request.id.slice(0, 8)} · ${labels[request.state] || request.state}`
93
+ if (progress && (message !== lastMessage || Date.now() - lastAt >= 1000)) { emit(message); lastMessage = message; lastAt = Date.now() }
94
+ if (cancelled() || request.state !== 'submitted' || Date.now() >= deadline) break
95
+ await delay(Math.min(200, Math.max(0, deadline - Date.now())))
96
+ if (cancelled()) break
97
+ request = requestResult(request.id, cwd)
98
+ } while (true)
99
+ return textResult(JSON.stringify(request, null, 2), { schemaVersion: 1, type: 'request', request,
100
+ observationCancelled: cancelled() }, action === 'dispatch' && request.state === 'unknown')
101
+ }
102
+ throw new Error(`지원하지 않는 파이프라인 동작: ${action}`)
103
+ }
@@ -0,0 +1,135 @@
1
+ // Generated from src/pipeline-statusline.mts. Edit the .mts source and run npm run build.
2
+ /** Native CLI adapter. Reads local records only and preserves the user's old status command. */
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
10
+ import { createMonitor, fitTerminal, terminalText } from './monitor.mjs';
11
+ const root = fileURLToPath(new URL('../', import.meta.url));
12
+ const savedFile = (home, provider) => path.join(home, '.axmap', 'bridge', `statusline-${provider}.json`);
13
+ function client(value) { if (value !== 'claude' && value !== 'agy')
14
+ throw new Error('claude 또는 agy를 지정하세요.'); return value; }
15
+ function settings(home, provider) { return provider === 'claude' ? path.join(home, '.claude', 'settings.json') : path.join(home, '.gemini', 'antigravity-cli', 'settings.json'); }
16
+ export function installPipelineStatusline(providerName, home = os.homedir(), remove = false) {
17
+ const provider = client(providerName), target = settings(home, provider), file = savedFile(home, provider);
18
+ const original = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
19
+ const config = original === null ? {} : JSON.parse(original);
20
+ if (!config || typeof config !== 'object' || Array.isArray(config))
21
+ throw new Error('기존 설정을 읽지 못했습니다. 변경하지 않았습니다.');
22
+ const current = config.statusLine;
23
+ if (current !== undefined && (!current || typeof current !== 'object' || Array.isArray(current)))
24
+ throw new Error('기존 statusLine 설정이 객체가 아닙니다. 변경하지 않았습니다.');
25
+ const owned = typeof current?.command === 'string' && current.command.includes(`axmap-statusline-${provider}`);
26
+ if (remove && !owned)
27
+ return { provider, changed: false, note: '다른 상태줄 설정을 유지합니다.' };
28
+ const saved = fs.existsSync(file) ? readJSON(file) : null;
29
+ if (owned && !saved)
30
+ throw new Error('기존 상태줄 복원 정보를 찾지 못했습니다. 변경하지 않았습니다.');
31
+ const previous = owned ? saved.previous : current;
32
+ const script = path.join(root, 'tools', 'pipeline.mjs');
33
+ const wrapper = path.join(home, '.axmap', 'bridge', `axmap-statusline-${provider}${process.platform === 'win32' ? '.cmd' : '.sh'}`);
34
+ if (process.platform === 'win32' && [process.execPath, script, wrapper].some(p => /["\r\n%&|<>^!]/.test(p)))
35
+ throw new Error('상태줄 실행 경로에 지원하지 않는 셸 문자가 있습니다.');
36
+ if (process.platform === 'win32' && /\s/.test(wrapper))
37
+ throw new Error('이 Windows 상태줄 실행 경로에는 공백을 사용할 수 없습니다. 기존 설정을 유지합니다.');
38
+ const backup = `${target}.axmap-${randomUUID()}.bak`;
39
+ if (original !== null)
40
+ fs.writeFileSync(backup, original, { mode: 0o600 });
41
+ writeJSON(file, { previous, active: !remove });
42
+ if (remove) {
43
+ if (previous === undefined)
44
+ delete config.statusLine;
45
+ else
46
+ config.statusLine = previous;
47
+ }
48
+ else {
49
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true });
50
+ const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
51
+ const command = process.platform === 'win32'
52
+ ? `@echo off\r\n"${process.execPath}" "${script}" statusline --provider ${provider}\r\n`
53
+ : `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} statusline --provider ${provider}\n`;
54
+ fs.writeFileSync(wrapper, command, { mode: 0o700 });
55
+ config.statusLine = { ...previous, type: 'command',
56
+ command: provider === 'claude' ? `"${wrapper.replaceAll('\\', '/')}"` : process.platform === 'win32' ? wrapper : quote(wrapper),
57
+ ...(provider === 'claude' ? { refreshInterval: previous?.refreshInterval ?? 2 } : { enabled: true, stack_with_default: previous?.stack_with_default ?? true }) };
58
+ }
59
+ writeJSON(target, config);
60
+ return { provider, changed: true, settings: target, backup: original === null ? null : backup,
61
+ previousPreserved: !!previous, installed: !remove };
62
+ }
63
+ function previousOutput(provider, home, input, previous) {
64
+ if (typeof previous?.command !== 'string' || !previous.command.trim())
65
+ return '';
66
+ let executable, args;
67
+ if (process.platform === 'win32' && provider === 'claude') {
68
+ const bash = [process.env.CLAUDE_CODE_GIT_BASH_PATH, path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe')].find(p => p && fs.existsSync(p));
69
+ if (!bash)
70
+ return '기존 상태줄: Git Bash 경로 확인 필요\n';
71
+ executable = bash;
72
+ args = ['-c', previous.command];
73
+ }
74
+ else if (process.platform === 'win32') {
75
+ executable = process.env.ComSpec || 'cmd.exe';
76
+ args = ['/d', '/s', '/c', previous.command];
77
+ }
78
+ else {
79
+ executable = '/bin/sh';
80
+ args = ['-c', previous.command];
81
+ }
82
+ // Only the user's pre-existing command is executed; provider JSON stays on stdin.
83
+ const options = { input, encoding: 'utf8', windowsHide: true, timeout: 1500, maxBuffer: 65536 };
84
+ const result = process.platform === 'win32' && provider === 'agy'
85
+ ? spawnSync(previous.command, { ...options, shell: process.env.ComSpec || 'cmd.exe' })
86
+ : spawnSync(executable, args, options);
87
+ return result.stdout || '';
88
+ }
89
+ export async function pipelineStatusline(providerName, input, home = os.homedir()) {
90
+ const provider = client(providerName), file = savedFile(home, provider);
91
+ const saved = fs.existsSync(file) ? readJSON(file) : null;
92
+ const prior = previousOutput(provider, home, input, saved?.previous);
93
+ if (saved?.active === false)
94
+ return prior;
95
+ const payload = JSON.parse(input);
96
+ const candidates = new Set([payload.workspace?.current_dir, payload.cwd].filter((p) => typeof p === 'string' && path.isAbsolute(p)));
97
+ const conversationId = payload.conversation_id || payload.session_id;
98
+ if (conversationId) {
99
+ const directory = path.join(bridgeHome(), 'sessions');
100
+ if (fs.existsSync(directory))
101
+ for (const name of fs.readdirSync(directory).filter(n => /^[0-9a-f-]{36}\.json$/i.test(n))) {
102
+ try {
103
+ const s = readJSON(path.join(directory, name));
104
+ if (s.conversationId === conversationId && s.workspaces?.length === 1)
105
+ candidates.add(s.workspaces[0]);
106
+ }
107
+ catch { /* a partial record cannot establish scope */ }
108
+ }
109
+ }
110
+ let cwd;
111
+ for (const candidate of candidates) {
112
+ try {
113
+ repoKey(candidate);
114
+ cwd = candidate;
115
+ break;
116
+ }
117
+ catch { /* try an explicitly attached workspace */ }
118
+ }
119
+ const width = Math.max(20, Math.min(240, Number(payload.terminal_width || process.env.COLUMNS) || 100));
120
+ let line = 'axMap · 이 대화는 Git 저장소에 연결되지 않았습니다.';
121
+ if (cwd) {
122
+ const snapshot = await createMonitor(cwd, { probe: async () => [],
123
+ resolve: id => readJSON(path.join(bridgeHome(), 'requests', `${id}.json`)) })();
124
+ const active = snapshot.requests.find(r => ['sending', 'submitted', 'unknown'].includes(r.state));
125
+ const request = active || snapshot.requests[0];
126
+ const state = payload.tool_confirmation_pending ? '승인 필요' : { working: '작업 중', tool_use: '도구 실행', reviewing: '검토 중', idle: '대기', error: '오류', initializing: '시작 중', authenticating: '인증 중' }[payload.agent_state || ''];
127
+ const spin = ['◐', '◓', '◑', '◒'][Math.floor(Date.now() / 300) % 4];
128
+ const flow = !request ? '새 요청 대기' : request.state === 'completed' ? '접수 ✓ → 전달 ✓ → 응답 ✓ → 완료 ✓' : request.state === 'submitted'
129
+ ? `접수 ✓ → 전달 ✓ → ${spin} 응답 대기 → 완료 미확정` : request.state === 'sending' ? `${spin} 전달 중 → 응답 대기` : request.state === 'unknown' ? '전달 확인 필요 → 완료 미확정' : `${request.state} → 완료 미확정`;
130
+ line = `axMap${state ? ` · ${provider} ${state}` : ''} · ${!active && request ? '최근 요청 · ' : ''}${flow}`;
131
+ if (snapshot.issues.length)
132
+ line += ' · 기록 확인 필요';
133
+ }
134
+ return prior + (prior && !prior.endsWith('\n') ? '\n' : '') + fitTerminal(terminalText(line), width) + '\n';
135
+ }