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,128 @@
|
|
|
1
|
+
/** Attach to an existing Windows console; never launch or resume a model. */
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
|
|
9
|
+
import { nativeConsoleHelper } from './windows-console.mjs'
|
|
10
|
+
import type { SessionRecord, RequestRecord, ConsoleSnapshot, HookInput, TranscriptEntry } from './session-types.mjs'
|
|
11
|
+
|
|
12
|
+
const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url))
|
|
13
|
+
export function consoleCall(pid: number, action?: 'snapshot' | 'send', payload?: Record<string, unknown>): ConsoleSnapshot
|
|
14
|
+
export function consoleCall(pid: number, action: 'ancestor', payload?: Record<string, unknown>): ConsoleSnapshot | { ignored: true }
|
|
15
|
+
export function consoleCall(pid: number, action = 'snapshot', payload: Record<string, unknown> = {}): ConsoleSnapshot | { ignored: true } {
|
|
16
|
+
if (process.platform !== 'win32') throw new Error('독립 agy 콘솔 연결은 현재 Windows에서 지원합니다.')
|
|
17
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('양의 정수 PID가 필요합니다.')
|
|
18
|
+
const native = nativeConsoleHelper()
|
|
19
|
+
const r = spawnSync(native || 'powershell.exe', native ? [String(pid), action] : ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
20
|
+
'-File', helper, '-TargetPid', String(pid), '-Action', action], {
|
|
21
|
+
input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
|
|
22
|
+
})
|
|
23
|
+
let data: (Omit<Partial<ConsoleSnapshot>, 'ignored'> & { error?: string; ignored?: boolean }) | undefined
|
|
24
|
+
try { data = JSON.parse(r.stdout) } catch { /* report the underlying failure */ }
|
|
25
|
+
if (r.error || r.status !== 0 || data?.error || !data) throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패')
|
|
26
|
+
if (action === 'ancestor' && data.ignored) return { ignored: true }
|
|
27
|
+
if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string')) throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.')
|
|
28
|
+
return data as ConsoleSnapshot
|
|
29
|
+
}
|
|
30
|
+
function identityID({ pid, startedAt }: Pick<ConsoleSnapshot, 'pid' | 'startedAt'>): string {
|
|
31
|
+
const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).digest('hex')
|
|
32
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`
|
|
33
|
+
}
|
|
34
|
+
const target = (id: string) => path.join(bridgeHome(), 'sessions', `${id}.json`)
|
|
35
|
+
export function consoleIdle(screen: string): boolean {
|
|
36
|
+
// Use the last input box only. Old answers may contain quoted prompt boxes.
|
|
37
|
+
const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)]
|
|
38
|
+
if (borders.length < 2) return false
|
|
39
|
+
const top = borders.at(-2), bottom = borders.at(-1)
|
|
40
|
+
if (!top || !bottom) return false
|
|
41
|
+
const input = screen.slice(top.index + top[0].length, bottom.index).trim()
|
|
42
|
+
const footer = screen.slice(bottom.index + bottom[0].length)
|
|
43
|
+
return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer)
|
|
44
|
+
}
|
|
45
|
+
export function attachConsole({ pid, cwd }: { pid: number; cwd: string }) {
|
|
46
|
+
const live = consoleCall(pid)
|
|
47
|
+
const id = identityID(live)
|
|
48
|
+
const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
|
|
49
|
+
const repo = repoKey(cwd)
|
|
50
|
+
if (previous.repos && !previous.repos.includes(repo)) throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.')
|
|
51
|
+
const idle = consoleIdle(live.screen)
|
|
52
|
+
const s: SessionRecord = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
53
|
+
workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
|
|
54
|
+
state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' }
|
|
55
|
+
writeJSON(target(id), s)
|
|
56
|
+
return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces }
|
|
57
|
+
}
|
|
58
|
+
export function registerExternalHook(input: HookInput, event: string) {
|
|
59
|
+
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
|
|
60
|
+
const identity = consoleCall(process.pid, 'ancestor')
|
|
61
|
+
if (identity.ignored) return {}
|
|
62
|
+
const id = identityID(identity)
|
|
63
|
+
const previous = fs.existsSync(target(id)) ? readJSON<Partial<SessionRecord>>(target(id)) : {}
|
|
64
|
+
const workspaces = input.workspacePaths || previous.workspaces
|
|
65
|
+
if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) throw new Error('훅의 작업 저장소 경로가 없습니다.')
|
|
66
|
+
const conversationId = input.conversationId
|
|
67
|
+
if (typeof conversationId !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId)) throw new Error('훅의 대화 UUID가 없습니다.')
|
|
68
|
+
const transcript = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
|
|
69
|
+
'brain', conversationId, '.system_generated', 'logs', 'transcript.jsonl')
|
|
70
|
+
if (path.resolve(input.transcriptPath || '') !== transcript) throw new Error('훅의 대화 기록 경로가 다릅니다.')
|
|
71
|
+
const idle = event === 'Stop' && input.fullyIdle === true
|
|
72
|
+
writeJSON(target(id), { ...previous, ...identity, id, provider: 'agy', transport: 'console', conversationId,
|
|
73
|
+
transcript, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || previous.model,
|
|
74
|
+
event, state: idle ? 'idle' : 'running', fullyIdle: idle, registeredAt: new Date().toISOString() })
|
|
75
|
+
return { id }
|
|
76
|
+
}
|
|
77
|
+
export function externalStatus(session: Pick<SessionRecord, 'id'>) {
|
|
78
|
+
const s = readJSON<SessionRecord>(target(session.id))
|
|
79
|
+
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
|
|
80
|
+
const state: 'idle' | 'unknown' = consoleIdle(live.screen) ? 'idle' : 'unknown'
|
|
81
|
+
writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state, fullyIdle: state === 'idle' })
|
|
82
|
+
return { id: s.id, state }
|
|
83
|
+
}
|
|
84
|
+
function consolePID(s: SessionRecord): number {
|
|
85
|
+
if (!s.pid || !s.startedAt) throw new Error('등록된 콘솔 프로세스 신원이 없습니다.')
|
|
86
|
+
return s.pid
|
|
87
|
+
}
|
|
88
|
+
export function checkConsole(s: SessionRecord): ConsoleSnapshot {
|
|
89
|
+
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt })
|
|
90
|
+
if (!consoleIdle(live.screen)) throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.')
|
|
91
|
+
return live
|
|
92
|
+
}
|
|
93
|
+
export function validateConsolePrompt(prompt: string, from: string): void {
|
|
94
|
+
if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt.replace(/\r\n/g, '\n')) || /[\x00-\x1f\x7f]/.test(from)) throw new Error('콘솔 요청에는 제어 문자를 포함할 수 없습니다.')
|
|
95
|
+
}
|
|
96
|
+
export function findConsoleTranscript(s: SessionRecord, request: RequestRecord): SessionRecord {
|
|
97
|
+
if (s.transcript) return s
|
|
98
|
+
const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain')
|
|
99
|
+
if (!fs.existsSync(brain)) return s
|
|
100
|
+
const marker = `[axMap request ${request.id}]`
|
|
101
|
+
const matches = []
|
|
102
|
+
for (const entry of fs.readdirSync(brain, { withFileTypes: true })) {
|
|
103
|
+
if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name)) continue
|
|
104
|
+
const log = path.join(brain, entry.name, '.system_generated', 'logs', 'transcript.jsonl')
|
|
105
|
+
let stat
|
|
106
|
+
try { stat = fs.statSync(log) } catch { continue }
|
|
107
|
+
if (stat.mtimeMs < Date.parse(request.createdAt) || stat.size > 32 * 1024 * 1024) continue
|
|
108
|
+
const records = fs.readFileSync(log, 'utf8').split('\n').slice(0, -1)
|
|
109
|
+
if (records.some(line => {
|
|
110
|
+
try { const e = JSON.parse(line) as TranscriptEntry; return e.type === 'USER_INPUT' && String(e.content).includes(marker) }
|
|
111
|
+
catch { return false }
|
|
112
|
+
})) matches.push({ conversationId: entry.name, transcript: log })
|
|
113
|
+
}
|
|
114
|
+
if (matches.length !== 1) return s
|
|
115
|
+
const updated = { ...readJSON<SessionRecord>(target(s.id)), ...matches[0] }
|
|
116
|
+
writeJSON(target(s.id), updated)
|
|
117
|
+
return updated
|
|
118
|
+
}
|
|
119
|
+
export function submitConsole(s: SessionRecord, request: RequestRecord, preflight?: ConsoleSnapshot) {
|
|
120
|
+
validateConsolePrompt(request.prompt, request.from)
|
|
121
|
+
const live = preflight || checkConsole(s)
|
|
122
|
+
// Bracketed paste preserves multiline prompts and prevents embedded Enter keys
|
|
123
|
+
// from becoming separate submissions. Only the final CR submits the message.
|
|
124
|
+
const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`
|
|
125
|
+
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen, text })
|
|
126
|
+
writeJSON(target(s.id), { ...readJSON<SessionRecord>(target(s.id)), state: 'running', fullyIdle: false })
|
|
127
|
+
return { accepted: true, pid: s.pid }
|
|
128
|
+
}
|
package/src/managed-session.mjs
CHANGED
|
@@ -1,178 +1,272 @@
|
|
|
1
|
+
// Generated from src/managed-session.mts. Edit the .mts source and run npm run build.
|
|
1
2
|
/** Explicitly launched persistent CLI sessions. No transcript scraping, global
|
|
2
3
|
* 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
|
-
|
|
11
|
-
const pipeFor = id => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
|
|
12
|
-
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 || '')
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
|
|
111
|
-
save()
|
|
112
|
-
const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
|
|
113
|
-
: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--effort', 'low', ...(model ? ['--model', model] : [])])
|
|
114
|
-
child = spawn(executable, [...prefix, ...cliArgs], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
115
|
-
env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } })
|
|
116
|
-
child.stdin.on('error', e => shutdown(e.message))
|
|
117
|
-
const exited = new Promise(resolve => {
|
|
118
|
-
child.once('error', e => { shutdown(e.message); resolve({ code: 1, error: e.message }) })
|
|
119
|
-
child.once('exit', (code, signal) => { shutdown(`CLI가 종료됐습니다 (${code ?? signal}). ${stderr.slice(-1000)}`); resolve({ code, signal }) })
|
|
120
|
-
})
|
|
121
|
-
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192) })
|
|
122
|
-
child.stdout.setEncoding('utf8')
|
|
123
|
-
child.stdout.on('data', chunk => {
|
|
124
|
-
buffer += chunk
|
|
125
|
-
if (Buffer.byteLength(buffer) > 4 * 1024 * 1024) return shutdown('CLI 응답 크기 제한 초과')
|
|
126
|
-
while (buffer.includes('\n') && !closed) {
|
|
127
|
-
const end = buffer.indexOf('\n'), line = buffer.slice(0, end); buffer = buffer.slice(end + 1)
|
|
128
|
-
if (!line.trim()) continue
|
|
129
|
-
try {
|
|
130
|
-
const m = JSON.parse(line)
|
|
131
|
-
if (provider === 'agy') {
|
|
132
|
-
if (m.event === 'init') { s.conversationId = m.conversation_id; s.state = 'idle'; s.fullyIdle = true; save(); emit({ event: 'ready', conversationId: s.conversationId }) }
|
|
133
|
-
if (m.event === 'result' && pending) {
|
|
134
|
-
const r = m.result
|
|
135
|
-
if (r.conversation_id !== s.conversationId) throw new Error('agy 대화 ID가 바뀌었습니다.')
|
|
136
|
-
finish(r.status === 'SUCCESS' ? 'completed' : 'failed', r.response, r.error, r.usage)
|
|
137
|
-
}
|
|
138
|
-
continue
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { bridgeHome, repoKey, writeJSON } from './bridge.mjs';
|
|
10
|
+
import { normalizeUsage, usageDelta } from './session-usage.mjs';
|
|
11
|
+
import { errorMessage } from './session-types.mjs';
|
|
12
|
+
const pipeFor = (id) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
|
|
13
|
+
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
|
+
if (!['codex', 'agy'].includes(provider))
|
|
16
|
+
throw new Error('지원하는 세션은 codex 또는 agy입니다.');
|
|
17
|
+
if (!Number.isSafeInteger(maxTokens) || maxTokens < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
18
|
+
throw new Error('양의 토큰/시간 한도가 필요합니다.');
|
|
19
|
+
if (typeof role !== 'string' || role.length > 1000)
|
|
20
|
+
throw new Error('역할은 1000자 이하여야 합니다.');
|
|
21
|
+
const id = randomUUID(), token = randomBytes(32).toString('hex');
|
|
22
|
+
const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`);
|
|
23
|
+
const s = { id, token, provider, transport: 'stdio', role, model: model || null,
|
|
24
|
+
workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
|
|
25
|
+
registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens };
|
|
26
|
+
const jobs = new Map();
|
|
27
|
+
const calls = new Map();
|
|
28
|
+
let child, sequence = 0, pending = null, closed = false, buffer = '', stderr = '', turnTimer;
|
|
29
|
+
const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionPath, s); };
|
|
30
|
+
const emit = (event) => onEvent({ session: id, ...event });
|
|
31
|
+
const write = (message) => {
|
|
32
|
+
if (closed || !child?.stdin.writable)
|
|
33
|
+
throw new Error('세션 입력 연결이 종료됐습니다.');
|
|
34
|
+
child.stdin.write(JSON.stringify(message) + '\n');
|
|
35
|
+
};
|
|
36
|
+
const rpc = (method, params) => new Promise((resolve, reject) => {
|
|
37
|
+
const callId = ++sequence;
|
|
38
|
+
const timer = setTimeout(() => { calls.delete(callId); reject(new Error(`${method}: 응답 시간 초과`)); }, 15000);
|
|
39
|
+
calls.set(callId, { resolve: value => resolve(value), reject, timer });
|
|
40
|
+
try {
|
|
41
|
+
write({ id: callId, method, params });
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
calls.delete(callId);
|
|
46
|
+
reject(e);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const finish = (state, result, error, rawUsage) => {
|
|
50
|
+
if (!pending)
|
|
51
|
+
return;
|
|
52
|
+
clearTimeout(turnTimer);
|
|
53
|
+
const usage = normalizeUsage(provider, rawUsage);
|
|
54
|
+
Object.assign(pending, { state, result, error, usage: pending.baselineKnown ? usageDelta(usage, pending.baseline) : null,
|
|
55
|
+
completedAt: new Date().toISOString(), conversationId: s.conversationId });
|
|
56
|
+
delete pending.baseline;
|
|
57
|
+
delete pending.baselineKnown;
|
|
58
|
+
if (usage)
|
|
59
|
+
s.usage = usage;
|
|
60
|
+
s.usageKnown = usage !== null;
|
|
61
|
+
emit({ event: 'result', request: { ...pending } });
|
|
62
|
+
pending = null;
|
|
63
|
+
s.state = 'idle';
|
|
64
|
+
s.fullyIdle = true;
|
|
65
|
+
delete s.pendingApproval;
|
|
66
|
+
save();
|
|
67
|
+
};
|
|
68
|
+
const shutdown = (reason) => {
|
|
69
|
+
if (closed)
|
|
70
|
+
return;
|
|
71
|
+
finish('unknown', undefined, reason);
|
|
72
|
+
closed = true;
|
|
73
|
+
clearTimeout(turnTimer);
|
|
74
|
+
for (const c of calls.values()) {
|
|
75
|
+
clearTimeout(c.timer);
|
|
76
|
+
c.reject(new Error(reason));
|
|
77
|
+
}
|
|
78
|
+
calls.clear();
|
|
79
|
+
s.state = 'closed';
|
|
80
|
+
s.fullyIdle = false;
|
|
81
|
+
save();
|
|
82
|
+
child?.stdin.destroy();
|
|
83
|
+
child?.kill();
|
|
84
|
+
server.close();
|
|
85
|
+
};
|
|
86
|
+
const handle = (message) => {
|
|
87
|
+
const offered = Buffer.from(typeof message.token === 'string' ? message.token : '');
|
|
88
|
+
const expected = Buffer.from(token);
|
|
89
|
+
if (offered.length !== expected.length || !timingSafeEqual(offered, expected))
|
|
90
|
+
throw new Error('로컬 세션 인증 실패');
|
|
91
|
+
if (message.action === 'status')
|
|
92
|
+
return { id, state: s.state, usage: s.usage, pendingApproval: s.pendingApproval };
|
|
93
|
+
if (message.action === 'result') {
|
|
94
|
+
const result = jobs.get(message.id);
|
|
95
|
+
if (!result)
|
|
96
|
+
throw new Error('이 세션에 해당 요청이 없습니다.');
|
|
97
|
+
const { baseline, baselineKnown, ...publicResult } = result;
|
|
98
|
+
return publicResult;
|
|
99
|
+
}
|
|
100
|
+
if (message.action === 'answer') {
|
|
101
|
+
const approval = s.pendingApproval;
|
|
102
|
+
if (!approval || String(approval.id) !== String(message.id))
|
|
103
|
+
throw new Error('현재 대기 중인 승인 요청이 아닙니다.');
|
|
104
|
+
if (!['accept', 'decline', 'cancel'].includes(message.decision))
|
|
105
|
+
throw new Error('이번 실행의 accept, decline 또는 cancel만 지원합니다.');
|
|
106
|
+
write({ id: approval.id, result: { decision: message.decision } });
|
|
107
|
+
delete s.pendingApproval;
|
|
108
|
+
s.state = 'running';
|
|
109
|
+
save();
|
|
110
|
+
return { answered: true };
|
|
139
111
|
}
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
112
|
+
if (message.action !== 'submit')
|
|
113
|
+
throw new Error('지원하지 않는 로컬 세션 요청입니다.');
|
|
114
|
+
if (closed || s.state !== 'idle' || pending)
|
|
115
|
+
throw new Error('세션이 대기 중이 아닙니다. 요청을 전달하지 않았습니다.');
|
|
116
|
+
if (!s.usageKnown)
|
|
117
|
+
throw new Error('직전 요청의 사용량을 확인하지 못했습니다. 새 연결에서 예산을 확인하세요.');
|
|
118
|
+
if (s.usage && s.usage.totalTokens >= maxTokens)
|
|
119
|
+
throw new Error('세션 토큰 한도에 도달했습니다.');
|
|
120
|
+
const request = message.request;
|
|
121
|
+
if (!uuid(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768)
|
|
122
|
+
throw new Error('요청 형식이 올바르지 않습니다.');
|
|
123
|
+
pending = { id: request.id, to: id, state: 'submitted', baseline: s.usage, baselineKnown: s.usageKnown, result: '' };
|
|
124
|
+
jobs.set(request.id, pending);
|
|
125
|
+
s.state = 'running';
|
|
126
|
+
s.fullyIdle = false;
|
|
127
|
+
save();
|
|
128
|
+
const prompt = `[axMap request ${request.id}]\n요청자: ${request.from || 'axmap'}\n${role ? `역할: ${role}\n` : ''}${request.prompt}`;
|
|
129
|
+
turnTimer = setTimeout(() => shutdown('실행 시간 한도 초과. 완료 여부가 불명확하므로 자동 재시도하지 않습니다.'), timeoutMs);
|
|
130
|
+
if (provider === 'agy')
|
|
131
|
+
write({ event: 'user', message: { content: prompt } });
|
|
132
|
+
else
|
|
133
|
+
rpc('turn/start', { threadId: s.conversationId, input: [{ type: 'text', text: prompt }], effort: 'low' })
|
|
134
|
+
.then(r => { if (pending?.id === request.id)
|
|
135
|
+
pending.turnId = r.turn.id; })
|
|
136
|
+
.catch(e => shutdown(e.message));
|
|
137
|
+
return { accepted: true, session: id };
|
|
138
|
+
};
|
|
139
|
+
const server = net.createServer(socket => {
|
|
140
|
+
socket.setEncoding('utf8');
|
|
141
|
+
let input = '', handled = false;
|
|
142
|
+
socket.setTimeout(15000, () => socket.destroy());
|
|
143
|
+
socket.on('error', () => { });
|
|
144
|
+
socket.on('data', chunk => {
|
|
145
|
+
if (handled)
|
|
146
|
+
return;
|
|
147
|
+
input += chunk;
|
|
148
|
+
if (Buffer.byteLength(input) > 65536)
|
|
149
|
+
return socket.destroy();
|
|
150
|
+
const end = input.indexOf('\n');
|
|
151
|
+
if (end < 0)
|
|
152
|
+
return;
|
|
153
|
+
handled = true;
|
|
154
|
+
try {
|
|
155
|
+
socket.end(JSON.stringify({ result: handle(JSON.parse(input.slice(0, end))) }) + '\n');
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
|
|
163
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve); });
|
|
164
|
+
if (process.platform !== 'win32')
|
|
165
|
+
fs.chmodSync(pipeFor(id), 0o600);
|
|
166
|
+
save();
|
|
167
|
+
const cliArgs = args || (provider === 'codex' ? ['app-server', '--stdio']
|
|
168
|
+
: ['--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'],
|
|
170
|
+
env: { ...process.env, AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id } });
|
|
171
|
+
child.stdin.on('error', e => shutdown(e.message));
|
|
172
|
+
const exited = new Promise(resolve => {
|
|
173
|
+
child.once('error', e => { shutdown(e.message); resolve({ code: 1, error: e.message }); });
|
|
174
|
+
child.once('exit', (code, signal) => { shutdown(`CLI가 종료됐습니다 (${code ?? signal}). ${stderr.slice(-1000)}`); resolve({ code, signal }); });
|
|
175
|
+
});
|
|
176
|
+
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-8192); });
|
|
177
|
+
child.stdout.setEncoding('utf8');
|
|
178
|
+
child.stdout.on('data', chunk => {
|
|
179
|
+
buffer += chunk;
|
|
180
|
+
if (Buffer.byteLength(buffer) > 4 * 1024 * 1024)
|
|
181
|
+
return shutdown('CLI 응답 크기 제한 초과');
|
|
182
|
+
while (buffer.includes('\n') && !closed) {
|
|
183
|
+
const end = buffer.indexOf('\n'), line = buffer.slice(0, end);
|
|
184
|
+
buffer = buffer.slice(end + 1);
|
|
185
|
+
if (!line.trim())
|
|
186
|
+
continue;
|
|
187
|
+
try {
|
|
188
|
+
const m = JSON.parse(line);
|
|
189
|
+
if (provider === 'agy') {
|
|
190
|
+
if (m.event === 'init') {
|
|
191
|
+
s.conversationId = m.conversation_id;
|
|
192
|
+
s.state = 'idle';
|
|
193
|
+
s.fullyIdle = true;
|
|
194
|
+
save();
|
|
195
|
+
emit({ event: 'ready', conversationId: s.conversationId });
|
|
196
|
+
}
|
|
197
|
+
if (m.event === 'result' && pending) {
|
|
198
|
+
const r = m.result;
|
|
199
|
+
if (r.conversation_id !== s.conversationId)
|
|
200
|
+
throw new Error('agy 대화 ID가 바뀌었습니다.');
|
|
201
|
+
finish(r.status === 'SUCCESS' ? 'completed' : 'failed', r.response, r.error, r.usage);
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (m.id !== undefined && !m.method) {
|
|
206
|
+
const call = calls.get(m.id);
|
|
207
|
+
if (call) {
|
|
208
|
+
clearTimeout(call.timer);
|
|
209
|
+
calls.delete(m.id);
|
|
210
|
+
m.error ? call.reject(new Error(m.error.message)) : call.resolve(m.result);
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (m.id !== undefined && m.method) {
|
|
215
|
+
if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(m.method)) {
|
|
216
|
+
s.pendingApproval = { id: m.id, method: m.method, ...m.params };
|
|
217
|
+
s.state = 'awaiting_user';
|
|
218
|
+
save();
|
|
219
|
+
emit({ event: 'awaiting_user', request: s.pendingApproval });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
write({ id: m.id, error: { code: -32601, message: '이 연결에서 지원하지 않는 사용자 입력입니다. 대화형 CLI에서 처리하세요.' } });
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (m.params?.threadId !== s.conversationId)
|
|
227
|
+
continue;
|
|
228
|
+
if (m.method === 'thread/tokenUsage/updated' && pending)
|
|
229
|
+
pending.rawUsage = m.params?.tokenUsage?.total;
|
|
230
|
+
if (m.method === 'item/completed' && pending && m.params?.item?.type === 'agentMessage')
|
|
231
|
+
pending.result = m.params.item.text;
|
|
232
|
+
if (m.method === 'turn/completed' && pending) {
|
|
233
|
+
const r = m.params?.turn;
|
|
234
|
+
if (!r)
|
|
235
|
+
throw new Error('Codex 실행 결과가 없습니다.');
|
|
236
|
+
if (pending.turnId && pending.turnId !== r.id)
|
|
237
|
+
throw new Error('Codex 실행 ID가 다릅니다.');
|
|
238
|
+
finish(r.status === 'completed' ? 'completed' : 'failed', pending.result, r.error?.message, pending.rawUsage);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
shutdown(`CLI 프로토콜 오류: ${errorMessage(e)}`);
|
|
243
|
+
}
|
|
144
244
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
if (provider === 'codex') {
|
|
248
|
+
await rpc('initialize', { clientInfo: { name: 'axmap', version: '1' } });
|
|
249
|
+
write({ method: 'initialized' });
|
|
250
|
+
const r = await rpc('thread/start', { cwd: path.resolve(cwd), ...(model ? { model } : {}),
|
|
251
|
+
sandbox: 'read-only', approvalPolicy: 'on-request' });
|
|
252
|
+
s.conversationId = r.thread.id;
|
|
253
|
+
s.model = r.model || model || null;
|
|
254
|
+
s.state = 'idle';
|
|
255
|
+
s.fullyIdle = true;
|
|
256
|
+
save();
|
|
257
|
+
emit({ event: 'ready', conversationId: s.conversationId });
|
|
151
258
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
finish(r.status === 'completed' ? 'completed' : 'failed', pending.result, r.error?.message, pending.rawUsage)
|
|
259
|
+
else {
|
|
260
|
+
const deadline = Date.now() + 30000;
|
|
261
|
+
while (s.state === 'starting' && !closed && Date.now() < deadline)
|
|
262
|
+
await new Promise(r => setTimeout(r, 25));
|
|
263
|
+
if (s.state !== 'idle')
|
|
264
|
+
throw new Error(`agy 시작 실패: ${stderr.slice(-1000) || s.state}`);
|
|
159
265
|
}
|
|
160
|
-
} catch (e) { shutdown(`CLI 프로토콜 오류: ${e.message}`) }
|
|
161
266
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
await rpc('initialize', { clientInfo: { name: 'axmap', version: '1' } })
|
|
166
|
-
write({ method: 'initialized' })
|
|
167
|
-
const r = await rpc('thread/start', { cwd: path.resolve(cwd), ...(model ? { model } : {}),
|
|
168
|
-
sandbox: 'read-only', approvalPolicy: 'on-request' })
|
|
169
|
-
s.conversationId = r.thread.id; s.model = r.model || model || null; s.state = 'idle'; s.fullyIdle = true; save()
|
|
170
|
-
emit({ event: 'ready', conversationId: s.conversationId })
|
|
171
|
-
} else {
|
|
172
|
-
const deadline = Date.now() + 30000
|
|
173
|
-
while (s.state === 'starting' && !closed && Date.now() < deadline) await new Promise(r => setTimeout(r, 25))
|
|
174
|
-
if (s.state !== 'idle') throw new Error(`agy 시작 실패: ${stderr.slice(-1000) || s.state}`)
|
|
267
|
+
catch (e) {
|
|
268
|
+
shutdown(errorMessage(e));
|
|
269
|
+
throw e;
|
|
175
270
|
}
|
|
176
|
-
|
|
177
|
-
return { id, pid: child.pid, conversationId: s.conversationId, exited, close: () => shutdown('사용자가 관리형 연결을 종료했습니다.') }
|
|
271
|
+
return { id, pid: child.pid, conversationId: s.conversationId, exited, close: () => shutdown('사용자가 관리형 연결을 종료했습니다.') };
|
|
178
272
|
}
|