axmap-cli 1.5.0 → 1.7.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/.claude/commands/pipeline.md +9 -0
- package/README.ko.md +37 -0
- package/README.md +37 -0
- package/bin/axmap.mjs +6 -0
- package/mcp/README.md +9 -0
- package/mcp/pipeline.html +184 -0
- package/mcp/server.mjs +161 -20
- package/package.json +12 -3
- package/skills/pipeline/SKILL.md +14 -0
- package/src/agentcli.mjs +17 -14
- package/src/agentcli.mts +19 -0
- package/src/bridge.mjs +297 -219
- package/src/bridge.mts +282 -0
- package/src/codex-connection.mjs +151 -0
- package/src/codex-connection.mts +83 -0
- package/src/external-console.mjs +140 -97
- package/src/external-console.mts +128 -0
- package/src/managed-session.mjs +325 -169
- package/src/managed-session.mts +242 -0
- package/src/monitor-cli.mjs +89 -0
- package/src/monitor-cli.mts +66 -0
- package/src/monitor.mjs +245 -0
- package/src/monitor.mts +199 -0
- package/src/node-pty.d.ts +13 -0
- package/src/pipeline-graph.mjs +55 -0
- package/src/pipeline-graph.mts +51 -0
- package/src/pipeline-mcp.mjs +111 -0
- package/src/pipeline-mcp.mts +105 -0
- package/src/pipeline-statusline.mjs +135 -0
- package/src/pipeline-statusline.mts +106 -0
- package/src/pipeline-web.mjs +56 -0
- package/src/pipeline-web.mts +31 -0
- package/src/session-dialogue.mjs +168 -116
- package/src/session-dialogue.mts +136 -0
- package/src/session-mailbox.mjs +190 -0
- package/src/session-mailbox.mts +157 -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 +119 -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 +41 -6
- package/tools/build.mjs +39 -0
- package/tools/monitor.mjs +5 -0
- package/tools/pipeline.mjs +34 -0
- package/tools/run-tests.mjs +3 -2
- package/tools/windows-console.cs +116 -0
package/src/monitor.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
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(), 'notices'), issues, '안내')) {
|
|
59
|
+
if (!object(raw) || raw.repo !== repo || typeof raw.id !== 'string' || typeof raw.to !== 'string' || typeof raw.createdAt !== 'string' || !Number.isFinite(Date.parse(raw.createdAt)))
|
|
60
|
+
continue;
|
|
61
|
+
event(`notice:${raw.id}`, raw.createdAt, `문맥 안내 기록 · ${raw.to.slice(0, 8)} · 모델 턴 없음`, 'success');
|
|
62
|
+
}
|
|
63
|
+
for (const raw of records(path.join(bridgeHome(), 'requests'), issues, '요청')) {
|
|
64
|
+
if (!object(raw) || raw.repo !== repo)
|
|
65
|
+
continue;
|
|
66
|
+
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]) {
|
|
67
|
+
issues.push('형식이 올바르지 않은 요청 기록이 있습니다.');
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
requests.push(raw);
|
|
71
|
+
}
|
|
72
|
+
requests.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
|
73
|
+
// Refresh at most one pending request per session, never submit or retry it.
|
|
74
|
+
const refreshed = new Set();
|
|
75
|
+
for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
|
|
76
|
+
const request = requests[index];
|
|
77
|
+
if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to))
|
|
78
|
+
continue;
|
|
79
|
+
refreshed.add(request.to);
|
|
80
|
+
try {
|
|
81
|
+
requests[index] = resolve(request.id, workspace);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
issues.push(`요청 ${request.id.slice(0, 8)}: ${errorMessage(e)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const request of requests) {
|
|
88
|
+
const key = `request:${request.id}`, previous = observed.get(key);
|
|
89
|
+
event(`${key}:created`, request.createdAt, `요청 접수 · ${request.from} → ${request.to.slice(0, 8)}`);
|
|
90
|
+
if (request.completedAt)
|
|
91
|
+
event(`${key}:completed`, request.completedAt, `완료 확인 · ${request.to.slice(0, 8)}`, 'success');
|
|
92
|
+
if (previous !== undefined && previous !== request.state && !(request.state === 'completed' && request.completedAt))
|
|
93
|
+
event(`${key}:${request.state}`, checkedAt, `${requestLabels[request.state]} · ${request.to.slice(0, 8)}`, request.state === 'completed' ? 'success' : request.state === 'submitted' ? 'info' : 'warning');
|
|
94
|
+
observed.set(key, request.state);
|
|
95
|
+
}
|
|
96
|
+
for (const session of sessions) {
|
|
97
|
+
const key = `session:${session.id}`, value = `${session.connection}:${session.state}`, previous = observed.get(key);
|
|
98
|
+
if (previous !== undefined && previous !== value)
|
|
99
|
+
event(`${key}:${value}`, checkedAt, `${session.provider} ${session.id.slice(0, 8)} · ${sessionLabels[session.state]}`, session.state === 'awaiting_user' || session.connection === 'unreachable' ? 'warning' : 'info');
|
|
100
|
+
observed.set(key, value);
|
|
101
|
+
}
|
|
102
|
+
const dialogues = [];
|
|
103
|
+
for (const raw of records(path.join(bridgeHome(), 'dialogues'), issues, '대화')) {
|
|
104
|
+
if (!object(raw) || raw.repo !== repo)
|
|
105
|
+
continue;
|
|
106
|
+
if (typeof raw.id !== 'string' || typeof raw.state !== 'string' || !dialogueLabels[raw.state] || !Array.isArray(raw.turns)) {
|
|
107
|
+
issues.push('형식이 올바르지 않은 대화 기록이 있습니다.');
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
dialogues.push(raw);
|
|
111
|
+
}
|
|
112
|
+
dialogues.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
|
113
|
+
const claims = [];
|
|
114
|
+
const ledger = path.join(path.dirname(repo), '.axmap', 'ledger', 'claims');
|
|
115
|
+
for (const raw of records(ledger, issues, '선점', false)) {
|
|
116
|
+
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') {
|
|
117
|
+
issues.push('형식이 올바르지 않은 선점 기록이 있습니다.');
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const expiresAt = time(raw.since) + raw.ttlMs;
|
|
121
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now())
|
|
122
|
+
continue;
|
|
123
|
+
claims.push({ agent: raw.agent, paths: raw.paths, expiresAt,
|
|
124
|
+
session: typeof raw.session === 'string' ? raw.session : null, task: typeof raw.task === 'string' ? raw.task : null, intent: typeof raw.intent === 'string' ? raw.intent : null });
|
|
125
|
+
}
|
|
126
|
+
const recent = [...events.values()].sort((a, b) => time(b.at) - time(a.at)).slice(0, 40);
|
|
127
|
+
events.clear();
|
|
128
|
+
for (const entry of recent)
|
|
129
|
+
events.set(entry.id, entry);
|
|
130
|
+
return { cwd: workspace, repo, checkedAt, sessions, requests: requests.slice(0, 50), dialogues: dialogues.slice(0, 20), claims, events: recent, issues };
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Strip untrusted terminal controls, including OSC, CSI and bidi overrides. */
|
|
134
|
+
export function terminalText(value) {
|
|
135
|
+
return String(value ?? '').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
|
136
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
137
|
+
}
|
|
138
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
139
|
+
function cellWidth(text) {
|
|
140
|
+
if (/\p{Extended_Pictographic}/u.test(text))
|
|
141
|
+
return 2;
|
|
142
|
+
const cp = text.codePointAt(0) || 0;
|
|
143
|
+
if (/^\p{Mark}+$/u.test(text))
|
|
144
|
+
return 0;
|
|
145
|
+
return cp >= 0x1100 && (cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
|
|
146
|
+
cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
|
|
147
|
+
cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0xffe0 && cp <= 0xffe6 || cp >= 0x20000) ? 2 : 1;
|
|
148
|
+
}
|
|
149
|
+
export function fitTerminal(value, width) {
|
|
150
|
+
const text = terminalText(value), parts = [...segmenter.segment(text)].map(s => s.segment);
|
|
151
|
+
if (parts.reduce((n, c) => n + cellWidth(c), 0) <= width)
|
|
152
|
+
return text;
|
|
153
|
+
let out = '', used = 0;
|
|
154
|
+
for (const part of parts) {
|
|
155
|
+
const next = cellWidth(part);
|
|
156
|
+
if (used + next > width - 1)
|
|
157
|
+
break;
|
|
158
|
+
out += part;
|
|
159
|
+
used += next;
|
|
160
|
+
}
|
|
161
|
+
return width > 0 ? out + '…' : '';
|
|
162
|
+
}
|
|
163
|
+
const elapsed = (start, end) => {
|
|
164
|
+
const seconds = Math.max(0, Math.floor((end - time(start)) / 1000));
|
|
165
|
+
return seconds < 60 ? `${seconds}초` : `${Math.floor(seconds / 60)}분 ${seconds % 60}초`;
|
|
166
|
+
};
|
|
167
|
+
const clock = (value) => new Date(value).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
|
168
|
+
export function renderMonitor(snapshot, { columns = 100, rows = 36, color = false, now = Date.now(), frame = 0 } = {}) {
|
|
169
|
+
const width = Math.max(1, Math.floor(columns)), height = Math.max(1, Math.floor(rows)), lines = [];
|
|
170
|
+
const paint = (text, tone = 'info') => color ? `\x1b[${{ info: 36, success: 32, warning: 33, muted: 90 }[tone]}m${text}\x1b[0m` : text;
|
|
171
|
+
const add = (text, tone) => {
|
|
172
|
+
const indent = Math.min(text.length - text.trimStart().length, 4, width);
|
|
173
|
+
lines.push(paint(' '.repeat(indent) + fitTerminal(text, width - indent), tone));
|
|
174
|
+
};
|
|
175
|
+
const heading = (text) => add(`── ${text} ${'─'.repeat(Math.max(0, width - 4 - [...text].reduce((n, c) => n + cellWidth(c), 0)))}`, 'muted');
|
|
176
|
+
const active = snapshot.requests.filter(r => pending.has(r.state)), attention = snapshot.sessions.filter(s => s.state === 'awaiting_user').length;
|
|
177
|
+
add(`axMap LIVE · 진행 확인 ${active.length}개${attention ? ` · 승인 대기 ${attention}` : ''}`, attention ? 'warning' : 'info');
|
|
178
|
+
add(snapshot.cwd, 'muted');
|
|
179
|
+
add(`관측 ${clock(snapshot.checkedAt)} · 확인된 정보만 표시 · 모델 호출 없음`, 'muted');
|
|
180
|
+
if (width < 56 || height < 16) {
|
|
181
|
+
add('터미널을 넓히면 파이프라인과 최근 이벤트가 표시됩니다.', 'muted');
|
|
182
|
+
for (const s of snapshot.sessions.slice(0, Math.max(0, height - 7)))
|
|
183
|
+
add(`${s.provider} ${s.id.slice(0, 8)} · ${sessionLabels[s.state]}`);
|
|
184
|
+
for (const r of active.slice(0, 2))
|
|
185
|
+
add(`${r.id.slice(0, 8)} · ${requestLabels[r.state]}`, 'warning');
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
heading('에이전트');
|
|
189
|
+
if (!snapshot.sessions.length)
|
|
190
|
+
add('연결된 세션 기록이 없습니다. axmap bridge attach / serve로 연결하세요.', 'muted');
|
|
191
|
+
for (const session of snapshot.sessions.slice(0, Math.min(5, Math.max(1, Math.floor((height - 14) / 5))))) {
|
|
192
|
+
const state = sessionLabels[session.state], connection = { connected: '연결됨', closed: '종료됨', unreachable: '연결 확인 불가', unverified: '확인 미지원' }[session.connection];
|
|
193
|
+
const usage = session.usage?.totalTokens == null ? '토큰 미측정' : `처리 ${session.usage.totalTokens.toLocaleString()} 토큰`;
|
|
194
|
+
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');
|
|
195
|
+
if (session.role)
|
|
196
|
+
add(` 역할 ${session.role}`, 'muted');
|
|
197
|
+
if (session.pendingApproval) {
|
|
198
|
+
const detail = session.pendingApproval.command || session.pendingApproval.commandLine || session.pendingApproval.reason;
|
|
199
|
+
if (typeof detail === 'string')
|
|
200
|
+
add(` 승인 요청 ${detail}`, 'warning');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
heading('요청 파이프라인');
|
|
204
|
+
const selected = [...active, ...snapshot.requests.filter(r => !pending.has(r.state))].slice(0, height >= 32 ? 3 : 1);
|
|
205
|
+
if (!selected.length) {
|
|
206
|
+
add('새 요청을 기다리는 중', 'muted');
|
|
207
|
+
add('별도 터미널이나 AI에서 요청을 보내면 여기에 흐름이 나타납니다.', 'muted');
|
|
208
|
+
}
|
|
209
|
+
for (const request of selected) {
|
|
210
|
+
const session = snapshot.sessions.find(s => s.id === request.to);
|
|
211
|
+
const target = `${session?.provider || '세션'} ${request.to.slice(0, 8)}`;
|
|
212
|
+
const tone = request.state === 'completed' ? 'success' : ['unknown', 'failed', 'superseded'].includes(request.state) ? 'warning' : 'info';
|
|
213
|
+
add(`${request.from} → ${target} · ${requestLabels[request.state]} · ${elapsed(request.createdAt, request.completedAt ? time(request.completedAt) : now)}`, tone);
|
|
214
|
+
const spin = ['◐', '◓', '◑', '◒'][frame % 4];
|
|
215
|
+
const stages = request.state === 'completed' ? '● 접수 ── ● 전달 ── ● 응답 ── ● 완료 확인' :
|
|
216
|
+
request.state === 'submitted' ? `● 접수 ── ● 전달 ── ${spin} 응답 대기 ── ○ 완료 확인` :
|
|
217
|
+
request.state === 'sending' ? `● 접수 ── ${spin} 전달 중 ── ○ 응답 ── ○ 완료 확인` :
|
|
218
|
+
request.state === 'unknown' ? '● 접수 ── ? 전달 확인 필요 ── ○ 완료 미확정' : '● 접수 ── ! 진행 중단 ── ○ 완료 미확정';
|
|
219
|
+
add(` ${stages}`, tone);
|
|
220
|
+
add(` 작업 ${request.prompt}`, 'muted');
|
|
221
|
+
if (request.error)
|
|
222
|
+
add(` 사유 ${request.error}`, 'warning');
|
|
223
|
+
}
|
|
224
|
+
const dialogue = snapshot.dialogues.find(d => ['queued', 'running', 'stopping'].includes(d.state));
|
|
225
|
+
if (dialogue) {
|
|
226
|
+
const used = dialogue.usage?.totalTokens ?? dialogue.knownUsage?.totalTokens;
|
|
227
|
+
add(`자동 대화 ${dialogueLabels[dialogue.state]} · ${dialogue.turns.length}/${dialogue.limits?.maxTurns ?? '?'}회 · 처리 ${used == null ? '미확인' : used.toLocaleString()} 토큰${dialogue.usagePending ? ' (측정 대기 포함)' : ''}`);
|
|
228
|
+
}
|
|
229
|
+
if (snapshot.claims.length && lines.length < height - 6) {
|
|
230
|
+
heading('작업 선점');
|
|
231
|
+
for (const claim of snapshot.claims.slice(0, 2))
|
|
232
|
+
add(`${claim.agent} · ${claim.intent || claim.task || '작업 중'} · ${claim.paths.slice(0, 2).join(', ')}${claim.paths.length > 2 ? ` 외 ${claim.paths.length - 2}개` : ''}`, 'muted');
|
|
233
|
+
}
|
|
234
|
+
if (lines.length < height - 4) {
|
|
235
|
+
heading('최근 이벤트');
|
|
236
|
+
if (!snapshot.events.length)
|
|
237
|
+
add('관측된 이벤트가 없습니다.', 'muted');
|
|
238
|
+
for (const event of snapshot.events.slice(0, Math.min(5, height - lines.length - 3)))
|
|
239
|
+
add(`${clock(event.at)} ${event.label}`, event.tone);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const warnings = snapshot.issues.slice(0, Math.min(2, height - 1)).map(issue => paint(fitTerminal(`! ${issue}`, width), 'warning'));
|
|
243
|
+
const footer = paint(fitTerminal('q / Esc 종료 · 모니터를 닫아도 진행 중인 작업은 계속됩니다.', width), 'muted');
|
|
244
|
+
return [...lines.slice(0, height - 1 - warnings.length), ...warnings, footer].join('\n');
|
|
245
|
+
}
|
package/src/monitor.mts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
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(), 'notices'), issues, '안내')) {
|
|
54
|
+
if (!object(raw) || raw.repo !== repo || typeof raw.id !== 'string' || typeof raw.to !== 'string' || typeof raw.createdAt !== 'string' || !Number.isFinite(Date.parse(raw.createdAt))) continue
|
|
55
|
+
event(`notice:${raw.id}`, raw.createdAt, `문맥 안내 기록 · ${raw.to.slice(0, 8)} · 모델 턴 없음`, 'success')
|
|
56
|
+
}
|
|
57
|
+
for (const raw of records(path.join(bridgeHome(), 'requests'), issues, '요청')) {
|
|
58
|
+
if (!object(raw) || raw.repo !== repo) continue
|
|
59
|
+
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 }
|
|
60
|
+
requests.push(raw as unknown as RequestRecord)
|
|
61
|
+
}
|
|
62
|
+
requests.sort((a, b) => time(b.createdAt) - time(a.createdAt))
|
|
63
|
+
// Refresh at most one pending request per session, never submit or retry it.
|
|
64
|
+
const refreshed = new Set<string>()
|
|
65
|
+
for (let index = 0; index < requests.length && refreshed.size < 4; index++) {
|
|
66
|
+
const request = requests[index]!
|
|
67
|
+
if (!['submitted', 'unknown'].includes(request.state) || refreshed.has(request.to)) continue
|
|
68
|
+
refreshed.add(request.to)
|
|
69
|
+
try { requests[index] = resolve(request.id, workspace) }
|
|
70
|
+
catch (e) { issues.push(`요청 ${request.id.slice(0, 8)}: ${errorMessage(e)}`) }
|
|
71
|
+
}
|
|
72
|
+
for (const request of requests) {
|
|
73
|
+
const key = `request:${request.id}`, previous = observed.get(key)
|
|
74
|
+
event(`${key}:created`, request.createdAt, `요청 접수 · ${request.from} → ${request.to.slice(0, 8)}`)
|
|
75
|
+
if (request.completedAt) event(`${key}:completed`, request.completedAt, `완료 확인 · ${request.to.slice(0, 8)}`, 'success')
|
|
76
|
+
if (previous !== undefined && previous !== request.state && !(request.state === 'completed' && request.completedAt)) event(`${key}:${request.state}`, checkedAt,
|
|
77
|
+
`${requestLabels[request.state]} · ${request.to.slice(0, 8)}`, request.state === 'completed' ? 'success' : request.state === 'submitted' ? 'info' : 'warning')
|
|
78
|
+
observed.set(key, request.state)
|
|
79
|
+
}
|
|
80
|
+
for (const session of sessions) {
|
|
81
|
+
const key = `session:${session.id}`, value = `${session.connection}:${session.state}`, previous = observed.get(key)
|
|
82
|
+
if (previous !== undefined && previous !== value) event(`${key}:${value}`, checkedAt,
|
|
83
|
+
`${session.provider} ${session.id.slice(0, 8)} · ${sessionLabels[session.state]}`, session.state === 'awaiting_user' || session.connection === 'unreachable' ? 'warning' : 'info')
|
|
84
|
+
observed.set(key, value)
|
|
85
|
+
}
|
|
86
|
+
const dialogues: DialogueRecord[] = []
|
|
87
|
+
for (const raw of records(path.join(bridgeHome(), 'dialogues'), issues, '대화')) {
|
|
88
|
+
if (!object(raw) || raw.repo !== repo) continue
|
|
89
|
+
if (typeof raw.id !== 'string' || typeof raw.state !== 'string' || !dialogueLabels[raw.state] || !Array.isArray(raw.turns)) { issues.push('형식이 올바르지 않은 대화 기록이 있습니다.'); continue }
|
|
90
|
+
dialogues.push(raw as unknown as DialogueRecord)
|
|
91
|
+
}
|
|
92
|
+
dialogues.sort((a, b) => time(b.createdAt) - time(a.createdAt))
|
|
93
|
+
const claims: MonitorClaim[] = []
|
|
94
|
+
const ledger = path.join(path.dirname(repo), '.axmap', 'ledger', 'claims')
|
|
95
|
+
for (const raw of records(ledger, issues, '선점', false)) {
|
|
96
|
+
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 }
|
|
97
|
+
const expiresAt = time(raw.since) + raw.ttlMs
|
|
98
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now()) continue
|
|
99
|
+
claims.push({ agent: raw.agent, paths: raw.paths as string[], expiresAt,
|
|
100
|
+
session: typeof raw.session === 'string' ? raw.session : null, task: typeof raw.task === 'string' ? raw.task : null, intent: typeof raw.intent === 'string' ? raw.intent : null })
|
|
101
|
+
}
|
|
102
|
+
const recent = [...events.values()].sort((a, b) => time(b.at) - time(a.at)).slice(0, 40)
|
|
103
|
+
events.clear(); for (const entry of recent) events.set(entry.id, entry)
|
|
104
|
+
return { cwd: workspace, repo, checkedAt, sessions, requests: requests.slice(0, 50), dialogues: dialogues.slice(0, 20), claims, events: recent, issues }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Strip untrusted terminal controls, including OSC, CSI and bidi overrides. */
|
|
109
|
+
export function terminalText(value: unknown): string {
|
|
110
|
+
return String(value ?? '').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
|
111
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
112
|
+
}
|
|
113
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
114
|
+
function cellWidth(text: string): number {
|
|
115
|
+
if (/\p{Extended_Pictographic}/u.test(text)) return 2
|
|
116
|
+
const cp = text.codePointAt(0) || 0
|
|
117
|
+
if (/^\p{Mark}+$/u.test(text)) return 0
|
|
118
|
+
return cp >= 0x1100 && (cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
|
|
119
|
+
cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
|
|
120
|
+
cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0xffe0 && cp <= 0xffe6 || cp >= 0x20000) ? 2 : 1
|
|
121
|
+
}
|
|
122
|
+
export function fitTerminal(value: unknown, width: number): string {
|
|
123
|
+
const text = terminalText(value), parts = [...segmenter.segment(text)].map(s => s.segment)
|
|
124
|
+
if (parts.reduce((n, c) => n + cellWidth(c), 0) <= width) return text
|
|
125
|
+
let out = '', used = 0
|
|
126
|
+
for (const part of parts) { const next = cellWidth(part); if (used + next > width - 1) break; out += part; used += next }
|
|
127
|
+
return width > 0 ? out + '…' : ''
|
|
128
|
+
}
|
|
129
|
+
const elapsed = (start: string, end: number): string => {
|
|
130
|
+
const seconds = Math.max(0, Math.floor((end - time(start)) / 1000))
|
|
131
|
+
return seconds < 60 ? `${seconds}초` : `${Math.floor(seconds / 60)}분 ${seconds % 60}초`
|
|
132
|
+
}
|
|
133
|
+
const clock = (value: string) => new Date(value).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
|
134
|
+
|
|
135
|
+
export function renderMonitor(snapshot: MonitorSnapshot, { columns = 100, rows = 36, color = false, now = Date.now(), frame = 0 } = {}): string {
|
|
136
|
+
const width = Math.max(1, Math.floor(columns)), height = Math.max(1, Math.floor(rows)), lines: string[] = []
|
|
137
|
+
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
|
|
138
|
+
const add = (text: string, tone?: Parameters<typeof paint>[1]) => {
|
|
139
|
+
const indent = Math.min(text.length - text.trimStart().length, 4, width)
|
|
140
|
+
lines.push(paint(' '.repeat(indent) + fitTerminal(text, width - indent), tone))
|
|
141
|
+
}
|
|
142
|
+
const heading = (text: string) => add(`── ${text} ${'─'.repeat(Math.max(0, width - 4 - [...text].reduce((n, c) => n + cellWidth(c), 0)))}`, 'muted')
|
|
143
|
+
const active = snapshot.requests.filter(r => pending.has(r.state)), attention = snapshot.sessions.filter(s => s.state === 'awaiting_user').length
|
|
144
|
+
add(`axMap LIVE · 진행 확인 ${active.length}개${attention ? ` · 승인 대기 ${attention}` : ''}`, attention ? 'warning' : 'info')
|
|
145
|
+
add(snapshot.cwd, 'muted')
|
|
146
|
+
add(`관측 ${clock(snapshot.checkedAt)} · 확인된 정보만 표시 · 모델 호출 없음`, 'muted')
|
|
147
|
+
if (width < 56 || height < 16) {
|
|
148
|
+
add('터미널을 넓히면 파이프라인과 최근 이벤트가 표시됩니다.', 'muted')
|
|
149
|
+
for (const s of snapshot.sessions.slice(0, Math.max(0, height - 7))) add(`${s.provider} ${s.id.slice(0, 8)} · ${sessionLabels[s.state]}`)
|
|
150
|
+
for (const r of active.slice(0, 2)) add(`${r.id.slice(0, 8)} · ${requestLabels[r.state]}`, 'warning')
|
|
151
|
+
} else {
|
|
152
|
+
heading('에이전트')
|
|
153
|
+
if (!snapshot.sessions.length) add('연결된 세션 기록이 없습니다. axmap bridge attach / serve로 연결하세요.', 'muted')
|
|
154
|
+
for (const session of snapshot.sessions.slice(0, Math.min(5, Math.max(1, Math.floor((height - 14) / 5))))) {
|
|
155
|
+
const state = sessionLabels[session.state], connection = { connected: '연결됨', closed: '종료됨', unreachable: '연결 확인 불가', unverified: '확인 미지원' }[session.connection]
|
|
156
|
+
const usage = session.usage?.totalTokens == null ? '토큰 미측정' : `처리 ${session.usage.totalTokens.toLocaleString()} 토큰`
|
|
157
|
+
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')
|
|
158
|
+
if (session.role) add(` 역할 ${session.role}`, 'muted')
|
|
159
|
+
if (session.pendingApproval) {
|
|
160
|
+
const detail = session.pendingApproval.command || session.pendingApproval.commandLine || session.pendingApproval.reason
|
|
161
|
+
if (typeof detail === 'string') add(` 승인 요청 ${detail}`, 'warning')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
heading('요청 파이프라인')
|
|
165
|
+
const selected = [...active, ...snapshot.requests.filter(r => !pending.has(r.state))].slice(0, height >= 32 ? 3 : 1)
|
|
166
|
+
if (!selected.length) { add('새 요청을 기다리는 중', 'muted'); add('별도 터미널이나 AI에서 요청을 보내면 여기에 흐름이 나타납니다.', 'muted') }
|
|
167
|
+
for (const request of selected) {
|
|
168
|
+
const session = snapshot.sessions.find(s => s.id === request.to)
|
|
169
|
+
const target = `${session?.provider || '세션'} ${request.to.slice(0, 8)}`
|
|
170
|
+
const tone = request.state === 'completed' ? 'success' : ['unknown', 'failed', 'superseded'].includes(request.state) ? 'warning' : 'info'
|
|
171
|
+
add(`${request.from} → ${target} · ${requestLabels[request.state]} · ${elapsed(request.createdAt, request.completedAt ? time(request.completedAt) : now)}`, tone)
|
|
172
|
+
const spin = ['◐', '◓', '◑', '◒'][frame % 4]
|
|
173
|
+
const stages = request.state === 'completed' ? '● 접수 ── ● 전달 ── ● 응답 ── ● 완료 확인' :
|
|
174
|
+
request.state === 'submitted' ? `● 접수 ── ● 전달 ── ${spin} 응답 대기 ── ○ 완료 확인` :
|
|
175
|
+
request.state === 'sending' ? `● 접수 ── ${spin} 전달 중 ── ○ 응답 ── ○ 완료 확인` :
|
|
176
|
+
request.state === 'unknown' ? '● 접수 ── ? 전달 확인 필요 ── ○ 완료 미확정' : '● 접수 ── ! 진행 중단 ── ○ 완료 미확정'
|
|
177
|
+
add(` ${stages}`, tone)
|
|
178
|
+
add(` 작업 ${request.prompt}`, 'muted')
|
|
179
|
+
if (request.error) add(` 사유 ${request.error}`, 'warning')
|
|
180
|
+
}
|
|
181
|
+
const dialogue = snapshot.dialogues.find(d => ['queued', 'running', 'stopping'].includes(d.state))
|
|
182
|
+
if (dialogue) {
|
|
183
|
+
const used = dialogue.usage?.totalTokens ?? dialogue.knownUsage?.totalTokens
|
|
184
|
+
add(`자동 대화 ${dialogueLabels[dialogue.state]} · ${dialogue.turns.length}/${dialogue.limits?.maxTurns ?? '?'}회 · 처리 ${used == null ? '미확인' : used.toLocaleString()} 토큰${dialogue.usagePending ? ' (측정 대기 포함)' : ''}`)
|
|
185
|
+
}
|
|
186
|
+
if (snapshot.claims.length && lines.length < height - 6) {
|
|
187
|
+
heading('작업 선점')
|
|
188
|
+
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')
|
|
189
|
+
}
|
|
190
|
+
if (lines.length < height - 4) {
|
|
191
|
+
heading('최근 이벤트')
|
|
192
|
+
if (!snapshot.events.length) add('관측된 이벤트가 없습니다.', 'muted')
|
|
193
|
+
for (const event of snapshot.events.slice(0, Math.min(5, height - lines.length - 3))) add(`${clock(event.at)} ${event.label}`, event.tone)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const warnings = snapshot.issues.slice(0, Math.min(2, height - 1)).map(issue => paint(fitTerminal(`! ${issue}`, width), 'warning'))
|
|
197
|
+
const footer = paint(fitTerminal('q / Esc 종료 · 모니터를 닫아도 진행 중인 작업은 계속됩니다.', width), 'muted')
|
|
198
|
+
return [...lines.slice(0, height - 1 - warnings.length), ...warnings, footer].join('\n')
|
|
199
|
+
}
|
|
@@ -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,55 @@
|
|
|
1
|
+
// Generated from src/pipeline-graph.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Terminal topology, rendered from observations rather than fabricated progress. */
|
|
3
|
+
import { terminalText, fitTerminal } from './monitor.mjs';
|
|
4
|
+
export function renderPipelineGraph(snapshot, { columns = 110, rows = 42, color = false, frame = 0 } = {}) {
|
|
5
|
+
const width = Math.max(20, columns), lines = [];
|
|
6
|
+
const put = (text) => lines.push(fitTerminal(text, width));
|
|
7
|
+
const cell = (text) => [...text].reduce((v, c) => v + (c.codePointAt(0) >= 0x2e80 ? 2 : 1), 0);
|
|
8
|
+
const card = (title, details, inner = 42) => {
|
|
9
|
+
const n = Math.min(inner, width - 4);
|
|
10
|
+
const output = [`┌${'─'.repeat(n)}┐`];
|
|
11
|
+
for (const text of [title, ...details]) {
|
|
12
|
+
const s = fitTerminal(text, n - 2);
|
|
13
|
+
output.push(`│ ${s}${' '.repeat(Math.max(0, n - 1 - cell(s)))}│`);
|
|
14
|
+
}
|
|
15
|
+
output.push(`└${'─'.repeat(n)}┘`);
|
|
16
|
+
return output;
|
|
17
|
+
};
|
|
18
|
+
put('axMap /pipeline · LOCAL SESSION MAP');
|
|
19
|
+
put(snapshot.cwd);
|
|
20
|
+
put(`관측 ${snapshot.checkedAt} · 실제 이벤트만 표시`);
|
|
21
|
+
lines.push(...card('axMap · 로컬 메시지 라우터', ['요청 ID · 기존 대화 유지 · Git 왕복 없음']));
|
|
22
|
+
const labels = { idle: '대기', running: '실행 중', closed: '종료', awaiting_user: '사용자 승인 대기', unknown: '연결/상태 미확인', starting: '연결 중', settling: '대기 전환 중' };
|
|
23
|
+
const sessions = [...snapshot.sessions].sort((a, b) => Number(b.connection === 'connected') - Number(a.connection === 'connected')).slice(0, 6);
|
|
24
|
+
const count = width >= 105 ? 3 : width >= 70 ? 2 : 1;
|
|
25
|
+
const inner = Math.floor((width - (count - 1) * 2) / count) - 2;
|
|
26
|
+
const cards = [];
|
|
27
|
+
for (const s of sessions) {
|
|
28
|
+
const active = snapshot.requests.filter(r => r.to === s.id && ['sending', 'submitted'].includes(r.state));
|
|
29
|
+
const moving = active.length && s.connection === 'connected';
|
|
30
|
+
const link = `${moving ? ['●─────↓', '──●───↓', '────●─↓'][frame % 3] : '──────┤'} ${active.length ? `${active.length}건 응답 대기` : '전달 중인 요청 없음'}`;
|
|
31
|
+
const token = s.usage?.totalTokens == null ? '토큰 미측정' : `누적 처리 ${s.usage.totalTokens.toLocaleString()} tokens`;
|
|
32
|
+
cards.push([fitTerminal(link, inner + 2), ...card(`${s.provider.toUpperCase()} · ${s.role || s.id.slice(0, 8)}`, [`${labels[s.state] || s.state} · ${s.connection}`, `${s.model || '모델 미확인'} · ${s.transport || '연결 방식 미확인'}`, token], inner)]);
|
|
33
|
+
}
|
|
34
|
+
for (let at = 0; at < cards.length; at += count) {
|
|
35
|
+
const row = cards.slice(at, at + count);
|
|
36
|
+
for (let line = 0; line < row[0].length; line++)
|
|
37
|
+
lines.push(row.map(c => { const text = c[line] || ''; return text + ' '.repeat(Math.max(0, inner + 2 - cell(text))); }).join(' ').trimEnd());
|
|
38
|
+
}
|
|
39
|
+
if (!sessions.length)
|
|
40
|
+
put('등록된 세션 없음 · bridge connect / serve로 연결');
|
|
41
|
+
put('── 요청 · 응답 ──');
|
|
42
|
+
for (const r of snapshot.requests.slice(0, 4)) {
|
|
43
|
+
const elapsed = r.completedAt ? `${((Date.parse(r.completedAt) - Date.parse(r.createdAt)) / 1000).toFixed(2)}s` : '완료 미확정';
|
|
44
|
+
put(`${terminalText(r.from)} → ${r.to.slice(0, 8)} · ${r.state} · ${elapsed}`);
|
|
45
|
+
put(` ${r.id.slice(0, 8)} · ${terminalText(r.prompt)}`);
|
|
46
|
+
}
|
|
47
|
+
put(`── 선점 ${snapshot.claims.length}건 · 최근 이벤트 ──`);
|
|
48
|
+
for (const e of snapshot.events.slice(0, 3))
|
|
49
|
+
put(`${e.at.slice(11, 19)} ${terminalText(e.label)}`);
|
|
50
|
+
for (const issue of snapshot.issues.slice(0, 2))
|
|
51
|
+
put(`확인 필요: ${terminalText(issue)}`);
|
|
52
|
+
put('q / Esc 종료 · 화면 종료는 작업 취소가 아님');
|
|
53
|
+
const view = lines.slice(0, Math.max(1, rows)).join('\n');
|
|
54
|
+
return color ? `\x1b[36m${view}\x1b[0m` : view;
|
|
55
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Terminal topology, rendered from observations rather than fabricated progress. */
|
|
2
|
+
import { terminalText, fitTerminal } from './monitor.mjs'
|
|
3
|
+
import type { MonitorSnapshot } from './monitor.mjs'
|
|
4
|
+
export function renderPipelineGraph(snapshot: MonitorSnapshot, { columns = 110, rows = 42, color = false, frame = 0 } = {}): string {
|
|
5
|
+
const width = Math.max(20, columns), lines: string[] = []
|
|
6
|
+
const put = (text: string) => lines.push(fitTerminal(text, width))
|
|
7
|
+
const cell = (text: string) => [...text].reduce((v, c) => v + (c.codePointAt(0)! >= 0x2e80 ? 2 : 1), 0)
|
|
8
|
+
const card = (title: string, details: string[], inner = 42): string[] => {
|
|
9
|
+
const n = Math.min(inner, width - 4)
|
|
10
|
+
const output = [`┌${'─'.repeat(n)}┐`]
|
|
11
|
+
for (const text of [title, ...details]) {
|
|
12
|
+
const s = fitTerminal(text, n - 2)
|
|
13
|
+
output.push(`│ ${s}${' '.repeat(Math.max(0, n - 1 - cell(s)))}│`)
|
|
14
|
+
}
|
|
15
|
+
output.push(`└${'─'.repeat(n)}┘`)
|
|
16
|
+
return output
|
|
17
|
+
}
|
|
18
|
+
put('axMap /pipeline · LOCAL SESSION MAP')
|
|
19
|
+
put(snapshot.cwd)
|
|
20
|
+
put(`관측 ${snapshot.checkedAt} · 실제 이벤트만 표시`)
|
|
21
|
+
lines.push(...card('axMap · 로컬 메시지 라우터', ['요청 ID · 기존 대화 유지 · Git 왕복 없음']))
|
|
22
|
+
const labels: Record<string, string> = { idle: '대기', running: '실행 중', closed: '종료', awaiting_user: '사용자 승인 대기', unknown: '연결/상태 미확인', starting: '연결 중', settling: '대기 전환 중' }
|
|
23
|
+
const sessions = [...snapshot.sessions].sort((a, b) => Number(b.connection === 'connected') - Number(a.connection === 'connected')).slice(0, 6)
|
|
24
|
+
const count = width >= 105 ? 3 : width >= 70 ? 2 : 1
|
|
25
|
+
const inner = Math.floor((width - (count - 1) * 2) / count) - 2
|
|
26
|
+
const cards: string[][] = []
|
|
27
|
+
for (const s of sessions) {
|
|
28
|
+
const active = snapshot.requests.filter(r => r.to === s.id && ['sending', 'submitted'].includes(r.state))
|
|
29
|
+
const moving = active.length && s.connection === 'connected'
|
|
30
|
+
const link = `${moving ? ['●─────↓', '──●───↓', '────●─↓'][frame % 3] : '──────┤'} ${active.length ? `${active.length}건 응답 대기` : '전달 중인 요청 없음'}`
|
|
31
|
+
const token = s.usage?.totalTokens == null ? '토큰 미측정' : `누적 처리 ${s.usage.totalTokens.toLocaleString()} tokens`
|
|
32
|
+
cards.push([fitTerminal(link, inner + 2), ...card(`${s.provider.toUpperCase()} · ${s.role || s.id.slice(0, 8)}`, [`${labels[s.state] || s.state} · ${s.connection}`, `${s.model || '모델 미확인'} · ${s.transport || '연결 방식 미확인'}`, token], inner)])
|
|
33
|
+
}
|
|
34
|
+
for (let at = 0; at < cards.length; at += count) {
|
|
35
|
+
const row = cards.slice(at, at + count)
|
|
36
|
+
for (let line = 0; line < row[0]!.length; line++) lines.push(row.map(c => { const text = c[line] || ''; return text + ' '.repeat(Math.max(0, inner + 2 - cell(text))) }).join(' ').trimEnd())
|
|
37
|
+
}
|
|
38
|
+
if (!sessions.length) put('등록된 세션 없음 · bridge connect / serve로 연결')
|
|
39
|
+
put('── 요청 · 응답 ──')
|
|
40
|
+
for (const r of snapshot.requests.slice(0, 4)) {
|
|
41
|
+
const elapsed = r.completedAt ? `${((Date.parse(r.completedAt) - Date.parse(r.createdAt)) / 1000).toFixed(2)}s` : '완료 미확정'
|
|
42
|
+
put(`${terminalText(r.from)} → ${r.to.slice(0, 8)} · ${r.state} · ${elapsed}`)
|
|
43
|
+
put(` ${r.id.slice(0, 8)} · ${terminalText(r.prompt)}`)
|
|
44
|
+
}
|
|
45
|
+
put(`── 선점 ${snapshot.claims.length}건 · 최근 이벤트 ──`)
|
|
46
|
+
for (const e of snapshot.events.slice(0, 3)) put(`${e.at.slice(11, 19)} ${terminalText(e.label)}`)
|
|
47
|
+
for (const issue of snapshot.issues.slice(0, 2)) put(`확인 필요: ${terminalText(issue)}`)
|
|
48
|
+
put('q / Esc 종료 · 화면 종료는 작업 취소가 아님')
|
|
49
|
+
const view = lines.slice(0, Math.max(1, rows)).join('\n')
|
|
50
|
+
return color ? `\x1b[36m${view}\x1b[0m` : view
|
|
51
|
+
}
|