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
package/mcp/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  있게 하는 규격**)로 axMap 을 AI CLI 에 붙인다.
5
5
 
6
6
  - 붙이면 AI 도구가 **"나는 이 파일들을 건드리겠다"를 선언**하게 된다. 겹치면 그 자리에서 거부당한다
7
- - 코드 충돌이 나기 전에 **의도 충돌**을 먼저 터뜨리는 것이고, 뷰어는 그 선언을 읽어 화면을 그린다
7
+ - 코드 충돌이 나기 전에 **의도 충돌**을 먼저 터뜨리는 것이고, 다른 에이전트는 그 선언을 읽어 작업 범위를 조정한다
8
8
  - **한 번 설치하면 아무 저장소에서나 붙는다.** 작업할 저장소에는 파일을 하나도 남기지 않는다
9
9
 
10
10
  ---
@@ -39,6 +39,10 @@ https://github.com/rl2aderjoon/axmap/blob/main/mcp/SETUP-FOR-AI.md
39
39
  | `ax_release(paths?)` | 끝나면 즉시. 생략하면 전부 반납 |
40
40
  | `ax_renew()` | 길어지면 시간 연장 |
41
41
  | `ax_inbox(id?)` | 다른 에이전트가 나에게 보낸 쪽지를 읽는다 |
42
+ | `ax_sessions()` | 같은 repo의 연결된 세션·역할·상태·사용량 |
43
+ | `ax_dispatch(to, prompt)` / `ax_result(id)` | 연결된 세션에 단일 작업 전달·결과 조회 |
44
+ | `ax_chat(left, right, prompt, maxTurns?, maxTokens?)` | 관리형 세션 두 개의 제한된 자동 대화를 백그라운드 접수 |
45
+ | `ax_dialogue(id)` / `ax_chat_stop(id)` | 대화·토큰 조회 / 추가 중계 중단 |
42
46
  | `ax_send(to?, subject, body)` | 다른 에이전트에게 쪽지를 보낸다 |
43
47
  | `ax_init(remote?)` | 명시적 준비용. 조회·선점은 장부를 자동 준비하므로 먼저 부를 필요가 없다 |
44
48
 
@@ -133,7 +137,7 @@ CLI 를 부를 때마다 넘긴다. 앞 두 칸은 CLI 와 순서가 같아서,
133
137
 
134
138
  ### 🔴 `AXMAP_REPO` 는 비운다 — 대상은 부를 때마다 다시 정해진다
135
139
 
136
- 서버는 도구를 부를 때마다 대상 저장소를 다시 정한다. 순서는 `AXMAP_REPO` → 도구의 절대 경로 `cwd` → 클라이언트 작업 폴더(roots) → 시작 폴더의 Git 루트다. 앱의 전역 `current.json`은 사용하지 않는다. 근거는 [DECISIONS.md의 D22](../docs/DECISIONS.md).
140
+ 서버는 도구를 부를 때마다 대상 저장소를 다시 정한다. 순서는 `AXMAP_REPO` → 도구의 절대 경로 `cwd` → 클라이언트 작업 폴더(roots) → 시작 폴더의 Git 루트다. 근거는 [DECISIONS.md의 D22](../docs/DECISIONS.md).
137
141
 
138
142
  장부가 없는 repo도 첫 조회·선점 때 자동으로 준비한다. 기존 원격 장부가 있으면 이어 사용하고, 원격 접근 실패는 빈 장부로 바꾸지 않는다. roots가 여러 repo를 가리켜 대상을 고를 수 없으면 도구의 `cwd`를 지정한다. roots 미지원 클라이언트에서 실행 중 폴더를 바꿀 때도 `cwd`를 전달한다. 서로 다른 MCP 세션의 타겟은 독립적이다.
139
143
 
@@ -193,10 +197,7 @@ clone 하면 장부에는 `claude` 라는 **한 명**만 존재하고, 겹침
193
197
  관문 1(push CAS — **"내가 읽은 뒤로 바뀐 게 없을 때만 쓴다"**. git push 가 원래
194
198
  이렇게 동작한다)과 관문 2(겹침 판정)가 CLI 와 완전히 동일하게 적용된다.
195
199
 
196
- 전송은 stdio + 줄바꿈 구분 JSON-RPC 2.0 이다. SDK 의존성 없이 직접 구현했다 —
197
- 폐쇄망에서 npm 설치 없이 돌아야 한다는 제약이 여기에도 적용된다.
198
- 설치기가 `bin` `src` `mcp` `tools` `app` `package.json` `LICENSE` 만 복사하고
199
- `desktop/`(Electron 껍데기)을 빼는 것도 같은 이유다. 받아야 할 것이 없다.
200
+ 전송은 stdio + 줄바꿈 구분 JSON-RPC 2.0이다. MCP 서버는 SDK 없이 구현했다. 설치기는 `bin`, `src`, `mcp`, `tools`, `package.json`, `LICENSE`와 터미널 연결 의존성을 준비한다.
200
201
 
201
202
  ---
202
203
 
@@ -220,5 +221,4 @@ clone 하면 장부에는 `claude` 라는 **한 명**만 존재하고, 겹침
220
221
 
221
222
  **`doctor` 는 전역 등록을 검사하지 않는다.** 저장소의 `.mcp.json` 만 본다. 그래서
222
223
  전역으로 설치한 PC 에서 `MCP 설정` 줄이 `~~` 로 뜨는 것은 정상이다 — 무게가 전역
223
- 등록으로 옮겨간 지금 그 줄은 거짓 보고에 가깝다. 배경은
224
- [docs/HANDOVER.md](../../docs/HANDOVER.md) 4.4 절과 4.5 절에 있다.
224
+ 등록으로 옮겨간 지금 그 줄은 거짓 보고에 가깝다. 설치 후에는 실제 전역 등록 경로도 함께 확인한다.
package/mcp/install.ps1 CHANGED
@@ -142,7 +142,7 @@ if (-not (Test-Path -LiteralPath (Join-Path $Source 'bin\axmap.mjs'))) {
142
142
  $inner = Join-Path $Source 'axmap'
143
143
  if (Test-Path -LiteralPath (Join-Path $inner 'bin\axmap.mjs')) { $Source = (Resolve-Path -LiteralPath $inner).Path }
144
144
  }
145
- foreach ($need in @('bin\axmap.mjs', 'mcp\server.mjs', 'src\protocol.mjs', 'tools\mcp-register.mjs', 'app\lib\agentcli.mjs', 'package.json')) {
145
+ foreach ($need in @('bin\axmap.mjs', 'mcp\server.mjs', 'src\protocol.mjs', 'tools\mcp-register.mjs', 'src\agentcli.mjs', 'package.json')) {
146
146
  if (-not (Test-Path -LiteralPath (Join-Path $Source $need))) {
147
147
  Fail @"
148
148
  axMap 원본이 온전하지 않습니다. 없는 파일: $need
@@ -198,8 +198,8 @@ Say ''
198
198
  Say "복사합니다 -> $Dest"
199
199
  New-Item -ItemType Directory -Force -Path $Dest | Out-Null
200
200
 
201
- # desktop\ 은 뺀다. Electron 껍데기라 MCP 에 필요 없고 node_modules 가 수백 MB 다.
202
- foreach ($item in @('bin', 'src', 'mcp', 'tools', 'app', 'package.json', 'LICENSE')) {
201
+ # Copy the CLI, MCP and their shared runtime sources.
202
+ foreach ($item in @('bin', 'src', 'mcp', 'tools', 'package.json', 'LICENSE')) {
203
203
  $from = Join-Path $Source $item
204
204
  $to = Join-Path $Dest $item
205
205
  if (-not (Test-Path -LiteralPath $from)) { Hm "$item 없음 — 건너뜁니다"; continue }
@@ -219,6 +219,9 @@ foreach ($item in @('bin', 'src', 'mcp', 'tools', 'app', 'package.json', 'LICENS
219
219
  }
220
220
  }
221
221
 
222
+ & node (Join-Path $Dest 'tools\run.mjs') install-runtime $Source
223
+ if ($LASTEXITCODE -ne 0) { Fail '터미널 런타임 설치에 실패했습니다.' }
224
+
222
225
  $server = Join-Path $Dest 'mcp\server.mjs'
223
226
  if (-not (Test-Path -LiteralPath $server)) { Fail "복사가 끝났는데 서버가 없습니다: $server" }
224
227
 
package/mcp/install.sh CHANGED
@@ -144,7 +144,7 @@ SOURCE=$(cd "$SOURCE" && pwd)
144
144
  if [ ! -f "$SOURCE/bin/axmap.mjs" ] && [ -f "$SOURCE/axmap/bin/axmap.mjs" ]; then
145
145
  SOURCE=$(cd "$SOURCE/axmap" && pwd)
146
146
  fi
147
- for need in bin/axmap.mjs mcp/server.mjs src/protocol.mjs tools/mcp-register.mjs app/lib/agentcli.mjs package.json; do
147
+ for need in bin/axmap.mjs mcp/server.mjs src/protocol.mjs tools/mcp-register.mjs src/agentcli.mjs package.json; do
148
148
  if [ ! -e "$SOURCE/$need" ]; then
149
149
  fail "axMap 원본이 온전하지 않습니다. 없는 파일: $need
150
150
  본 곳: $SOURCE
@@ -198,8 +198,8 @@ say ''
198
198
  say "복사합니다 -> $DEST"
199
199
  mkdir -p "$DEST" || fail "설치 폴더를 만들지 못했습니다: $DEST"
200
200
 
201
- # desktop/ 은 뺀다. Electron 껍데기라 MCP 에 필요 없고 node_modules 가 수백 MB 다.
202
- for item in bin src mcp tools app package.json LICENSE; do
201
+ # Copy the CLI, MCP and their shared runtime sources.
202
+ for item in bin src mcp tools package.json LICENSE; do
203
203
  from="$SOURCE/$item"
204
204
  to="$DEST/$item"
205
205
  if [ ! -e "$from" ]; then hm "$item 없음 — 건너뜁니다"; continue; fi
@@ -218,6 +218,8 @@ for item in bin src mcp tools app package.json LICENSE; do
218
218
  fi
219
219
  done
220
220
 
221
+ node "$DEST/tools/run.mjs" install-runtime "$SOURCE" || fail "터미널 런타임 설치에 실패했습니다."
222
+
221
223
  SERVER="$DEST/mcp/server.mjs"
222
224
  [ -f "$SERVER" ] || fail "복사가 끝났는데 서버가 없습니다: $SERVER"
223
225
 
package/mcp/server.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * axMap MCP 서버.
4
4
  *
5
5
  * Claude Code 같은 AI 도구가 "나는 이 파일들을 건드리겠다"를 선언하게 만든다.
6
- * 그래야 뷰어가 지금 무슨 일이 일어나는지 보여줄 수 있다.
6
+ * 다른 에이전트가 작업 범위와 목적을 확인할 수 있다.
7
7
  *
8
8
  * 설정 (.mcp.json 또는 도구별 MCP 설정):
9
9
  * {
@@ -212,6 +212,15 @@ const TTL = process.env.AXMAP_TTL ?? '45m'
212
212
  */
213
213
  const SESSION = process.env.AXMAP_SESSION || process.env.CLAUDE_CODE_SESSION_ID || `mcp-${process.pid}`
214
214
 
215
+ const BRIDGE_TOOLS = new Set(['ax_sessions', 'ax_dispatch', 'ax_result', 'ax_chat', 'ax_dialogue', 'ax_chat_stop'])
216
+ function bridge(args, input) {
217
+ const r = spawnSync(process.execPath, [path.join(SELF, 'tools', 'bridge.mjs'), ...args], {
218
+ cwd: REPO, input, encoding: 'utf8', windowsHide: true,
219
+ env: { ...process.env, AXMAP_AGENT: AGENT }, timeout: 45000,
220
+ })
221
+ return { ok: r.status === 0, text: (r.stdout || '') + (r.stderr || '') + (r.error ? r.error.message : '') }
222
+ }
223
+
215
224
  /**
216
225
  * 쪽지함 CLI 를 부른다. `cli` 와 달리 stdin 으로 본문을 넘긴다.
217
226
  *
@@ -376,6 +385,48 @@ function cli(args) {
376
385
  // ---------------------------------------------------------------------------
377
386
 
378
387
  const TOOLS = [
388
+ {
389
+ name: 'ax_sessions',
390
+ description: '현재 repo에 연결된 로컬 AI 세션, 역할, 상태와 측정 토큰 사용량을 확인한다. stdio는 사용자가 bridge serve로 시작한 관리형 세션이다. 등록만으로 연결 가능성이나 실행 성공을 보장하지 않는다.',
391
+ inputSchema: { type: 'object', properties: {} },
392
+ },
393
+ {
394
+ name: 'ax_dispatch',
395
+ description: '사용자가 허용한 작업을 연결된 상대 세션에 전달한다. 기존 맥락을 유지하며 새 모델 프로세스를 만들지 않는다. submitted는 완료가 아니다. unknown이면 자동 재시도하지 말고 ax_result로 확인한다. Codex/agy 관리형 stdio, 별도로 실행한 Windows agy console, 실험용 PTY/agentapi 연결을 지원한다.',
396
+ inputSchema: { type: 'object', properties: {
397
+ to: { type: 'string', description: 'ax_sessions에서 확인한 상대 세션 UUID' },
398
+ prompt: { type: 'string', description: '상대가 실행할 작업 요청' },
399
+ }, required: ['to', 'prompt'] },
400
+ },
401
+ {
402
+ name: 'ax_result',
403
+ description: '작업 요청의 제출/완료/불명 상태, 결과와 측정 사용량을 확인한다. 관리형 세션은 구조화된 완료 이벤트, PTY는 종료 훅, 독립 Windows console은 요청 UUID가 있는 대화 기록과 현재 빈 입력창의 대기 상태로 판정한다.',
404
+ inputSchema: { type: 'object', properties: {
405
+ id: { type: 'string', description: 'ax_dispatch가 반환한 요청 UUID' },
406
+ }, required: ['id'] },
407
+ },
408
+ {
409
+ name: 'ax_chat',
410
+ description: '사용자가 허용한 범위에서 대기 중인 관리형 세션 두 개의 자동 대화를 시작한다. 기본 최대 4회 실행, 측정 처리 토큰 50000, 메시지 2000자. 대기 중 모델 호출 없음. 한 번의 실행은 토큰 한도를 넘을 수 있으며 다음 실행 전에 검사한다. 접수는 완료가 아니다. ax_dialogue로 조회한다. 사용자 승인이나 연결 실패를 자동 우회하지 않는다.',
411
+ inputSchema: { type: 'object', properties: {
412
+ left: { type: 'string', description: '먼저 요청받을 stdio 세션 UUID' },
413
+ right: { type: 'string', description: '다른 stdio 세션 UUID' },
414
+ prompt: { type: 'string', description: '사용자가 부여한 작업과 종료 기준. 기본 2000자 이하' },
415
+ maxTurns: { type: 'integer', minimum: 1, maximum: 20 },
416
+ maxTokens: { type: 'integer', minimum: 1 },
417
+ maxMessageChars: { type: 'integer', minimum: 1, maximum: 8000 },
418
+ }, required: ['left', 'right', 'prompt'] },
419
+ },
420
+ {
421
+ name: 'ax_dialogue',
422
+ description: '자동 대화의 상태, 요청별 사용량과 누적 사용량을 확인한다. usage_unavailable는 측정 불가로 중단, token_limit는 다음 실행 시작을 막은 상태다.',
423
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
424
+ },
425
+ {
426
+ name: 'ax_chat_stop',
427
+ description: '자동 대화의 추가 중계를 중단한다. 이미 전달된 모델 요청은 계속 실행될 수 있다. 상대 CLI를 강제 종료하지 않는다.',
428
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
429
+ },
379
430
  {
380
431
  name: 'ax_brief',
381
432
  description:
@@ -493,7 +544,7 @@ for (const tool of TOOLS) {
493
544
  const readFrom = (base, rel) => {
494
545
  try { return fs.readFileSync(path.join(base, rel), 'utf8') } catch { return null }
495
546
  }
496
- /** axMap 자신의 파일. 코드 지도(`MAP`)처럼 **이 도구에 관한 것**만 여기서 읽는다. */
547
+ /** axMap 자신의 파일. 구성 목록처럼 **이 도구에 관한 것**만 여기서 읽는다. */
497
548
  const readSelf = (rel) => readFrom(SELF, rel)
498
549
  /** 대상 저장소의 파일. 브리핑의 내용은 원칙적으로 전부 이쪽이어야 한다. */
499
550
  const readRepo = (rel) => readFrom(REPO, rel)
@@ -548,23 +599,19 @@ function gist(rel) {
548
599
  * 경로만 여기 적고 설명은 파일에서 가져온다.
549
600
  * 경로는 거의 안 바뀌고 설명은 자주 바뀌므로, 낡을 수 있는 쪽을 자동화한다.
550
601
  */
551
- const MAP = [
552
- ['판정 (순수)', 'src/protocol.mjs'],
553
- ['불변식 검사기 (공유)', 'src/invariants.mjs'],
554
- ['CLI · git · 훅', 'bin/axmap.mjs'],
555
- ['MCP 서버 (이 파일)', 'mcp/server.mjs'],
556
- ['뷰어 서버 · API', 'app/server.mjs'],
557
- ['대화 세션 = CLI 프로세스', 'app/lib/session.mjs'],
558
- ['세션마다의 이름 (슬롯)', 'app/lib/slots.mjs'],
559
- ['AI CLI 감지 · 로그인', 'app/lib/agentcli.mjs'],
560
- ['사다리 배치', 'app/lib/ladder.mjs'],
561
- ['화면 부트스트랩', 'app/web/shell.js'],
562
- ['가운데 칸 그리기', 'app/web/stage.js'],
563
- ['화면에 나가는 말 (어휘)', 'app/web/words.js'],
564
- ['데스크톱 셸 (Electron)', 'desktop/main.mjs'],
602
+ const COMPONENTS = [
603
+ ['선점 판정', 'src/protocol.mjs'],
604
+ ['불변식 검사', 'src/invariants.mjs'],
605
+ ['CLI · Git · 훅', 'bin/axmap.mjs'],
606
+ ['MCP 서버', 'mcp/server.mjs'],
607
+ ['AI CLI 탐지', 'src/agentcli.mjs'],
608
+ ['로컬 작업 요청', 'src/bridge.mjs'],
609
+ ['터미널 세션 연결', 'src/terminal-bridge.mjs'],
610
+ ['설치·설정', 'tools/setup.mjs'],
611
+ ['쪽지함', 'tools/bus.mjs'],
565
612
  ]
566
613
 
567
- /** axMap 자신을 겨눴을 때. 코드 지도가 있는 유일한 경우다. */
614
+ /** axMap 자신을 겨눴을 때만 자체 구성 목록을 보여준다. */
568
615
  function briefSelf() {
569
616
  const out = []
570
617
  out.push('# axMap — 새로 들어온 에이전트를 위한 브리핑')
@@ -589,12 +636,11 @@ function briefSelf() {
589
636
  // 번호를 여기 적지 않는다. 늘릴 때마다 이 줄이 낡는다 — 실제로 I8 을 넣고 놓쳤다.
590
637
  out.push('| 지켜야 할 성질 | `docs/INVARIANTS.md` |')
591
638
  out.push('| 왜 파서인가 | `docs/EXPERIMENT.md` |')
592
- out.push('| 무엇을 재는가 | `docs/BENCH.md` — M3 도달 호출 번호가 주 지표다 |')
593
639
  out.push('')
594
640
 
595
- out.push('## 코드 지도')
641
+ out.push('## 주요 구성')
596
642
  out.push('')
597
- for (const [role, rel] of MAP) out.push(`- **${role}** — \`${rel}\`\n ${gist(rel)}`)
643
+ for (const [role, rel] of COMPONENTS) out.push(`- **${role}** — \`${rel}\`\n ${gist(rel)}`)
598
644
  out.push('')
599
645
  return out
600
646
  }
@@ -605,7 +651,7 @@ function briefSelf() {
605
651
  * 🔴 여기서 **axMap 의 문서를 대신 보여주지 않는다.**
606
652
  *
607
653
  * 예전에는 브리핑이 통째로 `readSelf` 였다. 그래서 남의 프로젝트에 붙여도
608
- * `src/protocol.mjs` · `app/web/graph.js` 같은 **axMap 의 코드 지도**가 나왔고,
654
+ * `src/protocol.mjs` · `src/bridge.mjs` 같은 **axMap 의 주요 구성**이 나왔고,
609
655
  * 받는 쪽에는 자기 저장소를 설명하는 것처럼 보인다.
610
656
  *
611
657
  * 이 저장소는 "낡은 지도는 없는 지도보다 나쁘다" 고 적어 두었는데,
@@ -613,7 +659,7 @@ function briefSelf() {
613
659
  * 낡은 지도는 언젠가 맞았기라도 하다.
614
660
  *
615
661
  * 그래서 대상에 문서가 없으면 **없다고 말하고 끝낸다.** 채워 넣지 않는다.
616
- * 코드 지도는 아예 만들지 않는다 — 지어낸 지도가 정확히 그 실패이기 때문이다.
662
+ * 구성 목록을 추측해서 만들지 않는다.
617
663
  */
618
664
  function briefForeign() {
619
665
  const out = []
@@ -665,8 +711,8 @@ function brief() {
665
711
  /**
666
712
  * 🔴 검증 목록을 `package.json` 에서 뽑지 않는다.
667
713
  *
668
- * scripts 에는 검사(`test` · `smoke` · `demo:*`)와 실행(`app` · `desktop`)이
669
- * 섞여 있다. 전부 나열하면 새 에이전트가 앱을 띄워 놓고 "검증했다" 고 여긴다.
714
+ * scripts 에는 검사와 설치 같은 실행 명령이 섞일 수 있다.
715
+ * 전부 나열하면 새 에이전트가 실행만 해 놓고 "검증했다" 고 여긴다.
670
716
  * **무엇이 통과해야 하는지를 정한 곳은 규칙 문서의 「검증」 절**이므로
671
717
  * 거기서 읽어 온다. 규격이 진실이고 목록은 그 사본이다.
672
718
  *
@@ -698,12 +744,6 @@ function brief() {
698
744
  out.push('')
699
745
  for (const c of cmds) out.push(`- \`${c}\``)
700
746
  out.push('')
701
- // axMap 자신에게만 해당하는 주의다. 남의 저장소에 붙여 말하지 않는다.
702
- if (!isForeign()) {
703
- out.push('🔴 `app/web/` 를 건드렸으면 `npm run smoke` 는 **선택이 아니다.**')
704
- out.push('`npm test` 는 브라우저 코드를 실행하지 않으므로 화면이 죽어도 초록이다.')
705
- out.push('')
706
- }
707
747
  }
708
748
 
709
749
  out.push('장부는 선점·조회 시 자동으로 준비한다. 별도 `ax_init` 호출은 필요 없다.')
@@ -807,6 +847,14 @@ function dispatch(name, args = {}) {
807
847
  const paths = Array.isArray(args.paths) ? args.paths.map(String) : []
808
848
 
809
849
  switch (name) {
850
+ case 'ax_sessions': return bridge(['sessions'])
851
+ case 'ax_dispatch': return bridge(['send', '--to', args.to], args.prompt)
852
+ case 'ax_result': return bridge(['result', args.id])
853
+ case 'ax_chat': return bridge(['chat', '--background', '--left', args.left, '--right', args.right,
854
+ ...['maxTurns', 'maxTokens', 'maxMessageChars'].flatMap(key => args[key] === undefined ? [] :
855
+ [`--${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}`, String(args[key])])], args.prompt)
856
+ case 'ax_dialogue': return bridge(['dialogue', args.id])
857
+ case 'ax_chat_stop': return bridge(['stop', args.id])
810
858
  case 'ax_brief':
811
859
  return { ok: true, text: brief() }
812
860
  case 'ax_init': {
@@ -1008,8 +1056,9 @@ async function handle(req) {
1008
1056
  do { task = rootsTask; await task } while (task !== rootsTask)
1009
1057
  }
1010
1058
  const r = callTool(params?.name, params?.arguments ?? {})
1011
- const warning = r.ok ? syncConfiguredProfile(REPO, BUS, { ...process.env, AXMAP_AGENT: AGENT }) : ''
1012
- return { content: [{ type: 'text', text: r.text + (warning ? '\n' + warning : '') + (r.ok ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
1059
+ const localBridge = BRIDGE_TOOLS.has(params?.name)
1060
+ const warning = r.ok && !localBridge ? syncConfiguredProfile(REPO, BUS, { ...process.env, AXMAP_AGENT: AGENT }) : ''
1061
+ return { content: [{ type: 'text', text: r.text + (warning ? '\n' + warning : '') + (r.ok && !localBridge ? unreadBanner(params?.name, r.text) : '') }], isError: !r.ok }
1013
1062
  }
1014
1063
  if (method === 'ping') return {}
1015
1064
  return null // 알림이거나 지원하지 않는 메서드
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
3
3
  "name": "axmap-cli",
4
- "version": "1.3.3",
4
+ "version": "1.5.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
10
10
  "type": "module",
11
- "description": "axMap — AI Experience 의 지도이자 개발 환경의 AI Transformation. AI 에이전트들이 서로의 작업 영역을 침범하지 않도록, 코드 충돌 대신 의도 충돌을 먼저 터뜨리는 git 위의 선점 프로토콜",
11
+ "description": "Coordinate AI agents in Git with path claims, messaging, commit verification and MCP.",
12
12
  "license": "MIT",
13
13
  "repository": {
14
14
  "type": "git",
@@ -26,13 +26,12 @@
26
26
  "bin": {
27
27
  "axmap": "bin/axmap.mjs"
28
28
  },
29
- "//files": "npm 꾸러미에 들어갈 것. 이 목록이 없으면 벤치 산출물·데모·테스트까지 실려 꾸러미가 수십 MB 가 된다. 여기서 빼는 것들(test·demo·corpus·desktop·docs)은 설치한 사람에게 필요 없다 — 그건 이 저장소를 clone 한 사람의 것이다.",
29
+ "//files": "Published CLI, MCP, collaboration tools and shared runtime sources. Development tests and documentation stay in the source repository.",
30
30
  "files": [
31
31
  "bin/",
32
32
  "src/",
33
33
  "mcp/",
34
34
  "tools/",
35
- "app/",
36
35
  "governance/",
37
36
  ".claude/",
38
37
  "CLAUDE.md"
@@ -42,13 +41,7 @@
42
41
  "setup": "node tools/setup.mjs",
43
42
  "demo": "bash demo/run-demo.sh",
44
43
  "demo:compare": "bash demo/conflict-vs-parser.sh",
45
- "demo:chaos": "bash demo/chaos.sh",
46
- "app": "node app/server.mjs",
47
- "smoke": "node tools/smoke.mjs",
48
- "desktop": "npm --prefix desktop start",
49
- "desktop:smoke": "npm --prefix desktop run selftest",
50
- "dist": "npm --prefix desktop run dist",
51
- "shortcut": "node tools/shortcut.mjs --dev"
44
+ "demo:chaos": "bash demo/chaos.sh"
52
45
  },
53
46
  "engines": {
54
47
  "node": ">=20"
@@ -56,5 +49,8 @@
56
49
  "homepage": "https://github.com/rl2aderjoon/axmap#readme",
57
50
  "bugs": {
58
51
  "url": "https://github.com/rl2aderjoon/axmap/issues"
52
+ },
53
+ "dependencies": {
54
+ "node-pty": "npm:@lydell/node-pty@1.1.0"
59
55
  }
60
56
  }
@@ -0,0 +1,19 @@
1
+ /** AI CLI executable discovery shared by setup and MCP registration. */
2
+ import { spawnSync } from 'node:child_process'
3
+
4
+ export const AGENTS = [
5
+ { id: 'claude', name: 'Claude Code', bin: 'claude' },
6
+ { id: 'agy', name: 'Antigravity', bin: 'agy' },
7
+ { id: 'codex', name: 'Codex CLI', bin: 'codex' },
8
+ ]
9
+
10
+ /** Resolve the executable without a shell so paths and arguments stay intact. */
11
+ export function resolveBin(bin) {
12
+ try {
13
+ const r = spawnSync(process.platform === 'win32' ? 'where.exe' : 'which', [bin], {
14
+ encoding: 'utf8', windowsHide: true,
15
+ })
16
+ if (r.status !== 0) return null
17
+ return r.stdout.split(/\r?\n/).map(s => s.trim()).find(Boolean) || null
18
+ } catch { return null }
19
+ }
package/src/bridge.mjs ADDED
@@ -0,0 +1,257 @@
1
+ /** Local sessions are attached by the provider's own hooks. Sending a message
2
+ * must never silently create/resume a second model process. */
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { randomUUID } from 'node:crypto'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { withRepoLock } from './repolock.mjs'
10
+ import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs'
11
+
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')
16
+ 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')) }
21
+ 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) }
28
+ }
29
+ 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
36
+ }
37
+ 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('연결 포트가 잘못되었습니다.')
44
+ }
45
+ 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
61
+ }
62
+ 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))
69
+ }
70
+ 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 }
75
+ }
76
+ 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)
82
+ }
83
+ 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
88
+ }
89
+
90
+ /** Hook input and env come from Antigravity, not a remote message or repo file. */
91
+ 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)
109
+ }
110
+
111
+ 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
129
+ }
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
147
+ }
148
+ }
149
+ if (['completed', 'superseded'].includes(request.state)) writeJSON(target, request)
150
+ return request
151
+ }
152
+
153
+ export function sessionLeasePath(id) {
154
+ if (!UUID.test(id || '')) throw new Error('세션 ID는 UUID여야 합니다.')
155
+ return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
156
+ }
157
+
158
+ 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
+ })
196
+ }
197
+
198
+ 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)
204
+ }
205
+
206
+ export { bridgeHome, repoKey, readJSON, writeJSON }
207
+
208
+ 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')
222
+ }
223
+ }
224
+ return { removed: true, hooks: target, note: '다른 훅은 유지합니다. 기존 CLI에 캐시된 훅은 정상 종료 시 해제됩니다.' }
225
+ }
226
+
227
+ 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
247
+ }
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은 필요 없습니다.' }
257
+ }