axmap-cli 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/pipeline.md +9 -0
- package/README.ko.md +37 -0
- package/README.md +37 -0
- package/bin/axmap.mjs +6 -0
- package/mcp/README.md +9 -0
- package/mcp/pipeline.html +184 -0
- package/mcp/server.mjs +161 -20
- package/package.json +12 -3
- package/skills/pipeline/SKILL.md +14 -0
- package/src/agentcli.mjs +17 -14
- package/src/agentcli.mts +19 -0
- package/src/bridge.mjs +297 -219
- package/src/bridge.mts +282 -0
- package/src/codex-connection.mjs +151 -0
- package/src/codex-connection.mts +83 -0
- package/src/external-console.mjs +140 -97
- package/src/external-console.mts +128 -0
- package/src/managed-session.mjs +325 -169
- package/src/managed-session.mts +242 -0
- package/src/monitor-cli.mjs +89 -0
- package/src/monitor-cli.mts +66 -0
- package/src/monitor.mjs +245 -0
- package/src/monitor.mts +199 -0
- package/src/node-pty.d.ts +13 -0
- package/src/pipeline-graph.mjs +55 -0
- package/src/pipeline-graph.mts +51 -0
- package/src/pipeline-mcp.mjs +111 -0
- package/src/pipeline-mcp.mts +105 -0
- package/src/pipeline-statusline.mjs +135 -0
- package/src/pipeline-statusline.mts +106 -0
- package/src/pipeline-web.mjs +56 -0
- package/src/pipeline-web.mts +31 -0
- package/src/session-dialogue.mjs +168 -116
- package/src/session-dialogue.mts +136 -0
- package/src/session-mailbox.mjs +190 -0
- package/src/session-mailbox.mts +157 -0
- package/src/session-status.mjs +47 -41
- package/src/session-status.mts +50 -0
- package/src/session-types.mjs +7 -0
- package/src/session-types.mts +119 -0
- package/src/session-usage.mjs +33 -28
- package/src/session-usage.mts +39 -0
- package/src/terminal-bridge.mjs +293 -209
- package/src/terminal-bridge.mts +235 -0
- package/src/windows-console.mjs +42 -0
- package/src/windows-console.mts +29 -0
- package/tools/bench-console.mjs +70 -0
- package/tools/bridge.mjs +41 -6
- package/tools/build.mjs +39 -0
- package/tools/monitor.mjs +5 -0
- package/tools/pipeline.mjs +34 -0
- package/tools/run-tests.mjs +3 -2
- package/tools/windows-console.cs +116 -0
package/src/bridge.mts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
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
|
+
conversationId: s.conversationId,
|
|
85
|
+
transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
|
|
86
|
+
registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
|
|
87
|
+
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval }
|
|
88
|
+
}
|
|
89
|
+
export function sessions(cwd: string): PublicSession[] {
|
|
90
|
+
const dir = path.join(bridgeHome(), 'sessions')
|
|
91
|
+
if (!fs.existsSync(dir)) return []
|
|
92
|
+
const repo = repoKey(cwd)
|
|
93
|
+
return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
|
|
94
|
+
.map(n => readJSON<SessionRecord>(path.join(dir, n))).filter(s => s.repos.includes(repo)).map(publicSession)
|
|
95
|
+
}
|
|
96
|
+
function sessionFor(id: string, cwd: string): SessionRecord {
|
|
97
|
+
const s = readJSON<SessionRecord>(file('sessions', id))
|
|
98
|
+
if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
|
|
99
|
+
if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || '')) throw new Error('아직 지원하지 않는 수신 세션입니다.')
|
|
100
|
+
return s
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Hook input and env come from Antigravity, not a remote message or repo file. */
|
|
104
|
+
export function registerHook(input: HookInput, event: string, env: NodeJS.ProcessEnv = process.env): PublicSession {
|
|
105
|
+
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
|
|
106
|
+
addressError(env.ANTIGRAVITY_LS_ADDRESS)
|
|
107
|
+
const id = input.conversationId || ''
|
|
108
|
+
const target = file('sessions', id)
|
|
109
|
+
const workspaces = input.workspacePaths
|
|
110
|
+
if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) {
|
|
111
|
+
throw new Error('훅의 작업 저장소 경로가 없습니다.')
|
|
112
|
+
}
|
|
113
|
+
const executable = env.AXMAP_AGY_EXECUTABLE
|
|
114
|
+
if (!executable || !path.isAbsolute(executable) || !fs.existsSync(executable)) throw new Error('등록된 agy 실행 파일이 없습니다.')
|
|
115
|
+
const expected = path.join(agyHome(), 'brain', id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
116
|
+
if (path.resolve(input.transcriptPath || '') !== expected) throw new Error('Antigravity CLI 대화 기록 경로가 아닙니다.')
|
|
117
|
+
const s: SessionRecord = { id, provider: 'agy', executable, address: env.ANTIGRAVITY_LS_ADDRESS,
|
|
118
|
+
transcript: expected, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || null,
|
|
119
|
+
registeredAt: new Date().toISOString(), event, fullyIdle: event === 'Stop' && input.fullyIdle === true }
|
|
120
|
+
writeJSON(target, s)
|
|
121
|
+
return publicSession(s)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function requestResult(id: string, cwd: string): RequestRecord {
|
|
125
|
+
const target = file('requests', id)
|
|
126
|
+
const request = readJSON<RequestRecord>(target)
|
|
127
|
+
if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
|
|
128
|
+
if (!['submitted', 'unknown'].includes(request.state)) return request
|
|
129
|
+
let s = sessionFor(request.to, cwd)
|
|
130
|
+
if (['pty', 'stdio'].includes(s.transport || '')) {
|
|
131
|
+
try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
|
|
132
|
+
catch (e) { request.state = 'unknown'; request.error = errorMessage(e) }
|
|
133
|
+
writeJSON(target, request)
|
|
134
|
+
return request
|
|
135
|
+
}
|
|
136
|
+
if (s.transport === 'console') {
|
|
137
|
+
try { s.fullyIdle = externalStatus(s).state === 'idle' }
|
|
138
|
+
catch (e) {
|
|
139
|
+
request.state = 'unknown'; request.error = errorMessage(e)
|
|
140
|
+
writeJSON(target, request)
|
|
141
|
+
return request
|
|
142
|
+
}
|
|
143
|
+
s = findConsoleTranscript(s, request)
|
|
144
|
+
if (!s.transcript) return request
|
|
145
|
+
}
|
|
146
|
+
const entries = transcript(s)
|
|
147
|
+
const marker = `[axMap request ${id}]`
|
|
148
|
+
const start = entries.findIndex((e, i) => i >= (request.baseline ?? 0) && e.type === 'USER_INPUT' && String(e.content).includes(marker))
|
|
149
|
+
if (start < 0) return request
|
|
150
|
+
const tail = entries.slice(start + 1)
|
|
151
|
+
if (tail.some(e => e.type === 'USER_INPUT')) {
|
|
152
|
+
request.state = 'superseded'
|
|
153
|
+
request.error = '다른 사용자 입력이 이어져 이 요청의 완료를 확정할 수 없습니다.'
|
|
154
|
+
} else if (s.transport === 'console' ? s.fullyIdle : s.event === 'Stop' && s.fullyIdle && s.registeredAt > request.createdAt) {
|
|
155
|
+
const answer = tail.filter(e => e.source === 'MODEL' && e.type === 'PLANNER_RESPONSE' && e.status === 'DONE').at(-1)
|
|
156
|
+
if (answer) {
|
|
157
|
+
request.state = 'completed'
|
|
158
|
+
request.result = answer.content
|
|
159
|
+
request.completedAt = s.transport === 'console' ? new Date().toISOString() : s.registeredAt
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (['completed', 'superseded'].includes(request.state)) writeJSON(target, request)
|
|
163
|
+
return request
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Wait locally, without spending model turns on repeated status tool calls. */
|
|
167
|
+
export async function waitForRequest(id: string, cwd: string, timeoutMs = 30000): Promise<RequestRecord> {
|
|
168
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 30000) throw new Error('wait-ms는 0~30000 정수여야 합니다.')
|
|
169
|
+
const deadline = performance.now() + timeoutMs
|
|
170
|
+
for (;;) {
|
|
171
|
+
const result = requestResult(id, cwd)
|
|
172
|
+
if (result.state !== 'submitted' || performance.now() >= deadline) return result
|
|
173
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sessionLeasePath(id: string): string {
|
|
178
|
+
if (!UUID.test(id || '')) throw new Error('세션 ID는 UUID여야 합니다.')
|
|
179
|
+
return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
|
|
183
|
+
if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768) throw new Error('요청 본문은 1~32768바이트여야 합니다.')
|
|
184
|
+
const s = sessionFor(to, cwd)
|
|
185
|
+
const lease = sessionLeasePath(to)
|
|
186
|
+
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
|
|
187
|
+
const dir = path.join(bridgeHome(), 'requests')
|
|
188
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
189
|
+
return withRepoLock(path.join(dir, `${to}.lock`), () => {
|
|
190
|
+
if (fs.existsSync(lease) && readJSON<{ dialogueId: string }>(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
|
|
191
|
+
for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
|
|
192
|
+
const previous = readJSON<RequestRecord>(path.join(dir, n))
|
|
193
|
+
if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
|
|
194
|
+
const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
|
|
195
|
+
if (['sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
|
|
196
|
+
}
|
|
197
|
+
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
198
|
+
let consolePreflight
|
|
199
|
+
if (s.transport === 'console') { validateConsolePrompt(prompt, from); consolePreflight = checkConsole(s) }
|
|
200
|
+
if (!['pty', 'stdio', 'console'].includes(s.transport || '')) {
|
|
201
|
+
runAPI(s, ['get-conversation-metadata', to])
|
|
202
|
+
if (!s.fullyIdle || s.event !== 'Stop') throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.')
|
|
203
|
+
}
|
|
204
|
+
const request: RequestRecord = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
|
|
205
|
+
createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
|
|
206
|
+
const target = file('requests', request.id)
|
|
207
|
+
writeJSON(target, request)
|
|
208
|
+
try {
|
|
209
|
+
request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
|
|
210
|
+
? terminalRPC(to, { action: 'submit', request })
|
|
211
|
+
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
|
|
212
|
+
request.state = 'submitted'
|
|
213
|
+
} catch (e) {
|
|
214
|
+
// A timeout may occur after delivery. Never retry automatically.
|
|
215
|
+
request.state = 'unknown'
|
|
216
|
+
request.error = errorMessage(e)
|
|
217
|
+
}
|
|
218
|
+
writeJSON(target, request)
|
|
219
|
+
return request
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function terminalRPC(id: string, message: unknown): unknown {
|
|
224
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
|
|
225
|
+
input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
|
|
226
|
+
})
|
|
227
|
+
if (r.error || r.status !== 0) throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.')
|
|
228
|
+
return JSON.parse(r.stdout)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export { bridgeHome, repoKey, readJSON, writeJSON }
|
|
232
|
+
|
|
233
|
+
export function uninstallHooks(home = os.homedir()) {
|
|
234
|
+
const target = path.join(home, '.gemini', 'config', 'hooks.json')
|
|
235
|
+
if (!fs.existsSync(target)) return { removed: false }
|
|
236
|
+
const config = readJSON<Record<string, unknown>>(target)
|
|
237
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.')
|
|
238
|
+
if (!Object.hasOwn(config, 'axmap-bridge')) return { removed: false }
|
|
239
|
+
delete config['axmap-bridge']
|
|
240
|
+
writeJSON(target, config)
|
|
241
|
+
// A running provider can retain the old hook command until exit. Make its
|
|
242
|
+
// wrapper harmless immediately, without requiring another session restart.
|
|
243
|
+
if (process.platform === 'win32') {
|
|
244
|
+
for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
|
|
245
|
+
const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`)
|
|
246
|
+
if (fs.existsSync(wrapper)) fs.writeFileSync(wrapper, '@echo off\r\necho {}\r\n')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { removed: true, hooks: target, note: '다른 훅은 유지합니다. 기존 CLI에 캐시된 훅은 정상 종료 시 해제됩니다.' }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function installHooks({ executable, home = os.homedir() }: { executable: string; home?: string }) {
|
|
253
|
+
if (!path.isAbsolute(executable || '') || !fs.existsSync(executable)) throw new Error('agy 실행 파일의 절대 경로가 필요합니다.')
|
|
254
|
+
const target = path.join(home, '.gemini', 'config', 'hooks.json')
|
|
255
|
+
const original = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null
|
|
256
|
+
const config = original === null ? {} : JSON.parse(original)
|
|
257
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.')
|
|
258
|
+
const script = path.join(ROOT, 'tools', 'bridge.mjs')
|
|
259
|
+
if ([process.execPath, script, executable].some(s => /["\r\n%&|<>^]/.test(s))) throw new Error('훅 명령 경로에 지원하지 않는 셸 문자가 있습니다.')
|
|
260
|
+
const definition: Record<string, Array<{ type: string; command: string; timeout: number }>> = {}
|
|
261
|
+
for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
|
|
262
|
+
let command = `"${process.execPath}" "${script}" hook --event ${event} --executable "${executable}"`
|
|
263
|
+
if (process.platform === 'win32') {
|
|
264
|
+
// Antigravity's command runner escapes a quoted executable incorrectly on
|
|
265
|
+
// Windows. A bare .cmd entry point matches its documented hook examples;
|
|
266
|
+
// quotes inside the wrapper are interpreted by cmd, not its command parser.
|
|
267
|
+
const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`)
|
|
268
|
+
if (/[\s"%&|<>^]/.test(wrapper)) throw new Error('Windows 훅 실행 경로에는 공백이나 셸 특수 문자가 없어야 합니다. 설정을 변경하지 않았습니다.')
|
|
269
|
+
fs.mkdirSync(path.dirname(wrapper), { recursive: true })
|
|
270
|
+
fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`, { mode: 0o600 })
|
|
271
|
+
command = wrapper
|
|
272
|
+
}
|
|
273
|
+
definition[event] = [{ type: 'command', command, timeout: 10 }]
|
|
274
|
+
}
|
|
275
|
+
config['axmap-bridge'] = definition
|
|
276
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
277
|
+
const backup = `${target}.axmap-${randomUUID()}.bak`
|
|
278
|
+
if (original !== null) fs.writeFileSync(backup, original, { flag: 'wx', mode: 0o600 })
|
|
279
|
+
writeJSON(target, config)
|
|
280
|
+
return { hooks: target, backup: original === null ? null : backup,
|
|
281
|
+
note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
|
|
282
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Generated from src/codex-connection.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Attach to an existing Codex daemon through its official byte proxy.
|
|
3
|
+
* The proxy carries WebSocket frames, not JSONL. No daemon/model is started. */
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { PassThrough, Writable } from 'node:stream';
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const MAX = 4 * 1024 * 1024;
|
|
9
|
+
export function websocketFrame(payload, opcode = 1) {
|
|
10
|
+
if (payload.length > MAX)
|
|
11
|
+
throw new Error('Codex message exceeds 4MiB');
|
|
12
|
+
const mask = randomBytes(4), header = Buffer.alloc(payload.length < 126 ? 2 : payload.length < 65536 ? 4 : 10);
|
|
13
|
+
header[0] = 0x80 | opcode;
|
|
14
|
+
if (payload.length < 126)
|
|
15
|
+
header[1] = 0x80 | payload.length;
|
|
16
|
+
else if (payload.length < 65536) {
|
|
17
|
+
header[1] = 254;
|
|
18
|
+
header.writeUInt16BE(payload.length, 2);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
header[1] = 255;
|
|
22
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
23
|
+
}
|
|
24
|
+
const body = Buffer.from(payload);
|
|
25
|
+
for (let i = 0; i < body.length; i++)
|
|
26
|
+
body[i] = body[i] ^ mask[i % 4];
|
|
27
|
+
return Buffer.concat([header, mask, body]);
|
|
28
|
+
}
|
|
29
|
+
export function connectCodexProxy({ executable, prefix = [], socketPath, cwd, timeoutMs = 10000 }) {
|
|
30
|
+
if (!path.isAbsolute(socketPath) || /[\r\n\0]/.test(socketPath))
|
|
31
|
+
throw new Error('An absolute local Codex socket path is required');
|
|
32
|
+
const process = spawn(executable, [...prefix, 'app-server', 'proxy', '--sock', socketPath], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
33
|
+
const output = new PassThrough(), key = randomBytes(16).toString('base64');
|
|
34
|
+
const accept = createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
35
|
+
let buffer = Buffer.alloc(0), upgraded = false, closed = false, fragment = [], size = 0, input = '';
|
|
36
|
+
const queued = [];
|
|
37
|
+
const fail = (message) => { if (closed)
|
|
38
|
+
return; closed = true; clearTimeout(timer); output.destroy(new Error(message)); process.kill(); };
|
|
39
|
+
const timer = setTimeout(() => fail('Codex local socket handshake timed out'), timeoutMs);
|
|
40
|
+
const send = (text) => process.stdin.write(websocketFrame(Buffer.from(text)));
|
|
41
|
+
const stdin = new Writable({ write(chunk, _encoding, callback) {
|
|
42
|
+
input += chunk.toString();
|
|
43
|
+
if (Buffer.byteLength(input) > MAX) {
|
|
44
|
+
callback(new Error('Codex input exceeds 4MiB'));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
while (input.includes('\n')) {
|
|
48
|
+
const at = input.indexOf('\n'), line = input.slice(0, at);
|
|
49
|
+
input = input.slice(at + 1);
|
|
50
|
+
if (!line.trim())
|
|
51
|
+
continue;
|
|
52
|
+
if (upgraded)
|
|
53
|
+
send(line);
|
|
54
|
+
else
|
|
55
|
+
queued.push(line);
|
|
56
|
+
}
|
|
57
|
+
callback();
|
|
58
|
+
} });
|
|
59
|
+
process.stdin.on('error', e => fail(e.message));
|
|
60
|
+
process.stdout.on('data', chunk => {
|
|
61
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
62
|
+
if (buffer.length > MAX + 16384) {
|
|
63
|
+
fail('Codex frame buffer exceeds limit');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!upgraded) {
|
|
67
|
+
const end = buffer.indexOf('\r\n\r\n');
|
|
68
|
+
if (end < 0) {
|
|
69
|
+
if (buffer.length > 16384)
|
|
70
|
+
fail('Invalid Codex upgrade response');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const lines = buffer.subarray(0, end).toString('ascii').split('\r\n');
|
|
74
|
+
const headers = new Map(lines.slice(1).map(line => { const at = line.indexOf(':'); return [line.slice(0, at).toLowerCase(), line.slice(at + 1).trim()]; }));
|
|
75
|
+
if (!/^HTTP\/1\.1 101\b/.test(lines[0] || '') || headers.get('sec-websocket-accept') !== accept || headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
76
|
+
fail('Codex socket did not confirm the WebSocket handshake');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
buffer = buffer.subarray(end + 4);
|
|
80
|
+
upgraded = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
for (const text of queued.splice(0))
|
|
83
|
+
send(text);
|
|
84
|
+
}
|
|
85
|
+
while (buffer.length >= 2 && !closed) {
|
|
86
|
+
const first = buffer[0], second = buffer[1], opcode = first & 15, fin = !!(first & 128);
|
|
87
|
+
let count = second & 127, offset = 2;
|
|
88
|
+
if (first & 112 || second & 128) {
|
|
89
|
+
fail('Invalid Codex WebSocket flags');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (count === 126) {
|
|
93
|
+
if (buffer.length < 4)
|
|
94
|
+
return;
|
|
95
|
+
count = buffer.readUInt16BE(2);
|
|
96
|
+
offset = 4;
|
|
97
|
+
}
|
|
98
|
+
if (count === 127) {
|
|
99
|
+
if (buffer.length < 10)
|
|
100
|
+
return;
|
|
101
|
+
const big = buffer.readBigUInt64BE(2);
|
|
102
|
+
if (big > BigInt(MAX)) {
|
|
103
|
+
fail('Codex frame exceeds limit');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
;
|
|
107
|
+
count = Number(big);
|
|
108
|
+
offset = 10;
|
|
109
|
+
}
|
|
110
|
+
if (count > MAX || (opcode >= 8 && (!fin || count > 125))) {
|
|
111
|
+
fail('Invalid Codex control frame');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (buffer.length < offset + count)
|
|
115
|
+
return;
|
|
116
|
+
const body = Buffer.from(buffer.subarray(offset, offset + count));
|
|
117
|
+
buffer = buffer.subarray(offset + count);
|
|
118
|
+
if (opcode === 8) {
|
|
119
|
+
closed = true;
|
|
120
|
+
output.end();
|
|
121
|
+
process.kill();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (opcode === 9) {
|
|
125
|
+
process.stdin.write(websocketFrame(body, 10));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (opcode === 10)
|
|
129
|
+
continue;
|
|
130
|
+
if ((opcode !== 1 && opcode !== 0) || (opcode === 0 && !fragment.length) || (opcode === 1 && fragment.length)) {
|
|
131
|
+
fail('Invalid Codex text sequence');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
fragment.push(body);
|
|
135
|
+
size += body.length;
|
|
136
|
+
if (size > MAX) {
|
|
137
|
+
fail('Codex fragmented message exceeds limit');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (fin) {
|
|
141
|
+
output.write(Buffer.concat(fragment).toString('utf8') + '\n');
|
|
142
|
+
fragment = [];
|
|
143
|
+
size = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
process.once('exit', () => { clearTimeout(timer); output.end(); });
|
|
148
|
+
process.stdin.write(`GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
|
|
149
|
+
return { pid: process.pid, stdin, stdout: output, stderr: process.stderr,
|
|
150
|
+
once: process.once.bind(process), kill: process.kill.bind(process) };
|
|
151
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Attach to an existing Codex daemon through its official byte proxy.
|
|
2
|
+
* The proxy carries WebSocket frames, not JSONL. No daemon/model is started. */
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { PassThrough, Writable } from 'node:stream'
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
export type ProviderProcess = Pick<ChildProcessWithoutNullStreams, 'pid' | 'once' | 'kill'> & {
|
|
10
|
+
stdin: Writable; stdout: PassThrough | ChildProcessWithoutNullStreams['stdout']; stderr: ChildProcessWithoutNullStreams['stderr']
|
|
11
|
+
}
|
|
12
|
+
const MAX = 4 * 1024 * 1024
|
|
13
|
+
export function websocketFrame(payload: Buffer, opcode = 1): Buffer {
|
|
14
|
+
if (payload.length > MAX) throw new Error('Codex message exceeds 4MiB')
|
|
15
|
+
const mask = randomBytes(4), header = Buffer.alloc(payload.length < 126 ? 2 : payload.length < 65536 ? 4 : 10)
|
|
16
|
+
header[0] = 0x80 | opcode
|
|
17
|
+
if (payload.length < 126) header[1] = 0x80 | payload.length
|
|
18
|
+
else if (payload.length < 65536) { header[1] = 254; header.writeUInt16BE(payload.length, 2) }
|
|
19
|
+
else { header[1] = 255; header.writeBigUInt64BE(BigInt(payload.length), 2) }
|
|
20
|
+
const body = Buffer.from(payload)
|
|
21
|
+
for (let i = 0; i < body.length; i++) body[i] = body[i]! ^ mask[i % 4]!
|
|
22
|
+
return Buffer.concat([header, mask, body])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function connectCodexProxy({ executable, prefix = [], socketPath, cwd, timeoutMs = 10000 }:
|
|
26
|
+
{ executable: string; prefix?: string[]; socketPath: string; cwd: string; timeoutMs?: number }): ProviderProcess {
|
|
27
|
+
if (!path.isAbsolute(socketPath) || /[\r\n\0]/.test(socketPath)) throw new Error('An absolute local Codex socket path is required')
|
|
28
|
+
const process = spawn(executable, [...prefix, 'app-server', 'proxy', '--sock', socketPath], { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
29
|
+
const output = new PassThrough(), key = randomBytes(16).toString('base64')
|
|
30
|
+
const accept = createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64')
|
|
31
|
+
let buffer: Buffer = Buffer.alloc(0), upgraded = false, closed = false, fragment: Buffer[] = [], size = 0, input = ''
|
|
32
|
+
const queued: string[] = []
|
|
33
|
+
const fail = (message: string) => { if (closed) return; closed = true; clearTimeout(timer); output.destroy(new Error(message)); process.kill() }
|
|
34
|
+
const timer = setTimeout(() => fail('Codex local socket handshake timed out'), timeoutMs)
|
|
35
|
+
const send = (text: string) => process.stdin.write(websocketFrame(Buffer.from(text)))
|
|
36
|
+
const stdin = new Writable({ write(chunk, _encoding, callback) {
|
|
37
|
+
input += chunk.toString()
|
|
38
|
+
if (Buffer.byteLength(input) > MAX) { callback(new Error('Codex input exceeds 4MiB')); return }
|
|
39
|
+
while (input.includes('\n')) {
|
|
40
|
+
const at = input.indexOf('\n'), line = input.slice(0, at); input = input.slice(at + 1)
|
|
41
|
+
if (!line.trim()) continue
|
|
42
|
+
if (upgraded) send(line); else queued.push(line)
|
|
43
|
+
}
|
|
44
|
+
callback()
|
|
45
|
+
} })
|
|
46
|
+
process.stdin.on('error', e => fail(e.message))
|
|
47
|
+
process.stdout.on('data', chunk => {
|
|
48
|
+
buffer = Buffer.concat([buffer, chunk])
|
|
49
|
+
if (buffer.length > MAX + 16384) { fail('Codex frame buffer exceeds limit'); return }
|
|
50
|
+
if (!upgraded) {
|
|
51
|
+
const end = buffer.indexOf('\r\n\r\n')
|
|
52
|
+
if (end < 0) { if (buffer.length > 16384) fail('Invalid Codex upgrade response'); return }
|
|
53
|
+
const lines = buffer.subarray(0, end).toString('ascii').split('\r\n')
|
|
54
|
+
const headers = new Map(lines.slice(1).map(line => { const at = line.indexOf(':'); return [line.slice(0, at).toLowerCase(), line.slice(at + 1).trim()] }))
|
|
55
|
+
if (!/^HTTP\/1\.1 101\b/.test(lines[0] || '') || headers.get('sec-websocket-accept') !== accept || headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
56
|
+
fail('Codex socket did not confirm the WebSocket handshake'); return
|
|
57
|
+
}
|
|
58
|
+
buffer = buffer.subarray(end + 4); upgraded = true; clearTimeout(timer)
|
|
59
|
+
for (const text of queued.splice(0)) send(text)
|
|
60
|
+
}
|
|
61
|
+
while (buffer.length >= 2 && !closed) {
|
|
62
|
+
const first = buffer[0]!, second = buffer[1]!, opcode = first & 15, fin = !!(first & 128)
|
|
63
|
+
let count = second & 127, offset = 2
|
|
64
|
+
if (first & 112 || second & 128) { fail('Invalid Codex WebSocket flags'); return }
|
|
65
|
+
if (count === 126) { if (buffer.length < 4) return; count = buffer.readUInt16BE(2); offset = 4 }
|
|
66
|
+
if (count === 127) { if (buffer.length < 10) return; const big = buffer.readBigUInt64BE(2); if (big > BigInt(MAX)) { fail('Codex frame exceeds limit'); return }; count = Number(big); offset = 10 }
|
|
67
|
+
if (count > MAX || (opcode >= 8 && (!fin || count > 125))) { fail('Invalid Codex control frame'); return }
|
|
68
|
+
if (buffer.length < offset + count) return
|
|
69
|
+
const body = Buffer.from(buffer.subarray(offset, offset + count)); buffer = buffer.subarray(offset + count)
|
|
70
|
+
if (opcode === 8) { closed = true; output.end(); process.kill(); return }
|
|
71
|
+
if (opcode === 9) { process.stdin.write(websocketFrame(body, 10)); continue }
|
|
72
|
+
if (opcode === 10) continue
|
|
73
|
+
if ((opcode !== 1 && opcode !== 0) || (opcode === 0 && !fragment.length) || (opcode === 1 && fragment.length)) { fail('Invalid Codex text sequence'); return }
|
|
74
|
+
fragment.push(body); size += body.length
|
|
75
|
+
if (size > MAX) { fail('Codex fragmented message exceeds limit'); return }
|
|
76
|
+
if (fin) { output.write(Buffer.concat(fragment).toString('utf8') + '\n'); fragment = []; size = 0 }
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
process.once('exit', () => { clearTimeout(timer); output.end() })
|
|
80
|
+
process.stdin.write(`GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`)
|
|
81
|
+
return { pid: process.pid, stdin, stdout: output, stderr: process.stderr,
|
|
82
|
+
once: process.once.bind(process), kill: process.kill.bind(process) }
|
|
83
|
+
}
|