axmap-cli 1.3.3 → 1.5.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 (70) hide show
  1. package/CLAUDE.md +15 -9
  2. package/README.ko.md +103 -87
  3. package/README.md +104 -88
  4. package/bin/axmap.mjs +9 -3
  5. package/mcp/README.md +8 -8
  6. package/mcp/install.ps1 +6 -3
  7. package/mcp/install.sh +5 -3
  8. package/mcp/server.mjs +81 -32
  9. package/package.json +7 -11
  10. package/src/agentcli.mjs +19 -0
  11. package/src/bridge.mjs +257 -0
  12. package/src/external-console.mjs +116 -0
  13. package/src/invariants.mjs +1 -1
  14. package/src/managed-session.mjs +178 -0
  15. package/src/protocol.mjs +1 -1
  16. package/src/session-dialogue.mjs +131 -0
  17. package/src/session-status.mjs +48 -0
  18. package/src/session-usage.mjs +34 -0
  19. package/src/state.mjs +8 -0
  20. package/src/terminal-bridge.mjs +225 -0
  21. package/src/update.mjs +1 -1
  22. package/tools/bridge.mjs +96 -0
  23. package/tools/mcp-register.mjs +2 -2
  24. package/tools/run-tests.mjs +10 -3
  25. package/tools/run.mjs +64 -0
  26. package/tools/setup.mjs +11 -0
  27. package/tools/windows-console.ps1 +91 -0
  28. package/app/README.md +0 -366
  29. package/app/eval/edges.mjs +0 -242
  30. package/app/lib/adjacent.mjs +0 -125
  31. package/app/lib/agentcli.mjs +0 -153
  32. package/app/lib/analyze.mjs +0 -1159
  33. package/app/lib/cochange.mjs +0 -421
  34. package/app/lib/datanodes.mjs +0 -127
  35. package/app/lib/entry.mjs +0 -192
  36. package/app/lib/featuregraph.mjs +0 -389
  37. package/app/lib/features.mjs +0 -645
  38. package/app/lib/fetchrepo-run.mjs +0 -37
  39. package/app/lib/fetchrepo.mjs +0 -164
  40. package/app/lib/flow.mjs +0 -1089
  41. package/app/lib/ladder.mjs +0 -387
  42. package/app/lib/langs.mjs +0 -630
  43. package/app/lib/live.mjs +0 -346
  44. package/app/lib/llm.mjs +0 -594
  45. package/app/lib/newfile.mjs +0 -126
  46. package/app/lib/prdiff.mjs +0 -651
  47. package/app/lib/reveal.mjs +0 -316
  48. package/app/lib/roots.mjs +0 -186
  49. package/app/lib/scope.mjs +0 -342
  50. package/app/lib/session.mjs +0 -389
  51. package/app/lib/slots.mjs +0 -233
  52. package/app/lib/ssot.mjs +0 -277
  53. package/app/lib/teamview.mjs +0 -975
  54. package/app/lib/terms.ko.mjs +0 -169
  55. package/app/server.mjs +0 -1959
  56. package/app/web/shell.css +0 -538
  57. package/app/web/shell.html +0 -197
  58. package/app/web/shell.js +0 -638
  59. package/app/web/stage.js +0 -347
  60. package/app/web/words.js +0 -85
  61. package/src/repotarget.mjs +0 -84
  62. package/tools/cluster-experiment.mjs +0 -256
  63. package/tools/cluster-sweep.mjs +0 -226
  64. package/tools/make-icon.mjs +0 -108
  65. package/tools/persona-bench.mjs +0 -362
  66. package/tools/pick-repo.mjs +0 -229
  67. package/tools/reveal-demo.mjs +0 -158
  68. package/tools/shortcut.mjs +0 -121
  69. package/tools/smoke.mjs +0 -174
  70. package/tools/topicgraph.py +0 -154
@@ -0,0 +1,225 @@
1
+ /** The user launches an interactive CLI. axMap owns its PTY and a local,
2
+ * authenticated pipe; remote requests never launch/resume another model. */
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import net from 'node:net'
7
+ import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { createRequire } from 'node:module'
10
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
13
+ const validID = id => /^[0-9a-f-]{36}$/i.test(id || '')
14
+ const sessionFile = id => {
15
+ if (!validID(id)) throw new Error('잘못된 터미널 세션 ID입니다.')
16
+ return path.join(bridgeHome(), 'sessions', `${id}.json`)
17
+ }
18
+ const pipeFor = id => process.platform === 'win32'
19
+ ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
20
+
21
+ export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } = {}) {
22
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 응답 시간 한도가 필요합니다.')
23
+ const s = readJSON(sessionFile(id))
24
+ if (!['pty', 'stdio'].includes(s.transport)) throw new Error('로컬 관리형 세션이 아닙니다.')
25
+ return new Promise((resolve, reject) => {
26
+ const socket = net.createConnection(pipeFor(id))
27
+ socket.setEncoding('utf8')
28
+ let buffer = ''
29
+ const fail = e => { socket.destroy(); reject(e) }
30
+ socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')))
31
+ socket.on('error', fail)
32
+ socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'))
33
+ socket.on('data', chunk => {
34
+ buffer += chunk
35
+ if (Buffer.byteLength(buffer) > 1024 * 1024) return fail(new Error('로컬 응답 크기 제한 초과'))
36
+ const end = buffer.indexOf('\n')
37
+ if (end < 0) return
38
+ socket.end()
39
+ try {
40
+ const response = JSON.parse(buffer.slice(0, end))
41
+ if (response.error) reject(new Error(response.error)); else resolve(response.result)
42
+ } catch (e) { reject(e) }
43
+ })
44
+ socket.on('end', () => { if (!buffer.includes('\n')) reject(new Error('세션 연결이 응답 없이 종료됐습니다.')) })
45
+ })
46
+ }
47
+
48
+ export async function startTerminal({ executable, args = [], provider, cwd = process.cwd(), onData = () => {}, cols = 100, rows = 30 }) {
49
+ const { spawn } = await import('node-pty')
50
+ const id = randomUUID(), token = randomBytes(32).toString('hex')
51
+ const s = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
52
+ state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' }
53
+ const jobs = new Map()
54
+ let pending = null, draft = false, term, closed = false, stopTimer
55
+ const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s) }
56
+ const finish = (state, result, error) => {
57
+ if (!pending) return
58
+ Object.assign(pending, { state, result, error, completedAt: new Date().toISOString() })
59
+ jobs.set(pending.id, { ...pending }); pending = null
60
+ }
61
+ const handle = message => {
62
+ const offered = Buffer.from(typeof message.token === 'string' ? message.token : '')
63
+ const expected = Buffer.from(token)
64
+ if (offered.length !== expected.length || !timingSafeEqual(offered, expected)) throw new Error('로컬 세션 인증 실패')
65
+ if (message.action === 'status') return { state: s.state, id, provider }
66
+ if (message.action === 'result') {
67
+ const job = jobs.get(message.id)
68
+ if (!job) throw new Error('이 터미널에 해당 요청이 없습니다.')
69
+ return job
70
+ }
71
+ if (message.action === 'hook') {
72
+ clearTimeout(stopTimer)
73
+ const { input, event } = message
74
+ s.event = event; s.model = input.modelName || input.model || s.model
75
+ s.conversationId = input.conversationId || input.session_id || s.conversationId
76
+ if (event !== 'Stop') {
77
+ s.state = 'running'; s.fullyIdle = false
78
+ if (pending && event === 'UserPromptSubmit' && typeof input.prompt === 'string') {
79
+ pending.observed = input.prompt.includes(`[axMap request ${pending.id}]`)
80
+ }
81
+ save(); return {}
82
+ }
83
+ if (provider === 'agy' && input.fullyIdle !== true) { save(); return {} }
84
+ let answer = input.last_assistant_message
85
+ if (provider === 'agy') {
86
+ const log = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
87
+ 'brain', s.conversationId || '', '.system_generated', 'logs', 'transcript.jsonl')
88
+ if (path.resolve(input.transcriptPath || '') !== log) throw new Error('종료 훅의 대화 기록 경로가 다릅니다.')
89
+ const lines = fs.readFileSync(log, 'utf8').trimEnd().split('\n').filter(Boolean).map(line => JSON.parse(line))
90
+ const lastUser = lines.findLast(e => e.type === 'USER_INPUT')
91
+ if (pending) pending.observed = String(lastUser?.content).includes(`[axMap request ${pending.id}]`)
92
+ answer = lines.findLast(e => e.type === 'PLANNER_RESPONSE' && e.source === 'MODEL' && e.status === 'DONE')?.content
93
+ }
94
+ if (pending) finish(pending.observed && !pending.interrupted && typeof answer === 'string' ? 'completed' : 'superseded',
95
+ pending.observed && !pending.interrupted ? answer : undefined,
96
+ pending.observed && !pending.interrupted ? undefined : '다른 입력이 섞였거나 이 요청의 응답을 확인하지 못했습니다.')
97
+ // Stop runs before the provider restores its input UI. Allow that handoff
98
+ // to finish before accepting another injected prompt.
99
+ s.state = 'settling'; s.fullyIdle = false; save()
100
+ stopTimer = setTimeout(() => { if (!closed && s.state === 'settling') { s.state = 'idle'; s.fullyIdle = true; draft = false; save() } }, 500)
101
+ return {}
102
+ }
103
+ if (message.action === 'submit') {
104
+ const request = message.request
105
+ if (closed || s.state !== 'idle' || draft || pending) throw new Error('상대가 대기 중이 아니거나 입력 중입니다. 작업을 전달하지 않았습니다.')
106
+ if (!validID(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw new Error('요청 형식이 올바르지 않습니다.')
107
+ const prompt = `[axMap request ${request.id}]\n요청자: ${String(request.from || 'axmap')}\n${request.prompt}`
108
+ if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt)) throw new Error('터미널 제어 문자가 포함된 요청은 보낼 수 없습니다.')
109
+ pending = { id: request.id, to: id, state: 'submitted' }; jobs.set(request.id, pending)
110
+ s.state = 'running'; s.fullyIdle = false; save()
111
+ term.write(`\x1b[200~${prompt}\x1b[201~`)
112
+ setTimeout(() => { if (!closed) term.write('\r') }, 100)
113
+ return { accepted: true, session: id }
114
+ }
115
+ throw new Error('지원하지 않는 로컬 세션 요청입니다.')
116
+ }
117
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 })
118
+ const server = net.createServer(socket => {
119
+ socket.setEncoding('utf8')
120
+ let buffer = '', handled = false
121
+ socket.setTimeout(15000, () => socket.destroy())
122
+ socket.on('error', () => {})
123
+ socket.on('data', chunk => {
124
+ if (handled) return
125
+ buffer += chunk
126
+ if (Buffer.byteLength(buffer) > 65536) return socket.destroy()
127
+ const end = buffer.indexOf('\n'); if (end < 0) return
128
+ handled = true
129
+ try { socket.end(JSON.stringify({ result: handle(JSON.parse(buffer.slice(0, end))) }) + '\n') }
130
+ catch (e) { socket.end(JSON.stringify({ error: e.message }) + '\n') }
131
+ })
132
+ })
133
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve) })
134
+ if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
135
+ save()
136
+ try {
137
+ term = spawn(executable, args, { name: 'xterm-256color', cols, rows, cwd, env: { ...process.env,
138
+ AXMAP_TERMINAL_SESSION: id, AXMAP_TERMINAL_TOKEN: token,
139
+ AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id,
140
+ }, useConpty: true, useConptyDll: process.platform === 'win32' })
141
+ } catch (e) { server.close(); s.state = 'closed'; save(); throw e }
142
+ term.onData(onData)
143
+ const exited = new Promise(resolve => term.onExit(ev => {
144
+ if (closed) return
145
+ closed = true; clearTimeout(stopTimer)
146
+ // ConPTY may keep its input handle alive after a normal child exit.
147
+ if (process.platform === 'win32') { try { term.kill() } catch { /* already closed */ } }
148
+ finish('unknown', undefined, '요청의 완료를 확인하기 전에 세션이 종료됐습니다.')
149
+ s.state = 'closed'; s.fullyIdle = false; save(); server.close(); resolve(ev)
150
+ }))
151
+ return { id, pid: term.pid, exited,
152
+ input(data) {
153
+ const terminalReply = /^\x1b\[(?:\d+;\d+R|\?[^a-zA-Z]*[a-zA-Z])$/.test(String(data))
154
+ if (terminalReply) { term.write(data); return }
155
+ if (pending) pending.interrupted = true
156
+ draft = !String(data).includes('\r')
157
+ if (!draft) { s.state = 'running'; s.fullyIdle = false; save() }
158
+ term.write(data)
159
+ },
160
+ resize: (c, r) => term.resize(c, r),
161
+ close: () => term.kill(),
162
+ }
163
+ }
164
+
165
+ /** Copy only production dependencies from the npm package, not a developer's
166
+ * entire node_modules. This also keeps the setup copy independent of npm cache. */
167
+ export function installTerminalRuntime(sourceRoot, destRoot = ROOT) {
168
+ const source = path.resolve(sourceRoot), dest = path.resolve(destRoot)
169
+ const require = createRequire(path.join(source, 'package.json'))
170
+ const copied = new Set()
171
+ function copy(name) {
172
+ if (copied.has(name)) return
173
+ const descriptor = require.resolve(`${name}/package.json`)
174
+ const pkg = readJSON(descriptor)
175
+ const target = path.join(dest, 'node_modules', ...name.split('/'))
176
+ if (path.resolve(path.dirname(descriptor)) !== path.resolve(target)) {
177
+ fs.mkdirSync(path.dirname(target), { recursive: true })
178
+ fs.cpSync(path.dirname(descriptor), target, { recursive: true, force: true })
179
+ }
180
+ copied.add(name)
181
+ for (const dependency of Object.keys(pkg.dependencies || {})) copy(dependency)
182
+ for (const dependency of Object.keys(pkg.optionalDependencies || {})) {
183
+ try { require.resolve(`${dependency}/package.json`) }
184
+ catch (e) { if (e.code === 'MODULE_NOT_FOUND') continue; throw e }
185
+ copy(dependency)
186
+ }
187
+ }
188
+ copy('node-pty')
189
+ const installed = createRequire(path.join(dest, 'package.json'))('node-pty')
190
+ if (typeof installed.spawn !== 'function') throw new Error('설치된 터미널 라이브러리를 불러오지 못했습니다.')
191
+ return { packages: [...copied], destination: dest }
192
+ }
193
+
194
+ export function installTerminalHooks(home = os.homedir(), provider = 'agy') {
195
+ if (!['agy', 'codex'].includes(provider)) throw new Error('지원하지 않는 CLI 훅입니다.')
196
+ const target = provider === 'agy' ? path.join(home, '.gemini', 'config', 'hooks.json')
197
+ : path.join(home, '.codex', 'hooks.json')
198
+ const config = fs.existsSync(target) ? readJSON(target) : {}
199
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json을 읽지 못했습니다.')
200
+ if (provider === 'codex' && config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks))) throw new Error('기존 Codex hooks 설정이 객체가 아닙니다.')
201
+ const definition = {}
202
+ const script = path.join(ROOT, 'tools', 'run.mjs')
203
+ if ([process.execPath, script].some(p => /["\r\n%&|<>^]/.test(p))) throw new Error('훅 경로에 지원하지 않는 셸 문자가 있습니다.')
204
+ for (const event of provider === 'agy' ? ['SessionStart', 'PreInvocation', 'Stop'] : ['SessionStart', 'UserPromptSubmit', 'Stop']) {
205
+ let command = `"${process.execPath}" "${script}" hook ${event}`
206
+ if (process.platform === 'win32') {
207
+ const wrapper = path.join(home, '.axmap', 'bridge', `terminal-${provider}-${event}.cmd`)
208
+ if (/[\s"%&|<>^]/.test(wrapper)) throw new Error('이 Windows 훅 경로에는 공백/셸 문자를 사용할 수 없습니다.')
209
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true })
210
+ fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`)
211
+ command = wrapper
212
+ }
213
+ if (provider === 'agy') definition[event] = [{ type: 'command', command, timeout: 10 }]
214
+ else {
215
+ config.hooks ||= {}
216
+ const existing = config.hooks[event] || []
217
+ if (!Array.isArray(existing)) throw new Error(`기존 Codex ${event} 설정이 배열이 아닙니다.`)
218
+ config.hooks[event] = existing.filter(group => !(Array.isArray(group.hooks) && group.hooks.some(h => h.statusMessage === 'axMap terminal bridge')))
219
+ config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: 10, statusMessage: 'axMap terminal bridge' }] })
220
+ }
221
+ }
222
+ if (provider === 'agy') config['axmap-terminal'] = definition
223
+ writeJSON(target, config)
224
+ return target
225
+ }
package/src/update.mjs CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  import fs from 'node:fs'
30
30
  import path from 'node:path'
31
- import { stateDir } from './repotarget.mjs'
31
+ import { stateDir } from './state.mjs'
32
32
 
33
33
  /**
34
34
  * 꾸러미 이름. **`package.json` 의 `name` 과 반드시 같아야 한다.**
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import { resolveBin } from '../src/agentcli.mjs'
4
+ import { codexInvocation } from './mcp-register.mjs'
5
+ import { dispatchRequest, installHooks, registerHook, requestResult, sessions, uninstallHooks } from '../src/bridge.mjs'
6
+ import { startManagedSession } from '../src/managed-session.mjs'
7
+ import { runDialogue, startBackgroundDialogue, dialogueResult, stopDialogue } from '../src/session-dialogue.mjs'
8
+ import { sendTerminal } from '../src/terminal-bridge.mjs'
9
+ import { sessionStatus, formatSessionStatus } from '../src/session-status.mjs'
10
+ import { attachConsole } from '../src/external-console.mjs'
11
+
12
+ const args = process.argv.slice(2)
13
+ const [command] = args
14
+ const option = (name) => { const i = args.indexOf(`--${name}`); return i < 0 ? undefined : args[i + 1] }
15
+ const output = (value) => console.log(JSON.stringify(value, null, 2))
16
+ try {
17
+ switch (command) {
18
+ case 'attach': output(attachConsole({ pid: Number(option('pid')), cwd: process.cwd() })); break
19
+ case 'serve': {
20
+ const provider = args[1]
21
+ if (!['codex', 'agy'].includes(provider)) throw new Error('axmap bridge serve codex|agy를 사용하세요.')
22
+ const binary = resolveBin(provider)
23
+ if (!binary) throw new Error(`${provider} 실행 파일을 찾지 못했습니다.`)
24
+ const [executable, ...prefix] = provider === 'codex' ? codexInvocation(binary) : [binary]
25
+ const session = await startManagedSession({ provider, executable, prefix, model: option('model'), role: option('role'),
26
+ maxTokens: Number(option('max-tokens') || 100000), onEvent: output })
27
+ output({ event: 'connected', ...session, exited: undefined, close: undefined })
28
+ process.once('SIGINT', session.close); process.once('SIGTERM', session.close)
29
+ const exit = await session.exited
30
+ process.exitCode = exit.code || 0
31
+ break
32
+ }
33
+ case 'chat': {
34
+ const options = { ...(option('id') ? { id: option('id') } : {}), left: option('left'), right: option('right'), prompt: fs.readFileSync(0, 'utf8'),
35
+ maxTurns: Number(option('max-turns') || 4), maxTokens: Number(option('max-tokens') || 50000),
36
+ maxMessageChars: Number(option('max-message-chars') || 2000),
37
+ onEvent: event => console.error(JSON.stringify(event)) }
38
+ const r = args.includes('--background') ? await startBackgroundDialogue(options) : await runDialogue(options)
39
+ output(r); if (r.state !== 'completed') process.exitCode = 2
40
+ if (r.state === 'queued') process.exitCode = 0
41
+ break
42
+ }
43
+ case 'dialogue': output(dialogueResult(args[1])); break
44
+ case 'stop': output(stopDialogue(args[1])); break
45
+ case 'answer': output(await sendTerminal(option('to'), { action: 'answer', id: option('request'), decision: option('decision') })); break
46
+ case 'install':
47
+ if (!args.includes('--experimental')) throw new Error('실험용 연결입니다. 현재 검증한 CLI는 필요한 연결 정보를 훅에 제공하지 않습니다. 확인 후 --experimental로 명시하세요.')
48
+ output(installHooks({ executable: option('executable') })); break
49
+ case 'uninstall': output(uninstallHooks()); break
50
+ case 'hook': {
51
+ const input = JSON.parse(fs.readFileSync(0, 'utf8'))
52
+ try {
53
+ registerHook(input, option('event'), { ...process.env, AXMAP_AGY_EXECUTABLE: option('executable') })
54
+ } catch (e) { console.error(`axMap bridge: ${e.message}`) }
55
+ // Observe only. Never alter a provider's permission or continuation decision.
56
+ output({})
57
+ break
58
+ }
59
+ case 'sessions': output(sessions(process.cwd())); break
60
+ case 'status': {
61
+ const listed = await sessionStatus()
62
+ if (args.includes('--json')) output(listed)
63
+ else console.log(formatSessionStatus(listed))
64
+ break
65
+ }
66
+ case 'send': {
67
+ const r = dispatchRequest({ to: option('to'), prompt: fs.readFileSync(0, 'utf8'), cwd: process.cwd(), from: process.env.AXMAP_AGENT || 'axmap' })
68
+ output(r)
69
+ if (r.state === 'unknown') process.exitCode = 2
70
+ break
71
+ }
72
+ case 'result': output(requestResult(args[1], process.cwd())); break
73
+ case undefined:
74
+ case '--help':
75
+ console.log(`axMap 로컬 세션 연결 — 관리형 Codex/agy와 제한된 자동 대화
76
+ axmap bridge serve codex|agy [--model 이름] [--role 역할] [--max-tokens 100000]
77
+ 명시적으로 새 관리형 CLI를 시작합니다. 기존 창에 임의로 붙지 않습니다.
78
+ axmap bridge chat --left <UUID> --right <UUID> [--max-turns 4] [--max-tokens 50000]
79
+ stdin 작업을 자동 중계합니다. 기본 메시지 한도 2000자, 대기 중 모델 호출 없음.
80
+ axmap bridge answer --to <UUID> --request <ID> --decision accept|decline|cancel
81
+ Codex가 요청한 이번 실행의 승인을 사용자가 결정합니다.
82
+ axmap bridge chat ... --background 접수 ID를 반환하고 백그라운드 중계
83
+ axmap bridge dialogue <대화 UUID> 상태·회신·누적 토큰 조회
84
+ axmap bridge stop <대화 UUID> 추가 중계 중단 (이미 보낸 요청은 계속될 수 있음)
85
+ axmap bridge install --experimental --executable <agy 절대 경로> 연결 훅 실험
86
+ axmap bridge uninstall 추가한 연결 훅 해제
87
+ axmap bridge sessions 이 repo에 연결된 세션
88
+ axmap bridge attach --pid <PID> 이미 실행한 Windows agy 콘솔을 현재 repo에 연결
89
+ axmap bridge status [--json] 로컬 연결·작업·승인 대기·종료 상태 조회 (모델 호출 없음)
90
+ axmap bridge send --to <세션 UUID> 본문은 stdin. 기존 세션에 전달
91
+ axmap bridge result <요청 UUID> 제출/완료/불명 상태와 결과
92
+ 관리형 stdio: Codex App Server / agy stream-json. PTY와 agentapi는 별도 실험 경로입니다.`)
93
+ break
94
+ default: throw new Error(`알 수 없는 bridge 명령: ${command}`)
95
+ }
96
+ } catch (e) { console.error(e.message); process.exitCode = 1 }
@@ -30,7 +30,7 @@
30
30
  *
31
31
  * ── 감지는 새로 짜지 않는다 ───────────────────────────────────────────────
32
32
  *
33
- * 어떤 CLI 가 이 PC 에 있는지는 `app/lib/agentcli.mjs` 가 이미 안다(`AGENTS`, `resolveBin`).
33
+ * 어떤 CLI 가 이 PC 에 있는지는 `src/agentcli.mjs` 가 이미 안다(`AGENTS`, `resolveBin`).
34
34
  * 거기서 그대로 가져온다. 바이너리 이름이 두 군데에 적히면 한쪽만 낡는다.
35
35
  */
36
36
 
@@ -40,7 +40,7 @@ import os from 'node:os'
40
40
  import path from 'node:path'
41
41
  import { fileURLToPath } from 'node:url'
42
42
 
43
- import { AGENTS, resolveBin } from '../app/lib/agentcli.mjs'
43
+ import { AGENTS, resolveBin } from '../src/agentcli.mjs'
44
44
  import { claudeUserEntry } from '../src/mcpstate.mjs'
45
45
 
46
46
  const HERE = path.dirname(fileURLToPath(import.meta.url))
@@ -37,6 +37,13 @@ if (files.length === 0) {
37
37
  process.exit(1)
38
38
  }
39
39
 
40
- const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit', cwd: root })
41
- // status 가 null 이면 신호로 죽은 것이다. 통과로 세지 않는다.
42
- process.exit(r.status ?? 1)
40
+ // Windows ConPTY owns native console workers. Exercise it after other test
41
+ // processes have exited so temporary console teardown cannot race the suite's
42
+ // many concurrent subprocess fixtures. Both batches remain mandatory.
43
+ const native = process.platform === 'win32' ? files.filter(f => path.basename(f) === 'terminal-bridge.test.mjs') : []
44
+ const ordinary = files.filter(f => !native.includes(f))
45
+ for (const batch of [ordinary, native].filter(batch => batch.length)) {
46
+ const r = spawnSync(process.execPath, ['--test', ...batch], { stdio: 'inherit', cwd: root })
47
+ if (r.status !== 0) process.exit(r.status ?? 1)
48
+ }
49
+ process.exit(0)
package/tools/run.mjs ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { startTerminal, sendTerminal, installTerminalHooks, installTerminalRuntime } from '../src/terminal-bridge.mjs'
6
+ import { registerExternalHook } from '../src/external-console.mjs'
7
+
8
+ const [command, ...args] = process.argv.slice(2)
9
+ try {
10
+ if (command === 'install-runtime') {
11
+ console.log(JSON.stringify(installTerminalRuntime(args[0])))
12
+ } else if (command === 'install-hooks') {
13
+ console.log(JSON.stringify({ hooks: [installTerminalHooks(), installTerminalHooks(undefined, 'codex')] }))
14
+ } else if (command === 'rpc') {
15
+ console.log(JSON.stringify(await sendTerminal(args[0], JSON.parse(fs.readFileSync(0, 'utf8')))))
16
+ } else if (command === 'hook') {
17
+ const input = JSON.parse(fs.readFileSync(0, 'utf8'))
18
+ if (process.env.AXMAP_TERMINAL_SESSION && process.env.AXMAP_TERMINAL_TOKEN) {
19
+ await sendTerminal(process.env.AXMAP_TERMINAL_SESSION, { action: 'hook', event: args[0], input }, process.env.AXMAP_TERMINAL_TOKEN)
20
+ } else if (process.platform === 'win32' && input.conversationId) {
21
+ try { registerExternalHook(input, args[0]) }
22
+ catch (e) { console.error(`axMap 독립 콘솔: ${e.message}`) }
23
+ }
24
+ console.log('{}')
25
+ } else if (command === '--help' || !command) {
26
+ console.log('axmap run agy|codex [기존 CLI 인수...] [--cwd 작업폴더]\n사용자가 여는 실제 CLI 터미널을 유지합니다. 별도 연결 URL은 필요 없습니다.')
27
+ } else {
28
+ if (!['agy', 'codex'].includes(command)) throw new Error('현재 실행 도구는 agy 또는 codex입니다.')
29
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('사용자가 연 대화형 터미널에서 실행하세요. 새 백그라운드 AI 세션은 만들지 않습니다.')
30
+ const cwdAt = args.indexOf('--cwd')
31
+ let cwd = process.cwd()
32
+ if (cwdAt >= 0) {
33
+ if (!args[cwdAt + 1]) throw new Error('--cwd 뒤에 작업 폴더를 지정하세요.')
34
+ cwd = path.resolve(args[cwdAt + 1]); args.splice(cwdAt, 2)
35
+ }
36
+ const found = spawnSync(process.platform === 'win32' ? 'where.exe' : 'which', [command], { encoding: 'utf8', windowsHide: true })
37
+ const executable = found.stdout?.split(/\r?\n/).map(s => s.trim()).find(s => s && (process.platform !== 'win32' || path.extname(s).toLowerCase() === '.exe'))
38
+ if (!executable) throw new Error(`${command} 실행 파일을 찾지 못했습니다.`)
39
+ installTerminalHooks(undefined, command)
40
+ if (command === 'codex') console.error('Codex가 새 훅의 신뢰 확인을 요청하면 /hooks에서 axMap 훅을 검토하세요. 승인을 자동으로 우회하지 않습니다.')
41
+ let terminal
42
+ const oldRaw = process.stdin.isRaw
43
+ try {
44
+ terminal = await startTerminal({ executable, args, provider: command, cwd, cols: process.stdout.columns || 100, rows: process.stdout.rows || 30, onData: data => process.stdout.write(data) })
45
+ console.error(`axMap 연결 세션: ${terminal.id}`)
46
+ process.stdin.setRawMode(true); process.stdin.resume()
47
+ const input = data => terminal.input(data.toString('utf8'))
48
+ const resize = () => terminal.resize(process.stdout.columns || 100, process.stdout.rows || 30)
49
+ process.stdin.on('data', input); process.stdout.on('resize', resize)
50
+ const stop = () => terminal.close()
51
+ process.once('SIGTERM', stop)
52
+ const ev = await terminal.exited
53
+ process.removeListener('SIGTERM', stop)
54
+ process.stdin.off('data', input); process.stdout.off('resize', resize)
55
+ process.exitCode = ev.exitCode
56
+ } finally {
57
+ process.stdin.setRawMode(oldRaw); process.stdin.pause()
58
+ process.stdout.write('\x1b[?2004l\x1b[?25h')
59
+ }
60
+ // node-pty's Windows output worker can outlive the exited console. This
61
+ // standalone terminal host has finished; release its remaining native handles.
62
+ process.exit(process.exitCode || 0)
63
+ }
64
+ } catch (e) { console.error(e.message); process.exitCode = 1 }
package/tools/setup.mjs CHANGED
@@ -251,6 +251,17 @@ function installAndRegister() {
251
251
 
252
252
  installAndRegister()
253
253
 
254
+ // The installed copy owns stable hook paths, including the bundled PTY runtime.
255
+ if (!has('--no-register')) {
256
+ if (DRY) hm('세션 연결', '(dry-run) 설치된 사본에 CLI 연결 훅을 등록합니다')
257
+ else {
258
+ const hookRunner = path.join(DEST, 'tools', 'run.mjs')
259
+ const r = spawnSync(process.execPath, [hookRunner, 'install-hooks'], { encoding: 'utf8', windowsHide: true })
260
+ if (r.status === 0) ok('세션 연결', 'Windows에서 직접 여는 agy와 axmap run CLI가 자동 연결됩니다')
261
+ else no('세션 연결', r.stderr?.trim() || '연결 훅 등록 실패')
262
+ }
263
+ }
264
+
254
265
  // ── 6. 장부와 훅 ────────────────────────────────────────────────────────────
255
266
  //
256
267
  // 🔴 **설치된 사본**(`~/.axmap/app`)의 CLI 로 부른다. npx 가 푼 임시 폴더의 것으로
@@ -0,0 +1,91 @@
1
+ param([int]$TargetPid, [string]$Action = 'snapshot')
2
+ $ErrorActionPreference = 'Stop'
3
+ $utf8 = New-Object System.Text.UTF8Encoding($false)
4
+ [Console]::InputEncoding = $utf8
5
+ [Console]::OutputEncoding = $utf8
6
+ $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
7
+ # Keep the original pipe writer: AttachConsole replaces the standard handles.
8
+ $reply = [Console]::Out
9
+ if ($Action -eq 'ancestor') {
10
+ for ($i = 0; $i -lt 12 -and $TargetPid -gt 0; $i++) {
11
+ $entry = Get-CimInstance Win32_Process -Filter "ProcessId=$TargetPid"
12
+ if (!$entry) { break }
13
+ if ($entry.Name -eq 'agy.exe') {
14
+ if ($entry.CommandLine -match '--input-format|--print(?:\s|=|$)|(?:^|\s)-p(?:\s|$)') { $reply.WriteLine('{"ignored":true}'); exit 0 }
15
+ $proc = Get-Process -Id $TargetPid
16
+ $reply.WriteLine((@{ pid=$TargetPid; startedAt=$proc.StartTime.ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress))
17
+ exit 0
18
+ }
19
+ $TargetPid = $entry.ParentProcessId
20
+ }
21
+ throw 'No interactive agy ancestor found'
22
+ }
23
+ Add-Type -TypeDefinition @'
24
+ using System;
25
+ using System.Text;
26
+ using System.Runtime.InteropServices;
27
+ using System.ComponentModel;
28
+ public static class AxConsole {
29
+ [StructLayout(LayoutKind.Sequential)] public struct Coord { public short X,Y; }
30
+ [StructLayout(LayoutKind.Sequential)] public struct Rect { public short Left,Top,Right,Bottom; }
31
+ [StructLayout(LayoutKind.Sequential)] public struct Info { public Coord Size,Cursor; public short Attr; public Rect Window; public Coord Max; }
32
+ [StructLayout(LayoutKind.Explicit, CharSet=CharSet.Unicode, Size=20)] public struct Record {
33
+ [FieldOffset(0)] public short Type;
34
+ [FieldOffset(4)] public int Down;
35
+ [FieldOffset(8)] public short Repeat;
36
+ [FieldOffset(10)] public short Key;
37
+ [FieldOffset(14)] public char Char;
38
+ }
39
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool AttachConsole(uint pid);
40
+ [DllImport("kernel32.dll")] public static extern bool FreeConsole();
41
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateFile(string name,uint access,uint share,IntPtr sec,uint mode,uint flags,IntPtr template);
42
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetConsoleScreenBufferInfo(IntPtr h,out Info info);
43
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool ReadConsoleOutputCharacter(IntPtr h,[Out] char[] text,uint count,Coord at,out uint read);
44
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool WriteConsoleInput(IntPtr h,Record[] input,uint count,out uint written);
45
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
46
+ static void Check(bool ok) { if(!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); }
47
+ public static void Attach(int pid) { FreeConsole(); Check(AttachConsole((uint)pid)); }
48
+ static IntPtr Open(string name) { var h=CreateFile(name,0xC0000000,3,IntPtr.Zero,3,0,IntPtr.Zero); if(h==new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error()); return h; }
49
+ public static string Snapshot() {
50
+ var h=Open("CONOUT$"); try {
51
+ Info info; Check(GetConsoleScreenBufferInfo(h,out info));
52
+ var text=new StringBuilder();
53
+ for(short y=info.Window.Top;y<=info.Window.Bottom;y++) {
54
+ uint read; var row=new char[info.Size.X]; Check(ReadConsoleOutputCharacter(h,row,(uint)row.Length,new Coord { X=0,Y=y },out read)); text.AppendLine(new string(row,0,(int)read).TrimEnd());
55
+ }
56
+ return text.ToString();
57
+ } finally { CloseHandle(h); }
58
+ }
59
+ public static void Send(string text) {
60
+ var h=Open("CONIN$"); try {
61
+ var records=new Record[text.Length*2];
62
+ for(int i=0;i<text.Length;i++) {
63
+ var r=new Record {Type=1,Down=1,Repeat=1,Char=text[i],Key=(short)(text[i]=='\r'?13:0)};
64
+ records[i*2]=r; r.Down=0; records[i*2+1]=r;
65
+ }
66
+ uint written; Check(WriteConsoleInput(h,records,(uint)records.Length,out written));
67
+ if(written!=records.Length) throw new Exception("Partial console delivery; do not retry automatically.");
68
+ } finally { CloseHandle(h); }
69
+ }
70
+ }
71
+ '@
72
+ try {
73
+ $proc = Get-Process -Id $TargetPid
74
+ if ($proc.ProcessName -ne 'agy') { throw 'Target is not agy.exe' }
75
+ $commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId=$TargetPid").CommandLine
76
+ if ($commandLine -match '--input-format|--print(?:\s|=|$)|(?:^|\s)-p(?:\s|$)') { throw 'Target is not an interactive agy console' }
77
+ if ($payload.startedAt -and $proc.StartTime.ToUniversalTime().ToString('o') -ne $payload.startedAt) { throw 'Process identity changed' }
78
+ [AxConsole]::Attach($TargetPid)
79
+ $screen = [AxConsole]::Snapshot()
80
+ if ($Action -eq 'send') {
81
+ if ($payload.expectedScreen -cne $screen) { throw 'Console changed before delivery; input was not sent' }
82
+ [AxConsole]::Send($payload.text)
83
+ }
84
+ $result = @{ screen = $screen; startedAt = $proc.StartTime.ToUniversalTime().ToString('o'); pid = $TargetPid }
85
+ [AxConsole]::FreeConsole() | Out-Null
86
+ $reply.WriteLine(($result | ConvertTo-Json -Compress))
87
+ } catch {
88
+ [AxConsole]::FreeConsole() | Out-Null
89
+ $reply.WriteLine((@{ error = $_.Exception.Message } | ConvertTo-Json -Compress))
90
+ exit 1
91
+ }