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.
- package/CLAUDE.md +15 -9
- package/README.ko.md +103 -87
- package/README.md +104 -88
- package/bin/axmap.mjs +9 -3
- package/mcp/README.md +8 -8
- package/mcp/install.ps1 +6 -3
- package/mcp/install.sh +5 -3
- package/mcp/server.mjs +81 -32
- package/package.json +7 -11
- package/src/agentcli.mjs +19 -0
- package/src/bridge.mjs +257 -0
- package/src/external-console.mjs +116 -0
- package/src/invariants.mjs +1 -1
- package/src/managed-session.mjs +178 -0
- package/src/protocol.mjs +1 -1
- package/src/session-dialogue.mjs +131 -0
- package/src/session-status.mjs +48 -0
- package/src/session-usage.mjs +34 -0
- package/src/state.mjs +8 -0
- package/src/terminal-bridge.mjs +225 -0
- package/src/update.mjs +1 -1
- package/tools/bridge.mjs +96 -0
- package/tools/mcp-register.mjs +2 -2
- package/tools/run-tests.mjs +10 -3
- package/tools/run.mjs +64 -0
- package/tools/setup.mjs +11 -0
- package/tools/windows-console.ps1 +91 -0
- package/app/README.md +0 -366
- package/app/eval/edges.mjs +0 -242
- package/app/lib/adjacent.mjs +0 -125
- package/app/lib/agentcli.mjs +0 -153
- package/app/lib/analyze.mjs +0 -1159
- package/app/lib/cochange.mjs +0 -421
- package/app/lib/datanodes.mjs +0 -127
- package/app/lib/entry.mjs +0 -192
- package/app/lib/featuregraph.mjs +0 -389
- package/app/lib/features.mjs +0 -645
- package/app/lib/fetchrepo-run.mjs +0 -37
- package/app/lib/fetchrepo.mjs +0 -164
- package/app/lib/flow.mjs +0 -1089
- package/app/lib/ladder.mjs +0 -387
- package/app/lib/langs.mjs +0 -630
- package/app/lib/live.mjs +0 -346
- package/app/lib/llm.mjs +0 -594
- package/app/lib/newfile.mjs +0 -126
- package/app/lib/prdiff.mjs +0 -651
- package/app/lib/reveal.mjs +0 -316
- package/app/lib/roots.mjs +0 -186
- package/app/lib/scope.mjs +0 -342
- package/app/lib/session.mjs +0 -389
- package/app/lib/slots.mjs +0 -233
- package/app/lib/ssot.mjs +0 -277
- package/app/lib/teamview.mjs +0 -975
- package/app/lib/terms.ko.mjs +0 -169
- package/app/server.mjs +0 -1959
- package/app/web/shell.css +0 -538
- package/app/web/shell.html +0 -197
- package/app/web/shell.js +0 -638
- package/app/web/stage.js +0 -347
- package/app/web/words.js +0 -85
- package/src/repotarget.mjs +0 -84
- package/tools/cluster-experiment.mjs +0 -256
- package/tools/cluster-sweep.mjs +0 -226
- package/tools/make-icon.mjs +0 -108
- package/tools/persona-bench.mjs +0 -362
- package/tools/pick-repo.mjs +0 -229
- package/tools/reveal-demo.mjs +0 -158
- package/tools/shortcut.mjs +0 -121
- package/tools/smoke.mjs +0 -174
- 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 루트다.
|
|
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
|
|
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', '
|
|
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
|
-
#
|
|
202
|
-
foreach ($item in @('bin', 'src', 'mcp', 'tools', '
|
|
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
|
|
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
|
-
#
|
|
202
|
-
for item in bin src mcp tools
|
|
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 자신의 파일.
|
|
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
|
|
552
|
-
['판정
|
|
553
|
-
['불변식
|
|
554
|
-
['CLI ·
|
|
555
|
-
['MCP 서버
|
|
556
|
-
['
|
|
557
|
-
['
|
|
558
|
-
['
|
|
559
|
-
['
|
|
560
|
-
['
|
|
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
|
|
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` · `
|
|
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 에는
|
|
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
|
|
1012
|
-
|
|
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.
|
|
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": "
|
|
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": "
|
|
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
|
}
|
package/src/agentcli.mjs
ADDED
|
@@ -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
|
+
}
|