axmap-cli 1.4.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.
Files changed (46) hide show
  1. package/README.ko.md +36 -0
  2. package/README.md +36 -0
  3. package/bin/axmap.mjs +6 -0
  4. package/mcp/README.md +6 -0
  5. package/mcp/pipeline.html +157 -0
  6. package/mcp/server.mjs +130 -20
  7. package/package.json +11 -3
  8. package/src/agentcli.mjs +17 -14
  9. package/src/agentcli.mts +19 -0
  10. package/src/bridge.mjs +299 -210
  11. package/src/bridge.mts +281 -0
  12. package/src/external-console.mjs +159 -0
  13. package/src/external-console.mts +128 -0
  14. package/src/managed-session.mjs +263 -169
  15. package/src/managed-session.mts +198 -0
  16. package/src/monitor-cli.mjs +87 -0
  17. package/src/monitor-cli.mts +64 -0
  18. package/src/monitor.mjs +240 -0
  19. package/src/monitor.mts +195 -0
  20. package/src/node-pty.d.ts +13 -0
  21. package/src/pipeline-mcp.mjs +109 -0
  22. package/src/pipeline-mcp.mts +103 -0
  23. package/src/pipeline-statusline.mjs +135 -0
  24. package/src/pipeline-statusline.mts +106 -0
  25. package/src/session-dialogue.mjs +168 -116
  26. package/src/session-dialogue.mts +136 -0
  27. package/src/session-status.mjs +54 -0
  28. package/src/session-status.mts +50 -0
  29. package/src/session-types.mjs +7 -0
  30. package/src/session-types.mts +118 -0
  31. package/src/session-usage.mjs +33 -28
  32. package/src/session-usage.mts +39 -0
  33. package/src/terminal-bridge.mjs +294 -209
  34. package/src/terminal-bridge.mts +235 -0
  35. package/src/windows-console.mjs +42 -0
  36. package/src/windows-console.mts +29 -0
  37. package/tools/bench-console.mjs +70 -0
  38. package/tools/bridge.mjs +20 -6
  39. package/tools/build.mjs +39 -0
  40. package/tools/monitor.mjs +5 -0
  41. package/tools/pipeline.mjs +26 -0
  42. package/tools/run-tests.mjs +3 -2
  43. package/tools/run.mjs +6 -2
  44. package/tools/setup.mjs +1 -1
  45. package/tools/windows-console.cs +116 -0
  46. package/tools/windows-console.ps1 +91 -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
+ }
@@ -0,0 +1,159 @@
1
+ // Generated from src/external-console.mts. Edit the .mts source and run npm run build.
2
+ /** Attach to an existing Windows console; never launch or resume a model. */
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));
12
+ export function consoleCall(pid, action = 'snapshot', payload = {}) {
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;
34
+ }
35
+ function identityID({ pid, startedAt }) {
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)}`;
38
+ }
39
+ const target = (id) => path.join(bridgeHome(), 'sessions', `${id}.json`);
40
+ export function consoleIdle(screen) {
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);
51
+ }
52
+ export function attachConsole({ pid, cwd }) {
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 };
65
+ }
66
+ export function registerExternalHook(input, event) {
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 };
88
+ }
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;
100
+ }
101
+ export function checkConsole(s) {
102
+ const live = consoleCall(consolePID(s), 'snapshot', { startedAt: s.startedAt });
103
+ if (!consoleIdle(live.screen))
104
+ throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.');
105
+ return live;
106
+ }
107
+ export function validateConsolePrompt(prompt, from) {
108
+ if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt.replace(/\r\n/g, '\n')) || /[\x00-\x1f\x7f]/.test(from))
109
+ throw new Error('콘솔 요청에는 제어 문자를 포함할 수 없습니다.');
110
+ }
111
+ export function findConsoleTranscript(s, request) {
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;
149
+ }
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 };
159
+ }
@@ -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
+ }