axmap-cli 1.4.0 → 1.6.0

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