axmap-cli 1.6.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 +17 -0
- package/README.md +17 -0
- package/mcp/README.md +3 -0
- package/mcp/pipeline.html +34 -7
- package/mcp/server.mjs +34 -3
- package/package.json +2 -1
- package/skills/pipeline/SKILL.md +14 -0
- package/src/bridge.mjs +1 -0
- package/src/bridge.mts +1 -0
- package/src/codex-connection.mjs +151 -0
- package/src/codex-connection.mts +83 -0
- package/src/managed-session.mjs +76 -14
- package/src/managed-session.mts +57 -13
- package/src/monitor-cli.mjs +5 -3
- package/src/monitor-cli.mts +5 -3
- package/src/monitor.mjs +5 -0
- package/src/monitor.mts +4 -0
- package/src/pipeline-graph.mjs +55 -0
- package/src/pipeline-graph.mts +51 -0
- package/src/pipeline-mcp.mjs +3 -1
- package/src/pipeline-mcp.mts +3 -1
- package/src/pipeline-web.mjs +56 -0
- package/src/pipeline-web.mts +31 -0
- package/src/session-mailbox.mjs +190 -0
- package/src/session-mailbox.mts +157 -0
- package/src/session-types.mts +2 -1
- package/tools/bridge.mjs +32 -0
- package/tools/pipeline.mjs +10 -2
package/src/managed-session.mjs
CHANGED
|
@@ -9,21 +9,26 @@ import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
9
9
|
import { bridgeHome, repoKey, writeJSON } from './bridge.mjs';
|
|
10
10
|
import { normalizeUsage, usageDelta } from './session-usage.mjs';
|
|
11
11
|
import { errorMessage } from './session-types.mjs';
|
|
12
|
+
import { connectCodexProxy } from './codex-connection.mjs';
|
|
12
13
|
const pipeFor = (id) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
|
|
13
14
|
const uuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '');
|
|
14
|
-
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
|
|
15
|
+
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false, cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
|
|
15
16
|
if (!['codex', 'agy'].includes(provider))
|
|
16
17
|
throw new Error('지원하는 세션은 codex 또는 agy입니다.');
|
|
17
18
|
if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
18
19
|
throw new Error('양의 토큰/시간 한도가 필요합니다.');
|
|
19
20
|
if (typeof role !== 'string' || role.length > 1000)
|
|
20
21
|
throw new Error('역할은 1000자 이하여야 합니다.');
|
|
22
|
+
if (existingThreadId && (provider !== 'codex' || !uuid(existingThreadId) || !socketPath || model || args))
|
|
23
|
+
throw new Error('기존 Codex thread와 로컬 socket이 필요하며 모델·실행 인수를 바꾸지 않습니다.');
|
|
21
24
|
const id = randomUUID(), token = randomBytes(32).toString('hex');
|
|
22
25
|
const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`);
|
|
23
26
|
const s = { id, token, provider, transport: 'stdio', role, model: model || null,
|
|
24
27
|
workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
|
|
25
28
|
registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens };
|
|
26
29
|
const jobs = new Map();
|
|
30
|
+
let attachedUsageOrigin = null;
|
|
31
|
+
let initialThreadState = 'idle';
|
|
27
32
|
const calls = new Map();
|
|
28
33
|
let child, sequence = 0, pending = null, closed = false, buffer = '', stderr = '', turnTimer;
|
|
29
34
|
const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionPath, s); };
|
|
@@ -55,8 +60,11 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
55
60
|
completedAt: new Date().toISOString(), conversationId: s.conversationId });
|
|
56
61
|
delete pending.baseline;
|
|
57
62
|
delete pending.baselineKnown;
|
|
58
|
-
if (usage)
|
|
63
|
+
if (usage) {
|
|
59
64
|
s.usage = usage;
|
|
65
|
+
if (existingThreadId && !attachedUsageOrigin)
|
|
66
|
+
attachedUsageOrigin = usage;
|
|
67
|
+
}
|
|
60
68
|
s.usageKnown = usage !== null;
|
|
61
69
|
emit({ event: 'result', request: { ...pending } });
|
|
62
70
|
pending = null;
|
|
@@ -83,11 +91,20 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
83
91
|
child?.kill();
|
|
84
92
|
server.close();
|
|
85
93
|
};
|
|
86
|
-
|
|
94
|
+
let checkingSubmission = false;
|
|
95
|
+
const handle = async (message) => {
|
|
87
96
|
const offered = Buffer.from(typeof message.token === 'string' ? message.token : '');
|
|
88
97
|
const expected = Buffer.from(token);
|
|
89
98
|
if (offered.length !== expected.length || !timingSafeEqual(offered, expected))
|
|
90
99
|
throw new Error('로컬 세션 인증 실패');
|
|
100
|
+
if (message.action === 'notice') {
|
|
101
|
+
if (!existingThreadId || provider !== 'codex' || typeof message.text !== 'string' || !message.text.trim() || Buffer.byteLength(message.text) > 8192)
|
|
102
|
+
throw new Error('기존 Codex 대화에 1~8192바이트 안내만 기록할 수 있습니다.');
|
|
103
|
+
const noticeId = randomUUID();
|
|
104
|
+
await rpc('thread/inject_items', { threadId: existingThreadId, items: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: `[axMap notice ${noticeId}; context only, no new model turn]\n${message.text}` }] }] });
|
|
105
|
+
writeJSON(path.join(bridgeHome(), 'notices', `${noticeId}.json`), { id: noticeId, to: id, conversationId: existingThreadId, repo: repoKey(cwd), text: message.text, createdAt: new Date().toISOString(), state: 'context_recorded', modelInvoked: false });
|
|
106
|
+
return { noticeId, conversationId: existingThreadId, state: 'context_recorded', modelInvoked: false };
|
|
107
|
+
}
|
|
91
108
|
if (message.action === 'status')
|
|
92
109
|
return { id, state: s.state, usage: s.usage, pendingApproval: s.pendingApproval };
|
|
93
110
|
if (message.action === 'result') {
|
|
@@ -111,16 +128,31 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
111
128
|
}
|
|
112
129
|
if (message.action !== 'submit')
|
|
113
130
|
throw new Error('지원하지 않는 로컬 세션 요청입니다.');
|
|
131
|
+
if (jobs.has(message.request?.id))
|
|
132
|
+
throw new Error('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.');
|
|
114
133
|
if (closed || s.state !== 'idle' || pending)
|
|
115
134
|
throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.');
|
|
135
|
+
if (existingThreadId) {
|
|
136
|
+
if (checkingSubmission)
|
|
137
|
+
throw new Error('다른 요청의 상태를 확인 중입니다.');
|
|
138
|
+
checkingSubmission = true;
|
|
139
|
+
try {
|
|
140
|
+
const read = await rpc('thread/read', { threadId: existingThreadId });
|
|
141
|
+
if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle')
|
|
142
|
+
throw new Error('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.');
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
checkingSubmission = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
116
148
|
if (!s.usageKnown)
|
|
117
149
|
throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.');
|
|
118
|
-
if (s.usage && s.usage.totalTokens >= maxTokens)
|
|
150
|
+
if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens)
|
|
119
151
|
throw new Error('세션 토큰 한도에 도달했습니다.');
|
|
120
152
|
const request = message.request;
|
|
121
153
|
if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768)
|
|
122
154
|
throw new Error('요청 형식이 올바르지 않습니다.');
|
|
123
|
-
pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown, result: '' };
|
|
155
|
+
pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown && (!existingThreadId || !!s.usage), result: '' };
|
|
124
156
|
jobs.set(request.id, pending);
|
|
125
157
|
s.state = 'running';
|
|
126
158
|
s.fullyIdle = false;
|
|
@@ -130,7 +162,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
130
162
|
if (provider === 'agy')
|
|
131
163
|
write({ event: 'user', message: { content: prompt } });
|
|
132
164
|
else
|
|
133
|
-
rpc('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], effort: 'low' })
|
|
165
|
+
rpc('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], ...(!existingThreadId ? { effort: 'low' } : {}) })
|
|
134
166
|
.then(r => { if (pending?.id === request.id)
|
|
135
167
|
pending.turnId = r.turn.id; })
|
|
136
168
|
.catch(e => shutdown(e.message));
|
|
@@ -152,7 +184,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
152
184
|
return;
|
|
153
185
|
handled = true;
|
|
154
186
|
try {
|
|
155
|
-
|
|
187
|
+
void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n'));
|
|
156
188
|
}
|
|
157
189
|
catch (e) {
|
|
158
190
|
socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n');
|
|
@@ -166,7 +198,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
166
198
|
save();
|
|
167
199
|
const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
|
|
168
200
|
: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])]);
|
|
169
|
-
child = spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
201
|
+
child = existingThreadId ? connectCodexProxy({ executable, prefix, socketPath: socketPath, cwd }) : spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
170
202
|
env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } });
|
|
171
203
|
child.stdin.on('error', e => shutdown(e.message));
|
|
172
204
|
const exited = new Promise(resolve => {
|
|
@@ -175,6 +207,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
175
207
|
});
|
|
176
208
|
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192); });
|
|
177
209
|
child.stdout.setEncoding('utf8');
|
|
210
|
+
child.stdout.on('error', e => shutdown(e.message));
|
|
178
211
|
child.stdout.on('data', chunk => {
|
|
179
212
|
buffer += chunk;
|
|
180
213
|
if (Buffer.byteLength(buffer) > 4 * 1024 * 1024)
|
|
@@ -225,8 +258,24 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
225
258
|
}
|
|
226
259
|
if (m.params?.threadId !== s.conversationId)
|
|
227
260
|
continue;
|
|
228
|
-
if (m.method === 'thread/
|
|
229
|
-
|
|
261
|
+
if (m.method === 'thread/status/changed' && !pending) {
|
|
262
|
+
const status = m.params?.status;
|
|
263
|
+
s.state = status?.type === 'active' ? 'running' : status?.type === 'idle' ? 'idle' : 'unknown';
|
|
264
|
+
s.fullyIdle = s.state === 'idle';
|
|
265
|
+
save();
|
|
266
|
+
}
|
|
267
|
+
if (m.method === 'thread/tokenUsage/updated') {
|
|
268
|
+
if (pending)
|
|
269
|
+
pending.rawUsage = m.params?.tokenUsage?.total;
|
|
270
|
+
else {
|
|
271
|
+
const usage = normalizeUsage(provider, m.params?.tokenUsage?.total);
|
|
272
|
+
if (usage) {
|
|
273
|
+
s.usage = usage;
|
|
274
|
+
s.usageKnown = true;
|
|
275
|
+
save();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
230
279
|
if (m.method === 'item/completed' && pending && m.params?.item?.type === 'agentMessage')
|
|
231
280
|
pending.result = m.params.item.text;
|
|
232
281
|
if (m.method === 'turn/completed' && pending) {
|
|
@@ -247,12 +296,25 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
247
296
|
if (provider === 'codex') {
|
|
248
297
|
await rpc('initialize', { clientInfo: { name: 'axmap', version: '1' } });
|
|
249
298
|
write({ method: 'initialized' });
|
|
250
|
-
|
|
251
|
-
|
|
299
|
+
if (existingThreadId) {
|
|
300
|
+
const read = await rpc('thread/read', { threadId: existingThreadId });
|
|
301
|
+
initialThreadState = read.thread.status.type;
|
|
302
|
+
if (read.thread.id !== existingThreadId || (initialThreadState !== 'idle' && !(observeActive && initialThreadState === 'active')))
|
|
303
|
+
throw new Error('기존 thread가 idle 상태가 아닙니다. 전송하거나 새 대화를 만들지 않았습니다.');
|
|
304
|
+
// Parent workspace sessions can explicitly join a child Git repo. Never attach across unrelated roots.
|
|
305
|
+
const relative = path.relative(path.resolve(read.thread.cwd), path.resolve(cwd));
|
|
306
|
+
if (relative.startsWith('..') || path.isAbsolute(relative))
|
|
307
|
+
throw new Error('기존 thread와 요청 저장소 경로가 다릅니다.');
|
|
308
|
+
}
|
|
309
|
+
const r = await rpc(existingThreadId ? 'thread/resume' : 'thread/start', existingThreadId
|
|
310
|
+
? { threadId: existingThreadId, excludeTurns: true }
|
|
311
|
+
: { cwd: path.resolve(cwd), ...(model ? { model } : {}), sandbox: 'read-only', approvalPolicy: 'on-request' });
|
|
312
|
+
if (existingThreadId && r.thread.id !== existingThreadId)
|
|
313
|
+
throw new Error('기존 thread ID가 바뀌었습니다.');
|
|
252
314
|
s.conversationId = r.thread.id;
|
|
253
315
|
s.model = r.model || model || null;
|
|
254
|
-
s.state = 'idle';
|
|
255
|
-
s.fullyIdle =
|
|
316
|
+
s.state = initialThreadState === 'active' ? 'running' : 'idle';
|
|
317
|
+
s.fullyIdle = s.state === 'idle';
|
|
256
318
|
save();
|
|
257
319
|
emit({ event: 'ready', conversationId: s.conversationId });
|
|
258
320
|
}
|
package/src/managed-session.mts
CHANGED
|
@@ -8,12 +8,17 @@ import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
|
8
8
|
import { bridgeHome, repoKey, writeJSON } from './bridge.mjs'
|
|
9
9
|
import { normalizeUsage, usageDelta } from './session-usage.mjs'
|
|
10
10
|
import { errorMessage } from './session-types.mjs'
|
|
11
|
+
import { connectCodexProxy } from './codex-connection.mjs'
|
|
12
|
+
import type { ProviderProcess } from './codex-connection.mjs'
|
|
11
13
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
12
14
|
import type { Provider, SessionRecord, RequestState, LocalMessage, JobRecord } from './session-types.mjs'
|
|
15
|
+
import type { Usage } from './session-types.mjs'
|
|
13
16
|
|
|
14
17
|
interface ManagedSessionOptions {
|
|
15
18
|
provider: Provider; executable?: string; prefix?: string[]; args?: string[] | null; model?: string; role?: string
|
|
16
19
|
cwd?: string; maxTokens?: number; timeoutMs?: number; onEvent?: (event: Record<string, unknown>) => void
|
|
20
|
+
existingThreadId?: string; socketPath?: string
|
|
21
|
+
observeActive?: boolean
|
|
17
22
|
}
|
|
18
23
|
interface ProviderMessage {
|
|
19
24
|
id?: string | number; method?: string; event?: string; conversation_id?: string
|
|
@@ -29,19 +34,22 @@ interface AgyResult { conversation_id: string; status: string; response?: unknow
|
|
|
29
34
|
const pipeFor = (id: string) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
|
|
30
35
|
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
36
|
|
|
32
|
-
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '',
|
|
37
|
+
export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false,
|
|
33
38
|
cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => {} }: ManagedSessionOptions) {
|
|
34
39
|
if (!['codex', 'agy'].includes(provider)) throw new Error('지원하는 세션은 codex 또는 agy입니다.')
|
|
35
40
|
if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 토큰/시간 한도가 필요합니다.')
|
|
36
41
|
if (typeof role !== 'string' || role.length > 1000) throw new Error('역할은 1000자 이하여야 합니다.')
|
|
42
|
+
if (existingThreadId && (provider !== 'codex' || !uuid(existingThreadId) || !socketPath || model || args)) throw new Error('기존 Codex thread와 로컬 socket이 필요하며 모델·실행 인수를 바꾸지 않습니다.')
|
|
37
43
|
const id = randomUUID(), token = randomBytes(32).toString('hex')
|
|
38
44
|
const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`)
|
|
39
45
|
const s: SessionRecord = { id, token, provider, transport: 'stdio', role, model: model || null,
|
|
40
46
|
workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
|
|
41
47
|
registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens }
|
|
42
48
|
const jobs = new Map<string, JobRecord>()
|
|
49
|
+
let attachedUsageOrigin: Usage | null = null
|
|
50
|
+
let initialThreadState = 'idle'
|
|
43
51
|
const calls = new Map<string | number, { resolve: (result: unknown) => void; reject: (reason: Error) => void; timer: NodeJS.Timeout }>()
|
|
44
|
-
let child:
|
|
52
|
+
let child: ProviderProcess | undefined, sequence = 0, pending: JobRecord | null = null, closed = false, buffer = '', stderr = '', turnTimer: NodeJS.Timeout | undefined
|
|
45
53
|
const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionPath, s) }
|
|
46
54
|
const emit = (event: Record<string, unknown>) => onEvent({ session: id, ...event })
|
|
47
55
|
const write = (message: unknown) => {
|
|
@@ -62,7 +70,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
62
70
|
completedAt: new Date().toISOString(), conversationId: s.conversationId })
|
|
63
71
|
delete pending.baseline
|
|
64
72
|
delete pending.baselineKnown
|
|
65
|
-
if (usage) s.usage = usage
|
|
73
|
+
if (usage) { s.usage = usage; if (existingThreadId && !attachedUsageOrigin) attachedUsageOrigin = usage }
|
|
66
74
|
s.usageKnown = usage !== null
|
|
67
75
|
emit({ event: 'result', request: { ...pending } })
|
|
68
76
|
pending = null; s.state = 'idle'; s.fullyIdle = true; delete s.pendingApproval; save()
|
|
@@ -76,10 +84,18 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
76
84
|
s.state = 'closed'; s.fullyIdle = false; save()
|
|
77
85
|
child?.stdin.destroy(); child?.kill(); server.close()
|
|
78
86
|
}
|
|
79
|
-
|
|
87
|
+
let checkingSubmission = false
|
|
88
|
+
const handle = async (message: LocalMessage) => {
|
|
80
89
|
const offered = Buffer.from(typeof message.token === 'string' ? message.token : '')
|
|
81
90
|
const expected = Buffer.from(token)
|
|
82
91
|
if (offered.length !== expected.length || !timingSafeEqual(offered, expected)) throw new Error('로컬 세션 인증 실패')
|
|
92
|
+
if (message.action === 'notice') {
|
|
93
|
+
if (!existingThreadId || provider !== 'codex' || typeof message.text !== 'string' || !message.text.trim() || Buffer.byteLength(message.text) > 8192) throw new Error('기존 Codex 대화에 1~8192바이트 안내만 기록할 수 있습니다.')
|
|
94
|
+
const noticeId = randomUUID()
|
|
95
|
+
await rpc('thread/inject_items', { threadId: existingThreadId, items: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: `[axMap notice ${noticeId}; context only, no new model turn]\n${message.text}` }] }] })
|
|
96
|
+
writeJSON(path.join(bridgeHome(), 'notices', `${noticeId}.json`), { id: noticeId, to: id, conversationId: existingThreadId, repo: repoKey(cwd), text: message.text, createdAt: new Date().toISOString(), state: 'context_recorded', modelInvoked: false })
|
|
97
|
+
return { noticeId, conversationId: existingThreadId, state: 'context_recorded', modelInvoked: false }
|
|
98
|
+
}
|
|
83
99
|
if (message.action === 'status') return { id, state: s.state, usage: s.usage, pendingApproval: s.pendingApproval }
|
|
84
100
|
if (message.action === 'result') {
|
|
85
101
|
const result = jobs.get(message.id)
|
|
@@ -95,17 +111,26 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
95
111
|
delete s.pendingApproval; s.state = 'running'; save(); return { answered: true }
|
|
96
112
|
}
|
|
97
113
|
if (message.action !== 'submit') throw new Error('지원하지 않는 로컬 세션 요청입니다.')
|
|
114
|
+
if (jobs.has(message.request?.id)) throw new Error('이미 접수한 요청 ID입니다. 다시 전송하지 않았습니다.')
|
|
98
115
|
if (closed || s.state !== 'idle' || pending) throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.')
|
|
116
|
+
if (existingThreadId) {
|
|
117
|
+
if (checkingSubmission) throw new Error('다른 요청의 상태를 확인 중입니다.')
|
|
118
|
+
checkingSubmission = true
|
|
119
|
+
try {
|
|
120
|
+
const read = await rpc<{ thread: { id: string; status: { type: string } } }>('thread/read', { threadId: existingThreadId })
|
|
121
|
+
if (closed || pending || read.thread.id !== existingThreadId || read.thread.status.type !== 'idle') throw new Error('기존 대화가 다른 작업 중입니다. 새 요청을 전달하지 않았습니다.')
|
|
122
|
+
} finally { checkingSubmission = false }
|
|
123
|
+
}
|
|
99
124
|
if (!s.usageKnown) throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.')
|
|
100
|
-
if (s.usage && s.usage.totalTokens >= maxTokens) throw new Error('세션 토큰 한도에 도달했습니다.')
|
|
125
|
+
if (s.usage && s.usage.totalTokens - (existingThreadId ? attachedUsageOrigin?.totalTokens ?? s.usage.totalTokens : 0) >= maxTokens) throw new Error('세션 토큰 한도에 도달했습니다.')
|
|
101
126
|
const request = message.request
|
|
102
127
|
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: '' }
|
|
128
|
+
pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown && (!existingThreadId || !!s.usage), result: '' }
|
|
104
129
|
jobs.set(request.id, pending); s.state = 'running'; s.fullyIdle = false; save()
|
|
105
130
|
const prompt = `[axMap request ${request.id}]\n요청자: ${request.from || 'axmap'}\n${role ? `역할: ${role}\n` : ''}${request.prompt}`
|
|
106
131
|
turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs)
|
|
107
132
|
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' })
|
|
133
|
+
else rpc<{ turn: { id: string } }>('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], ...(!existingThreadId ? { effort: 'low' } : {}) })
|
|
109
134
|
.then(r => { if (pending?.id === request.id) pending.turnId = r.turn.id })
|
|
110
135
|
.catch(e => shutdown(e.message))
|
|
111
136
|
return { accepted: true, session: id }
|
|
@@ -120,7 +145,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
120
145
|
if (Buffer.byteLength(input) > 65536) return socket.destroy()
|
|
121
146
|
const end = input.indexOf('\n'); if (end < 0) return
|
|
122
147
|
handled = true
|
|
123
|
-
try {
|
|
148
|
+
try { void handle(JSON.parse(input.slice(0, end))).then(result => socket.end(JSON.stringify({ result }) + '\n'), e => socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n')) }
|
|
124
149
|
catch (e) { socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n') }
|
|
125
150
|
})
|
|
126
151
|
})
|
|
@@ -130,7 +155,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
130
155
|
save()
|
|
131
156
|
const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
|
|
132
157
|
: ['--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'],
|
|
158
|
+
child = existingThreadId ? connectCodexProxy({ executable, prefix, socketPath: socketPath!, cwd }) : spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
134
159
|
env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } })
|
|
135
160
|
child.stdin.on('error', e => shutdown(e.message))
|
|
136
161
|
const exited = new Promise<{ code: number | null; signal?: NodeJS.Signals | null; error?: string }>(resolve => {
|
|
@@ -139,6 +164,7 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
139
164
|
})
|
|
140
165
|
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192) })
|
|
141
166
|
child.stdout.setEncoding('utf8')
|
|
167
|
+
child.stdout.on('error', e => shutdown(e.message))
|
|
142
168
|
child.stdout.on('data', chunk => {
|
|
143
169
|
buffer += chunk
|
|
144
170
|
if (Buffer.byteLength(buffer) > 4 * 1024 * 1024) return shutdown('CLI 응답 크기 제한 초과')
|
|
@@ -169,7 +195,15 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
169
195
|
continue
|
|
170
196
|
}
|
|
171
197
|
if (m.params?.threadId !== s.conversationId) continue
|
|
172
|
-
if (m.method === 'thread/
|
|
198
|
+
if (m.method === 'thread/status/changed' && !pending) {
|
|
199
|
+
const status = m.params?.status as { type?: string } | undefined
|
|
200
|
+
s.state = status?.type === 'active' ? 'running' : status?.type === 'idle' ? 'idle' : 'unknown'
|
|
201
|
+
s.fullyIdle = s.state === 'idle'; save()
|
|
202
|
+
}
|
|
203
|
+
if (m.method === 'thread/tokenUsage/updated') {
|
|
204
|
+
if (pending) pending.rawUsage = m.params?.tokenUsage?.total
|
|
205
|
+
else { const usage = normalizeUsage(provider, m.params?.tokenUsage?.total); if (usage) { s.usage = usage; s.usageKnown = true; save() } }
|
|
206
|
+
}
|
|
173
207
|
if (m.method === 'item/completed' && pending && m.params?.item?.type === 'agentMessage') pending.result = m.params.item.text
|
|
174
208
|
if (m.method === 'turn/completed' && pending) {
|
|
175
209
|
const r = m.params?.turn
|
|
@@ -184,9 +218,19 @@ export async function startManagedSession({ provider, executable = provider, pre
|
|
|
184
218
|
if (provider === 'codex') {
|
|
185
219
|
await rpc('initialize', { clientInfo: { name: 'axmap', version: '1' } })
|
|
186
220
|
write({ method: 'initialized' })
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
221
|
+
if (existingThreadId) {
|
|
222
|
+
const read = await rpc<{ thread: { id: string; cwd: string; status: { type: string } } }>('thread/read', { threadId: existingThreadId })
|
|
223
|
+
initialThreadState = read.thread.status.type
|
|
224
|
+
if (read.thread.id !== existingThreadId || (initialThreadState !== 'idle' && !(observeActive && initialThreadState === 'active'))) throw new Error('기존 thread가 idle 상태가 아닙니다. 전송하거나 새 대화를 만들지 않았습니다.')
|
|
225
|
+
// Parent workspace sessions can explicitly join a child Git repo. Never attach across unrelated roots.
|
|
226
|
+
const relative = path.relative(path.resolve(read.thread.cwd), path.resolve(cwd))
|
|
227
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('기존 thread와 요청 저장소 경로가 다릅니다.')
|
|
228
|
+
}
|
|
229
|
+
const r = await rpc<{ thread: { id: string }; model?: string }>(existingThreadId ? 'thread/resume' : 'thread/start', existingThreadId
|
|
230
|
+
? { threadId: existingThreadId, excludeTurns: true }
|
|
231
|
+
: { cwd: path.resolve(cwd), ...(model ? { model } : {}), sandbox: 'read-only', approvalPolicy: 'on-request' })
|
|
232
|
+
if (existingThreadId && r.thread.id !== existingThreadId) throw new Error('기존 thread ID가 바뀌었습니다.')
|
|
233
|
+
s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = initialThreadState === 'active' ? 'running' : 'idle'; s.fullyIdle = s.state === 'idle'; save()
|
|
190
234
|
emit({ event: 'ready', conversationId: s.conversationId })
|
|
191
235
|
} else {
|
|
192
236
|
const deadline = Date.now() + 30000
|
package/src/monitor-cli.mjs
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createMonitor, renderMonitor } from './monitor.mjs';
|
|
4
4
|
import { errorMessage } from './session-types.mjs';
|
|
5
|
+
import { renderPipelineGraph } from './pipeline-graph.mjs';
|
|
5
6
|
export async function runMonitor(args) {
|
|
6
7
|
if (args.includes('--help') || args.includes('-h')) {
|
|
7
8
|
console.log('axmap monitor [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n세션·요청 파이프라인·자동 대화·선점·최근 이벤트를 실시간으로 표시합니다.\nTTY에서는 계속 갱신하며, 파이프/리디렉션에서는 한 번만 출력합니다. q / Esc / Ctrl+C 종료.');
|
|
8
9
|
return;
|
|
9
10
|
}
|
|
10
|
-
const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color']);
|
|
11
|
+
const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color', '--graph']);
|
|
11
12
|
for (let i = 0; i < args.length; i++) {
|
|
12
13
|
if (!allowed.has(args[i]))
|
|
13
14
|
throw new Error(`알 수 없는 옵션: ${args[i]}`);
|
|
@@ -22,11 +23,12 @@ export async function runMonitor(args) {
|
|
|
22
23
|
if (!Number.isInteger(interval) || interval < 250 || interval > 60000)
|
|
23
24
|
throw new Error('interval은 250~60000ms 정수여야 합니다.');
|
|
24
25
|
const collect = createMonitor(path.resolve(option('--cwd') || process.cwd()));
|
|
26
|
+
const render = args.includes('--graph') ? renderPipelineGraph : renderMonitor;
|
|
25
27
|
const live = !!process.stdout.isTTY && !!process.stdin.isTTY && process.env.TERM !== 'dumb' && !args.includes('--once') && !args.includes('--json');
|
|
26
28
|
const color = !!process.stdout.isTTY && !args.includes('--no-color') && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb';
|
|
27
29
|
if (!live) {
|
|
28
30
|
const snapshot = await collect();
|
|
29
|
-
console.log(args.includes('--json') ? JSON.stringify(snapshot, null, 2) :
|
|
31
|
+
console.log(args.includes('--json') ? JSON.stringify(snapshot, null, 2) : render(snapshot, { columns: process.stdout.columns || 100, rows: process.stdout.rows || 56, color }));
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
32
34
|
let stopped = false, frame = 0, timer, animation;
|
|
@@ -53,7 +55,7 @@ export async function runMonitor(args) {
|
|
|
53
55
|
const draw = () => {
|
|
54
56
|
if (stopped || !latest)
|
|
55
57
|
return;
|
|
56
|
-
const view =
|
|
58
|
+
const view = render(latest, { columns: process.stdout.columns || 100, rows: Math.max(1, (process.stdout.rows || 36) - 1), color, frame: frame++ });
|
|
57
59
|
process.stdout.write('\x1b[H' + view.replaceAll('\n', '\x1b[K\r\n') + '\x1b[K\x1b[J');
|
|
58
60
|
};
|
|
59
61
|
const update = async () => {
|
package/src/monitor-cli.mts
CHANGED
|
@@ -2,13 +2,14 @@ import path from 'node:path'
|
|
|
2
2
|
import { createMonitor, renderMonitor } from './monitor.mjs'
|
|
3
3
|
import type { MonitorSnapshot } from './monitor.mjs'
|
|
4
4
|
import { errorMessage } from './session-types.mjs'
|
|
5
|
+
import { renderPipelineGraph } from './pipeline-graph.mjs'
|
|
5
6
|
|
|
6
7
|
export async function runMonitor(args: string[]): Promise<void> {
|
|
7
8
|
if (args.includes('--help') || args.includes('-h')) {
|
|
8
9
|
console.log('axmap monitor [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n세션·요청 파이프라인·자동 대화·선점·최근 이벤트를 실시간으로 표시합니다.\nTTY에서는 계속 갱신하며, 파이프/리디렉션에서는 한 번만 출력합니다. q / Esc / Ctrl+C 종료.')
|
|
9
10
|
return
|
|
10
11
|
}
|
|
11
|
-
const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color'])
|
|
12
|
+
const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color', '--graph'])
|
|
12
13
|
for (let i = 0; i < args.length; i++) {
|
|
13
14
|
if (!allowed.has(args[i]!)) throw new Error(`알 수 없는 옵션: ${args[i]}`)
|
|
14
15
|
if (args[i] === '--cwd' || args[i] === '--interval') {
|
|
@@ -20,11 +21,12 @@ export async function runMonitor(args: string[]): Promise<void> {
|
|
|
20
21
|
const interval = Number(option('--interval') || 1000)
|
|
21
22
|
if (!Number.isInteger(interval) || interval < 250 || interval > 60000) throw new Error('interval은 250~60000ms 정수여야 합니다.')
|
|
22
23
|
const collect = createMonitor(path.resolve(option('--cwd') || process.cwd()))
|
|
24
|
+
const render = args.includes('--graph') ? renderPipelineGraph : renderMonitor
|
|
23
25
|
const live = !!process.stdout.isTTY && !!process.stdin.isTTY && process.env.TERM !== 'dumb' && !args.includes('--once') && !args.includes('--json')
|
|
24
26
|
const color = !!process.stdout.isTTY && !args.includes('--no-color') && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'
|
|
25
27
|
if (!live) {
|
|
26
28
|
const snapshot = await collect()
|
|
27
|
-
console.log(args.includes('--json') ? JSON.stringify(snapshot, null, 2) :
|
|
29
|
+
console.log(args.includes('--json') ? JSON.stringify(snapshot, null, 2) : render(snapshot, { columns: process.stdout.columns || 100, rows: process.stdout.rows || 56, color }))
|
|
28
30
|
return
|
|
29
31
|
}
|
|
30
32
|
let stopped = false, frame = 0, timer: NodeJS.Timeout | undefined, animation: NodeJS.Timeout | undefined
|
|
@@ -42,7 +44,7 @@ export async function runMonitor(args: string[]): Promise<void> {
|
|
|
42
44
|
const input = (text: string) => { if (text === '\x1b' || text.includes('\x03') || /^[qQ]+$/.test(text)) restore() }
|
|
43
45
|
const draw = () => {
|
|
44
46
|
if (stopped || !latest) return
|
|
45
|
-
const view =
|
|
47
|
+
const view = render(latest, { columns: process.stdout.columns || 100, rows: Math.max(1, (process.stdout.rows || 36) - 1), color, frame: frame++ })
|
|
46
48
|
process.stdout.write('\x1b[H' + view.replaceAll('\n', '\x1b[K\r\n') + '\x1b[K\x1b[J')
|
|
47
49
|
}
|
|
48
50
|
const update = async () => {
|
package/src/monitor.mjs
CHANGED
|
@@ -55,6 +55,11 @@ export function createMonitor(cwd, options = {}) {
|
|
|
55
55
|
issues.push(`세션 확인: ${errorMessage(e)}`);
|
|
56
56
|
}
|
|
57
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
|
+
}
|
|
58
63
|
for (const raw of records(path.join(bridgeHome(), 'requests'), issues, '요청')) {
|
|
59
64
|
if (!object(raw) || raw.repo !== repo)
|
|
60
65
|
continue;
|
package/src/monitor.mts
CHANGED
|
@@ -50,6 +50,10 @@ export function createMonitor(cwd: string, options: {
|
|
|
50
50
|
let sessions: SessionSnapshot[] = []
|
|
51
51
|
try { sessions = await probe(workspace) } catch (e) { issues.push(`세션 확인: ${errorMessage(e)}`) }
|
|
52
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
|
+
}
|
|
53
57
|
for (const raw of records(path.join(bridgeHome(), 'requests'), issues, '요청')) {
|
|
54
58
|
if (!object(raw) || raw.repo !== repo) continue
|
|
55
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 }
|
|
@@ -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
|
+
}
|
package/src/pipeline-mcp.mjs
CHANGED
|
@@ -8,9 +8,11 @@ export function pipelineView(snapshot) {
|
|
|
8
8
|
return {
|
|
9
9
|
schemaVersion: 1, type: 'pipeline', cwd: snapshot.cwd, checkedAt: snapshot.checkedAt,
|
|
10
10
|
sessions: snapshot.sessions.map(s => ({ id: s.id, provider: s.provider, state: s.state, connection: s.connection,
|
|
11
|
+
model: s.model ?? null, transport: s.transport ?? null, conversationId: s.conversationId ?? null,
|
|
11
12
|
role: fitTerminal(s.role, 120), usage: s.usage ?? null, pendingApproval: !!s.pendingApproval })),
|
|
12
13
|
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,
|
|
14
|
+
task: fitTerminal(r.prompt, 200), createdAt: r.createdAt, completedAt: r.completedAt, usage: r.usage ?? null,
|
|
15
|
+
latencyMs: r.completedAt ? Math.max(0, Date.parse(r.completedAt) - Date.parse(r.createdAt)) : null, error: fitTerminal(r.error, 240) })),
|
|
14
16
|
dialogues: snapshot.dialogues.map(d => ({ id: d.id, state: d.state, turns: d.turns.length,
|
|
15
17
|
maxTurns: d.limits?.maxTurns, usage: d.usage ?? null, usagePending: d.usagePending })),
|
|
16
18
|
claims: snapshot.claims.slice(0, 10).map(c => ({ agent: terminalText(c.agent), task: fitTerminal(c.intent || c.task, 160),
|
package/src/pipeline-mcp.mts
CHANGED
|
@@ -17,9 +17,11 @@ export function pipelineView(snapshot: MonitorSnapshot) {
|
|
|
17
17
|
return {
|
|
18
18
|
schemaVersion: 1, type: 'pipeline', cwd: snapshot.cwd, checkedAt: snapshot.checkedAt,
|
|
19
19
|
sessions: snapshot.sessions.map(s => ({ id: s.id, provider: s.provider, state: s.state, connection: s.connection,
|
|
20
|
+
model: s.model ?? null, transport: s.transport ?? null, conversationId: s.conversationId ?? null,
|
|
20
21
|
role: fitTerminal(s.role, 120), usage: s.usage ?? null, pendingApproval: !!s.pendingApproval })),
|
|
21
22
|
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,
|
|
23
|
+
task: fitTerminal(r.prompt, 200), createdAt: r.createdAt, completedAt: r.completedAt, usage: r.usage ?? null,
|
|
24
|
+
latencyMs: r.completedAt ? Math.max(0, Date.parse(r.completedAt) - Date.parse(r.createdAt)) : null, error: fitTerminal(r.error, 240) })),
|
|
23
25
|
dialogues: snapshot.dialogues.map(d => ({ id: d.id, state: d.state, turns: d.turns.length,
|
|
24
26
|
maxTurns: d.limits?.maxTurns, usage: d.usage ?? null, usagePending: d.usagePending })),
|
|
25
27
|
claims: snapshot.claims.slice(0, 10).map(c => ({ agent: terminalText(c.agent), task: fitTerminal(c.intent || c.task, 160),
|