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
@@ -1,224 +1,309 @@
1
+ // Generated from src/terminal-bridge.mts. Edit the .mts source and run npm run build.
1
2
  /** The user launches an interactive CLI. axMap owns its PTY and a local,
2
3
  * 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`)
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import net from 'node:net';
8
+ import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { createRequire } from 'node:module';
11
+ import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
12
+ import { errorCode, errorMessage } from './session-types.mjs';
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const validID = (id) => /^[0-9a-f-]{36}$/i.test(id || '');
15
+ const sessionFile = (id) => {
16
+ if (!validID(id))
17
+ throw new Error('잘못된 터미널 세션 ID입니다.');
18
+ return path.join(bridgeHome(), 'sessions', `${id}.json`);
19
+ };
20
+ const pipeFor = (id) => process.platform === 'win32'
21
+ ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
22
+ export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } = {}) {
23
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
24
+ throw new Error('양의 응답 시간 한도가 필요합니다.');
25
+ const s = readJSON(sessionFile(id));
26
+ if (!['pty', 'stdio'].includes(s.transport || ''))
27
+ throw new Error('로컬 관리형 세션이 아닙니다.');
28
+ return new Promise((resolve, reject) => {
29
+ const socket = net.createConnection(pipeFor(id));
30
+ socket.setEncoding('utf8');
31
+ let buffer = '';
32
+ const fail = (e) => { socket.destroy(); reject(e); };
33
+ socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')));
34
+ socket.on('error', fail);
35
+ socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'));
36
+ socket.on('data', chunk => {
37
+ buffer += chunk;
38
+ if (Buffer.byteLength(buffer) > 1024 * 1024)
39
+ return fail(new Error('로컬 응답 크기 제한 초과'));
40
+ const end = buffer.indexOf('\n');
41
+ if (end < 0)
42
+ return;
43
+ socket.end();
44
+ try {
45
+ const response = JSON.parse(buffer.slice(0, end));
46
+ if (response.error)
47
+ reject(new Error(response.error));
48
+ else
49
+ resolve(response.result);
50
+ }
51
+ catch (e) {
52
+ reject(e);
53
+ }
54
+ });
55
+ socket.on('end', () => { if (!buffer.includes('\n'))
56
+ reject(new Error('세션 연결이 응답 없이 종료됐습니다.')); });
57
+ });
17
58
  }
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) {
22
- const s = readJSON(sessionFile(id))
23
- if (!['pty', 'stdio'].includes(s.transport)) throw new Error('로컬 관리형 세션이 아닙니다.')
24
- return new Promise((resolve, reject) => {
25
- const socket = net.createConnection(pipeFor(id))
26
- socket.setEncoding('utf8')
27
- let buffer = ''
28
- const fail = e => { socket.destroy(); reject(e) }
29
- socket.setTimeout(10000, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')))
30
- socket.on('error', fail)
31
- socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'))
32
- socket.on('data', chunk => {
33
- buffer += chunk
34
- if (Buffer.byteLength(buffer) > 1024 * 1024) return fail(new Error('로컬 응답 크기 제한 초과'))
35
- const end = buffer.indexOf('\n')
36
- if (end < 0) return
37
- socket.end()
38
- try {
39
- const response = JSON.parse(buffer.slice(0, end))
40
- if (response.error) reject(new Error(response.error)); else resolve(response.result)
41
- } catch (e) { reject(e) }
42
- })
43
- socket.on('end', () => { if (!buffer.includes('\n')) reject(new Error('세션 연결이 응답 없이 종료됐습니다.')) })
44
- })
45
- }
46
-
47
- export async function startTerminal({ executable, args = [], provider, cwd = process.cwd(), onData = () => {}, cols = 100, rows = 30 }) {
48
- const { spawn } = await import('node-pty')
49
- const id = randomUUID(), token = randomBytes(32).toString('hex')
50
- const s = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
51
- state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' }
52
- const jobs = new Map()
53
- let pending = null, draft = false, term, closed = false, stopTimer
54
- const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s) }
55
- const finish = (state, result, error) => {
56
- if (!pending) return
57
- Object.assign(pending, { state, result, error, completedAt: new Date().toISOString() })
58
- jobs.set(pending.id, { ...pending }); pending = null
59
- }
60
- const handle = message => {
61
- const offered = Buffer.from(typeof message.token === 'string' ? message.token : '')
62
- const expected = Buffer.from(token)
63
- if (offered.length !== expected.length || !timingSafeEqual(offered, expected)) throw new Error('로컬 세션 인증 실패')
64
- if (message.action === 'status') return { state: s.state, id, provider }
65
- if (message.action === 'result') {
66
- const job = jobs.get(message.id)
67
- if (!job) throw new Error('이 터미널에 해당 요청이 없습니다.')
68
- return job
69
- }
70
- if (message.action === 'hook') {
71
- clearTimeout(stopTimer)
72
- const { input, event } = message
73
- s.event = event; s.model = input.modelName || input.model || s.model
74
- s.conversationId = input.conversationId || input.session_id || s.conversationId
75
- if (event !== 'Stop') {
76
- s.state = 'running'; s.fullyIdle = false
77
- if (pending && event === 'UserPromptSubmit' && typeof input.prompt === 'string') {
78
- pending.observed = input.prompt.includes(`[axMap request ${pending.id}]`)
59
+ export async function startTerminal({ executable, args = [], provider, cwd = process.cwd(), onData = () => { }, cols = 100, rows = 30 }) {
60
+ const { spawn } = await import('node-pty');
61
+ const id = randomUUID(), token = randomBytes(32).toString('hex');
62
+ const s = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
63
+ state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' };
64
+ const jobs = new Map();
65
+ let pending = null, draft = false, term, closed = false, stopTimer;
66
+ const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s); };
67
+ const finish = (state, result, error) => {
68
+ if (!pending)
69
+ return;
70
+ Object.assign(pending, { state, result, error, completedAt: new Date().toISOString() });
71
+ jobs.set(pending.id, { ...pending });
72
+ pending = null;
73
+ };
74
+ const handle = (message) => {
75
+ const offered = Buffer.from(typeof message.token === 'string' ? message.token : '');
76
+ const expected = Buffer.from(token);
77
+ if (offered.length !== expected.length || !timingSafeEqual(offered, expected))
78
+ throw new Error('로컬 세션 인증 실패');
79
+ if (message.action === 'status')
80
+ return { state: s.state, id, provider };
81
+ if (message.action === 'result') {
82
+ const job = jobs.get(message.id);
83
+ if (!job)
84
+ throw new Error('이 터미널에 해당 요청이 없습니다.');
85
+ return job;
86
+ }
87
+ if (message.action === 'hook') {
88
+ clearTimeout(stopTimer);
89
+ const { input, event } = message;
90
+ s.event = event;
91
+ s.model = input.modelName || input.model || s.model;
92
+ s.conversationId = input.conversationId || input.session_id || s.conversationId;
93
+ if (event !== 'Stop') {
94
+ s.state = 'running';
95
+ s.fullyIdle = false;
96
+ if (pending && event === 'UserPromptSubmit' && typeof input.prompt === 'string') {
97
+ pending.observed = input.prompt.includes(`[axMap request ${pending.id}]`);
98
+ }
99
+ save();
100
+ return {};
101
+ }
102
+ if (provider === 'agy' && input.fullyIdle !== true) {
103
+ save();
104
+ return {};
105
+ }
106
+ let answer = input.last_assistant_message;
107
+ if (provider === 'agy') {
108
+ const log = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain', s.conversationId || '', '.system_generated', 'logs', 'transcript.jsonl');
109
+ if (path.resolve(input.transcriptPath || '') !== log)
110
+ throw new Error('종료 훅의 대화 기록 경로가 다릅니다.');
111
+ const lines = fs.readFileSync(log, 'utf8').trimEnd().split('\n').filter(Boolean).map(line => JSON.parse(line));
112
+ const lastUser = lines.findLast(e => e.type === 'USER_INPUT');
113
+ if (pending)
114
+ pending.observed = String(lastUser?.content).includes(`[axMap request ${pending.id}]`);
115
+ answer = lines.findLast(e => e.type === 'PLANNER_RESPONSE' && e.source === 'MODEL' && e.status === 'DONE')?.content;
116
+ }
117
+ if (pending)
118
+ finish(pending.observed && !pending.interrupted && typeof answer === 'string' ? 'completed' : 'superseded', pending.observed && !pending.interrupted ? answer : undefined, pending.observed && !pending.interrupted ? undefined : '다른 입력이 섞였거나 이 요청의 응답을 확인하지 못했습니다.');
119
+ // Stop runs before the provider restores its input UI. Allow that handoff
120
+ // to finish before accepting another injected prompt.
121
+ s.state = 'settling';
122
+ s.fullyIdle = false;
123
+ save();
124
+ stopTimer = setTimeout(() => { if (!closed && s.state === 'settling') {
125
+ s.state = 'idle';
126
+ s.fullyIdle = true;
127
+ draft = false;
128
+ save();
129
+ } }, 500);
130
+ return {};
79
131
  }
80
- save(); return {}
81
- }
82
- if (provider === 'agy' && input.fullyIdle !== true) { save(); return {} }
83
- let answer = input.last_assistant_message
84
- if (provider === 'agy') {
85
- const log = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
86
- 'brain', s.conversationId || '', '.system_generated', 'logs', 'transcript.jsonl')
87
- if (path.resolve(input.transcriptPath || '') !== log) throw new Error('종료 훅의 대화 기록 경로가 다릅니다.')
88
- const lines = fs.readFileSync(log, 'utf8').trimEnd().split('\n').filter(Boolean).map(line => JSON.parse(line))
89
- const lastUser = lines.findLast(e => e.type === 'USER_INPUT')
90
- if (pending) pending.observed = String(lastUser?.content).includes(`[axMap request ${pending.id}]`)
91
- answer = lines.findLast(e => e.type === 'PLANNER_RESPONSE' && e.source === 'MODEL' && e.status === 'DONE')?.content
92
- }
93
- if (pending) finish(pending.observed && !pending.interrupted && typeof answer === 'string' ? 'completed' : 'superseded',
94
- pending.observed && !pending.interrupted ? answer : undefined,
95
- pending.observed && !pending.interrupted ? undefined : '다른 입력이 섞였거나 이 요청의 응답을 확인하지 못했습니다.')
96
- // Stop runs before the provider restores its input UI. Allow that handoff
97
- // to finish before accepting another injected prompt.
98
- s.state = 'settling'; s.fullyIdle = false; save()
99
- stopTimer = setTimeout(() => { if (!closed && s.state === 'settling') { s.state = 'idle'; s.fullyIdle = true; draft = false; save() } }, 500)
100
- return {}
132
+ if (message.action === 'submit') {
133
+ const request = message.request;
134
+ if (closed || s.state !== 'idle' || draft || pending)
135
+ throw new Error('상대가 대기 중이 아니거나 입력 중입니다. 작업을 전달하지 않았습니다.');
136
+ if (!validID(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768)
137
+ throw new Error('요청 형식이 올바르지 않습니다.');
138
+ const prompt = `[axMap request ${request.id}]\n요청자: ${String(request.from || 'axmap')}\n${request.prompt}`;
139
+ if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt))
140
+ throw new Error('터미널 제어 문자가 포함된 요청은 보낼 수 없습니다.');
141
+ pending = { id: request.id, to: id, state: 'submitted' };
142
+ jobs.set(request.id, pending);
143
+ s.state = 'running';
144
+ s.fullyIdle = false;
145
+ save();
146
+ term.write(`\x1b[200~${prompt}\x1b[201~`);
147
+ setTimeout(() => { if (!closed)
148
+ term.write('\r'); }, 100);
149
+ return { accepted: true, session: id };
150
+ }
151
+ throw new Error('지원하지 않는 로컬 세션 요청입니다.');
152
+ };
153
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
154
+ const server = net.createServer(socket => {
155
+ socket.setEncoding('utf8');
156
+ let buffer = '', handled = false;
157
+ socket.setTimeout(15000, () => socket.destroy());
158
+ socket.on('error', () => { });
159
+ socket.on('data', chunk => {
160
+ if (handled)
161
+ return;
162
+ buffer += chunk;
163
+ if (Buffer.byteLength(buffer) > 65536)
164
+ return socket.destroy();
165
+ const end = buffer.indexOf('\n');
166
+ if (end < 0)
167
+ return;
168
+ handled = true;
169
+ try {
170
+ socket.end(JSON.stringify({ result: handle(JSON.parse(buffer.slice(0, end))) }) + '\n');
171
+ }
172
+ catch (e) {
173
+ socket.end(JSON.stringify({ error: errorMessage(e) }) + '\n');
174
+ }
175
+ });
176
+ });
177
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve); });
178
+ if (process.platform !== 'win32')
179
+ fs.chmodSync(pipeFor(id), 0o600);
180
+ save();
181
+ try {
182
+ term = spawn(executable, args, { name: 'xterm-256color', cols, rows, cwd, env: { ...process.env,
183
+ AXMAP_TERMINAL_SESSION: id, AXMAP_TERMINAL_TOKEN: token,
184
+ AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id,
185
+ }, useConpty: true, useConptyDll: process.platform === 'win32' });
101
186
  }
102
- if (message.action === 'submit') {
103
- const request = message.request
104
- if (closed || s.state !== 'idle' || draft || pending) throw new Error('상대가 대기 중이 아니거나 입력 중입니다. 작업을 전달하지 않았습니다.')
105
- if (!validID(request?.id) || request.to !== id || typeof request.prompt !== 'string' || !request.prompt.trim() || Buffer.byteLength(request.prompt) > 32768) throw new Error('요청 형식이 올바르지 않습니다.')
106
- const prompt = `[axMap request ${request.id}]\n요청자: ${String(request.from || 'axmap')}\n${request.prompt}`
107
- if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt)) throw new Error('터미널 제어 문자가 포함된 요청은 보낼 수 없습니다.')
108
- pending = { id: request.id, to: id, state: 'submitted' }; jobs.set(request.id, pending)
109
- s.state = 'running'; s.fullyIdle = false; save()
110
- term.write(`\x1b[200~${prompt}\x1b[201~`)
111
- setTimeout(() => { if (!closed) term.write('\r') }, 100)
112
- return { accepted: true, session: id }
187
+ catch (e) {
188
+ server.close();
189
+ s.state = 'closed';
190
+ save();
191
+ throw e;
113
192
  }
114
- throw new Error('지원하지 않는 로컬 세션 요청입니다.')
115
- }
116
- fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 })
117
- const server = net.createServer(socket => {
118
- socket.setEncoding('utf8')
119
- let buffer = '', handled = false
120
- socket.setTimeout(15000, () => socket.destroy())
121
- socket.on('error', () => {})
122
- socket.on('data', chunk => {
123
- if (handled) return
124
- buffer += chunk
125
- if (Buffer.byteLength(buffer) > 65536) return socket.destroy()
126
- const end = buffer.indexOf('\n'); if (end < 0) return
127
- handled = true
128
- try { socket.end(JSON.stringify({ result: handle(JSON.parse(buffer.slice(0, end))) }) + '\n') }
129
- catch (e) { socket.end(JSON.stringify({ error: e.message }) + '\n') }
130
- })
131
- })
132
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(pipeFor(id), resolve) })
133
- if (process.platform !== 'win32') fs.chmodSync(pipeFor(id), 0o600)
134
- save()
135
- try {
136
- term = spawn(executable, args, { name: 'xterm-256color', cols, rows, cwd, env: { ...process.env,
137
- AXMAP_TERMINAL_SESSION: id, AXMAP_TERMINAL_TOKEN: token,
138
- AXMAP_AGENT: `${provider}-${id.slice(0, 8)}`, AXMAP_SESSION: id,
139
- }, useConpty: true, useConptyDll: process.platform === 'win32' })
140
- } catch (e) { server.close(); s.state = 'closed'; save(); throw e }
141
- term.onData(onData)
142
- const exited = new Promise(resolve => term.onExit(ev => {
143
- if (closed) return
144
- closed = true; clearTimeout(stopTimer)
145
- // ConPTY may keep its input handle alive after a normal child exit.
146
- if (process.platform === 'win32') { try { term.kill() } catch { /* already closed */ } }
147
- finish('unknown', undefined, '요청의 완료를 확인하기 전에 세션이 종료됐습니다.')
148
- s.state = 'closed'; s.fullyIdle = false; save(); server.close(); resolve(ev)
149
- }))
150
- return { id, pid: term.pid, exited,
151
- input(data) {
152
- const terminalReply = /^\x1b\[(?:\d+;\d+R|\?[^a-zA-Z]*[a-zA-Z])$/.test(String(data))
153
- if (terminalReply) { term.write(data); return }
154
- if (pending) pending.interrupted = true
155
- draft = !String(data).includes('\r')
156
- if (!draft) { s.state = 'running'; s.fullyIdle = false; save() }
157
- term.write(data)
158
- },
159
- resize: (c, r) => term.resize(c, r),
160
- close: () => term.kill(),
161
- }
193
+ term.onData(onData);
194
+ const exited = new Promise(resolve => term.onExit(ev => {
195
+ if (closed)
196
+ return;
197
+ closed = true;
198
+ clearTimeout(stopTimer);
199
+ // ConPTY may keep its input handle alive after a normal child exit.
200
+ if (process.platform === 'win32') {
201
+ try {
202
+ term.kill();
203
+ }
204
+ catch { /* already closed */ }
205
+ }
206
+ finish('unknown', undefined, '요청의 완료를 확인하기 전에 세션이 종료됐습니다.');
207
+ s.state = 'closed';
208
+ s.fullyIdle = false;
209
+ save();
210
+ server.close();
211
+ resolve(ev);
212
+ }));
213
+ return { id, pid: term.pid, exited,
214
+ input(data) {
215
+ const terminalReply = /^\x1b\[(?:\d+;\d+R|\?[^a-zA-Z]*[a-zA-Z])$/.test(String(data));
216
+ if (terminalReply) {
217
+ term.write(data);
218
+ return;
219
+ }
220
+ if (pending)
221
+ pending.interrupted = true;
222
+ draft = !String(data).includes('\r');
223
+ if (!draft) {
224
+ s.state = 'running';
225
+ s.fullyIdle = false;
226
+ save();
227
+ }
228
+ term.write(data);
229
+ },
230
+ resize: (c, r) => term.resize(c, r),
231
+ close: () => term.kill(), };
162
232
  }
163
-
164
233
  /** Copy only production dependencies from the npm package, not a developer's
165
234
  * entire node_modules. This also keeps the setup copy independent of npm cache. */
166
235
  export function installTerminalRuntime(sourceRoot, destRoot = ROOT) {
167
- const source = path.resolve(sourceRoot), dest = path.resolve(destRoot)
168
- const require = createRequire(path.join(source, 'package.json'))
169
- const copied = new Set()
170
- function copy(name) {
171
- if (copied.has(name)) return
172
- const descriptor = require.resolve(`${name}/package.json`)
173
- const pkg = readJSON(descriptor)
174
- const target = path.join(dest, 'node_modules', ...name.split('/'))
175
- if (path.resolve(path.dirname(descriptor)) !== path.resolve(target)) {
176
- fs.mkdirSync(path.dirname(target), { recursive: true })
177
- fs.cpSync(path.dirname(descriptor), target, { recursive: true, force: true })
178
- }
179
- copied.add(name)
180
- for (const dependency of Object.keys(pkg.dependencies || {})) copy(dependency)
181
- for (const dependency of Object.keys(pkg.optionalDependencies || {})) {
182
- try { require.resolve(`${dependency}/package.json`) }
183
- catch (e) { if (e.code === 'MODULE_NOT_FOUND') continue; throw e }
184
- copy(dependency)
236
+ const source = path.resolve(sourceRoot), dest = path.resolve(destRoot);
237
+ const require = createRequire(path.join(source, 'package.json'));
238
+ const copied = new Set();
239
+ function copy(name) {
240
+ if (copied.has(name))
241
+ return;
242
+ const descriptor = require.resolve(`${name}/package.json`);
243
+ const pkg = readJSON(descriptor);
244
+ const target = path.join(dest, 'node_modules', ...name.split('/'));
245
+ if (path.resolve(path.dirname(descriptor)) !== path.resolve(target)) {
246
+ fs.mkdirSync(path.dirname(target), { recursive: true });
247
+ fs.cpSync(path.dirname(descriptor), target, { recursive: true, force: true });
248
+ }
249
+ copied.add(name);
250
+ for (const dependency of Object.keys(pkg.dependencies || {}))
251
+ copy(dependency);
252
+ for (const dependency of Object.keys(pkg.optionalDependencies || {})) {
253
+ try {
254
+ require.resolve(`${dependency}/package.json`);
255
+ }
256
+ catch (e) {
257
+ if (errorCode(e) === 'MODULE_NOT_FOUND')
258
+ continue;
259
+ throw e;
260
+ }
261
+ copy(dependency);
262
+ }
185
263
  }
186
- }
187
- copy('node-pty')
188
- const installed = createRequire(path.join(dest, 'package.json'))('node-pty')
189
- if (typeof installed.spawn !== 'function') throw new Error('설치된 터미널 라이브러리를 불러오지 못했습니다.')
190
- return { packages: [...copied], destination: dest }
264
+ copy('node-pty');
265
+ const installed = createRequire(path.join(dest, 'package.json'))('node-pty');
266
+ if (typeof installed.spawn !== 'function')
267
+ throw new Error('설치된 터미널 라이브러리를 불러오지 못했습니다.');
268
+ return { packages: [...copied], destination: dest };
191
269
  }
192
-
193
270
  export function installTerminalHooks(home = os.homedir(), provider = 'agy') {
194
- if (!['agy', 'codex'].includes(provider)) throw new Error('지원하지 않는 CLI 훅입니다.')
195
- const target = provider === 'agy' ? path.join(home, '.gemini', 'config', 'hooks.json')
196
- : path.join(home, '.codex', 'hooks.json')
197
- const config = fs.existsSync(target) ? readJSON(target) : {}
198
- if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json을 읽지 못했습니다.')
199
- if (provider === 'codex' && config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks))) throw new Error('기존 Codex hooks 설정이 객체가 아닙니다.')
200
- const definition = {}
201
- const script = path.join(ROOT, 'tools', 'run.mjs')
202
- if ([process.execPath, script].some(p => /["\r\n%&|<>^]/.test(p))) throw new Error('훅 경로에 지원하지 않는 셸 문자가 있습니다.')
203
- for (const event of provider === 'agy' ? ['SessionStart', 'PreInvocation', 'Stop'] : ['SessionStart', 'UserPromptSubmit', 'Stop']) {
204
- let command = `"${process.execPath}" "${script}" hook ${event}`
205
- if (process.platform === 'win32') {
206
- const wrapper = path.join(home, '.axmap', 'bridge', `terminal-${provider}-${event}.cmd`)
207
- if (/[\s"%&|<>^]/.test(wrapper)) throw new Error('이 Windows 훅 경로에는 공백/셸 문자를 사용할 수 없습니다.')
208
- fs.mkdirSync(path.dirname(wrapper), { recursive: true })
209
- fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`)
210
- command = wrapper
211
- }
212
- if (provider === 'agy') definition[event] = [{ type: 'command', command, timeout: 10 }]
213
- else {
214
- config.hooks ||= {}
215
- const existing = config.hooks[event] || []
216
- if (!Array.isArray(existing)) throw new Error(`기존 Codex ${event} 설정이 배열이 아닙니다.`)
217
- config.hooks[event] = existing.filter(group => !(Array.isArray(group.hooks) && group.hooks.some(h => h.statusMessage === 'axMap terminal bridge')))
218
- config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: 10, statusMessage: 'axMap terminal bridge' }] })
271
+ if (!['agy', 'codex'].includes(provider))
272
+ throw new Error('지원하지 않는 CLI 훅입니다.');
273
+ const target = provider === 'agy' ? path.join(home, '.gemini', 'config', 'hooks.json')
274
+ : path.join(home, '.codex', 'hooks.json');
275
+ const config = fs.existsSync(target) ? readJSON(target) : {};
276
+ if (!config || typeof config !== 'object' || Array.isArray(config))
277
+ throw new Error('기존 hooks.json을 읽지 못했습니다.');
278
+ if (provider === 'codex' && config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))
279
+ throw new Error('기존 Codex hooks 설정이 객체가 아닙니다.');
280
+ const definition = {};
281
+ const script = path.join(ROOT, 'tools', 'run.mjs');
282
+ if ([process.execPath, script].some(p => /["\r\n%&|<>^]/.test(p)))
283
+ throw new Error('훅 경로에 지원하지 않는 셸 문자가 있습니다.');
284
+ for (const event of provider === 'agy' ? ['SessionStart', 'PreInvocation', 'Stop'] : ['SessionStart', 'UserPromptSubmit', 'Stop']) {
285
+ let command = `"${process.execPath}" "${script}" hook ${event}`;
286
+ if (process.platform === 'win32') {
287
+ const wrapper = path.join(home, '.axmap', 'bridge', `terminal-${provider}-${event}.cmd`);
288
+ if (/[\s"%&|<>^]/.test(wrapper))
289
+ throw new Error('이 Windows 훅 경로에는 공백/셸 문자를 사용할 수 없습니다.');
290
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true });
291
+ fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`);
292
+ command = wrapper;
293
+ }
294
+ if (provider === 'agy')
295
+ definition[event] = [{ type: 'command', command, timeout: 10 }];
296
+ else {
297
+ config.hooks ||= {};
298
+ const existing = config.hooks[event] || [];
299
+ if (!Array.isArray(existing))
300
+ throw new Error(`기존 Codex ${event} 설정이 배열이 아닙니다.`);
301
+ config.hooks[event] = existing.filter(group => !(Array.isArray(group.hooks) && group.hooks.some(h => h.statusMessage === 'axMap terminal bridge')));
302
+ config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: 10, statusMessage: 'axMap terminal bridge' }] });
303
+ }
219
304
  }
220
- }
221
- if (provider === 'agy') config['axmap-terminal'] = definition
222
- writeJSON(target, config)
223
- return target
305
+ if (provider === 'agy')
306
+ config['axmap-terminal'] = definition;
307
+ writeJSON(target, config);
308
+ return target;
224
309
  }