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
package/src/bridge.mts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/** Local sessions are attached by the provider's own hooks. Sending a message
|
|
2
|
+
* must never silently create/resume a second model process. */
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { withRepoLock } from './repolock.mjs'
|
|
10
|
+
import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs'
|
|
11
|
+
import { errorCode, errorMessage } from './session-types.mjs'
|
|
12
|
+
import type { SessionRecord, PublicSession, RequestRecord, DispatchOptions, HookInput, TranscriptEntry } from './session-types.mjs'
|
|
13
|
+
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
16
|
+
const bridgeHome = () => path.join(process.env.AXMAP_STATE_DIR || path.join(os.homedir(), '.axmap'), 'bridge')
|
|
17
|
+
const agyHome = () => process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli')
|
|
18
|
+
const file = (kind: string, id: string) => {
|
|
19
|
+
if (!UUID.test(id || '')) throw new Error('세션/요청 ID는 UUID여야 합니다.')
|
|
20
|
+
return path.join(bridgeHome(), kind, `${id}.json`)
|
|
21
|
+
}
|
|
22
|
+
// Disk schemas are supplied by callers; invalid JSON still fails closed.
|
|
23
|
+
function readJSON<T = unknown>(p: string): T { return JSON.parse(fs.readFileSync(p, 'utf8')) as T }
|
|
24
|
+
function writeJSON(p: string, data: unknown): void {
|
|
25
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 })
|
|
26
|
+
const tmp = `${p}.${randomUUID()}.tmp`
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
29
|
+
// Windows readers/AV can briefly deny replacement. Keep atomic rename and
|
|
30
|
+
// a bounded retry; never unlink the old record or retry a model submission.
|
|
31
|
+
for (let attempt = 0; ; attempt++) {
|
|
32
|
+
try { fs.renameSync(tmp, p); break }
|
|
33
|
+
catch (e) {
|
|
34
|
+
if (process.platform !== 'win32' || !['EPERM', 'EBUSY'].includes(errorCode(e) || '') || attempt >= 10) throw e
|
|
35
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp) }
|
|
39
|
+
}
|
|
40
|
+
function repoKey(cwd: string): string {
|
|
41
|
+
const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
42
|
+
cwd, encoding: 'utf8', windowsHide: true,
|
|
43
|
+
})
|
|
44
|
+
if (r.status !== 0) throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`)
|
|
45
|
+
const key = fs.realpathSync(r.stdout.trim())
|
|
46
|
+
return process.platform === 'win32' ? key.toLowerCase() : key
|
|
47
|
+
}
|
|
48
|
+
function addressError(address: unknown): asserts address is string {
|
|
49
|
+
// Only a loopback address exported by a local provider hook is accepted.
|
|
50
|
+
if (typeof address !== 'string' || !/^(?:https?:\/\/)?(?:127\.0\.0\.1|localhost|\[::1\]):[0-9]{1,5}$/.test(address)) {
|
|
51
|
+
throw new Error('공식 훅에서 받은 로컬 ANTIGRAVITY_LS_ADDRESS가 필요합니다.')
|
|
52
|
+
}
|
|
53
|
+
const port = Number(address.slice(address.lastIndexOf(':') + 1))
|
|
54
|
+
if (port < 1 || port > 65535) throw new Error('연결 포트가 잘못되었습니다.')
|
|
55
|
+
}
|
|
56
|
+
function runAPI(session: SessionRecord, args: string[]): unknown {
|
|
57
|
+
addressError(session.address)
|
|
58
|
+
if (!session.executable) throw new Error('등록된 agy 실행 파일이 없습니다.')
|
|
59
|
+
const r = spawnSync(session.executable, ['agentapi', ...args], {
|
|
60
|
+
cwd: session.workspaces[0],
|
|
61
|
+
env: { ...process.env, ANTIGRAVITY_LS_ADDRESS: session.address },
|
|
62
|
+
encoding: 'utf8', windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024,
|
|
63
|
+
})
|
|
64
|
+
if (r.error || r.status !== 0) throw new Error(`열린 세션 연결 실패: ${r.error?.message || r.stderr?.trim() || r.stdout?.trim() || r.status}`)
|
|
65
|
+
const text = r.stdout.trim()
|
|
66
|
+
if (text) {
|
|
67
|
+
let data: { error?: unknown }
|
|
68
|
+
try { data = JSON.parse(text) } catch { return text }
|
|
69
|
+
if (data?.error) throw new Error(`열린 세션 연결 실패: ${JSON.stringify(data.error)}`)
|
|
70
|
+
return data
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
function transcript(session: SessionRecord): TranscriptEntry[] {
|
|
75
|
+
const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
76
|
+
if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
|
|
77
|
+
const lines = fs.readFileSync(expected, 'utf8').split('\n')
|
|
78
|
+
// A trailing incomplete record may be in flight; malformed complete records fail.
|
|
79
|
+
lines.pop()
|
|
80
|
+
return lines.filter(s => s.trim()).map(s => JSON.parse(s) as TranscriptEntry)
|
|
81
|
+
}
|
|
82
|
+
function publicSession(s: SessionRecord): PublicSession {
|
|
83
|
+
return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
|
|
84
|
+
transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
|
|
85
|
+
registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
|
|
86
|
+
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval }
|
|
87
|
+
}
|
|
88
|
+
export function sessions(cwd: string): PublicSession[] {
|
|
89
|
+
const dir = path.join(bridgeHome(), 'sessions')
|
|
90
|
+
if (!fs.existsSync(dir)) return []
|
|
91
|
+
const repo = repoKey(cwd)
|
|
92
|
+
return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
|
|
93
|
+
.map(n => readJSON<SessionRecord>(path.join(dir, n))).filter(s => s.repos.includes(repo)).map(publicSession)
|
|
94
|
+
}
|
|
95
|
+
function sessionFor(id: string, cwd: string): SessionRecord {
|
|
96
|
+
const s = readJSON<SessionRecord>(file('sessions', id))
|
|
97
|
+
if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
|
|
98
|
+
if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
99
|
+
return s
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Hook input and env come from Antigravity, not a remote message or repo file. */
|
|
103
|
+
export function registerHook(input: HookInput, event: string, env: NodeJS.ProcessEnv = process.env): PublicSession {
|
|
104
|
+
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
|
|
105
|
+
addressError(env.ANTIGRAVITY_LS_ADDRESS)
|
|
106
|
+
const id = input.conversationId || ''
|
|
107
|
+
const target = file('sessions', id)
|
|
108
|
+
const workspaces = input.workspacePaths
|
|
109
|
+
if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) {
|
|
110
|
+
throw new Error('훅의 작업 저장소 경로가 없습니다.')
|
|
111
|
+
}
|
|
112
|
+
const executable = env.AXMAP_AGY_EXECUTABLE
|
|
113
|
+
if (!executable || !path.isAbsolute(executable) || !fs.existsSync(executable)) throw new Error('등록된 agy 실행 파일이 없습니다.')
|
|
114
|
+
const expected = path.join(agyHome(), 'brain', id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
115
|
+
if (path.resolve(input.transcriptPath || '') !== expected) throw new Error('Antigravity CLI 대화 기록 경로가 아닙니다.')
|
|
116
|
+
const s: SessionRecord = { id, provider: 'agy', executable, address: env.ANTIGRAVITY_LS_ADDRESS,
|
|
117
|
+
transcript: expected, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || null,
|
|
118
|
+
registeredAt: new Date().toISOString(), event, fullyIdle: event === 'Stop' && input.fullyIdle === true }
|
|
119
|
+
writeJSON(target, s)
|
|
120
|
+
return publicSession(s)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function requestResult(id: string, cwd: string): RequestRecord {
|
|
124
|
+
const target = file('requests', id)
|
|
125
|
+
const request = readJSON<RequestRecord>(target)
|
|
126
|
+
if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
|
|
127
|
+
if (!['submitted', 'unknown'].includes(request.state)) return request
|
|
128
|
+
let s = sessionFor(request.to, cwd)
|
|
129
|
+
if (['pty', 'stdio'].includes(s.transport || '')) {
|
|
130
|
+
try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
|
|
131
|
+
catch (e) { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
132
|
+
writeJSON(target, request)
|
|
133
|
+
return request
|
|
134
|
+
}
|
|
135
|
+
if (s.transport === 'console') {
|
|
136
|
+
try { s.fullyIdle = externalStatus(s).state === 'idle' }
|
|
137
|
+
catch (e) {
|
|
138
|
+
request.state = 'unknown'; request.error = errorMessage(e)
|
|
139
|
+
writeJSON(target, request)
|
|
140
|
+
return request
|
|
141
|
+
}
|
|
142
|
+
s = findConsoleTranscript(s, request)
|
|
143
|
+
if (!s.transcript) return request
|
|
144
|
+
}
|
|
145
|
+
const entries = transcript(s)
|
|
146
|
+
const marker = `[axMap request ${id}]`
|
|
147
|
+
const start = entries.findIndex((e, i) => i >= (request.baseline ?? 0) && e.type === 'USER_INPUT' && String(e.content).includes(marker))
|
|
148
|
+
if (start < 0) return request
|
|
149
|
+
const tail = entries.slice(start + 1)
|
|
150
|
+
if (tail.some(e => e.type === 'USER_INPUT')) {
|
|
151
|
+
request.state = 'superseded'
|
|
152
|
+
request.error = '다른 사용자 입력이 이어져 이 요청의 완료를 확정할 수 없습니다.'
|
|
153
|
+
} else if (s.transport === 'console' ? s.fullyIdle : s.event === 'Stop' && s.fullyIdle && s.registeredAt > request.createdAt) {
|
|
154
|
+
const answer = tail.filter(e => e.source === 'MODEL' && e.type === 'PLANNER_RESPONSE' && e.status === 'DONE').at(-1)
|
|
155
|
+
if (answer) {
|
|
156
|
+
request.state = 'completed'
|
|
157
|
+
request.result = answer.content
|
|
158
|
+
request.completedAt = s.transport === 'console' ? new Date().toISOString() : s.registeredAt
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (['completed', 'superseded'].includes(request.state)) writeJSON(target, request)
|
|
162
|
+
return request
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Wait locally, without spending model turns on repeated status tool calls. */
|
|
166
|
+
export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000): Promise<RequestRecord> {
|
|
167
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 30000) throw new Error('wait-ms는 0~30000 정수여야 합니다.')
|
|
168
|
+
const deadline = performance.now() + timeoutMs
|
|
169
|
+
for (;;) {
|
|
170
|
+
const result = requestResult(id, cwd)
|
|
171
|
+
if (result.state !== 'submitted' || performance.now() >= deadline) return result
|
|
172
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function sessionLeasePath(id: string): string {
|
|
177
|
+
if (!UUID.test(id || '')) throw new Error('세션 ID는 UUID여야 합니다.')
|
|
178
|
+
return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
|
|
182
|
+
if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768) throw new Error('요청 본문은 1~32768바이트여야 합니다.')
|
|
183
|
+
const s = sessionFor(to, cwd)
|
|
184
|
+
const lease = sessionLeasePath(to)
|
|
185
|
+
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
|
|
186
|
+
const dir = path.join(bridgeHome(), 'requests')
|
|
187
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
188
|
+
return withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
189
|
+
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
|
|
190
|
+
for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
|
|
191
|
+
const previous = readJSON<RequestRecord>(path.join(dir, n))
|
|
192
|
+
if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
193
|
+
const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
|
|
194
|
+
if (['sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
|
|
195
|
+
}
|
|
196
|
+
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
197
|
+
let consolePreflight
|
|
198
|
+
if (s.transport === 'console') { validateConsolePrompt(prompt, from); consolePreflight = checkConsole(s) }
|
|
199
|
+
if (!['pty', 'stdio', 'console'].includes(s.transport || '')) {
|
|
200
|
+
runAPI(s, ['get-conversation-metadata', to])
|
|
201
|
+
if (!s.fullyIdle || s.event !== 'Stop') throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.')
|
|
202
|
+
}
|
|
203
|
+
const request: RequestRecord = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
|
|
204
|
+
createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
|
|
205
|
+
const target = file('requests', request.id)
|
|
206
|
+
writeJSON(target, request)
|
|
207
|
+
try {
|
|
208
|
+
request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
|
|
209
|
+
? terminalRPC(to, { action: 'submit', request })
|
|
210
|
+
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
|
|
211
|
+
request.state = 'submitted'
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// A timeout may occur after delivery. Never retry automatically.
|
|
214
|
+
request.state = 'unknown'
|
|
215
|
+
request.error = errorMessage(e)
|
|
216
|
+
}
|
|
217
|
+
writeJSON(target, request)
|
|
218
|
+
return request
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function terminalRPC(id: string, message: unknown): unknown {
|
|
223
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
|
|
224
|
+
input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
|
|
225
|
+
})
|
|
226
|
+
if (r.error || r.status !== 0) throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.')
|
|
227
|
+
return JSON.parse(r.stdout)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export { bridgeHome, repoKey, readJSON, writeJSON }
|
|
231
|
+
|
|
232
|
+
export function uninstallHooks(home = os.homedir()) {
|
|
233
|
+
const target = path.join(home, '.gemini', 'config', 'hooks.json')
|
|
234
|
+
if (!fs.existsSync(target)) return { removed: false }
|
|
235
|
+
const config = readJSON<Record<string, unknown>>(target)
|
|
236
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.')
|
|
237
|
+
if (!Object.hasOwn(config, 'axmap-bridge')) return { removed: false }
|
|
238
|
+
delete config['axmap-bridge']
|
|
239
|
+
writeJSON(target, config)
|
|
240
|
+
// A running provider can retain the old hook command until exit. Make its
|
|
241
|
+
// wrapper harmless immediately, without requiring another session restart.
|
|
242
|
+
if (process.platform === 'win32') {
|
|
243
|
+
for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
|
|
244
|
+
const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`)
|
|
245
|
+
if (fs.existsSync(wrapper)) fs.writeFileSync(wrapper, '@echo off\r\necho {}\r\n')
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { removed: true, hooks: target, note: '다른 훅은 유지합니다. 기존 CLI에 캐시된 훅은 정상 종료 시 해제됩니다.' }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function installHooks({ executable, home = os.homedir() }: { executable: string; home?: string }) {
|
|
252
|
+
if (!path.isAbsolute(executable || '') || !fs.existsSync(executable)) throw new Error('agy 실행 파일의 절대 경로가 필요합니다.')
|
|
253
|
+
const target = path.join(home, '.gemini', 'config', 'hooks.json')
|
|
254
|
+
const original = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null
|
|
255
|
+
const config = original === null ? {} : JSON.parse(original)
|
|
256
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.')
|
|
257
|
+
const script = path.join(ROOT, 'tools', 'bridge.mjs')
|
|
258
|
+
if ([process.execPath, script, executable].some(s => /["\r\n%&|<>^]/.test(s))) throw new Error('훅 명령 경로에 지원하지 않는 셸 문자가 있습니다.')
|
|
259
|
+
const definition: Record<string, Array<{ type: string; command: string; timeout: number }>> = {}
|
|
260
|
+
for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
|
|
261
|
+
let command = `"${process.execPath}" "${script}" hook --event ${event} --executable "${executable}"`
|
|
262
|
+
if (process.platform === 'win32') {
|
|
263
|
+
// Antigravity's command runner escapes a quoted executable incorrectly on
|
|
264
|
+
// Windows. A bare .cmd entry point matches its documented hook examples;
|
|
265
|
+
// quotes inside the wrapper are interpreted by cmd, not its command parser.
|
|
266
|
+
const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`)
|
|
267
|
+
if (/[\s"%&|<>^]/.test(wrapper)) throw new Error('Windows 훅 실행 경로에는 공백이나 셸 특수 문자가 없어야 합니다. 설정을 변경하지 않았습니다.')
|
|
268
|
+
fs.mkdirSync(path.dirname(wrapper), { recursive: true })
|
|
269
|
+
fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`, { mode: 0o600 })
|
|
270
|
+
command = wrapper
|
|
271
|
+
}
|
|
272
|
+
definition[event] = [{ type: 'command', command, timeout: 10 }]
|
|
273
|
+
}
|
|
274
|
+
config['axmap-bridge'] = definition
|
|
275
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
276
|
+
const backup = `${target}.axmap-${randomUUID()}.bak`
|
|
277
|
+
if (original !== null) fs.writeFileSync(backup, original, { flag: 'wx', mode: 0o600 })
|
|
278
|
+
writeJSON(target, config)
|
|
279
|
+
return { hooks: target, backup: original === null ? null : backup,
|
|
280
|
+
note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
|
|
281
|
+
}
|
package/src/external-console.mjs
CHANGED
|
@@ -1,116 +1,159 @@
|
|
|
1
|
+
// Generated from src/external-console.mts. Edit the .mts source and run npm run build.
|
|
1
2
|
/** 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
|
-
|
|
10
|
-
const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url))
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
|
|
10
|
+
import { nativeConsoleHelper } from './windows-console.mjs';
|
|
11
|
+
const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url));
|
|
11
12
|
export function consoleCall(pid, action = 'snapshot', payload = {}) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
if (process.platform !== 'win32')
|
|
14
|
+
throw new Error('독립 agy 콘솔 연결은 현재 Windows에서 지원합니다.');
|
|
15
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
16
|
+
throw new Error('양의 정수 PID가 필요합니다.');
|
|
17
|
+
const native = nativeConsoleHelper();
|
|
18
|
+
const r = spawnSync(native || 'powershell.exe', native ? [String(pid), action] : ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
19
|
+
'-File', helper, '-TargetPid', String(pid), '-Action', action], {
|
|
20
|
+
input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
|
|
21
|
+
});
|
|
22
|
+
let data;
|
|
23
|
+
try {
|
|
24
|
+
data = JSON.parse(r.stdout);
|
|
25
|
+
}
|
|
26
|
+
catch { /* report the underlying failure */ }
|
|
27
|
+
if (r.error || r.status !== 0 || data?.error || !data)
|
|
28
|
+
throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패');
|
|
29
|
+
if (action === 'ancestor' && data.ignored)
|
|
30
|
+
return { ignored: true };
|
|
31
|
+
if (typeof data.pid !== 'number' || typeof data.startedAt !== 'string' || (action !== 'ancestor' && typeof data.screen !== 'string'))
|
|
32
|
+
throw new Error('기존 콘솔 응답 형식이 올바르지 않습니다.');
|
|
33
|
+
return data;
|
|
22
34
|
}
|
|
23
35
|
function identityID({ pid, startedAt }) {
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).digest('hex');
|
|
37
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
26
38
|
}
|
|
27
|
-
const target = id => path.join(bridgeHome(), 'sessions', `${id}.json`)
|
|
39
|
+
const target = (id) => path.join(bridgeHome(), 'sessions', `${id}.json`);
|
|
28
40
|
export function consoleIdle(screen) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
// Use the last input box only. Old answers may contain quoted prompt boxes.
|
|
42
|
+
const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)];
|
|
43
|
+
if (borders.length < 2)
|
|
44
|
+
return false;
|
|
45
|
+
const top = borders.at(-2), bottom = borders.at(-1);
|
|
46
|
+
if (!top || !bottom)
|
|
47
|
+
return false;
|
|
48
|
+
const input = screen.slice(top.index + top[0].length, bottom.index).trim();
|
|
49
|
+
const footer = screen.slice(bottom.index + bottom[0].length);
|
|
50
|
+
return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer);
|
|
36
51
|
}
|
|
37
52
|
export function attachConsole({ pid, cwd }) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
const live = consoleCall(pid);
|
|
54
|
+
const id = identityID(live);
|
|
55
|
+
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
|
|
56
|
+
const repo = repoKey(cwd);
|
|
57
|
+
if (previous.repos && !previous.repos.includes(repo))
|
|
58
|
+
throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.');
|
|
59
|
+
const idle = consoleIdle(live.screen);
|
|
60
|
+
const s = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
61
|
+
workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
|
|
62
|
+
state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' };
|
|
63
|
+
writeJSON(target(id), s);
|
|
64
|
+
return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces };
|
|
49
65
|
}
|
|
50
66
|
export function registerExternalHook(input, event) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event))
|
|
68
|
+
throw new Error('지원하지 않는 훅 이벤트입니다.');
|
|
69
|
+
const identity = consoleCall(process.pid, 'ancestor');
|
|
70
|
+
if (identity.ignored)
|
|
71
|
+
return {};
|
|
72
|
+
const id = identityID(identity);
|
|
73
|
+
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {};
|
|
74
|
+
const workspaces = input.workspacePaths || previous.workspaces;
|
|
75
|
+
if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p)))
|
|
76
|
+
throw new Error('훅의 작업 저장소 경로가 없습니다.');
|
|
77
|
+
const conversationId = input.conversationId;
|
|
78
|
+
if (typeof conversationId !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId))
|
|
79
|
+
throw new Error('훅의 대화 UUID가 없습니다.');
|
|
80
|
+
const transcript = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain', conversationId, '.system_generated', 'logs', 'transcript.jsonl');
|
|
81
|
+
if (path.resolve(input.transcriptPath || '') !== transcript)
|
|
82
|
+
throw new Error('훅의 대화 기록 경로가 다릅니다.');
|
|
83
|
+
const idle = event === 'Stop' && input.fullyIdle === true;
|
|
84
|
+
writeJSON(target(id), { ...previous, ...identity, id, provider: 'agy', transport: 'console', conversationId,
|
|
85
|
+
transcript, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || previous.model,
|
|
86
|
+
event, state: idle ? 'idle' : 'running', fullyIdle: idle, registeredAt: new Date().toISOString() });
|
|
87
|
+
return { id };
|
|
68
88
|
}
|
|
69
|
-
export function externalStatus(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
export function externalStatus(session) {
|
|
90
|
+
const s = readJSON(target(session.id));
|
|
91
|
+
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
|
|
92
|
+
const state = consoleIdle(live.screen) ? 'idle' : 'unknown';
|
|
93
|
+
writeJSON(target(s.id), { ...readJSON(target(s.id)), state, fullyIdle: state === 'idle' });
|
|
94
|
+
return { id: s.id, state };
|
|
95
|
+
}
|
|
96
|
+
function consolePID(s) {
|
|
97
|
+
if (!s.pid || !s.startedAt)
|
|
98
|
+
throw new Error('등록된 콘솔 프로세스 신원이 없습니다.');
|
|
99
|
+
return s.pid;
|
|
75
100
|
}
|
|
76
101
|
export function checkConsole(s) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
102
|
+
const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
|
|
103
|
+
if (!consoleIdle(live.screen))
|
|
104
|
+
throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.');
|
|
105
|
+
return live;
|
|
80
106
|
}
|
|
81
107
|
export function validateConsolePrompt(prompt, from) {
|
|
82
|
-
|
|
108
|
+
if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt.replace(/\r\n/g, '\n')) || /[\x00-\x1f\x7f]/.test(from))
|
|
109
|
+
throw new Error('콘솔 요청에는 제어 문자를 포함할 수 없습니다.');
|
|
83
110
|
}
|
|
84
111
|
export function findConsoleTranscript(s, request) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
if (s.transcript)
|
|
113
|
+
return s;
|
|
114
|
+
const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain');
|
|
115
|
+
if (!fs.existsSync(brain))
|
|
116
|
+
return s;
|
|
117
|
+
const marker = `[axMap request ${request.id}]`;
|
|
118
|
+
const matches = [];
|
|
119
|
+
for (const entry of fs.readdirSync(brain, { withFileTypes: true })) {
|
|
120
|
+
if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name))
|
|
121
|
+
continue;
|
|
122
|
+
const log = path.join(brain, entry.name, '.system_generated', 'logs', 'transcript.jsonl');
|
|
123
|
+
let stat;
|
|
124
|
+
try {
|
|
125
|
+
stat = fs.statSync(log);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (stat.mtimeMs < Date.parse(request.createdAt) || stat.size > 32 * 1024 * 1024)
|
|
131
|
+
continue;
|
|
132
|
+
const records = fs.readFileSync(log, 'utf8').split('\n').slice(0, -1);
|
|
133
|
+
if (records.some(line => {
|
|
134
|
+
try {
|
|
135
|
+
const e = JSON.parse(line);
|
|
136
|
+
return e.type === 'USER_INPUT' && String(e.content).includes(marker);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}))
|
|
142
|
+
matches.push({ conversationId: entry.name, transcript: log });
|
|
143
|
+
}
|
|
144
|
+
if (matches.length !== 1)
|
|
145
|
+
return s;
|
|
146
|
+
const updated = { ...readJSON(target(s.id)), ...matches[0] };
|
|
147
|
+
writeJSON(target(s.id), updated);
|
|
148
|
+
return updated;
|
|
106
149
|
}
|
|
107
|
-
export function submitConsole(s, request) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
150
|
+
export function submitConsole(s, request, preflight) {
|
|
151
|
+
validateConsolePrompt(request.prompt, request.from);
|
|
152
|
+
const live = preflight || checkConsole(s);
|
|
153
|
+
// Bracketed paste preserves multiline prompts and prevents embedded Enter keys
|
|
154
|
+
// from becoming separate submissions. Only the final CR submits the message.
|
|
155
|
+
const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`;
|
|
156
|
+
consoleCall(consolePID(s), 'send', { startedAt: s.startedAt, expectedScreen: live.screen, text });
|
|
157
|
+
writeJSON(target(s.id), { ...readJSON(target(s.id)), state: 'running', fullyIdle: false });
|
|
158
|
+
return { accepted: true, pid: s.pid };
|
|
116
159
|
}
|