axmap-cli 0.0.1

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 (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * 대화 세션 — 겉은 채팅, 속은 CLI 프로세스.
3
+ *
4
+ * 🔴 세션을 **화면이 아니라 여기(서버)가** 들고 있다.
5
+ *
6
+ * 요구는 하나였다: "앱이 느려져도 터미널에서의 작업은 정상이었으면 좋겠다."
7
+ * 그러려면 진행 중인 작업이 렌더러와 **아무 관계가 없어야** 한다.
8
+ * 그래서 이렇게 갈랐다.
9
+ *
10
+ * 프로세스를 띄우고 출력을 모으는 일 서버 (이 파일)
11
+ * 모인 것을 말풍선으로 그리는 일 화면
12
+ *
13
+ * 창을 닫아도, 그래프가 버벅여도, 렌더러가 죽어도 자식 프로세스는 계속 돈다.
14
+ * 화면은 나중에 붙어서 `?since=` 로 밀린 것을 받아 가면 된다.
15
+ *
16
+ * 🔴 그리고 **감추는 것은 터미널이지 결과가 아니다.**
17
+ *
18
+ * 사용자에게 "지금 CLI 를 쓰고 있다" 는 사실은 숨겨도 되지만, AI 가 어떤 파일을
19
+ * 고쳤는지는 숨기면 안 된다. 그것을 숨기는 순간 이 제품이 없애려던 공포
20
+ * (D1 의 F2 — "AI 가 뭘 망가뜨렸는지 모른다")를 이 제품이 직접 만들어내는 셈이 된다.
21
+ * 그래서 `touched` 를 따로 모아 화면이 반드시 띄우게 한다.
22
+ *
23
+ * 🔴 그리고 **세션마다 다른 사람이어야 한다.**
24
+ *
25
+ * 이 앱의 목적이 "여러 AI CLI 세션을 동시에 굴리는 것" 인데, 오랫동안 `spawn` 에
26
+ * `env` 옵션이 없어서 자식이 서버의 환경을 통째로 물려받았다. 그러면 세션이
27
+ * 몇 개든 전부 같은 `AXMAP_AGENT` 이고, `checkOverlap` 은 자기 claim 을 겹침으로
28
+ * 보지 않으므로 **서로를 하나도 못 막는다.** 이름을 정하는 규칙과 그 근거는
29
+ * `app/lib/slots.mjs` 머리말에 있다.
30
+ */
31
+
32
+ import { randomUUID } from 'node:crypto'
33
+ import { spawn } from 'node:child_process'
34
+ import { agentById, argvFor, pickId, resolveBin } from './agentcli.mjs'
35
+ import * as slots from './slots.mjs'
36
+
37
+ /**
38
+ * 한 세션이 들고 있을 최대 메시지 수.
39
+ * 넘으면 오래된 것부터 버리되 **버렸다고 말한다** — 조용히 사라지면
40
+ * 사용자는 대화가 원래 그랬던 줄 안다.
41
+ */
42
+ const MAX_MESSAGES = 2000
43
+
44
+ /** 파일을 실제로 바꾸는 도구들. 이 이름이 보이면 `touched` 에 올린다. */
45
+ const EDITORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch', 'edit_file'])
46
+
47
+ /**
48
+ * 대화가 아닌 이벤트들. 화면은 접어 두지만 기록은 남긴다.
49
+ * 실측에서 나온 것부터 넣었다 — 모르는 것은 여기 넣지 말고 `raw` 로 흘려보낸다.
50
+ */
51
+ const NOT_CONVERSATION = new Set([
52
+ 'system', 'rate_limit_event', 'stream_event', 'control_response', 'control_request',
53
+ ])
54
+
55
+ const sessions = new Map()
56
+
57
+ // ── 만들기 · 조회 ───────────────────────────────────────────────────────────
58
+
59
+ export function create({ agent = 'claude', cwd = process.cwd(), title = '' } = {}) {
60
+ const spec = agentById(agent)
61
+ if (!spec) throw new Error(`모르는 에이전트: ${agent}`)
62
+ if (!resolveBin(spec.bin)) throw new Error(`${spec.name} 이(가) 이 PC 에 없습니다`)
63
+
64
+ const id = randomUUID()
65
+ /**
66
+ * 🔴 이름을 못 정하면 세션을 만들지 않는다.
67
+ *
68
+ * 이름 없이 띄우면 자식이 서버의 `AXMAP_AGENT` 를 그대로 물려받아 세션들이
69
+ * 다시 한 사람이 된다 — 이 파일 머리말의 그 버그다. 못 막는 것보다 못 띄우는
70
+ * 것이 낫다(fail-closed). 이유는 메시지에 그대로 실어 보낸다.
71
+ */
72
+ let seat
73
+ try {
74
+ seat = slots.acquire(cwd, { sessionId: id })
75
+ } catch (e) {
76
+ throw new Error(`세션 이름을 정하지 못해 시작하지 않았습니다: ${e.message}`)
77
+ }
78
+
79
+ const s = {
80
+ id,
81
+ agent,
82
+ agentName: spec.name,
83
+ /** 이 세션이 장부에서 누구인가. 자식에게 `AXMAP_AGENT` 로 넘어간다 */
84
+ axmapAgent: seat.agent,
85
+ slot: seat.slot,
86
+ cwd,
87
+ title: title || '새 대화',
88
+ /** CLI 쪽 대화 id. 이게 있어야 다음 턴이 앞 대화에 이어붙는다. */
89
+ cid: null,
90
+ state: 'idle',
91
+ error: null,
92
+ seq: 0,
93
+ messages: [],
94
+ /** path -> {tool, n} — AI 가 건드린 파일. 반드시 화면에 보인다 */
95
+ touched: new Map(),
96
+ dropped: 0,
97
+ startedAt: Date.now(),
98
+ proc: null,
99
+ }
100
+ sessions.set(s.id, s)
101
+ return view(s)
102
+ }
103
+
104
+ /**
105
+ * 자식에게 줄 환경.
106
+ *
107
+ * 🔴 `env` 를 주지 않으면 자식은 서버의 환경을 **통째로** 물려받는다. 그래서
108
+ * 여기가 이 파일에서 가장 중요한 세 줄이다 — 이 한 줄이 빠져 있는 동안 앱에서
109
+ * 띄운 세션은 몇 개든 장부에서 한 사람이었다.
110
+ *
111
+ * `process.env` 를 먼저 펼치는 이유: CLI 는 PATH·HOME·자격증명 경로·프록시
112
+ * 설정을 전부 환경에서 읽는다. 필요한 것만 골라 넘기면 어느 CLI 가 무엇을 읽는지
113
+ * 우리가 다 아는 척하는 것이고, 빠뜨리면 "왜 로그인이 안 되지" 로 돌아온다.
114
+ */
115
+ export function childEnv(s, base = process.env) {
116
+ return { ...base, AXMAP_AGENT: s.axmapAgent }
117
+ }
118
+
119
+ export const get = (id) => sessions.get(id) ?? null
120
+
121
+ /** 목록. 사이드바가 이걸 그린다 — 이 목록이 곧 "지금 살아 있는 터미널들" 이다. */
122
+ export function list() {
123
+ return [...sessions.values()]
124
+ .sort((a, b) => b.startedAt - a.startedAt)
125
+ .map((s) => ({
126
+ id: s.id,
127
+ title: s.title,
128
+ agent: s.agent,
129
+ agentName: s.agentName,
130
+ // 사이드바가 "이 대화는 장부에서 누구인가" 를 그릴 수 있어야 한다.
131
+ // 이름을 사람이 읽는 것이 이 제품의 목적이라 UUID 대신 슬롯 번호를 쓴다.
132
+ axmapAgent: s.axmapAgent,
133
+ cwd: s.cwd,
134
+ state: s.state,
135
+ messages: s.messages.length,
136
+ touched: s.touched.size,
137
+ startedAt: s.startedAt,
138
+ }))
139
+ }
140
+
141
+ /** 화면에 주는 모양. `since` 뒤의 메시지만 준다 — 매번 전부 보내면 앱이 느려진다. */
142
+ export function view(s, since = -1) {
143
+ return {
144
+ id: s.id,
145
+ title: s.title,
146
+ agent: s.agent,
147
+ agentName: s.agentName,
148
+ axmapAgent: s.axmapAgent,
149
+ cwd: s.cwd,
150
+ state: s.state,
151
+ error: s.error,
152
+ seq: s.seq,
153
+ dropped: s.dropped,
154
+ messages: s.messages.filter((m) => m.seq > since),
155
+ touched: [...s.touched.entries()].map(([file, v]) => ({ file, ...v })),
156
+ }
157
+ }
158
+
159
+ export function remove(id) {
160
+ const s = sessions.get(id)
161
+ if (!s) return false
162
+ stop(id)
163
+ sessions.delete(id)
164
+ // 슬롯을 즉시 돌려준다. 안 돌려주면 다음 세션이 `s3`, `s4` 로 번호만 커지고,
165
+ // 그러면 앱을 껐다 켤 때 이름이 달라져 두고 간 claim 을 반납할 수 없다.
166
+ releaseSeat(s)
167
+ return true
168
+ }
169
+
170
+ /** 슬롯 반납은 실패해도 세션 정리를 막지 않는다 — 죽은 pid 는 다음 acquire 가 걷어낸다. */
171
+ function releaseSeat(s) {
172
+ try { slots.release(s.cwd, s.slot) } catch { /* 편의 기록이다. 여기서 죽을 이유가 없다 */ }
173
+ }
174
+
175
+ /** 진행 중인 턴을 끊는다. 세션 자체는 남는다 — 이어서 다시 물을 수 있다. */
176
+ export function stop(id) {
177
+ const s = sessions.get(id)
178
+ if (!s?.proc) return false
179
+ try { s.proc.kill() } catch { /* 이미 죽었으면 그만 */ }
180
+ s.proc = null
181
+ s.state = 'idle'
182
+ push(s, { role: 'note', text: '중단했습니다.' })
183
+ return true
184
+ }
185
+
186
+ // ── 메시지 ──────────────────────────────────────────────────────────────────
187
+
188
+ function push(s, m) {
189
+ s.messages.push({ seq: ++s.seq, at: Date.now(), ...m })
190
+ if (s.messages.length > MAX_MESSAGES) {
191
+ const cut = s.messages.length - MAX_MESSAGES
192
+ s.messages.splice(0, cut)
193
+ s.dropped += cut
194
+ }
195
+ }
196
+
197
+ // ── 한 턴 보내기 ────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * 프롬프트 하나를 보내고, 응답을 말풍선으로 쌓는다.
201
+ *
202
+ * 🔴 프롬프트를 **stdin 으로** 준다. 인자로 넘기면 따옴표·개행·백틱이 셸이나
203
+ * 인자 파서에 먹혀 **조용히 잘린다.** 사용자가 쓴 말이 반쯤 사라진 채 AI 에게
204
+ * 가는 것이라 최악의 실패다. stdin 은 그런 해석을 아예 안 거친다.
205
+ *
206
+ * 반환은 즉시 한다. 기다리지 않는다 — 화면이 뒤에서 `?since=` 로 받아 간다.
207
+ */
208
+ export function send(id, prompt) {
209
+ const s = sessions.get(id)
210
+ if (!s) throw new Error('없는 세션입니다')
211
+ if (s.state === 'running') throw new Error('아직 앞의 답을 기다리는 중입니다')
212
+
213
+ const spec = agentById(s.agent)
214
+ const bin = resolveBin(spec.bin)
215
+ if (!bin) throw new Error(`${spec.name} 이(가) 이 PC 에 없습니다`)
216
+
217
+ push(s, { role: 'user', text: prompt })
218
+ if (s.title === '새 대화') s.title = prompt.slice(0, 40).replace(/\s+/g, ' ').trim()
219
+
220
+ // 이 CLI 가 세션 id 를 받는 쪽이면 우리가 만들어 박는다 (agentcli.mjs 참고).
221
+ const newId = spec.takesId ? randomUUID() : null
222
+ const argv = argvFor(spec, { cid: s.cid, newId })
223
+
224
+ s.state = 'running'
225
+ s.error = null
226
+
227
+ const proc = spawn(bin, argv, {
228
+ cwd: s.cwd,
229
+ windowsHide: true,
230
+ stdio: ['pipe', 'pipe', 'pipe'],
231
+ // 🔴 이 줄이 없으면 세션이 몇 개든 전부 같은 사람이 된다. childEnv 머리말 참고.
232
+ env: childEnv(s),
233
+ })
234
+ s.proc = proc
235
+ if (spec.takesId && !s.cid) s.cid = newId
236
+
237
+ let out = ''
238
+ let err = ''
239
+
240
+ proc.stdout.setEncoding('utf8')
241
+ proc.stdout.on('data', (chunk) => {
242
+ out += chunk
243
+ // JSONL 이다. 마지막 조각은 아직 안 끝났을 수 있으니 남겨 둔다.
244
+ const lines = out.split('\n')
245
+ out = lines.pop() ?? ''
246
+ for (const line of lines) if (line.trim()) feed(s, line.trim())
247
+ })
248
+
249
+ proc.stderr.setEncoding('utf8')
250
+ proc.stderr.on('data', (chunk) => { err += chunk })
251
+
252
+ proc.on('error', (e) => {
253
+ s.state = 'error'
254
+ s.error = e.message
255
+ s.proc = null
256
+ push(s, { role: 'error', text: `실행하지 못했습니다: ${e.message}` })
257
+ })
258
+
259
+ proc.on('close', (code) => {
260
+ if (out.trim()) feed(s, out.trim())
261
+ s.proc = null
262
+ if (code === 0) {
263
+ s.state = 'idle'
264
+ return
265
+ }
266
+ s.state = 'error'
267
+ /**
268
+ * 🔴 여기가 로그인 안 된 경우가 튀어나오는 자리다.
269
+ * 터미널을 감춰 놨으므로 그냥 두면 사용자에게는 **아무 일도 안 일어난 것**으로
270
+ * 보인다. stderr 를 그대로 올려서 최소한 무엇 때문인지는 보이게 한다.
271
+ */
272
+ s.error = err.trim() || `종료 코드 ${code}`
273
+ push(s, { role: 'error', text: s.error })
274
+ })
275
+
276
+ try {
277
+ proc.stdin.end(prompt, 'utf8')
278
+ } catch (e) {
279
+ s.state = 'error'
280
+ push(s, { role: 'error', text: `프롬프트를 넘기지 못했습니다: ${e.message}` })
281
+ }
282
+
283
+ return view(s, s.seq - 1)
284
+ }
285
+
286
+ // ── stream-json 읽기 ────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * CLI 가 흘려주는 JSONL 한 줄을 말풍선으로 바꾼다.
290
+ *
291
+ * 🔴 못 읽은 줄을 **버리지 않는다.** 버리면 화면은 조용히 불완전한 대화를
292
+ * 보여주고 사용자는 그게 전부인 줄 안다. 모양이 낯설면 낯선 채로 올린다.
293
+ * (`애매하면 거부한다` 와 같은 이유 — CLAUDE.md)
294
+ */
295
+ function feed(s, line) {
296
+ let ev
297
+ try { ev = JSON.parse(line) } catch {
298
+ push(s, { role: 'raw', text: line })
299
+ return
300
+ }
301
+
302
+ const cid = pickId(ev)
303
+ if (cid) s.cid = cid
304
+
305
+ /**
306
+ * 계측·제어 이벤트는 대화가 아니다.
307
+ *
308
+ * 🔴 그래도 **버리지는 않는다.** `meta` 로 올려서 화면이 접어 두게 한다.
309
+ * 실측에서 `rate_limit_event` 와 MCP 경고가 말풍선 사이에 그대로 끼어들었다 —
310
+ * 사용자에게는 AI 가 헛소리를 한 것처럼 보인다. 그렇다고 조용히 지우면
311
+ * 사용량 한도에 걸려 멈춘 것인지 알 방법이 없어진다. 접어 두는 것이 답이다.
312
+ */
313
+ if (NOT_CONVERSATION.has(ev.type)) { push(s, { role: 'meta', text: line }); return }
314
+
315
+ const content = ev.message?.content ?? ev.content
316
+ if (Array.isArray(content)) {
317
+ for (const b of content) block(s, b)
318
+ return
319
+ }
320
+
321
+ if (ev.type === 'result') {
322
+ // 최종 답. 앞서 assistant 블록으로 이미 올라온 것과 같은 내용이면 중복이므로 건너뛴다.
323
+ const last = s.messages[s.messages.length - 1]
324
+ const text = typeof ev.result === 'string' ? ev.result : null
325
+ if (text && last?.role === 'assistant' && last.text.trim() === text.trim()) return
326
+ if (text) push(s, { role: 'assistant', text })
327
+ if (ev.is_error) push(s, { role: 'error', text: '에이전트가 오류로 끝났습니다.' })
328
+ return
329
+ }
330
+
331
+ if (typeof ev.text === 'string') { push(s, { role: 'assistant', text: ev.text }); return }
332
+
333
+ push(s, { role: 'raw', text: line })
334
+ }
335
+
336
+ function block(s, b) {
337
+ if (!b || typeof b !== 'object') return
338
+
339
+ if (b.type === 'text' && b.text) { push(s, { role: 'assistant', text: b.text }); return }
340
+
341
+ if (b.type === 'tool_use') {
342
+ const file = b.input?.file_path ?? b.input?.path ?? null
343
+ const edits = EDITORS.has(b.name)
344
+
345
+ if (edits && file) {
346
+ const prev = s.touched.get(file)
347
+ s.touched.set(file, { tool: b.name, n: (prev?.n ?? 0) + 1 })
348
+ }
349
+
350
+ push(s, {
351
+ role: 'tool',
352
+ tool: b.name,
353
+ file,
354
+ /** 파일을 실제로 바꾼 도구인가. 화면이 이걸로 강조 여부를 정한다 */
355
+ edits,
356
+ text: summarize(b),
357
+ })
358
+ return
359
+ }
360
+
361
+ // tool_result 는 대개 길고 사람이 읽을 것이 아니다. 오류일 때만 올린다.
362
+ if (b.type === 'tool_result' && b.is_error) {
363
+ push(s, { role: 'error', text: String(b.content ?? '도구가 실패했습니다').slice(0, 500) })
364
+ }
365
+ }
366
+
367
+ /** 도구 한 번을 한 줄로. 사람이 읽을 수 있는 만큼만. */
368
+ function summarize(b) {
369
+ const i = b.input ?? {}
370
+ if (i.file_path || i.path) return String(i.file_path ?? i.path)
371
+ if (i.command) return String(i.command).slice(0, 200)
372
+ if (i.pattern) return String(i.pattern).slice(0, 200)
373
+ if (i.query) return String(i.query).slice(0, 200)
374
+ if (i.prompt) return String(i.prompt).slice(0, 200)
375
+ return ''
376
+ }
377
+
378
+ /** 서버가 내려갈 때 자식들을 남기지 않는다. */
379
+ export function shutdown() {
380
+ for (const s of sessions.values()) {
381
+ try { s.proc?.kill() } catch { /* 그만 */ }
382
+ /**
383
+ * 슬롯도 함께 비운다. 여기서 못 비워도(강제 종료·정전) 다음 실행이 죽은
384
+ * pid 를 보고 회수하므로 영구히 새지는 않는다 — 그것이 pid 를 적어둔 이유다.
385
+ * 그래도 정상 종료에서는 비워둔다. 그래야 다음에 켰을 때 s1 부터 다시 준다.
386
+ */
387
+ releaseSeat(s)
388
+ }
389
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * 세션마다 안정적인 고유 이름 — `<base>-s<N>`.
3
+ *
4
+ * 🔴 이 파일이 없던 동안 **앱에서 띄운 세션은 몇 개든 전부 같은 사람이었다.**
5
+ *
6
+ * `app/lib/session.mjs` 의 `spawn` 에 `env` 옵션이 없어서 자식이 `app/server.mjs`
7
+ * 의 환경을 그대로 물려받았다. 그러면 세 세션이 전부 같은 `AXMAP_AGENT` 로
8
+ * claim 하고, `checkOverlap` 은 **자기 claim 을 겹침으로 보지 않으므로**
9
+ * 서로를 하나도 못 막는다. 이 저장소가 `.mcp.json` 에서 뿌리뽑은 바로 그 실패가
10
+ * (SPEC §3 "에이전트 이름을 정하는 순서") 앱 안에서 되살아나 있었다.
11
+ * "혼자서 에이전트 여러 개" 가 실제로 일어나는 곳이 여기다.
12
+ *
13
+ * ── 이름을 왜 이렇게 짓는가 ────────────────────────────────────────────────
14
+ *
15
+ * `base` 는 `live.mjs` 의 `whoAmI()` 가 정한다. **여기서 다시 구현하지 않는다.**
16
+ * 두 곳이 갈리면 CLI 는 `A` 로 claim 했는데 화면이 그것을 남의 것으로 칠한다.
17
+ *
18
+ * 랜덤 UUID 를 쓰지 않는다. 이 제품의 목적이 "누가 무엇을 잡았는지 **사람이 보는
19
+ * 것**" 이므로 화면에 `f47ac10b-…-s?` 가 뜨면 목적에 정면으로 반한다.
20
+ * `janghyojoon-s2` 는 읽는 순간 누구인지 안다.
21
+ *
22
+ * ── 슬롯 레지스트리(`<repo>/.axmap/sessions.json`)가 왜 필요한가 ───────────
23
+ *
24
+ * 세션 id(UUID)를 이름으로 쓰면 앱을 껐다 켤 때마다 이름이 바뀐다. 그러면
25
+ * **앱이 죽으며 두고 간 claim 을 아무도 반납할 수 없다** — `release` 는 이름이
26
+ * 다르므로 종료 코드 5 를 내고, 그동안 TTL 이 다 갈 때까지 남의 영역이 막힌다.
27
+ *
28
+ * 슬롯 번호는 pid 로 회수되므로 앱을 껐다 켜면 옛 pid 가 죽어 슬롯이 전부 비고
29
+ * 첫 세션이 다시 `s1` 을 받는다. **이름이 재시작을 넘어 안정적**이라는 뜻이고,
30
+ * 그래서 다시 켠 세션이 자기가 두고 간 claim 을 그대로 반납할 수 있다.
31
+ *
32
+ * pid 를 기록하는 이유는 **앱을 두 개 띄웠을 때**다. 그때 둘 다 `s1` 을 잡으면
33
+ * 이름 충돌이 그대로 돌아온다. 살아있는 pid 의 슬롯은 절대 뺏지 않는다.
34
+ *
35
+ * ── 이 파일은 fail-closed 의 예외다 ────────────────────────────────────────
36
+ *
37
+ * 레지스트리가 없거나 깨져 있으면 **빈 것으로 시작한다.** 장부 레코드였다면
38
+ * 전체를 중단해야 하지만(SPEC §6) 이것은 락이 아니라 **편의 기록**이다.
39
+ * 여기서 중단하면 얻는 것 없이 앱이 세션을 하나도 못 띄운다. 반대로 빈 것으로
40
+ * 시작해서 생기는 최악은 "살아있는 앱의 슬롯을 잊고 같은 번호를 준다" 인데,
41
+ * 그것은 아래 `alive()` 가 죽은 pid 만 버리는 것과 acquire 직후의 I7 검사가 막는다.
42
+ * 판정을 바꾸지 않는 기록에까지 fail-closed 를 적용하면 원칙이 아니라 미신이 된다.
43
+ *
44
+ * 다만 **이름을 정하지 못하는 것은 다르다.** 이름 없이 세션을 띄우면 그 순간
45
+ * 위의 버그가 그대로 돌아오므로, `acquire` 가 실패하면 세션을 만들지 않는다.
46
+ */
47
+
48
+ import fs from 'node:fs'
49
+ import path from 'node:path'
50
+ import { whoAmI } from './live.mjs'
51
+ import { sessionIdentityViolations } from '../../src/invariants.mjs'
52
+
53
+ /** `.axmap/` 는 이미 gitignore 다. 새 항목을 만들지 않는다. */
54
+ const REGISTRY_REL = path.join('.axmap', 'sessions.json')
55
+
56
+ /**
57
+ * `whoAmI()` 가 아무것도 못 찾았을 때의 이름.
58
+ *
59
+ * CLI 는 이름을 모르면 die 하지만(치환이 곧 소유권 충돌이므로) 여기서는 다르다.
60
+ * 슬롯 번호가 붙어 **세션끼리는 어차피 서로 다른 이름**이 되므로, 같은 이름이
61
+ * 되어 서로를 못 막는 그 실패는 일어나지 않는다. 사람 이름을 못 찾은 것은
62
+ * 화면에서 `agent-s1` 로 보이는 불편이지 상호배제의 구멍이 아니다.
63
+ */
64
+ const FALLBACK_BASE = 'agent'
65
+
66
+ export function registryPath(repoRoot) {
67
+ return path.join(repoRoot, REGISTRY_REL)
68
+ }
69
+
70
+ /** 이 저장소에서 '나'. 순서는 CLI·MCP·화면과 같아야 하므로 live.mjs 것을 그대로 쓴다. */
71
+ export function baseName(repoRoot) {
72
+ return whoAmI(repoRoot) || FALLBACK_BASE
73
+ }
74
+
75
+ export function sessionAgentName(base, slot) {
76
+ return `${base}-s${slot}`
77
+ }
78
+
79
+ /**
80
+ * 이 pid 가 아직 살아 있는가. `process.kill(pid, 0)` 은 신호를 보내지 않고
81
+ * 존재만 확인하며, 윈도우에서도 동작한다.
82
+ *
83
+ * 🔴 `EPERM` 은 **살아 있는 것**이다. 다른 사용자의 프로세스라 신호를 못 보낼
84
+ * 뿐 존재는 한다. 여기서 죽었다고 보면 남이 쓰는 슬롯을 뺏어 같은 이름이 둘
85
+ * 생긴다 — 애매하면 뺏지 않는 쪽이 맞다.
86
+ */
87
+ export function alive(pid) {
88
+ if (!Number.isInteger(pid) || pid <= 0) return false
89
+ try {
90
+ process.kill(pid, 0)
91
+ return true
92
+ } catch (e) {
93
+ return e?.code === 'EPERM'
94
+ }
95
+ }
96
+
97
+ /** 레지스트리를 읽는다. 없거나 깨졌으면 빈 것 (머리말의 "fail-closed 의 예외"). */
98
+ export function readRegistry(repoRoot) {
99
+ let raw
100
+ try {
101
+ raw = fs.readFileSync(registryPath(repoRoot), 'utf8')
102
+ } catch {
103
+ return {}
104
+ }
105
+ try {
106
+ const j = JSON.parse(raw)
107
+ const slots = j?.slots
108
+ return slots && typeof slots === 'object' && !Array.isArray(slots) ? slots : {}
109
+ } catch {
110
+ return {}
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 파일 하나를 원자적으로 쓴다 — 임시 파일에 다 쓴 뒤 같은 디렉터리에서 rename.
116
+ *
117
+ * 방식과 이유는 `bin/axmap.mjs` 의 `writeFileAtomic` 머리말에 적혀 있다. 요약하면
118
+ * `writeFileSync` 는 호출 하나로 보여도 커널이 여러 번에 나눠 쓸 수 있고, 그 사이에
119
+ * 다른 앱 인스턴스가 이 파일을 읽으면 **반쯤 쓰인 JSON** 을 본다. 그러면 위 규칙에
120
+ * 따라 빈 것으로 시작하고, 살아있는 슬롯을 잊어 같은 번호를 두 번 준다.
121
+ * 같은 디렉터리 안의 rename 은 같은 파일시스템이라 원자적이다.
122
+ */
123
+ function writeFileAtomic(target, data) {
124
+ const tmp = `${target}.tmp-${process.pid}`
125
+ try {
126
+ fs.writeFileSync(tmp, data)
127
+ fs.renameSync(tmp, target)
128
+ } catch (e) {
129
+ try { fs.rmSync(tmp, { force: true }) } catch { /* 원래 원인을 가리지 않는다 */ }
130
+ throw e
131
+ }
132
+ }
133
+
134
+ function writeRegistry(repoRoot, slots) {
135
+ const file = registryPath(repoRoot)
136
+ fs.mkdirSync(path.dirname(file), { recursive: true })
137
+ writeFileAtomic(file, JSON.stringify({ slots }, null, 2) + '\n')
138
+ }
139
+
140
+ /**
141
+ * 죽은 항목을 버리고 **가장 작은 빈 번호**를 준다. 순수 함수다 —
142
+ * `isAlive` 를 인자로 받으므로 진짜 프로세스 없이도 회수 규칙을 검증할 수 있다.
143
+ * (시각도 인자로 받는다. CLAUDE.md "시각은 항상 인자로 받는다")
144
+ *
145
+ * 가장 작은 빈 번호인 이유: 앱을 껐다 켜면 슬롯이 전부 비므로 첫 세션이 다시
146
+ * `s1` 을 받는다. 번호를 계속 키우면 재시작마다 이름이 달라져 두고 간 claim 을
147
+ * 반납할 수 없게 되고, 그것이 이 레지스트리를 만든 이유 자체를 무너뜨린다.
148
+ */
149
+ export function allocate(slots, { sessionId, agentFor, pid, now, isAlive = alive }) {
150
+ const live = {}
151
+ for (const [k, v] of Object.entries(slots ?? {})) {
152
+ const n = Number(k)
153
+ // 번호가 아닌 키는 이 파일을 손으로 고친 흔적이다. 편의 기록이므로 버린다.
154
+ if (!Number.isInteger(n) || n < 1) continue
155
+ if (!v || typeof v !== 'object') continue
156
+ if (!isAlive(v.pid)) continue
157
+ live[String(n)] = v
158
+ }
159
+ let slot = 1
160
+ while (live[String(slot)]) slot++
161
+ live[String(slot)] = {
162
+ pid,
163
+ sessionId,
164
+ since: new Date(now).toISOString(),
165
+ /**
166
+ * 확정된 이름을 함께 적는다. 번호만 적으면 이름을 읽는 쪽이 `base` 를 다시
167
+ * 계산해야 하는데, 그 `base` 는 **그 프로세스의 환경**에서 나온다 —
168
+ * 앱을 두 개 띄우면 두 인스턴스가 같은 슬롯에 대해 서로 다른 이름을 말하게
169
+ * 되고, 그러면 I7 을 검사할 방법 자체가 없어진다.
170
+ */
171
+ agent: agentFor(slot),
172
+ }
173
+ return { slot, slots: live }
174
+ }
175
+
176
+ /** 레지스트리 한 장을 I7 검사기가 읽을 모양으로. */
177
+ function sessionsOf(slots) {
178
+ return Object.entries(slots).map(([n, v]) => ({
179
+ id: v?.sessionId ?? `slot-${n}`,
180
+ agent: v?.agent ?? null,
181
+ pid: v?.pid ?? null,
182
+ }))
183
+ }
184
+
185
+ /**
186
+ * 슬롯 하나를 잡고 이 세션의 `AXMAP_AGENT` 를 확정한다.
187
+ *
188
+ * 실패하면 던진다. 이름 없이 세션을 띄우면 자식이 서버의 이름을 그대로 물려받아
189
+ * 세션들이 다시 한 사람이 되므로, **이름을 못 정하는 것은 세션을 못 만드는 것**이다.
190
+ */
191
+ export function acquire(repoRoot, { sessionId, pid = process.pid, now = Date.now(), isAlive = alive } = {}) {
192
+ const base = baseName(repoRoot)
193
+ const { slot, slots } = allocate(readRegistry(repoRoot), {
194
+ sessionId,
195
+ agentFor: (n) => sessionAgentName(base, n),
196
+ pid,
197
+ now,
198
+ isAlive,
199
+ })
200
+
201
+ /**
202
+ * I7 을 잡은 자리에서 바로 확인한다 (docs/INVARIANTS.md).
203
+ * 검사기는 모델 검증기·감사와 같은 `src/invariants.mjs` 를 쓴다 — 도구마다
204
+ * 판정이 다르면 무엇이 맞는지 알 수 없게 된다.
205
+ */
206
+ const bad = sessionIdentityViolations(sessionsOf(slots))
207
+ if (bad.length) {
208
+ throw new Error(
209
+ '세션 이름이 겹쳤습니다 (I7 위반). 이 상태로 띄우면 두 세션이 서로를 막지 못합니다.\n' +
210
+ bad.map((v) => ` ${v.message}`).join('\n'),
211
+ )
212
+ }
213
+
214
+ writeRegistry(repoRoot, slots)
215
+ return { slot, agent: sessionAgentName(base, slot), base }
216
+ }
217
+
218
+ /**
219
+ * 슬롯을 반납한다.
220
+ *
221
+ * 🔴 **내 pid 의 것만 지운다.** 앱이 두 개일 때, 내가 들고 있다고 믿는 번호를
222
+ * 그 사이 다른 인스턴스가 (내 pid 가 죽은 줄 알고) 가져갔을 수 있다. 그것을
223
+ * 지우면 살아있는 남의 슬롯을 비우는 것이고, 다음 세션이 같은 이름을 받는다.
224
+ */
225
+ export function release(repoRoot, slot, { pid = process.pid } = {}) {
226
+ const slots = readRegistry(repoRoot)
227
+ const key = String(slot)
228
+ const cur = slots[key]
229
+ if (!cur || cur.pid !== pid) return false
230
+ delete slots[key]
231
+ writeRegistry(repoRoot, slots)
232
+ return true
233
+ }