axmap-cli 1.3.2 → 1.4.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 (67) hide show
  1. package/CLAUDE.md +15 -9
  2. package/README.ko.md +89 -85
  3. package/README.md +90 -86
  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 +245 -0
  12. package/src/invariants.mjs +1 -1
  13. package/src/managed-session.mjs +178 -0
  14. package/src/protocol.mjs +1 -1
  15. package/src/session-dialogue.mjs +131 -0
  16. package/src/session-usage.mjs +34 -0
  17. package/src/state.mjs +8 -0
  18. package/src/terminal-bridge.mjs +224 -0
  19. package/src/update.mjs +1 -1
  20. package/tools/bridge.mjs +85 -0
  21. package/tools/mcp-register.mjs +22 -3
  22. package/tools/run-tests.mjs +10 -3
  23. package/tools/run.mjs +60 -0
  24. package/tools/setup.mjs +11 -0
  25. package/app/README.md +0 -366
  26. package/app/eval/edges.mjs +0 -242
  27. package/app/lib/adjacent.mjs +0 -125
  28. package/app/lib/agentcli.mjs +0 -153
  29. package/app/lib/analyze.mjs +0 -1159
  30. package/app/lib/cochange.mjs +0 -421
  31. package/app/lib/datanodes.mjs +0 -127
  32. package/app/lib/entry.mjs +0 -192
  33. package/app/lib/featuregraph.mjs +0 -389
  34. package/app/lib/features.mjs +0 -645
  35. package/app/lib/fetchrepo-run.mjs +0 -37
  36. package/app/lib/fetchrepo.mjs +0 -164
  37. package/app/lib/flow.mjs +0 -1089
  38. package/app/lib/ladder.mjs +0 -387
  39. package/app/lib/langs.mjs +0 -630
  40. package/app/lib/live.mjs +0 -346
  41. package/app/lib/llm.mjs +0 -594
  42. package/app/lib/newfile.mjs +0 -126
  43. package/app/lib/prdiff.mjs +0 -651
  44. package/app/lib/reveal.mjs +0 -316
  45. package/app/lib/roots.mjs +0 -186
  46. package/app/lib/scope.mjs +0 -342
  47. package/app/lib/session.mjs +0 -389
  48. package/app/lib/slots.mjs +0 -233
  49. package/app/lib/ssot.mjs +0 -277
  50. package/app/lib/teamview.mjs +0 -975
  51. package/app/lib/terms.ko.mjs +0 -169
  52. package/app/server.mjs +0 -1959
  53. package/app/web/shell.css +0 -538
  54. package/app/web/shell.html +0 -197
  55. package/app/web/shell.js +0 -638
  56. package/app/web/stage.js +0 -347
  57. package/app/web/words.js +0 -85
  58. package/src/repotarget.mjs +0 -84
  59. package/tools/cluster-experiment.mjs +0 -256
  60. package/tools/cluster-sweep.mjs +0 -226
  61. package/tools/make-icon.mjs +0 -108
  62. package/tools/persona-bench.mjs +0 -362
  63. package/tools/pick-repo.mjs +0 -229
  64. package/tools/reveal-demo.mjs +0 -158
  65. package/tools/shortcut.mjs +0 -121
  66. package/tools/smoke.mjs +0 -174
  67. package/tools/topicgraph.py +0 -154
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, 실험용 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: '작업 요청의 제출/완료/불명 상태, 결과와 측정 사용량을 확인한다. 관리형 세션은 CLI의 구조화된 실행 완료 이벤트, 실험용 터미널은 해당 요청의 종료 훅으로 판정한다.',
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.2",
4
+ "version": "1.4.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,245 @@
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
+
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')
15
+ 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')) }
20
+ 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) }
27
+ }
28
+ 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
35
+ }
36
+ 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('연결 포트가 잘못되었습니다.')
43
+ }
44
+ 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
60
+ }
61
+ 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))
68
+ }
69
+ 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 }
74
+ }
75
+ 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)
81
+ }
82
+ 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
87
+ }
88
+
89
+ /** Hook input and env come from Antigravity, not a remote message or repo file. */
90
+ 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
+
110
+ 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
136
+ }
137
+ }
138
+ if (['completed', 'superseded'].includes(request.state)) writeJSON(target, request)
139
+ return request
140
+ }
141
+
142
+ export function sessionLeasePath(id) {
143
+ if (!UUID.test(id || '')) throw new Error('세션 ID는 UUID여야 합니다.')
144
+ return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`)
145
+ }
146
+
147
+ 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
+ })
184
+ }
185
+
186
+ 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
+
196
+ 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')
210
+ }
211
+ }
212
+ return { removed: true, hooks: target, note: '다른 훅은 유지합니다. 기존 CLI에 캐시된 훅은 정상 종료 시 해제됩니다.' }
213
+ }
214
+
215
+ 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
235
+ }
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은 필요 없습니다.' }
245
+ }
@@ -96,7 +96,7 @@ export function resurrectionViolations({ before, after, requested, me, now }) {
96
96
 
97
97
  // ---------------------------------------------------------------------------
98
98
  // I7 · 세션 이름 유일성 (상태 불변식)
99
- // 한 슬롯 레지스트리 안에서 동시에 살아있는 두 세션은 서로 다른 AXMAP_AGENT 를 갖는다.
99
+ // 관리 중인 동시에 살아있는 두 세션은 서로 다른 AXMAP_AGENT 를 갖는다.
100
100
  //
101
101
  // 🔴 "모든 에이전트의 이름이 다르다" 는 여기에 적을 수 없다. 사람이 두 셸에
102
102
  // 같은 AXMAP_AGENT 를 넣으면 시스템이 막을 방법이 없고, 검사할 수 없는 문장을