axmap-cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.ko.md +20 -0
  2. package/README.md +20 -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 +128 -18
  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 +296 -219
  11. package/src/bridge.mts +281 -0
  12. package/src/external-console.mjs +140 -97
  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 +47 -41
  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 +293 -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 +9 -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/windows-console.cs +116 -0
package/src/bridge.mjs CHANGED
@@ -1,257 +1,334 @@
1
+ // Generated from src/bridge.mts. Edit the .mts source and run npm run build.
1
2
  /** Local sessions are attached by the provider's own hooks. Sending a message
2
3
  * 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
-
12
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
13
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
14
- const bridgeHome = () => path.join(process.env.AXMAP_STATE_DIR || path.join(os.homedir(), '.axmap'), 'bridge')
15
- const agyHome = () => process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli')
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { withRepoLock } from './repolock.mjs';
11
+ import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs';
12
+ import { errorCode, errorMessage } from './session-types.mjs';
13
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const bridgeHome = () => path.join(process.env.AXMAP_STATE_DIR || path.join(os.homedir(), '.axmap'), 'bridge');
16
+ const agyHome = () => process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli');
16
17
  const file = (kind, id) => {
17
- if (!UUID.test(id || '')) throw new Error('세션/요청 ID는 UUID여야 합니다.')
18
- return path.join(bridgeHome(), kind, `${id}.json`)
19
- }
20
- function readJSON(p) { return JSON.parse(fs.readFileSync(p, 'utf8')) }
18
+ if (!UUID.test(id || ''))
19
+ 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(p) { return JSON.parse(fs.readFileSync(p, 'utf8')); }
21
24
  function writeJSON(p, data) {
22
- fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 })
23
- const tmp = `${p}.${randomUUID()}.tmp`
24
- try {
25
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
26
- fs.renameSync(tmp, p)
27
- } finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp) }
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 {
33
+ fs.renameSync(tmp, p);
34
+ break;
35
+ }
36
+ catch (e) {
37
+ if (process.platform !== 'win32' || !['EPERM', 'EBUSY'].includes(errorCode(e) || '') || attempt >= 10)
38
+ throw e;
39
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
40
+ }
41
+ }
42
+ }
43
+ finally {
44
+ if (fs.existsSync(tmp))
45
+ fs.unlinkSync(tmp);
46
+ }
28
47
  }
29
48
  function repoKey(cwd) {
30
- const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
31
- cwd, encoding: 'utf8', windowsHide: true,
32
- })
33
- if (r.status !== 0) throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`)
34
- const key = fs.realpathSync(r.stdout.trim())
35
- return process.platform === 'win32' ? key.toLowerCase() : key
49
+ const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
50
+ cwd, encoding: 'utf8', windowsHide: true,
51
+ });
52
+ if (r.status !== 0)
53
+ throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`);
54
+ const key = fs.realpathSync(r.stdout.trim());
55
+ return process.platform === 'win32' ? key.toLowerCase() : key;
36
56
  }
37
57
  function addressError(address) {
38
- // Only a loopback address exported by a local provider hook is accepted.
39
- if (typeof address !== 'string' || !/^(?:https?:\/\/)?(?:127\.0\.0\.1|localhost|\[::1\]):[0-9]{1,5}$/.test(address)) {
40
- throw new Error('공식 훅에서 받은 로컬 ANTIGRAVITY_LS_ADDRESS가 필요합니다.')
41
- }
42
- const port = Number(address.slice(address.lastIndexOf(':') + 1))
43
- if (port < 1 || port > 65535) throw new Error('연결 포트가 잘못되었습니다.')
58
+ // Only a loopback address exported by a local provider hook is accepted.
59
+ if (typeof address !== 'string' || !/^(?:https?:\/\/)?(?:127\.0\.0\.1|localhost|\[::1\]):[0-9]{1,5}$/.test(address)) {
60
+ throw new Error('공식 훅에서 받은 로컬 ANTIGRAVITY_LS_ADDRESS가 필요합니다.');
61
+ }
62
+ const port = Number(address.slice(address.lastIndexOf(':') + 1));
63
+ if (port < 1 || port > 65535)
64
+ throw new Error('연결 포트가 잘못되었습니다.');
44
65
  }
45
66
  function runAPI(session, args) {
46
- addressError(session.address)
47
- const r = spawnSync(session.executable, ['agentapi', ...args], {
48
- cwd: session.workspaces[0],
49
- env: { ...process.env, ANTIGRAVITY_LS_ADDRESS: session.address },
50
- encoding: 'utf8', windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024,
51
- })
52
- if (r.error || r.status !== 0) throw new Error(`열린 세션 연결 실패: ${r.error?.message || r.stderr?.trim() || r.stdout?.trim() || r.status}`)
53
- const text = r.stdout.trim()
54
- if (text) {
55
- let data
56
- try { data = JSON.parse(text) } catch { return text }
57
- if (data?.error) throw new Error(`열린 세션 연결 실패: ${JSON.stringify(data.error)}`)
58
- return data
59
- }
60
- return null
67
+ addressError(session.address);
68
+ if (!session.executable)
69
+ throw new Error('등록된 agy 실행 파일이 없습니다.');
70
+ const r = spawnSync(session.executable, ['agentapi', ...args], {
71
+ cwd: session.workspaces[0],
72
+ env: { ...process.env, ANTIGRAVITY_LS_ADDRESS: session.address },
73
+ encoding: 'utf8', windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024,
74
+ });
75
+ if (r.error || r.status !== 0)
76
+ throw new Error(`열린 세션 연결 실패: ${r.error?.message || r.stderr?.trim() || r.stdout?.trim() || r.status}`);
77
+ const text = r.stdout.trim();
78
+ if (text) {
79
+ let data;
80
+ try {
81
+ data = JSON.parse(text);
82
+ }
83
+ catch {
84
+ return text;
85
+ }
86
+ if (data?.error)
87
+ throw new Error(`열린 세션 연결 실패: ${JSON.stringify(data.error)}`);
88
+ return data;
89
+ }
90
+ return null;
61
91
  }
62
92
  function transcript(session) {
63
- const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
64
- if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
65
- const lines = fs.readFileSync(expected, 'utf8').split('\n')
66
- // A trailing incomplete record may be in flight; malformed complete records fail.
67
- lines.pop()
68
- return lines.filter(s => s.trim()).map(s => JSON.parse(s))
93
+ const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl');
94
+ if (session.transcript !== expected)
95
+ throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.');
96
+ const lines = fs.readFileSync(expected, 'utf8').split('\n');
97
+ // A trailing incomplete record may be in flight; malformed complete records fail.
98
+ lines.pop();
99
+ return lines.filter(s => s.trim()).map(s => JSON.parse(s));
69
100
  }
70
101
  function publicSession(s) {
71
- return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
72
- transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
73
- registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
74
- role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval }
102
+ return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
103
+ transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
104
+ registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
105
+ role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval };
75
106
  }
76
107
  export function sessions(cwd) {
77
- const dir = path.join(bridgeHome(), 'sessions')
78
- if (!fs.existsSync(dir)) return []
79
- const repo = repoKey(cwd)
80
- return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
81
- .map(n => readJSON(path.join(dir, n))).filter(s => s.repos.includes(repo)).map(publicSession)
108
+ const dir = path.join(bridgeHome(), 'sessions');
109
+ if (!fs.existsSync(dir))
110
+ return [];
111
+ const repo = repoKey(cwd);
112
+ return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
113
+ .map(n => readJSON(path.join(dir, n))).filter(s => s.repos.includes(repo)).map(publicSession);
82
114
  }
83
115
  function sessionFor(id, cwd) {
84
- const s = readJSON(file('sessions', id))
85
- if (!s.repos.includes(repoKey(cwd))) throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.')
86
- if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport)) throw new Error('아직 지원하지 않는 수신 세션입니다.')
87
- return s
116
+ const s = readJSON(file('sessions', id));
117
+ if (!s.repos.includes(repoKey(cwd)))
118
+ throw new Error('이 세션은 현재 작업 저장소에 등록되지 않았습니다.');
119
+ if (s.provider !== 'agy' && !['pty', 'stdio'].includes(s.transport || ''))
120
+ throw new Error('아직 지원하지 않는 수신 세션입니다.');
121
+ return s;
88
122
  }
89
-
90
123
  /** Hook input and env come from Antigravity, not a remote message or repo file. */
91
124
  export function registerHook(input, event, env = process.env) {
92
- if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
93
- addressError(env.ANTIGRAVITY_LS_ADDRESS)
94
- const id = input.conversationId
95
- const target = file('sessions', id)
96
- const workspaces = input.workspacePaths
97
- if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) {
98
- throw new Error('훅의 작업 저장소 경로가 없습니다.')
99
- }
100
- const executable = env.AXMAP_AGY_EXECUTABLE
101
- if (!executable || !path.isAbsolute(executable) || !fs.existsSync(executable)) throw new Error('등록된 agy 실행 파일이 없습니다.')
102
- const expected = path.join(agyHome(), 'brain', id, '.system_generated', 'logs', 'transcript.jsonl')
103
- if (path.resolve(input.transcriptPath || '') !== expected) throw new Error('Antigravity CLI 대화 기록 경로가 아닙니다.')
104
- const s = { id, provider: 'agy', executable, address: env.ANTIGRAVITY_LS_ADDRESS,
105
- transcript: expected, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || null,
106
- registeredAt: new Date().toISOString(), event, fullyIdle: event === 'Stop' && input.fullyIdle === true }
107
- writeJSON(target, s)
108
- return publicSession(s)
125
+ if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event))
126
+ throw new Error('지원하지 않는 훅 이벤트입니다.');
127
+ addressError(env.ANTIGRAVITY_LS_ADDRESS);
128
+ const id = input.conversationId || '';
129
+ const target = file('sessions', id);
130
+ const workspaces = input.workspacePaths;
131
+ if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) {
132
+ throw new Error('훅의 작업 저장소 경로가 없습니다.');
133
+ }
134
+ const executable = env.AXMAP_AGY_EXECUTABLE;
135
+ if (!executable || !path.isAbsolute(executable) || !fs.existsSync(executable))
136
+ throw new Error('등록된 agy 실행 파일이 없습니다.');
137
+ const expected = path.join(agyHome(), 'brain', id, '.system_generated', 'logs', 'transcript.jsonl');
138
+ if (path.resolve(input.transcriptPath || '') !== expected)
139
+ throw new Error('Antigravity CLI 대화 기록 경로가 아닙니다.');
140
+ const s = { id, provider: 'agy', executable, address: env.ANTIGRAVITY_LS_ADDRESS,
141
+ transcript: expected, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || null,
142
+ registeredAt: new Date().toISOString(), event, fullyIdle: event === 'Stop' && input.fullyIdle === true };
143
+ writeJSON(target, s);
144
+ return publicSession(s);
109
145
  }
110
-
111
146
  export function requestResult(id, cwd) {
112
- const target = file('requests', id)
113
- const request = readJSON(target)
114
- if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
115
- if (!['submitted', 'unknown'].includes(request.state)) return request
116
- let s = sessionFor(request.to, cwd)
117
- if (['pty', 'stdio'].includes(s.transport)) {
118
- try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
119
- catch (e) { request.state = 'unknown'; request.error = e.message }
120
- writeJSON(target, request)
121
- return request
122
- }
123
- if (s.transport === 'console') {
124
- try { s.fullyIdle = externalStatus(s).state === 'idle' }
125
- catch (e) {
126
- request.state = 'unknown'; request.error = e.message
127
- writeJSON(target, request)
128
- return request
147
+ const target = file('requests', id);
148
+ const request = readJSON(target);
149
+ if (request.repo !== repoKey(cwd))
150
+ throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.');
151
+ if (!['submitted', 'unknown'].includes(request.state))
152
+ return request;
153
+ let s = sessionFor(request.to, cwd);
154
+ if (['pty', 'stdio'].includes(s.transport || '')) {
155
+ try {
156
+ Object.assign(request, terminalRPC(s.id, { action: 'result', id }));
157
+ }
158
+ catch (e) {
159
+ request.state = 'unknown';
160
+ request.error = errorMessage(e);
161
+ }
162
+ writeJSON(target, request);
163
+ return request;
164
+ }
165
+ if (s.transport === 'console') {
166
+ try {
167
+ s.fullyIdle = externalStatus(s).state === 'idle';
168
+ }
169
+ catch (e) {
170
+ request.state = 'unknown';
171
+ request.error = errorMessage(e);
172
+ writeJSON(target, request);
173
+ return request;
174
+ }
175
+ s = findConsoleTranscript(s, request);
176
+ if (!s.transcript)
177
+ return request;
129
178
  }
130
- s = findConsoleTranscript(s, request)
131
- if (!s.transcript) return request
132
- }
133
- const entries = transcript(s)
134
- const marker = `[axMap request ${id}]`
135
- const start = entries.findIndex((e, i) => i >= request.baseline && e.type === 'USER_INPUT' && String(e.content).includes(marker))
136
- if (start < 0) return request
137
- const tail = entries.slice(start + 1)
138
- if (tail.some(e => e.type === 'USER_INPUT')) {
139
- request.state = 'superseded'
140
- request.error = '다른 사용자 입력이 이어져 이 요청의 완료를 확정할 수 없습니다.'
141
- } else if (s.transport === 'console' ? s.fullyIdle : s.event === 'Stop' && s.fullyIdle && s.registeredAt > request.createdAt) {
142
- const answer = tail.filter(e => e.source === 'MODEL' && e.type === 'PLANNER_RESPONSE' && e.status === 'DONE').at(-1)
143
- if (answer) {
144
- request.state = 'completed'
145
- request.result = answer.content
146
- request.completedAt = s.transport === 'console' ? new Date().toISOString() : s.registeredAt
179
+ const entries = transcript(s);
180
+ const marker = `[axMap request ${id}]`;
181
+ const start = entries.findIndex((e, i) => i >= (request.baseline ?? 0) && e.type === 'USER_INPUT' && String(e.content).includes(marker));
182
+ if (start < 0)
183
+ return request;
184
+ const tail = entries.slice(start + 1);
185
+ if (tail.some(e => e.type === 'USER_INPUT')) {
186
+ request.state = 'superseded';
187
+ request.error = '다른 사용자 입력이 이어져 이 요청의 완료를 확정할 수 없습니다.';
188
+ }
189
+ else if (s.transport === 'console' ? s.fullyIdle : s.event === 'Stop' && s.fullyIdle && s.registeredAt > request.createdAt) {
190
+ const answer = tail.filter(e => e.source === 'MODEL' && e.type === 'PLANNER_RESPONSE' && e.status === 'DONE').at(-1);
191
+ if (answer) {
192
+ request.state = 'completed';
193
+ request.result = answer.content;
194
+ request.completedAt = s.transport === 'console' ? new Date().toISOString() : s.registeredAt;
195
+ }
196
+ }
197
+ if (['completed', 'superseded'].includes(request.state))
198
+ writeJSON(target, request);
199
+ return request;
200
+ }
201
+ /** Wait locally, without spending model turns on repeated status tool calls. */
202
+ export async function waitForRequest(id, cwd, timeoutMs = 30000) {
203
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 30000)
204
+ throw new Error('wait-ms는 0~30000 정수여야 합니다.');
205
+ const deadline = performance.now() + timeoutMs;
206
+ for (;;) {
207
+ const result = requestResult(id, cwd);
208
+ if (result.state !== 'submitted' || performance.now() >= deadline)
209
+ return result;
210
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - performance.now()))));
147
211
  }
148
- }
149
- if (['completed', 'superseded'].includes(request.state)) writeJSON(target, request)
150
- return request
151
212
  }
152
-
153
213
  export function sessionLeasePath(id) {
154
- if (!UUID.test(id || '')) throw new Error('세션 ID는 UUID여야 합니다.')
155
- return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
214
+ if (!UUID.test(id || ''))
215
+ throw new Error('세션 ID는 UUID여야 합니다.');
216
+ return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`);
156
217
  }
157
-
158
218
  export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }) {
159
- if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768) throw new Error('요청 본문은 1~32768바이트여야 합니다.')
160
- const s = sessionFor(to, cwd)
161
- const lease = sessionLeasePath(to)
162
- if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.')
163
- const dir = path.join(bridgeHome(), 'requests')
164
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
165
- return withRepoLock(path.join(dir, `${to}.lock`), () => {
166
- if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId) throw new Error('이 세션은 자동 대화가 사용 중입니다.')
167
- for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
168
- const previous = readJSON(path.join(dir, n))
169
- if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state)) continue
170
- const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd)
171
- if (['sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
172
- }
173
- // No fallback to `agy -p` or --conversation: both would start a separate process.
174
- if (s.transport === 'console') { validateConsolePrompt(prompt, from); checkConsole(s) }
175
- if (!['pty', 'stdio', 'console'].includes(s.transport)) {
176
- runAPI(s, ['get-conversation-metadata', to])
177
- if (!s.fullyIdle || s.event !== 'Stop') throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.')
178
- }
179
- const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
180
- createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport) ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
181
- const target = file('requests', request.id)
182
- writeJSON(target, request)
183
- try {
184
- request.receipt = s.transport === 'console' ? submitConsole(s, request) : ['pty', 'stdio'].includes(s.transport)
185
- ? terminalRPC(to, { action: 'submit', request })
186
- : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
187
- request.state = 'submitted'
188
- } catch (e) {
189
- // A timeout may occur after delivery. Never retry automatically.
190
- request.state = 'unknown'
191
- request.error = e.message
192
- }
193
- writeJSON(target, request)
194
- return request
195
- })
219
+ if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768)
220
+ throw new Error('요청 본문은 1~32768바이트여야 합니다.');
221
+ const s = sessionFor(to, cwd);
222
+ const lease = sessionLeasePath(to);
223
+ if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
224
+ throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.');
225
+ const dir = path.join(bridgeHome(), 'requests');
226
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
227
+ return withRepoLock(path.join(dir, `${to}.lock`), () => {
228
+ if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
229
+ throw new Error('이 세션은 자동 대화가 사용 중입니다.');
230
+ for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
231
+ const previous = readJSON(path.join(dir, n));
232
+ if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state))
233
+ continue;
234
+ const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd);
235
+ if (['sending', 'submitted', 'unknown'].includes(latest.state))
236
+ throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`);
237
+ }
238
+ // No fallback to `agy -p` or --conversation: both would start a separate process.
239
+ let consolePreflight;
240
+ if (s.transport === 'console') {
241
+ validateConsolePrompt(prompt, from);
242
+ consolePreflight = checkConsole(s);
243
+ }
244
+ if (!['pty', 'stdio', 'console'].includes(s.transport || '')) {
245
+ runAPI(s, ['get-conversation-metadata', to]);
246
+ if (!s.fullyIdle || s.event !== 'Stop')
247
+ throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.');
248
+ }
249
+ const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
250
+ createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' };
251
+ const target = file('requests', request.id);
252
+ writeJSON(target, request);
253
+ try {
254
+ request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
255
+ ? terminalRPC(to, { action: 'submit', request })
256
+ : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`]);
257
+ request.state = 'submitted';
258
+ }
259
+ catch (e) {
260
+ // A timeout may occur after delivery. Never retry automatically.
261
+ request.state = 'unknown';
262
+ request.error = errorMessage(e);
263
+ }
264
+ writeJSON(target, request);
265
+ return request;
266
+ });
196
267
  }
197
-
198
268
  function terminalRPC(id, message) {
199
- const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
200
- input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
201
- })
202
- if (r.error || r.status !== 0) throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.')
203
- return JSON.parse(r.stdout)
269
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
270
+ input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
271
+ });
272
+ if (r.error || r.status !== 0)
273
+ throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.');
274
+ return JSON.parse(r.stdout);
204
275
  }
205
-
206
- export { bridgeHome, repoKey, readJSON, writeJSON }
207
-
276
+ export { bridgeHome, repoKey, readJSON, writeJSON };
208
277
  export function uninstallHooks(home = os.homedir()) {
209
- const target = path.join(home, '.gemini', 'config', 'hooks.json')
210
- if (!fs.existsSync(target)) return { removed: false }
211
- const config = readJSON(target)
212
- if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.')
213
- if (!Object.hasOwn(config, 'axmap-bridge')) return { removed: false }
214
- delete config['axmap-bridge']
215
- writeJSON(target, config)
216
- // A running provider can retain the old hook command until exit. Make its
217
- // wrapper harmless immediately, without requiring another session restart.
218
- if (process.platform === 'win32') {
219
- for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
220
- const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`)
221
- if (fs.existsSync(wrapper)) fs.writeFileSync(wrapper, '@echo off\r\necho {}\r\n')
278
+ const target = path.join(home, '.gemini', 'config', 'hooks.json');
279
+ if (!fs.existsSync(target))
280
+ return { removed: false };
281
+ const config = readJSON(target);
282
+ if (!config || typeof config !== 'object' || Array.isArray(config))
283
+ throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.');
284
+ if (!Object.hasOwn(config, 'axmap-bridge'))
285
+ return { removed: false };
286
+ delete config['axmap-bridge'];
287
+ writeJSON(target, config);
288
+ // A running provider can retain the old hook command until exit. Make its
289
+ // wrapper harmless immediately, without requiring another session restart.
290
+ if (process.platform === 'win32') {
291
+ for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
292
+ const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`);
293
+ if (fs.existsSync(wrapper))
294
+ fs.writeFileSync(wrapper, '@echo off\r\necho {}\r\n');
295
+ }
222
296
  }
223
- }
224
- return { removed: true, hooks: target, note: '다른 훅은 유지합니다. 기존 CLI에 캐시된 훅은 정상 종료 시 해제됩니다.' }
297
+ return { removed: true, hooks: target, note: '다른 훅은 유지합니다. 기존 CLI에 캐시된 훅은 정상 종료 시 해제됩니다.' };
225
298
  }
226
-
227
299
  export function installHooks({ executable, home = os.homedir() }) {
228
- if (!path.isAbsolute(executable || '') || !fs.existsSync(executable)) throw new Error('agy 실행 파일의 절대 경로가 필요합니다.')
229
- const target = path.join(home, '.gemini', 'config', 'hooks.json')
230
- const original = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null
231
- const config = original === null ? {} : JSON.parse(original)
232
- if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.')
233
- const script = path.join(ROOT, 'tools', 'bridge.mjs')
234
- if ([process.execPath, script, executable].some(s => /["\r\n%&|<>^]/.test(s))) throw new Error('훅 명령 경로에 지원하지 않는 셸 문자가 있습니다.')
235
- const definition = {}
236
- for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
237
- let command = `"${process.execPath}" "${script}" hook --event ${event} --executable "${executable}"`
238
- if (process.platform === 'win32') {
239
- // Antigravity's command runner escapes a quoted executable incorrectly on
240
- // Windows. A bare .cmd entry point matches its documented hook examples;
241
- // quotes inside the wrapper are interpreted by cmd, not its command parser.
242
- const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`)
243
- if (/[\s"%&|<>^]/.test(wrapper)) throw new Error('Windows 훅 실행 경로에는 공백이나 셸 특수 문자가 없어야 합니다. 설정을 변경하지 않았습니다.')
244
- fs.mkdirSync(path.dirname(wrapper), { recursive: true })
245
- fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`, { mode: 0o600 })
246
- command = wrapper
300
+ if (!path.isAbsolute(executable || '') || !fs.existsSync(executable))
301
+ throw new Error('agy 실행 파일의 절대 경로가 필요합니다.');
302
+ const target = path.join(home, '.gemini', 'config', 'hooks.json');
303
+ const original = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
304
+ const config = original === null ? {} : JSON.parse(original);
305
+ if (!config || typeof config !== 'object' || Array.isArray(config))
306
+ throw new Error('기존 hooks.json이 객체가 아닙니다. 변경하지 않았습니다.');
307
+ const script = path.join(ROOT, 'tools', 'bridge.mjs');
308
+ if ([process.execPath, script, executable].some(s => /["\r\n%&|<>^]/.test(s)))
309
+ throw new Error('훅 명령 경로에 지원하지 않는 셸 문자가 있습니다.');
310
+ const definition = {};
311
+ for (const event of ['SessionStart', 'PreInvocation', 'Stop']) {
312
+ let command = `"${process.execPath}" "${script}" hook --event ${event} --executable "${executable}"`;
313
+ if (process.platform === 'win32') {
314
+ // Antigravity's command runner escapes a quoted executable incorrectly on
315
+ // Windows. A bare .cmd entry point matches its documented hook examples;
316
+ // quotes inside the wrapper are interpreted by cmd, not its command parser.
317
+ const wrapper = path.join(home, '.axmap', 'bridge', `agy-${event}.cmd`);
318
+ if (/[\s"%&|<>^]/.test(wrapper))
319
+ throw new Error('Windows 훅 실행 경로에는 공백이나 셸 특수 문자가 없어야 합니다. 설정을 변경하지 않았습니다.');
320
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true });
321
+ fs.writeFileSync(wrapper, `@echo off\r\n${command}\r\n`, { mode: 0o600 });
322
+ command = wrapper;
323
+ }
324
+ definition[event] = [{ type: 'command', command, timeout: 10 }];
247
325
  }
248
- definition[event] = [{ type: 'command', command, timeout: 10 }]
249
- }
250
- config['axmap-bridge'] = definition
251
- fs.mkdirSync(path.dirname(target), { recursive: true })
252
- const backup = `${target}.axmap-${randomUUID()}.bak`
253
- if (original !== null) fs.writeFileSync(backup, original, { flag: 'wx', mode: 0o600 })
254
- writeJSON(target, config)
255
- return { hooks: target, backup: original === null ? null : backup,
256
- note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' }
326
+ config['axmap-bridge'] = definition;
327
+ fs.mkdirSync(path.dirname(target), { recursive: true });
328
+ const backup = `${target}.axmap-${randomUUID()}.bak`;
329
+ if (original !== null)
330
+ fs.writeFileSync(backup, original, { flag: 'wx', mode: 0o600 });
331
+ writeJSON(target, config);
332
+ return { hooks: target, backup: original === null ? null : backup,
333
+ note: '기존 실행 중인 CLI가 새 훅을 읽지 않으면 같은 대화를 재개해 한 번 다시 열어야 합니다. Remote Control은 필요 없습니다.' };
257
334
  }