axmap-cli 1.5.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.
- package/README.ko.md +20 -0
- package/README.md +20 -0
- package/bin/axmap.mjs +6 -0
- package/mcp/README.md +6 -0
- package/mcp/pipeline.html +157 -0
- package/mcp/server.mjs +128 -18
- package/package.json +11 -3
- package/src/agentcli.mjs +17 -14
- package/src/agentcli.mts +19 -0
- package/src/bridge.mjs +296 -219
- package/src/bridge.mts +281 -0
- package/src/external-console.mjs +140 -97
- package/src/external-console.mts +128 -0
- package/src/managed-session.mjs +263 -169
- package/src/managed-session.mts +198 -0
- package/src/monitor-cli.mjs +87 -0
- package/src/monitor-cli.mts +64 -0
- package/src/monitor.mjs +240 -0
- package/src/monitor.mts +195 -0
- package/src/node-pty.d.ts +13 -0
- package/src/pipeline-mcp.mjs +109 -0
- package/src/pipeline-mcp.mts +103 -0
- package/src/pipeline-statusline.mjs +135 -0
- package/src/pipeline-statusline.mts +106 -0
- package/src/session-dialogue.mjs +168 -116
- package/src/session-dialogue.mts +136 -0
- package/src/session-status.mjs +47 -41
- package/src/session-status.mts +50 -0
- package/src/session-types.mjs +7 -0
- package/src/session-types.mts +118 -0
- package/src/session-usage.mjs +33 -28
- package/src/session-usage.mts +39 -0
- package/src/terminal-bridge.mjs +293 -209
- package/src/terminal-bridge.mts +235 -0
- package/src/windows-console.mjs +42 -0
- package/src/windows-console.mts +29 -0
- package/tools/bench-console.mjs +70 -0
- package/tools/bridge.mjs +9 -6
- package/tools/build.mjs +39 -0
- package/tools/monitor.mjs +5 -0
- package/tools/pipeline.mjs +26 -0
- package/tools/run-tests.mjs +3 -2
- package/tools/windows-console.cs +116 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Explicitly launched persistent CLI sessions. No transcript scraping, global
|
|
2
|
+
* hook edits, per-message process spawning, or inference while the queue is idle. */
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import net from 'node:net'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
8
|
+
import { bridgeHome, repoKey, writeJSON } from './bridge.mjs'
|
|
9
|
+
import { normalizeUsage, usageDelta } from './session-usage.mjs'
|
|
10
|
+
import { errorMessage } from './session-types.mjs'
|
|
11
|
+
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
12
|
+
import type { Provider, SessionRecord, RequestState, LocalMessage, JobRecord } from './session-types.mjs'
|
|
13
|
+
|
|
14
|
+
interface ManagedSessionOptions {
|
|
15
|
+
provider: Provider; executable?: string; prefix?: string[]; args?: string[] | null; model?: string; role?: string
|
|
16
|
+
cwd?: string; maxTokens?: number; timeoutMs?: number; onEvent?: (event: Record<string, unknown>) => void
|
|
17
|
+
}
|
|
18
|
+
interface ProviderMessage {
|
|
19
|
+
id?: string | number; method?: string; event?: string; conversation_id?: string
|
|
20
|
+
error?: { message: string }; result?: unknown
|
|
21
|
+
params?: {
|
|
22
|
+
threadId?: string; tokenUsage?: { total: unknown }; item?: { type: string; text?: string }
|
|
23
|
+
turn?: { id: string; status: string; error?: { message: string } }
|
|
24
|
+
[key: string]: unknown
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
interface AgyResult { conversation_id: string; status: string; response?: unknown; error?: string; usage?: unknown }
|
|
28
|
+
|
|
29
|
+
const pipeFor = (id: string) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
|
|
30
|
+
const uuid = (value: string | undefined) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '')
|
|
31
|
+
|
|
32
|
+
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '',
|
|
33
|
+
cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => {} }: ManagedSessionOptions) {
|
|
34
|
+
if (!['codex', 'agy'].includes(provider)) throw new Error('지원하는 세션은 codex 또는 agy입니다.')
|
|
35
|
+
if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 토큰/시간 한도가 필요합니다.')
|
|
36
|
+
if (typeof role !== 'string' || role.length > 1000) throw new Error('역할은 1000자 이하여야 합니다.')
|
|
37
|
+
const id = randomUUID(), token = randomBytes(32).toString('hex')
|
|
38
|
+
const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`)
|
|
39
|
+
const s: SessionRecord = { id, token, provider, transport: 'stdio', role, model: model || null,
|
|
40
|
+
workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
|
|
41
|
+
registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens }
|
|
42
|
+
const jobs = new Map<string, JobRecord>()
|
|
43
|
+
const calls = new Map<string | number, { resolve: (result: unknown) => void; reject: (reason: Error) => void; timer: NodeJS.Timeout }>()
|
|
44
|
+
let child: ChildProcessWithoutNullStreams | undefined, sequence = 0, pending: JobRecord | null = null, closed = false, buffer = '', stderr = '', turnTimer: NodeJS.Timeout | undefined
|
|
45
|
+
const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionPath, s) }
|
|
46
|
+
const emit = (event: Record<string, unknown>) => onEvent({ session: id, ...event })
|
|
47
|
+
const write = (message: unknown) => {
|
|
48
|
+
if (closed || !child?.stdin.writable) throw new Error('세션 입력 연결이 종료됐습니다.')
|
|
49
|
+
child.stdin.write(JSON.stringify(message) + '\n')
|
|
50
|
+
}
|
|
51
|
+
const rpc = <T = unknown,>(method: string, params: unknown): Promise<T> => new Promise((resolve, reject) => {
|
|
52
|
+
const callId = ++sequence
|
|
53
|
+
const timer = setTimeout(() => { calls.delete(callId); reject(new Error(`${method}: 응답 시간 초과`)) }, 15000)
|
|
54
|
+
calls.set(callId, { resolve: value => resolve(value as T), reject, timer })
|
|
55
|
+
try { write({ id: callId, method, params }) } catch (e) { clearTimeout(timer); calls.delete(callId); reject(e) }
|
|
56
|
+
})
|
|
57
|
+
const finish = (state: RequestState, result?: unknown, error?: string, rawUsage?: unknown) => {
|
|
58
|
+
if (!pending) return
|
|
59
|
+
clearTimeout(turnTimer)
|
|
60
|
+
const usage = normalizeUsage(provider, rawUsage)
|
|
61
|
+
Object.assign(pending, { state, result, error, usage: pending.baselineKnown ? usageDelta(usage, pending.baseline) : null,
|
|
62
|
+
completedAt: new Date().toISOString(), conversationId: s.conversationId })
|
|
63
|
+
delete pending.baseline
|
|
64
|
+
delete pending.baselineKnown
|
|
65
|
+
if (usage) s.usage = usage
|
|
66
|
+
s.usageKnown = usage !== null
|
|
67
|
+
emit({ event: 'result', request: { ...pending } })
|
|
68
|
+
pending = null; s.state = 'idle'; s.fullyIdle = true; delete s.pendingApproval; save()
|
|
69
|
+
}
|
|
70
|
+
const shutdown = (reason: string) => {
|
|
71
|
+
if (closed) return
|
|
72
|
+
finish('unknown', undefined, reason)
|
|
73
|
+
closed = true; clearTimeout(turnTimer)
|
|
74
|
+
for (const c of calls.values()) { clearTimeout(c.timer); c.reject(new Error(reason)) }
|
|
75
|
+
calls.clear()
|
|
76
|
+
s.state = 'closed'; s.fullyIdle = false; save()
|
|
77
|
+
child?.stdin.destroy(); child?.kill(); server.close()
|
|
78
|
+
}
|
|
79
|
+
const handle = (message: LocalMessage) => {
|
|
80
|
+
const offered = Buffer.from(typeof message.token === 'string' ? message.token : '')
|
|
81
|
+
const expected = Buffer.from(token)
|
|
82
|
+
if (offered.length !== expected.length || !timingSafeEqual(offered, expected)) throw new Error('로컬 세션 인증 실패')
|
|
83
|
+
if (message.action === 'status') return { id, state: s.state, usage: s.usage, pendingApproval: s.pendingApproval }
|
|
84
|
+
if (message.action === 'result') {
|
|
85
|
+
const result = jobs.get(message.id)
|
|
86
|
+
if (!result) throw new Error('이 세션에 해당 요청이 없습니다.')
|
|
87
|
+
const { baseline, baselineKnown, ...publicResult } = result
|
|
88
|
+
return publicResult
|
|
89
|
+
}
|
|
90
|
+
if (message.action === 'answer') {
|
|
91
|
+
const approval = s.pendingApproval
|
|
92
|
+
if (!approval || String(approval.id) !== String(message.id)) throw new Error('현재 대기 중인 승인 요청이 아닙니다.')
|
|
93
|
+
if (!['accept', 'decline', 'cancel'].includes(message.decision)) throw new Error('이번 실행의 accept, decline 또는 cancel만 지원합니다.')
|
|
94
|
+
write({ id: approval.id, result: { decision: message.decision } })
|
|
95
|
+
delete s.pendingApproval; s.state = 'running'; save(); return { answered: true }
|
|
96
|
+
}
|
|
97
|
+
if (message.action !== 'submit') throw new Error('지원하지 않는 로컬 세션 요청입니다.')
|
|
98
|
+
if (closed || s.state !== 'idle' || pending) throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.')
|
|
99
|
+
if (!s.usageKnown) throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.')
|
|
100
|
+
if (s.usage && s.usage.totalTokens >= maxTokens) throw new Error('세션 토큰 한도에 도달했습니다.')
|
|
101
|
+
const request = message.request
|
|
102
|
+
if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw new Error('요청 형식이 올바르지 않습니다.')
|
|
103
|
+
pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown, result: '' }
|
|
104
|
+
jobs.set(request.id, pending); s.state = 'running'; s.fullyIdle = false; save()
|
|
105
|
+
const prompt = `[axMap request ${request.id}]\n요청자: ${request.from || 'axmap'}\n${role ? `역할: ${role}\n` : ''}${request.prompt}`
|
|
106
|
+
turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs)
|
|
107
|
+
if (provider === 'agy') write({ event: 'user', message: { content: prompt } })
|
|
108
|
+
else rpc<{ turn: { id: string } }>('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], effort: 'low' })
|
|
109
|
+
.then(r => { if (pending?.id === request.id) pending.turnId = r.turn.id })
|
|
110
|
+
.catch(e => shutdown(e.message))
|
|
111
|
+
return { accepted: true, session: id }
|
|
112
|
+
}
|
|
113
|
+
const server = net.createServer(socket => {
|
|
114
|
+
socket.setEncoding('utf8')
|
|
115
|
+
let input = '', handled = false
|
|
116
|
+
socket.setTimeout(15000, () => socket.destroy()); socket.on('error', () => {})
|
|
117
|
+
socket.on('data', chunk => {
|
|
118
|
+
if (handled) return
|
|
119
|
+
input += chunk
|
|
120
|
+
if (Buffer.byteLength(input) > 65536) return socket.destroy()
|
|
121
|
+
const end = input.indexOf('\n'); if (end < 0) return
|
|
122
|
+
handled = true
|
|
123
|
+
try { socket.end(JSON.stringify({ result: handle(JSON.parse(input.slice(0, end))) }) + '\n') }
|
|
124
|
+
catch (e) { socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n') }
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 })
|
|
128
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve) })
|
|
129
|
+
if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
|
|
130
|
+
save()
|
|
131
|
+
const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
|
|
132
|
+
: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])])
|
|
133
|
+
child = spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
134
|
+
env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } })
|
|
135
|
+
child.stdin.on('error', e => shutdown(e.message))
|
|
136
|
+
const exited = new Promise<{ code: number | null; signal?: NodeJS.Signals | null; error?: string }>(resolve => {
|
|
137
|
+
child.once('error', e => { shutdown(e.message); resolve({ code: 1, error: e.message }) })
|
|
138
|
+
child.once('exit', (code, signal) => { shutdown(`CLI가 종료됐습니다 (${code ?? signal}). ${stderr.slice(-1000)}`); resolve({ code, signal }) })
|
|
139
|
+
})
|
|
140
|
+
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192) })
|
|
141
|
+
child.stdout.setEncoding('utf8')
|
|
142
|
+
child.stdout.on('data', chunk => {
|
|
143
|
+
buffer += chunk
|
|
144
|
+
if (Buffer.byteLength(buffer) > 4 * 1024 * 1024) return shutdown('CLI 응답 크기 제한 초과')
|
|
145
|
+
while (buffer.includes('\n') && !closed) {
|
|
146
|
+
const end = buffer.indexOf('\n'), line = buffer.slice(0, end); buffer = buffer.slice(end + 1)
|
|
147
|
+
if (!line.trim()) continue
|
|
148
|
+
try {
|
|
149
|
+
const m = JSON.parse(line) as ProviderMessage
|
|
150
|
+
if (provider === 'agy') {
|
|
151
|
+
if (m.event === 'init') { s.conversationId = m.conversation_id; s.state = 'idle'; s.fullyIdle = true; save(); emit({ event: 'ready', conversationId: s.conversationId }) }
|
|
152
|
+
if (m.event === 'result' && pending) {
|
|
153
|
+
const r = m.result as AgyResult
|
|
154
|
+
if (r.conversation_id !== s.conversationId) throw new Error('agy 대화 ID가 바뀌었습니다.')
|
|
155
|
+
finish(r.status === 'SUCCESS' ? 'completed' : 'failed', r.response, r.error, r.usage)
|
|
156
|
+
}
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (m.id !== undefined && !m.method) {
|
|
160
|
+
const call = calls.get(m.id)
|
|
161
|
+
if (call) { clearTimeout(call.timer); calls.delete(m.id); m.error ? call.reject(new Error(m.error.message)) : call.resolve(m.result) }
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (m.id !== undefined && m.method) {
|
|
165
|
+
if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(m.method)) {
|
|
166
|
+
s.pendingApproval = { id: m.id, method: m.method, ...m.params }; s.state = 'awaiting_user'; save()
|
|
167
|
+
emit({ event: 'awaiting_user', request: s.pendingApproval })
|
|
168
|
+
} else { write({ id: m.id, error: { code: -32601, message: '이 연결에서 지원하지 않는 사용자 입력입니다. 대화형 CLI에서 처리하세요.' } }) }
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if (m.params?.threadId !== s.conversationId) continue
|
|
172
|
+
if (m.method === 'thread/tokenUsage/updated' && pending) pending.rawUsage = m.params?.tokenUsage?.total
|
|
173
|
+
if (m.method === 'item/completed' && pending && m.params?.item?.type === 'agentMessage') pending.result = m.params.item.text
|
|
174
|
+
if (m.method === 'turn/completed' && pending) {
|
|
175
|
+
const r = m.params?.turn
|
|
176
|
+
if (!r) throw new Error('Codex 실행 결과가 없습니다.')
|
|
177
|
+
if (pending.turnId && pending.turnId !== r.id) throw new Error('Codex 실행 ID가 다릅니다.')
|
|
178
|
+
finish(r.status === 'completed' ? 'completed' : 'failed', pending.result, r.error?.message, pending.rawUsage)
|
|
179
|
+
}
|
|
180
|
+
} catch (e) { shutdown(`CLI 프로토콜 오류: ${errorMessage(e)}`) }
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
try {
|
|
184
|
+
if (provider === 'codex') {
|
|
185
|
+
await rpc('initialize', { clientInfo: { name: 'axmap', version: '1' } })
|
|
186
|
+
write({ method: 'initialized' })
|
|
187
|
+
const r = await rpc<{ thread: { id: string }; model?: string }>('thread/start', { cwd: path.resolve(cwd), ...(model ? { model } : {}),
|
|
188
|
+
sandbox: 'read-only', approvalPolicy: 'on-request' })
|
|
189
|
+
s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = 'idle'; s.fullyIdle = true; save()
|
|
190
|
+
emit({ event: 'ready', conversationId: s.conversationId })
|
|
191
|
+
} else {
|
|
192
|
+
const deadline = Date.now() + 30000
|
|
193
|
+
while (s.state === 'starting' && !closed && Date.now() < deadline) await new Promise(r => setTimeout(r, 25))
|
|
194
|
+
if (s.state !== 'idle') throw new Error(`agy 시작 실패: ${stderr.slice(-1000) || s.state}`)
|
|
195
|
+
}
|
|
196
|
+
} catch (e) { shutdown(errorMessage(e)); throw e }
|
|
197
|
+
return { id, pid: child.pid, conversationId: s.conversationId, exited, close: () => shutdown('사용자가 관리형 연결을 종료했습니다.') }
|
|
198
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Generated from src/monitor-cli.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createMonitor, renderMonitor } from './monitor.mjs';
|
|
4
|
+
import { errorMessage } from './session-types.mjs';
|
|
5
|
+
export async function runMonitor(args) {
|
|
6
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
7
|
+
console.log('axmap monitor [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n세션·요청 파이프라인·자동 대화·선점·최근 이벤트를 실시간으로 표시합니다.\nTTY에서는 계속 갱신하며, 파이프/리디렉션에서는 한 번만 출력합니다. q / Esc / Ctrl+C 종료.');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color']);
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
if (!allowed.has(args[i]))
|
|
13
|
+
throw new Error(`알 수 없는 옵션: ${args[i]}`);
|
|
14
|
+
if (args[i] === '--cwd' || args[i] === '--interval') {
|
|
15
|
+
if (!args[i + 1] || args[i + 1].startsWith('--'))
|
|
16
|
+
throw new Error(`${args[i]} 값이 필요합니다.`);
|
|
17
|
+
i++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const option = (name) => { const index = args.indexOf(name); return index < 0 ? undefined : args[index + 1]; };
|
|
21
|
+
const interval = Number(option('--interval') || 1000);
|
|
22
|
+
if (!Number.isInteger(interval) || interval < 250 || interval > 60000)
|
|
23
|
+
throw new Error('interval은 250~60000ms 정수여야 합니다.');
|
|
24
|
+
const collect = createMonitor(path.resolve(option('--cwd') || process.cwd()));
|
|
25
|
+
const live = !!process.stdout.isTTY && !!process.stdin.isTTY && process.env.TERM !== 'dumb' && !args.includes('--once') && !args.includes('--json');
|
|
26
|
+
const color = !!process.stdout.isTTY && !args.includes('--no-color') && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb';
|
|
27
|
+
if (!live) {
|
|
28
|
+
const snapshot = await collect();
|
|
29
|
+
console.log(args.includes('--json') ? JSON.stringify(snapshot, null, 2) : renderMonitor(snapshot, { columns: process.stdout.columns || 100, rows: process.stdout.rows || 40, color }));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let stopped = false, frame = 0, timer, animation;
|
|
33
|
+
let latest = null;
|
|
34
|
+
const wasRaw = !!process.stdin.isRaw;
|
|
35
|
+
await new Promise(resolve => {
|
|
36
|
+
const restore = () => {
|
|
37
|
+
if (stopped)
|
|
38
|
+
return;
|
|
39
|
+
stopped = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
clearInterval(animation);
|
|
42
|
+
process.stdin.off('data', input);
|
|
43
|
+
process.off('SIGINT', restore);
|
|
44
|
+
process.off('SIGTERM', restore);
|
|
45
|
+
process.off('exit', restore);
|
|
46
|
+
process.stdin.setRawMode(wasRaw);
|
|
47
|
+
process.stdin.pause();
|
|
48
|
+
process.stdout.write('\x1b[0m\x1b[?25h\x1b[?1049l');
|
|
49
|
+
resolve();
|
|
50
|
+
};
|
|
51
|
+
const input = (text) => { if (text === '\x1b' || text.includes('\x03') || /^[qQ]+$/.test(text))
|
|
52
|
+
restore(); };
|
|
53
|
+
const draw = () => {
|
|
54
|
+
if (stopped || !latest)
|
|
55
|
+
return;
|
|
56
|
+
const view = renderMonitor(latest, { columns: process.stdout.columns || 100, rows: Math.max(1, (process.stdout.rows || 36) - 1), color, frame: frame++ });
|
|
57
|
+
process.stdout.write('\x1b[H' + view.replaceAll('\n', '\x1b[K\r\n') + '\x1b[K\x1b[J');
|
|
58
|
+
};
|
|
59
|
+
const update = async () => {
|
|
60
|
+
try {
|
|
61
|
+
const snapshot = await collect();
|
|
62
|
+
if (stopped)
|
|
63
|
+
return;
|
|
64
|
+
latest = snapshot;
|
|
65
|
+
draw();
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
restore();
|
|
69
|
+
console.error(`axMap monitor: ${errorMessage(e)}`);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!stopped)
|
|
74
|
+
timer = setTimeout(update, interval);
|
|
75
|
+
};
|
|
76
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H');
|
|
77
|
+
process.stdin.setEncoding('utf8');
|
|
78
|
+
process.stdin.setRawMode(true);
|
|
79
|
+
process.stdin.resume();
|
|
80
|
+
process.stdin.on('data', input);
|
|
81
|
+
process.once('SIGINT', restore);
|
|
82
|
+
process.once('SIGTERM', restore);
|
|
83
|
+
process.once('exit', restore);
|
|
84
|
+
animation = setInterval(draw, 180);
|
|
85
|
+
void update();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { createMonitor, renderMonitor } from './monitor.mjs'
|
|
3
|
+
import type { MonitorSnapshot } from './monitor.mjs'
|
|
4
|
+
import { errorMessage } from './session-types.mjs'
|
|
5
|
+
|
|
6
|
+
export async function runMonitor(args: string[]): Promise<void> {
|
|
7
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
8
|
+
console.log('axmap monitor [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n세션·요청 파이프라인·자동 대화·선점·최근 이벤트를 실시간으로 표시합니다.\nTTY에서는 계속 갱신하며, 파이프/리디렉션에서는 한 번만 출력합니다. q / Esc / Ctrl+C 종료.')
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color'])
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
if (!allowed.has(args[i]!)) throw new Error(`알 수 없는 옵션: ${args[i]}`)
|
|
14
|
+
if (args[i] === '--cwd' || args[i] === '--interval') {
|
|
15
|
+
if (!args[i + 1] || args[i + 1]!.startsWith('--')) throw new Error(`${args[i]} 값이 필요합니다.`)
|
|
16
|
+
i++
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const option = (name: string) => { const index = args.indexOf(name); return index < 0 ? undefined : args[index + 1] }
|
|
20
|
+
const interval = Number(option('--interval') || 1000)
|
|
21
|
+
if (!Number.isInteger(interval) || interval < 250 || interval > 60000) throw new Error('interval은 250~60000ms 정수여야 합니다.')
|
|
22
|
+
const collect = createMonitor(path.resolve(option('--cwd') || process.cwd()))
|
|
23
|
+
const live = !!process.stdout.isTTY && !!process.stdin.isTTY && process.env.TERM !== 'dumb' && !args.includes('--once') && !args.includes('--json')
|
|
24
|
+
const color = !!process.stdout.isTTY && !args.includes('--no-color') && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'
|
|
25
|
+
if (!live) {
|
|
26
|
+
const snapshot = await collect()
|
|
27
|
+
console.log(args.includes('--json') ? JSON.stringify(snapshot, null, 2) : renderMonitor(snapshot, { columns: process.stdout.columns || 100, rows: process.stdout.rows || 40, color }))
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
let stopped = false, frame = 0, timer: NodeJS.Timeout | undefined, animation: NodeJS.Timeout | undefined
|
|
31
|
+
let latest: MonitorSnapshot | null = null
|
|
32
|
+
const wasRaw = !!process.stdin.isRaw
|
|
33
|
+
await new Promise<void>(resolve => {
|
|
34
|
+
const restore = () => {
|
|
35
|
+
if (stopped) return
|
|
36
|
+
stopped = true; clearTimeout(timer); clearInterval(animation)
|
|
37
|
+
process.stdin.off('data', input); process.off('SIGINT', restore); process.off('SIGTERM', restore); process.off('exit', restore)
|
|
38
|
+
process.stdin.setRawMode(wasRaw); process.stdin.pause()
|
|
39
|
+
process.stdout.write('\x1b[0m\x1b[?25h\x1b[?1049l')
|
|
40
|
+
resolve()
|
|
41
|
+
}
|
|
42
|
+
const input = (text: string) => { if (text === '\x1b' || text.includes('\x03') || /^[qQ]+$/.test(text)) restore() }
|
|
43
|
+
const draw = () => {
|
|
44
|
+
if (stopped || !latest) return
|
|
45
|
+
const view = renderMonitor(latest, { columns: process.stdout.columns || 100, rows: Math.max(1, (process.stdout.rows || 36) - 1), color, frame: frame++ })
|
|
46
|
+
process.stdout.write('\x1b[H' + view.replaceAll('\n', '\x1b[K\r\n') + '\x1b[K\x1b[J')
|
|
47
|
+
}
|
|
48
|
+
const update = async () => {
|
|
49
|
+
try {
|
|
50
|
+
const snapshot = await collect()
|
|
51
|
+
if (stopped) return
|
|
52
|
+
latest = snapshot; draw()
|
|
53
|
+
} catch (e) {
|
|
54
|
+
restore(); console.error(`axMap monitor: ${errorMessage(e)}`); process.exitCode = 1; return
|
|
55
|
+
}
|
|
56
|
+
if (!stopped) timer = setTimeout(update, interval)
|
|
57
|
+
}
|
|
58
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H')
|
|
59
|
+
process.stdin.setEncoding('utf8'); process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on('data', input)
|
|
60
|
+
process.once('SIGINT', restore); process.once('SIGTERM', restore); process.once('exit', restore)
|
|
61
|
+
animation = setInterval(draw, 180)
|
|
62
|
+
void update()
|
|
63
|
+
})
|
|
64
|
+
}
|
package/src/monitor.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Generated from src/monitor.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Local observation only: no model submission, Git fetch, ledger bootstrap or approval. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { bridgeHome, repoKey, requestResult } from './bridge.mjs';
|
|
6
|
+
import { sessionStatus } from './session-status.mjs';
|
|
7
|
+
import { errorCode, errorMessage } from './session-types.mjs';
|
|
8
|
+
const uuidFile = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/i;
|
|
9
|
+
const pending = new Set(['sending', 'submitted', 'unknown']);
|
|
10
|
+
const requestLabels = { sending: '전달 중', submitted: '응답 대기', completed: '완료', failed: '실패', unknown: '전송 확인 필요', superseded: '다른 입력으로 대체됨' };
|
|
11
|
+
const sessionLabels = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '사용자 승인 필요', settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' };
|
|
12
|
+
const dialogueLabels = { queued: '접수', running: '중계 중', stopping: '중단 대기', completed: '완료', failed: '실패', cancelled: '중단', token_limit: '토큰 한도', unknown: '확인 필요', usage_unavailable: '사용량 미확인', message_limit: '메시지 한도', invalid_response: '응답 형식 확인 필요', turn_limit: '횟수 한도' };
|
|
13
|
+
function records(directory, issues, label, uuidOnly = true) {
|
|
14
|
+
try {
|
|
15
|
+
const files = fs.readdirSync(directory).filter(name => uuidOnly ? uuidFile.test(name) : name.endsWith('.json'))
|
|
16
|
+
.map(name => ({ name, stat: fs.statSync(path.join(directory, name)) }))
|
|
17
|
+
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
18
|
+
if (files.length > 500)
|
|
19
|
+
issues.push(`${label}: 최근 500개 기록만 표시합니다.`);
|
|
20
|
+
const result = [];
|
|
21
|
+
for (const { name, stat } of files.slice(0, 500)) {
|
|
22
|
+
if (stat.size > 1024 * 1024) {
|
|
23
|
+
issues.push(`${label}: 큰 기록을 건너뛰었습니다 (${name}).`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
result.push(JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8')));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
issues.push(`${label}: 읽을 수 없는 기록 (${name}).`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (errorCode(e) !== 'ENOENT')
|
|
37
|
+
issues.push(`${label}: ${errorMessage(e)}`);
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
42
|
+
const time = (v) => typeof v === 'string' ? Date.parse(v) || 0 : 0;
|
|
43
|
+
export function createMonitor(cwd, options = {}) {
|
|
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(), events = new Map();
|
|
47
|
+
const event = (id, at, label, tone = 'info') => events.set(id, { id, at, label, tone });
|
|
48
|
+
return async () => {
|
|
49
|
+
const checkedAt = new Date(now()).toISOString(), issues = [];
|
|
50
|
+
let sessions = [];
|
|
51
|
+
try {
|
|
52
|
+
sessions = await probe(workspace);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
issues.push(`세션 확인: ${errorMessage(e)}`);
|
|
56
|
+
}
|
|
57
|
+
const requests = [];
|
|
58
|
+
for (const raw of records(path.join(bridgeHome(), 'requests'), issues, '요청')) {
|
|
59
|
+
if (!object(raw) || raw.repo !== repo)
|
|
60
|
+
continue;
|
|
61
|
+
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]) {
|
|
62
|
+
issues.push('형식이 올바르지 않은 요청 기록이 있습니다.');
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
requests.push(raw);
|
|
66
|
+
}
|
|
67
|
+
requests.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
|
68
|
+
// Refresh at most one pending request per session, never submit or retry it.
|
|
69
|
+
const refreshed = new Set();
|
|
70
|
+
for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
|
|
71
|
+
const request = requests[index];
|
|
72
|
+
if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to))
|
|
73
|
+
continue;
|
|
74
|
+
refreshed.add(request.to);
|
|
75
|
+
try {
|
|
76
|
+
requests[index] = resolve(request.id, workspace);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
issues.push(`요청 ${request.id.slice(0, 8)}: ${errorMessage(e)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const request of requests) {
|
|
83
|
+
const key = `request:${request.id}`, previous = observed.get(key);
|
|
84
|
+
event(`${key}:created`, request.createdAt, `요청 접수 · ${request.from} → ${request.to.slice(0, 8)}`);
|
|
85
|
+
if (request.completedAt)
|
|
86
|
+
event(`${key}:completed`, request.completedAt, `완료 확인 · ${request.to.slice(0, 8)}`, 'success');
|
|
87
|
+
if (previous !== undefined && previous !== request.state && !(request.state === 'completed' && request.completedAt))
|
|
88
|
+
event(`${key}:${request.state}`, checkedAt, `${requestLabels[request.state]} · ${request.to.slice(0, 8)}`, request.state === 'completed' ? 'success' : request.state === 'submitted' ? 'info' : 'warning');
|
|
89
|
+
observed.set(key, request.state);
|
|
90
|
+
}
|
|
91
|
+
for (const session of sessions) {
|
|
92
|
+
const key = `session:${session.id}`, value = `${session.connection}:${session.state}`, previous = observed.get(key);
|
|
93
|
+
if (previous !== undefined && previous !== value)
|
|
94
|
+
event(`${key}:${value}`, checkedAt, `${session.provider} ${session.id.slice(0, 8)} · ${sessionLabels[session.state]}`, session.state === 'awaiting_user' || session.connection === 'unreachable' ? 'warning' : 'info');
|
|
95
|
+
observed.set(key, value);
|
|
96
|
+
}
|
|
97
|
+
const dialogues = [];
|
|
98
|
+
for (const raw of records(path.join(bridgeHome(), 'dialogues'), issues, '대화')) {
|
|
99
|
+
if (!object(raw) || raw.repo !== repo)
|
|
100
|
+
continue;
|
|
101
|
+
if (typeof raw.id !== 'string' || typeof raw.state !== 'string' || !dialogueLabels[raw.state] || !Array.isArray(raw.turns)) {
|
|
102
|
+
issues.push('형식이 올바르지 않은 대화 기록이 있습니다.');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
dialogues.push(raw);
|
|
106
|
+
}
|
|
107
|
+
dialogues.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
|
108
|
+
const claims = [];
|
|
109
|
+
const ledger = path.join(path.dirname(repo), '.axmap', 'ledger', 'claims');
|
|
110
|
+
for (const raw of records(ledger, issues, '선점', false)) {
|
|
111
|
+
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') {
|
|
112
|
+
issues.push('형식이 올바르지 않은 선점 기록이 있습니다.');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const expiresAt = time(raw.since) + raw.ttlMs;
|
|
116
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now())
|
|
117
|
+
continue;
|
|
118
|
+
claims.push({ agent: raw.agent, paths: raw.paths, expiresAt,
|
|
119
|
+
session: typeof raw.session === 'string' ? raw.session : null, task: typeof raw.task === 'string' ? raw.task : null, intent: typeof raw.intent === 'string' ? raw.intent : null });
|
|
120
|
+
}
|
|
121
|
+
const recent = [...events.values()].sort((a, b) => time(b.at) - time(a.at)).slice(0, 40);
|
|
122
|
+
events.clear();
|
|
123
|
+
for (const entry of recent)
|
|
124
|
+
events.set(entry.id, entry);
|
|
125
|
+
return { cwd: workspace, repo, checkedAt, sessions, requests: requests.slice(0, 50), dialogues: dialogues.slice(0, 20), claims, events: recent, issues };
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Strip untrusted terminal controls, including OSC, CSI and bidi overrides. */
|
|
129
|
+
export function terminalText(value) {
|
|
130
|
+
return String(value ?? '').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
|
131
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
132
|
+
}
|
|
133
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
134
|
+
function cellWidth(text) {
|
|
135
|
+
if (/\p{Extended_Pictographic}/u.test(text))
|
|
136
|
+
return 2;
|
|
137
|
+
const cp = text.codePointAt(0) || 0;
|
|
138
|
+
if (/^\p{Mark}+$/u.test(text))
|
|
139
|
+
return 0;
|
|
140
|
+
return cp >= 0x1100 && (cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
|
|
141
|
+
cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
|
|
142
|
+
cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0xffe0 && cp <= 0xffe6 || cp >= 0x20000) ? 2 : 1;
|
|
143
|
+
}
|
|
144
|
+
export function fitTerminal(value, width) {
|
|
145
|
+
const text = terminalText(value), parts = [...segmenter.segment(text)].map(s => s.segment);
|
|
146
|
+
if (parts.reduce((n, c) => n + cellWidth(c), 0) <= width)
|
|
147
|
+
return text;
|
|
148
|
+
let out = '', used = 0;
|
|
149
|
+
for (const part of parts) {
|
|
150
|
+
const next = cellWidth(part);
|
|
151
|
+
if (used + next > width - 1)
|
|
152
|
+
break;
|
|
153
|
+
out += part;
|
|
154
|
+
used += next;
|
|
155
|
+
}
|
|
156
|
+
return width > 0 ? out + '…' : '';
|
|
157
|
+
}
|
|
158
|
+
const elapsed = (start, end) => {
|
|
159
|
+
const seconds = Math.max(0, Math.floor((end - time(start)) / 1000));
|
|
160
|
+
return seconds < 60 ? `${seconds}초` : `${Math.floor(seconds / 60)}분 ${seconds % 60}초`;
|
|
161
|
+
};
|
|
162
|
+
const clock = (value) => new Date(value).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
|
163
|
+
export function renderMonitor(snapshot, { columns = 100, rows = 36, color = false, now = Date.now(), frame = 0 } = {}) {
|
|
164
|
+
const width = Math.max(1, Math.floor(columns)), height = Math.max(1, Math.floor(rows)), lines = [];
|
|
165
|
+
const paint = (text, tone = 'info') => color ? `\x1b[${{ info: 36, success: 32, warning: 33, muted: 90 }[tone]}m${text}\x1b[0m` : text;
|
|
166
|
+
const add = (text, tone) => {
|
|
167
|
+
const indent = Math.min(text.length - text.trimStart().length, 4, width);
|
|
168
|
+
lines.push(paint(' '.repeat(indent) + fitTerminal(text, width - indent), tone));
|
|
169
|
+
};
|
|
170
|
+
const heading = (text) => add(`── ${text} ${'─'.repeat(Math.max(0, width - 4 - [...text].reduce((n, c) => n + cellWidth(c), 0)))}`, 'muted');
|
|
171
|
+
const active = snapshot.requests.filter(r => pending.has(r.state)), attention = snapshot.sessions.filter(s => s.state === 'awaiting_user').length;
|
|
172
|
+
add(`axMap LIVE · 진행 확인 ${active.length}개${attention ? ` · 승인 대기 ${attention}` : ''}`, attention ? 'warning' : 'info');
|
|
173
|
+
add(snapshot.cwd, 'muted');
|
|
174
|
+
add(`관측 ${clock(snapshot.checkedAt)} · 확인된 정보만 표시 · 모델 호출 없음`, 'muted');
|
|
175
|
+
if (width < 56 || height < 16) {
|
|
176
|
+
add('터미널을 넓히면 파이프라인과 최근 이벤트가 표시됩니다.', 'muted');
|
|
177
|
+
for (const s of snapshot.sessions.slice(0, Math.max(0, height - 7)))
|
|
178
|
+
add(`${s.provider} ${s.id.slice(0, 8)} · ${sessionLabels[s.state]}`);
|
|
179
|
+
for (const r of active.slice(0, 2))
|
|
180
|
+
add(`${r.id.slice(0, 8)} · ${requestLabels[r.state]}`, 'warning');
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
heading('에이전트');
|
|
184
|
+
if (!snapshot.sessions.length)
|
|
185
|
+
add('연결된 세션 기록이 없습니다. axmap bridge attach / serve로 연결하세요.', 'muted');
|
|
186
|
+
for (const session of snapshot.sessions.slice(0, Math.min(5, Math.max(1, Math.floor((height - 14) / 5))))) {
|
|
187
|
+
const state = sessionLabels[session.state], connection = { connected: '연결됨', closed: '종료됨', unreachable: '연결 확인 불가', unverified: '확인 미지원' }[session.connection];
|
|
188
|
+
const usage = session.usage?.totalTokens == null ? '토큰 미측정' : `처리 ${session.usage.totalTokens.toLocaleString()} 토큰`;
|
|
189
|
+
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');
|
|
190
|
+
if (session.role)
|
|
191
|
+
add(` 역할 ${session.role}`, 'muted');
|
|
192
|
+
if (session.pendingApproval) {
|
|
193
|
+
const detail = session.pendingApproval.command || session.pendingApproval.commandLine || session.pendingApproval.reason;
|
|
194
|
+
if (typeof detail === 'string')
|
|
195
|
+
add(` 승인 요청 ${detail}`, 'warning');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
heading('요청 파이프라인');
|
|
199
|
+
const selected = [...active, ...snapshot.requests.filter(r => !pending.has(r.state))].slice(0, height >= 32 ? 3 : 1);
|
|
200
|
+
if (!selected.length) {
|
|
201
|
+
add('새 요청을 기다리는 중', 'muted');
|
|
202
|
+
add('별도 터미널이나 AI에서 요청을 보내면 여기에 흐름이 나타납니다.', 'muted');
|
|
203
|
+
}
|
|
204
|
+
for (const request of selected) {
|
|
205
|
+
const session = snapshot.sessions.find(s => s.id === request.to);
|
|
206
|
+
const target = `${session?.provider || '세션'} ${request.to.slice(0, 8)}`;
|
|
207
|
+
const tone = request.state === 'completed' ? 'success' : ['unknown', 'failed', 'superseded'].includes(request.state) ? 'warning' : 'info';
|
|
208
|
+
add(`${request.from} → ${target} · ${requestLabels[request.state]} · ${elapsed(request.createdAt, request.completedAt ? time(request.completedAt) : now)}`, tone);
|
|
209
|
+
const spin = ['◐', '◓', '◑', '◒'][frame % 4];
|
|
210
|
+
const stages = request.state === 'completed' ? '● 접수 ── ● 전달 ── ● 응답 ── ● 완료 확인' :
|
|
211
|
+
request.state === 'submitted' ? `● 접수 ── ● 전달 ── ${spin} 응답 대기 ── ○ 완료 확인` :
|
|
212
|
+
request.state === 'sending' ? `● 접수 ── ${spin} 전달 중 ── ○ 응답 ── ○ 완료 확인` :
|
|
213
|
+
request.state === 'unknown' ? '● 접수 ── ? 전달 확인 필요 ── ○ 완료 미확정' : '● 접수 ── ! 진행 중단 ── ○ 완료 미확정';
|
|
214
|
+
add(` ${stages}`, tone);
|
|
215
|
+
add(` 작업 ${request.prompt}`, 'muted');
|
|
216
|
+
if (request.error)
|
|
217
|
+
add(` 사유 ${request.error}`, 'warning');
|
|
218
|
+
}
|
|
219
|
+
const dialogue = snapshot.dialogues.find(d => ['queued', 'running', 'stopping'].includes(d.state));
|
|
220
|
+
if (dialogue) {
|
|
221
|
+
const used = dialogue.usage?.totalTokens ?? dialogue.knownUsage?.totalTokens;
|
|
222
|
+
add(`자동 대화 ${dialogueLabels[dialogue.state]} · ${dialogue.turns.length}/${dialogue.limits?.maxTurns ?? '?'}회 · 처리 ${used == null ? '미확인' : used.toLocaleString()} 토큰${dialogue.usagePending ? ' (측정 대기 포함)' : ''}`);
|
|
223
|
+
}
|
|
224
|
+
if (snapshot.claims.length && lines.length < height - 6) {
|
|
225
|
+
heading('작업 선점');
|
|
226
|
+
for (const claim of snapshot.claims.slice(0, 2))
|
|
227
|
+
add(`${claim.agent} · ${claim.intent || claim.task || '작업 중'} · ${claim.paths.slice(0, 2).join(', ')}${claim.paths.length > 2 ? ` 외 ${claim.paths.length - 2}개` : ''}`, 'muted');
|
|
228
|
+
}
|
|
229
|
+
if (lines.length < height - 4) {
|
|
230
|
+
heading('최근 이벤트');
|
|
231
|
+
if (!snapshot.events.length)
|
|
232
|
+
add('관측된 이벤트가 없습니다.', 'muted');
|
|
233
|
+
for (const event of snapshot.events.slice(0, Math.min(5, height - lines.length - 3)))
|
|
234
|
+
add(`${clock(event.at)} ${event.label}`, event.tone);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const warnings = snapshot.issues.slice(0, Math.min(2, height - 1)).map(issue => paint(fitTerminal(`! ${issue}`, width), 'warning'));
|
|
238
|
+
const footer = paint(fitTerminal('q / Esc 종료 · 모니터를 닫아도 진행 중인 작업은 계속됩니다.', width), 'muted');
|
|
239
|
+
return [...lines.slice(0, height - 1 - warnings.length), ...warnings, footer].join('\n');
|
|
240
|
+
}
|