axmap-cli 1.3.0 → 1.3.2

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/tools/bus.mjs CHANGED
@@ -34,6 +34,10 @@ import { execFileSync, spawnSync } from 'node:child_process'
34
34
  import fs from 'node:fs'
35
35
  import path from 'node:path'
36
36
  import { fileURLToPath } from 'node:url'
37
+ import { agentNameError } from '../src/protocol.mjs'
38
+ import { readProfile, profilePath, emailError } from '../src/identity.mjs'
39
+ import { withRepoLock } from '../src/repolock.mjs'
40
+ import { randomUUID } from 'node:crypto'
37
41
 
38
42
  /** **이 프로그램**의 뿌리. 데이터가 아니라 코드를 찾을 때만 쓴다 (`who` 의 axmap.mjs). */
39
43
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -85,7 +89,11 @@ const REPO = (() => {
85
89
  })()
86
90
 
87
91
  /** 쪽지 worktree 의 루트. 커밋·push 는 여기서 돈다. */
88
- const BUS_WT = REPO ? path.join(REPO, '.axmap', 'bus') : null
92
+ const COMMON = REPO ? path.resolve(REPO, execFileSync('git', ['rev-parse', '--git-common-dir'], {
93
+ cwd: REPO, encoding: 'utf8', windowsHide: true,
94
+ }).trim()) : null
95
+ const DATA_HOME = COMMON ? (path.basename(COMMON) === '.git' ? path.dirname(COMMON) : REPO) : null
96
+ const BUS_WT = REPO ? path.join(DATA_HOME, '.axmap', 'bus') : null
89
97
  const BUS_BRANCH = 'axmap/bus'
90
98
 
91
99
  /** **쓰는 곳은 하나뿐이다.** */
@@ -273,14 +281,14 @@ const recipientsOf = (m) => String(m.to ?? 'all').split(',').map(norm).filter(Bo
273
281
  */
274
282
  function me() {
275
283
  const v = process.env.AXMAP_AGENT
276
- if (v && /^[\w.-]{1,64}$/.test(v)) return v
284
+ if (v && !agentNameError(v)) return v
277
285
  try {
278
286
  const n = execFileSync('git', ['config', 'user.name'], {
279
287
  cwd: REPO ?? process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
280
288
  }).trim()
281
289
  // 서버와 달리 여기서는 형식을 본다. 통과 못 하면 아래에서 죽는다 —
282
290
  // 못 읽는 이름을 그냥 쓰면 파일 이름과 필터가 조용히 어긋난다.
283
- if (n && /^[\w.-]{1,64}$/.test(n)) return n
291
+ if (n && !agentNameError(n)) return n
284
292
  } catch { /* 아래에서 죽는다 */ }
285
293
  console.error(
286
294
  'AXMAP_AGENT 를 먼저 정하세요 (선점과 같은 값).\n' +
@@ -319,30 +327,14 @@ function roster() {
319
327
  if (rosterCache) return rosterCache
320
328
  const byEmail = new Map()
321
329
 
322
- /**
323
- * 🔴 **이름 하나는 사람 하나에게만 붙는다. 먼저 붙은 쪽이 이긴다.**
324
- *
325
- * 이 줄이 없으면 어긋난 쪽지 한 통이 두 사람을 영구히 합친다. 실제로 그랬다 —
326
- * 도구 이름은 `alice` 인데 git 이메일이 `bob@x.com` 인 쪽지가 하나 있었고,
327
- * 그 뒤로 `alice` 가 bob 의 이름이 됐다. 그러면 bob 은 alice 가 보낸 쪽지를
328
- * **자기가 보낸 것으로 보고 안 읽음에서 지운다.**
329
- *
330
- * 이름이 어긋나 쪽지가 묻히는 것이 이 판에서 고치려던 바로 그 증상인데,
331
- * 신원을 합치는 방식으로 고치면 **자리만 바꿔서 되살아난다.**
332
- *
333
- * 순서가 곧 우선순위다: git 이력(1) → 쪽지 머리말(2) → 적어 둔 별칭(3).
334
- * git 이력이 가장 믿을 만하다 — 사람이 자기 PC 에 직접 설정한 값이다.
335
- */
336
- const nameOwner = new Map()
330
+ // Keep homonyms as separate email identities. Recipient resolution rejects
331
+ // an ambiguous label instead of silently assigning it to the first author.
337
332
  const add = (email, name) => {
338
333
  const e = norm(email)
339
334
  if (!e || !e.includes('@')) return
340
335
  if (!byEmail.has(e)) byEmail.set(e, { email: e, names: new Set() })
341
336
  const n = norm(name)
342
337
  if (!n) return
343
- const owner = nameOwner.get(n)
344
- if (owner && owner !== e) return // 이미 남의 이름이다. 합치지 않는다
345
- nameOwner.set(n, e)
346
338
  byEmail.get(e).names.add(n)
347
339
  }
348
340
 
@@ -383,6 +375,7 @@ function roster() {
383
375
  const st = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
384
376
  const email = f.replace(/\.json$/, '')
385
377
  add(email, st.name)
378
+ if (byEmail.has(norm(email))) byEmail.get(norm(email)).displayName = st.name
386
379
  for (const a of (st.aliases ?? [])) add(email, a)
387
380
  }
388
381
  } catch { /* 사람별 상태가 아직 없다. 1)·2)만으로도 명단은 선다 */ }
@@ -399,8 +392,13 @@ function roster() {
399
392
  */
400
393
  function addressesOf(who) {
401
394
  const w = norm(who)
402
- const p = roster().find((x) => x.email === w || x.names.has(w))
403
- return p ? new Set([p.email, ...p.names]) : new Set([w])
395
+ const people = roster()
396
+ const matches = people.filter((p) => p.email === w || p.names.has(w))
397
+ if (matches.length !== 1) return new Set([w])
398
+ const p = matches[0]
399
+ // A shared label must never make two mailboxes the same person. Known
400
+ // homonyms can still receive mail through their unambiguous email addresses.
401
+ return new Set([p.email, ...[...p.names].filter((n) => people.filter((x) => x.names.has(n)).length === 1)])
404
402
  }
405
403
 
406
404
  /**
@@ -466,8 +464,12 @@ function myAddresses() {
466
464
  const s = new Set()
467
465
  const e = myEmail()
468
466
  if (e) for (const a of addressesOf(e)) s.add(a)
469
- s.add(norm(me()))
470
- if (process.env.AXMAP_AGENT) s.add(norm(process.env.AXMAP_AGENT))
467
+ const ownName = (name) => {
468
+ if (roster().filter((p) => p.names.has(norm(name))).length < 2) s.add(norm(name))
469
+ }
470
+ ownName(me())
471
+ if (process.env.AXMAP_AGENT) ownName(process.env.AXMAP_AGENT)
472
+ try { const p = readProfile(); if (p) ownName(p.name) } catch { /* reported during profile sync */ }
471
473
  return s
472
474
  }
473
475
 
@@ -489,7 +491,7 @@ function myAddresses() {
489
491
  const SEEN_FILE = REPO ? path.join(REPO, '.axmap-bus-seen.json') : null
490
492
 
491
493
  /** 새 자리 — 고아 브랜치 `axmap/users` 의 worktree. 사람마다 파일 하나. */
492
- const USERS_WT = REPO ? path.join(REPO, '.axmap', 'users') : null
494
+ const USERS_WT = REPO ? path.join(DATA_HOME, '.axmap', 'users') : null
493
495
  const USERS_BRANCH = 'axmap/users'
494
496
  const usersReady = () => USERS_WT !== null && fs.existsSync(path.join(USERS_WT, '.git'))
495
497
 
@@ -528,8 +530,9 @@ function openUsers() {
528
530
  /** 이 사람의 파일 자리. **이메일이 정본**이고, 없는 사람만 이름으로 떨어진다. */
529
531
  function userKey(who) {
530
532
  const w = norm(who)
531
- const p = roster().find((x) => x.email === w || x.names.has(w))
532
- return p ? p.email : w
533
+ if (w.includes('@')) return w
534
+ const matches = roster().filter((x) => x.names.has(w))
535
+ return matches.length === 1 ? matches[0].email : w
533
536
  }
534
537
 
535
538
  const userFile = (who) => path.join(USERS_WT, 'users', `${userKey(who)}.json`)
@@ -539,19 +542,148 @@ function readUserState(who) {
539
542
  }
540
543
 
541
544
  /** 원격의 사람별 상태를 받아온다. 자주 물을 이유가 없어 느슨하게 맞춘다. */
545
+ function usersRemote() {
546
+ const configured = process.env.AXMAP_REMOTE || gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out
547
+ if (configured) return configured
548
+ const names = gitUsers(['remote'], { cwd: REPO }).out.split('\n').filter(Boolean)
549
+ if (names.length === 1) return names[0]
550
+ if (names.includes('origin')) return 'origin'
551
+ if (names.length) throw new Error('사용자 정보를 공유할 원격이 모호합니다. git config axmap.remote <원격>을 지정하세요.')
552
+ return null
553
+ }
554
+
555
+ function mustUsers(args) {
556
+ const r = gitUsers(args)
557
+ if (r.code !== 0) throw new Error(r.err || r.out || `git ${args[0]} 실패`)
558
+ return r.out
559
+ }
560
+
561
+ function mergeState(local, incoming) {
562
+ const newest = (incoming.profileUpdatedAt ?? '') > (local.profileUpdatedAt ?? '') ? incoming : local
563
+ const aliases = [...new Set([...(local.aliases ?? []), ...(incoming.aliases ?? []), local.name, incoming.name].filter(Boolean))]
564
+ const seen = [local.seen ?? '', incoming.seen ?? ''].sort().at(-1)
565
+ return { ...incoming, ...local, ...newest, aliases: aliases.filter((a) => a !== newest.name), ...(seen ? { seen } : {}) }
566
+ }
567
+
568
+ // Never reset away unpublished profiles or read receipts. A rejected push is
569
+ // retried on the latest branch, merging only the dedicated JSON user records.
570
+ function syncUsers(remote) {
571
+ if (!remote) return
572
+ const exists = gitUsers(['ls-remote', '--exit-code', remote, `refs/heads/${USERS_BRANCH}`])
573
+ if (exists.code === 2) return
574
+ if (exists.code !== 0) throw new Error(exists.err || '사용자 정보 원격을 확인하지 못했습니다.')
575
+ mustUsers(['fetch', '--quiet', remote, USERS_BRANCH])
576
+ if (gitUsers(['merge', '--ff-only', '--quiet', 'FETCH_HEAD']).code === 0) return
577
+ const merged = gitUsers(['merge', '--no-commit', '--no-ff', '--allow-unrelated-histories', 'FETCH_HEAD'])
578
+ const conflicts = mustUsers(['diff', '--name-only', '--diff-filter=U']).split('\n').filter(Boolean)
579
+ try {
580
+ for (const f of conflicts) {
581
+ if (!/^users\/[^/]+\.json$/.test(f)) throw new Error(`사용자 정보 충돌을 자동으로 풀 수 없습니다: ${f}`)
582
+ const local = JSON.parse(mustUsers(['show', `:2:${f}`]))
583
+ const incoming = JSON.parse(mustUsers(['show', `:3:${f}`]))
584
+ fs.writeFileSync(path.join(USERS_WT, f), JSON.stringify(mergeState(local, incoming), null, 2) + '\n')
585
+ mustUsers(['add', '--', f])
586
+ }
587
+ if (merged.code !== 0 && !conflicts.length) throw new Error(merged.err || merged.out)
588
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: merge user profiles'])
589
+ } catch (e) {
590
+ gitUsers(['merge', '--abort'])
591
+ throw e
592
+ }
593
+ }
594
+
595
+ function withUsers(action) {
596
+ if (!COMMON) return false
597
+ return withRepoLock(path.join(COMMON, 'axmap-users.lock'), () => {
598
+ if (!openUsers()) throw new Error('사용자 정보 브랜치를 준비하지 못했습니다.')
599
+ return action()
600
+ })
601
+ }
602
+
542
603
  function pullUsers() {
543
- if (!openUsers()) return
544
- const stampFile = path.join(REPO, '.axmap', '.users-lastfetch')
545
- // 🔴 60초. 쪽지함(기본 0초)보다 훨씬 느슨한 것은 **늦어도 손해가 없기 때문**이다.
546
- // 늦으면 다른 PC 에서 읽은 것이 여기서 한 번 더 뜬다. 그뿐이다.
547
- // 반대로 매번 물으면 도구 호출마다 통신이 한 번씩 더 붙는다 (실측 0.8초).
604
+ if (!REPO) return
605
+ const stampFile = path.join(DATA_HOME, '.axmap', '.users-lastfetch')
606
+ try { if (Date.now() - fs.statSync(stampFile).mtimeMs < 60_000) return } catch { /* first sync */ }
607
+ try {
608
+ withUsers(() => {
609
+ // A fresh local-only orphan still has its seed files unstaged.
610
+ if (gitUsers(['status', '--porcelain']).out) {
611
+ mustUsers(['add', '-A'])
612
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: preserve user state'])
613
+ }
614
+ syncUsers(usersRemote())
615
+ fs.writeFileSync(stampFile, '')
616
+ })
617
+ } catch { /* Read receipts remain conservative when synchronization fails. */ }
618
+ }
619
+
620
+ function updateUser(email, change) {
621
+ if (emailError(email)) throw new Error(emailError(email))
622
+ return withUsers(() => {
623
+ const remote = usersRemote()
624
+ for (let attempt = 0; attempt < 5; attempt++) {
625
+ if (gitUsers(['status', '--porcelain']).out) {
626
+ mustUsers(['add', '-A'])
627
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', 'axmap: preserve user state'])
628
+ }
629
+ syncUsers(remote)
630
+ const f = path.join(USERS_WT, 'users', `${email}.json`)
631
+ let cur = {}
632
+ try { cur = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) { if (e.code !== 'ENOENT') throw e }
633
+ const next = change(cur)
634
+ if (JSON.stringify(cur) !== JSON.stringify(next)) {
635
+ fs.mkdirSync(path.dirname(f), { recursive: true })
636
+ fs.writeFileSync(f, JSON.stringify(next, null, 2) + '\n')
637
+ mustUsers(['add', '--', `users/${email}.json`])
638
+ mustUsers(['commit', '--quiet', '--no-verify', '-m', `user: ${email}`])
639
+ }
640
+ if (!remote) return 'local'
641
+ const pushed = gitUsers(['push', '--quiet', remote, `HEAD:refs/heads/${USERS_BRANCH}`])
642
+ if (pushed.code === 0) {
643
+ fs.writeFileSync(path.join(DATA_HOME, '.axmap', '.users-lastfetch'), '')
644
+ return 'shared'
645
+ }
646
+ if (attempt === 4) throw new Error(pushed.err || '사용자 정보 push 실패')
647
+ }
648
+ })
649
+ }
650
+
651
+ function publishProfile() {
652
+ const profile = readProfile()
653
+ if (!profile || !REPO) return null
654
+ const email = myEmail()
655
+ if (emailError(email)) throw new Error(emailError(email))
656
+ // Successful unchanged profiles need no network more than once a minute.
657
+ const stamp = path.join(DATA_HOME, '.axmap', '.profile-shared.json')
658
+ const revision = profile.updatedAt || fs.statSync(profilePath()).mtime.toISOString()
659
+ const key = JSON.stringify([profile.name, revision, email, me(), usersRemote()])
548
660
  try {
549
- if (Date.now() - fs.statSync(stampFile).mtimeMs < 60_000) return
550
- } catch { /* 처음이다. 받아온다 */ }
551
- const remote = gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
552
- if (gitUsers(['fetch', '--quiet', remote, USERS_BRANCH]).code !== 0) return
553
- gitUsers(['reset', '--hard', '--quiet', 'FETCH_HEAD'])
554
- try { fs.writeFileSync(stampFile, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ }
661
+ const last = JSON.parse(fs.readFileSync(stamp, 'utf8'))
662
+ if (last.key === key && Date.now() - last.at < 60_000) return last.result
663
+ } catch { /* first use / changed identity */ }
664
+ const result = updateUser(email, (cur) => {
665
+ // An older PC must not undo a name correction made on another PC just
666
+ // because it became active later. Revision belongs to setup, not to use.
667
+ const name = (cur.profileUpdatedAt ?? '') > revision ? cur.name : profile.name
668
+ const aliases = [...new Set([...(cur.aliases ?? []), cur.name, profile.name, me()].filter((n) => n && n !== name))]
669
+ return { ...cur, name, aliases,
670
+ profileUpdatedAt: (cur.profileUpdatedAt ?? '') > revision ? cur.profileUpdatedAt : revision }
671
+ })
672
+ rosterCache = null
673
+ fs.writeFileSync(stamp, JSON.stringify({ key, at: Date.now(), result }) + '\n')
674
+ if (result === 'local') console.error('※ 쪽지 프로필을 로컬에만 저장했습니다 — 원격이 없어 다른 팀원에게는 아직 보이지 않습니다.')
675
+ return result
676
+ }
677
+
678
+ function messageName() {
679
+ const profile = readProfile()
680
+ if (!profile) return me()
681
+ try {
682
+ const state = JSON.parse(fs.readFileSync(path.join(USERS_WT, 'users', `${myEmail()}.json`), 'utf8'))
683
+ const revision = profile.updatedAt || fs.statSync(profilePath()).mtime.toISOString()
684
+ if ((state.profileUpdatedAt ?? '') > revision && state.name) return state.name
685
+ } catch { /* unpublished profile / custom standalone mailbox */ }
686
+ return profile.name
555
687
  }
556
688
 
557
689
  function readSeen(who) {
@@ -564,7 +696,12 @@ function readSeen(who) {
564
696
  // 그건 이 도구가 고치려던 바로 그 상태다.
565
697
  let legacy = ''
566
698
  if (SEEN_FILE) {
567
- try { legacy = JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8'))[who] ?? '' } catch { /* 없다 */ }
699
+ try {
700
+ const known = addressesOf(who)
701
+ for (const [name, id] of Object.entries(JSON.parse(fs.readFileSync(SEEN_FILE, 'utf8')))) {
702
+ if (known.has(norm(name)) && id > legacy) legacy = id
703
+ }
704
+ } catch { /* no legacy receipts */ }
568
705
  }
569
706
  return fromBranch > legacy ? fromBranch : legacy
570
707
  }
@@ -592,42 +729,15 @@ function markSeen(who, id) {
592
729
  } catch { /* 조용히 */ }
593
730
  }
594
731
 
595
- if (!openUsers()) return
732
+ const email = myEmail() || userKey(who)
596
733
  try {
597
- const f = userFile(who)
598
- const cur = readUserState(who)
599
- if ((cur.seen ?? '') >= id) return // 뒤로 가지 않는다
600
- fs.mkdirSync(path.dirname(f), { recursive: true })
601
-
602
- // 🔴 **별칭은 저절로 적힌다.** 손으로 관리하게 하면 아무도 안 한다.
603
- //
604
- // 지금 쓰고 있는 이름이 이메일도 아니고 git 이 아는 이름도 아니면, 그것은
605
- // 이 사람이 띄운 도구의 이름이다 (codex-jinmiri · jaehyeon-2 처럼). 적어
606
- // 두면 다음부터 그 이름으로 온 쪽지도 이 사람 것이 된다.
607
- const known = addressesOf(who)
608
- const now = norm(me())
609
- const aliases = [...new Set([...(cur.aliases ?? []), ...(known.has(now) ? [] : [now])])]
610
-
611
- // 이름도 같이 남긴다 — 파일 이름이 이메일이라 사람이 열었을 때 누구인지 보이게.
612
- fs.writeFileSync(f, JSON.stringify({ ...cur, name: me(), aliases, seen: id }, null, 2) + '\n')
613
-
614
- gitUsers(['add', '--', path.relative(USERS_WT, f).replace(/\\/g, '/')])
615
- // --no-verify: 연결된 worktree 는 훅을 공유한다. 이 브랜치는 사용자 코드가 아니다.
616
- gitUsers(['commit', '--quiet', '--no-verify', '-m', `seen: ${userKey(who)}`])
617
-
618
- // 🔴 push 는 **60초에 한 번**만 시도한다. 커밋은 이 PC 안이라 싸지만 push 는
619
- // 통신이고, 쪽지를 볼 때마다 붙으면 모든 도구 호출이 그만큼 느려진다.
620
- // 늦게 가도 손해가 없다 — 다른 PC 에서 그 쪽지가 한 번 더 뜰 뿐이다.
621
- const stamp = path.join(REPO, '.axmap', '.users-lastpush')
622
- let due = true
623
- try { due = Date.now() - fs.statSync(stamp).mtimeMs >= 60_000 } catch { /* 처음이다 */ }
624
- if (due) {
625
- const remote = gitUsers(['config', '--get', 'axmap.remote'], { cwd: REPO }).out || 'origin'
626
- if (gitUsers(['push', '--quiet', remote, `HEAD:${USERS_BRANCH}`]).code === 0) {
627
- try { fs.writeFileSync(stamp, '') } catch { /* 스탬프 실패는 치명적이지 않다 */ }
628
- }
629
- }
630
- } catch { /* 조용히 — 못 찍으면 다음에 한 번 더 뜰 뿐이다 */ }
734
+ updateUser(email, (cur) => {
735
+ const profile = readProfile()
736
+ const name = profile ? messageName() : (cur.name || me())
737
+ const aliases = [...new Set([...(cur.aliases ?? []), me()].filter((n) => n !== name))]
738
+ return { ...cur, name, aliases, seen: (cur.seen ?? '') > id ? cur.seen : id }
739
+ })
740
+ } catch { /* Failed read receipts cause a repeat, never a hidden message. */ }
631
741
  }
632
742
 
633
743
  const stamp = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
@@ -734,7 +844,7 @@ function stdin() {
734
844
  }
735
845
 
736
846
  function post({ to, subject, body, replyTo = null }) {
737
- const from = me()
847
+ const from = messageName()
738
848
  // 부르는 쪽이 하나를 주든 여럿을 주든 여기서는 목록 하나로 본다.
739
849
  let tos = (Array.isArray(to) ? to : (to == null ? [] : [to])).map((s) => String(s).trim()).filter(Boolean)
740
850
 
@@ -761,15 +871,17 @@ function post({ to, subject, body, replyTo = null }) {
761
871
  // 나중에 `ensureBus` 가 "정상적인 쪽지함이 아닙니다 — 지운 뒤 다시 하세요"
762
872
  // 라고 안내한다. **안 간 쪽지를 지우라고 시키는 것**이다. 여기서 멈추면
763
873
  // 사람은 init 한 번 하고 다시 보내면 된다 — 잃는 것이 없다.
764
- if (!process.env.AXMAP_BUS_DIR && !busReady()) {
765
- console.error(
766
- '쪽지함이 아직 없습니다. 한 번만 준비하면 됩니다:\n' +
767
- ' axmap init (MCP 에서는 ax_init)\n\n' +
768
- '준비 전에 쪽지를 쓰지 않습니다 — 여기 남으면 아무에게도 안 가고,\n' +
769
- '나중에 쪽지함을 만들 때 지워야 할 것으로 보입니다.',
770
- )
874
+ if (!openBus()) {
875
+ reportBusProblem()
771
876
  process.exit(1)
772
877
  }
878
+ for (const one of tos) {
879
+ const matches = roster().filter((p) => p.email === norm(one) || p.names.has(norm(one)))
880
+ if (norm(one) !== 'all' && !norm(one).includes('@') && matches.length > 1) {
881
+ console.error(`같은 이름의 수신자가 여러 명입니다: ${one}\n이메일로 지정하세요: ${matches.map((p) => p.email).join(', ')}`)
882
+ process.exit(1)
883
+ }
884
+ }
773
885
  // 🔴 **모르는 주소로는 안 보낸다.**
774
886
  //
775
887
  // 지금까지는 받는 사람 이름을 아무도 검사하지 않았다. 오타 하나면 그 쪽지는
@@ -785,7 +897,7 @@ function post({ to, subject, body, replyTo = null }) {
785
897
  if (norm(one) === 'all') continue
786
898
  const known = roster().some((p) => p.email === norm(one) || p.names.has(norm(one)))
787
899
  if (!known) {
788
- const near = nearMatches(one)
900
+ const near = norm(one).includes('@') ? [] : nearMatches(one)
789
901
  if (near.length) {
790
902
  // 🔴 **거부하는 것은 오타뿐이다.** 아는 이름과 한 글자 차이거나 그 이름의
791
903
  // 앞부분이면 새 사람이 아니라 손이 미끄러진 것이다. 실측에서 실제로
@@ -806,9 +918,15 @@ function post({ to, subject, body, replyTo = null }) {
806
918
  }
807
919
  }
808
920
 
921
+ // Resolve labels now: later profile corrections cannot redirect an old note.
922
+ tos = [...new Set(tos.map((one) => {
923
+ if (norm(one) === 'all') return 'all'
924
+ const matches = roster().filter((p) => p.email === norm(one) || p.names.has(norm(one)))
925
+ return matches.length === 1 ? matches[0].email : one
926
+ }))]
809
927
  fs.mkdirSync(BOX, { recursive: true })
810
928
  const now = new Date()
811
- const id = `${stamp(now)}-${from}-${slug(subject)}`
929
+ const id = `${stamp(now)}-${from}-${slug(subject)}-${randomUUID().slice(0, 8)}`
812
930
  // 🔴 `from` 은 **이름 그대로** 둔다. 파일 이름에 들어가는 값이라 바꾸면 예전
813
931
  // 쪽지와 형식이 갈린다. 대신 이메일을 한 줄 더 적는다 — 이름이 나중에
814
932
  // 바뀌어도 **누구였는지는 남는다.** 이것이 명단을 세우는 재료가 된다.
@@ -918,7 +1036,7 @@ function selfCmd() {
918
1036
  function list() {
919
1037
  pull()
920
1038
  const all = readAll()
921
- const to = has('--mine') ? me() : flag('--to')
1039
+ const to = has('--mine') ? (myEmail() || me()) : flag('--to')
922
1040
  const from = flag('--from')
923
1041
 
924
1042
  // 🔴 **한 사람에게는 주소가 여럿이다.** `--to yeaseung-lee` 로 물어도
@@ -958,7 +1076,7 @@ function list() {
958
1076
  // **내 안 읽은 쪽지로 다시 잡힌다.** 주소 전부와 견준다.
959
1077
  const mine = has('--mine') ? myAddresses() : addressesOf(who)
960
1078
  rows = rows.filter((m) => m.id > seen
961
- && !mine.has(norm(m.from)) && !(m.fromEmail && mine.has(norm(m.fromEmail))))
1079
+ && !(m.fromEmail ? mine.has(norm(m.fromEmail)) : mine.has(norm(m.from))))
962
1080
  }
963
1081
 
964
1082
  // 🔴 **못 읽었으면 "없음" 이라고 답하지 않는다.** 이 세 줄이 이 파일에서 제일
@@ -1054,11 +1172,25 @@ function seen() {
1054
1172
  console.error(`찍을 쪽지 id 를 주세요. 예: ${selfCmd()} seen <아이디> [<아이디>…]`)
1055
1173
  process.exit(1)
1056
1174
  }
1057
- markSeen(has('--mine') ? me() : (flag('--to') || me()),
1175
+ markSeen(has('--mine') ? (myEmail() || me()) : (flag('--to') || me()),
1058
1176
  ids.reduce((hi, id) => (id > hi ? id : hi), ''))
1059
1177
  }
1060
1178
 
1179
+ let profileResult = null
1180
+ if (['profile', 'contacts', 'post', 'reply', 'list', 'read'].includes(cmd)) {
1181
+ try { profileResult = publishProfile() } catch (e) {
1182
+ console.error(`경고: 쪽지 프로필을 공유하지 못했습니다 — ${e.message}`)
1183
+ if (cmd === 'profile') process.exit(1)
1184
+ }
1185
+ }
1186
+
1061
1187
  switch (cmd) {
1188
+ case 'profile':
1189
+ if (profileResult) console.log(`쪽지 프로필: ${profileResult}`)
1190
+ break
1191
+ case 'contacts':
1192
+ for (const p of roster()) console.log(`${p.displayName || [...p.names][0] || p.email}\t${p.email}`)
1193
+ break
1062
1194
  case 'post': post({ to: recipientArgs(), subject: flag('--subject'), body: stdin() }); break
1063
1195
  case 'reply': {
1064
1196
  const target = args[1]
@@ -1068,7 +1200,7 @@ switch (cmd) {
1068
1200
  const src = readAll().find((x) => x.id === target || x.id.includes(target))
1069
1201
  if (!src && busProblem) { reportBusProblem(); process.exit(1) }
1070
1202
  if (!src) { console.error(`없는 쪽지: ${target}`); process.exit(1) }
1071
- post({ to: src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
1203
+ post({ to: src.fromEmail || src.from, subject: flag('--subject') ?? `Re: ${src.subject}`, body: stdin(), replyTo: src.id })
1072
1204
  break
1073
1205
  }
1074
1206
  case 'list': list(); break
@@ -1087,6 +1219,7 @@ switch (cmd) {
1087
1219
  ${selfCmd()} seen <아이디> [<아이디>…] 보여준 쪽지만 읽음으로 찍는다
1088
1220
  ${selfCmd()} reply <아이디> < 본문.md
1089
1221
  ${selfCmd()} who 지금 누가 무엇을 잡고 있나
1222
+ ${selfCmd()} contacts 이 저장소의 쪽지 수신자 이름·이메일
1090
1223
 
1091
1224
  목록을 잘라서 보여주는 쪽은 --no-mark 로 읽기만 하고, 자기가 그린 아이디만
1092
1225
  seen 에 넘기세요. 안 그러면 화면에 뜬 적 없는 쪽지가 읽음이 되어 안 보입니다.
@@ -105,6 +105,7 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
105
105
  */
106
106
  const sameEntry = (cur, want) =>
107
107
  !!cur && cur.command === want.command &&
108
+ !cur.cwd && !Object.hasOwn(cur.env ?? {}, 'AXMAP_REPO') &&
108
109
  JSON.stringify(cur.args || []) === JSON.stringify(want.args) &&
109
110
  Object.entries(want.env).every(([k, v]) => ((cur.env || {})[k] === v))
110
111
 
@@ -118,6 +119,8 @@ function doClaude(bin, repo) {
118
119
  snippetClaude()
119
120
  return
120
121
  }
122
+ want.env = { ...(cur?.env ?? {}), ...want.env }
123
+ delete want.env.AXMAP_REPO
121
124
  if (sameEntry(cur, want)) { ok('claude', '이미 등록돼 있습니다 (user 범위)'); return }
122
125
 
123
126
  // 문서에 있다고 믿고 부르지 않는다 — 먼저 물어본다. doCodex 와 같다.
@@ -133,7 +136,7 @@ function doClaude(bin, repo) {
133
136
  '--', want.command, ...want.args]
134
137
 
135
138
  if (DRY) {
136
- hm('claude', `(dry-run) ${cur ? 'claude mcp remove axmap -s user 뒤 ' : ''}claude ${add.join(' ')}`)
139
+ hm('claude', '(dry-run) Claude 사용자 설정의 axmap 서버 경로·환경을 갱신합니다')
137
140
  return
138
141
  }
139
142
 
@@ -220,6 +223,8 @@ function mergeJson(label, file) {
220
223
  }
221
224
 
222
225
  const servers = cfg.mcpServers ?? {}
226
+ want.env = { ...(servers.axmap?.env ?? {}), ...want.env }
227
+ delete want.env.AXMAP_REPO
223
228
  if (same(servers.axmap, want)) { ok(label, `이미 등록돼 있습니다: ${file}`); return }
224
229
 
225
230
  const had = servers.axmap !== undefined
@@ -261,31 +266,56 @@ function snippetJson() {
261
266
  // 🔴 TOML 을 직접 파싱하지 않는다. 파서를 새로 짜는 것은 이 저장소의 "의존성 없음" 과
262
267
  // "판정을 복잡하게 만들지 않는다" 에 둘 다 어긋나고, 어설픈 파서가 남의 config.toml 을
263
268
  // 깨뜨리는 것이 이 작업에서 나올 수 있는 최악의 결과다. 쓰는 일은 codex 자신에게 맡긴다.
264
- function doCodex(bin) {
265
- const run = (args) => spawnSync(bin, args, { encoding: 'utf8', windowsHide: true })
269
+ export function doCodex(bin, { run: injectedRun, dry = DRY } = {}) {
270
+ const run = injectedRun ?? ((args) => spawnSync(bin, args, { cwd: os.homedir(), encoding: 'utf8', windowsHide: true }))
266
271
 
267
272
  const help = run(['mcp', '--help'])
268
273
  if (help.status !== 0 || !/\badd\b/.test(`${help.stdout}${help.stderr}`)) {
269
274
  hm('codex', "'codex mcp add' 를 확인하지 못했습니다 — 손으로 넣으세요")
270
275
  snippetToml()
271
- return
276
+ return false
272
277
  }
273
278
 
274
- // 이미 들어 있으면 다시 넣지 않는다. setup 은 몇 번을 돌려도 안전해야 한다.
275
- const list = run(['mcp', 'list'])
276
- if (list.status === 0 && /\baxmap\b/.test(`${list.stdout}`)) {
277
- ok('codex', `이미 등록돼 있습니다 (codex mcp list)`)
278
- return
279
+ // Check the actual command and environment, not just the server name. An old
280
+ // registration may still point at a temporary install or pin AXMAP_REPO.
281
+ const list = run(['mcp', 'list', '--json'])
282
+ let servers
283
+ try { servers = JSON.parse(list.stdout) } catch { /* reported below */ }
284
+ if (list.status !== 0 || !Array.isArray(servers)) {
285
+ no('codex', '기존 MCP 설정을 확인하지 못했습니다 — 설정을 덮어쓰지 않았습니다')
286
+ return false
287
+ }
288
+ const current = servers.find((s) => s.name === 'axmap')
289
+ const transport = current?.transport
290
+ const want = entry()
291
+ const wantedEnv = { ...(transport?.env ?? {}), ...want.env }
292
+ delete wantedEnv.AXMAP_REPO
293
+ if (transport?.type === 'stdio' && sameEntry(transport, { ...want, env: wantedEnv }) &&
294
+ !transport.cwd && !Object.hasOwn(transport.env ?? {}, 'AXMAP_REPO') && current.enabled !== false) {
295
+ ok('codex', '전역 등록이 최신입니다 — 실행 위치의 repo를 사용합니다')
296
+ return true
279
297
  }
280
298
 
281
- const add = ['mcp', 'add', 'axmap', '--env', 'AXMAP_ACTOR=agent', '--env', 'AXMAP_TTL=45m', '--', 'node', SERVER]
282
- if (DRY) { hm('codex', `(dry-run) codex ${add.join(' ')}`); return }
299
+ const add = ['mcp', 'add', 'axmap', ...Object.entries(wantedEnv).flatMap(([k, v]) => ['--env', `${k}=${v}`]), '--', want.command, ...want.args]
300
+ if (dry) { hm('codex', '(dry-run) codex mcp add로 전역 axmap 서버 경로·환경을 갱신합니다'); return true }
283
301
 
284
302
  const r = run(add)
285
- if (r.status === 0) ok('codex', 'codex mcp add 로 등록했습니다')
303
+ if (r.status === 0) {
304
+ const check = run(['mcp', 'list', '--json'])
305
+ let saved
306
+ try { saved = JSON.parse(check.stdout)?.find((s) => s.name === 'axmap') } catch { /* below */ }
307
+ if (check.status === 0 && sameEntry(saved?.transport, { ...want, env: wantedEnv }) &&
308
+ !saved.transport.cwd && !Object.hasOwn(saved.transport.env ?? {}, 'AXMAP_REPO')) {
309
+ ok('codex', '전역 등록을 갱신했습니다 — 실행 위치의 repo를 사용합니다')
310
+ return true
311
+ }
312
+ no('codex', '등록 후 서버 경로·작업 폴더 설정이 일치하지 않습니다')
313
+ return false
314
+ }
286
315
  else {
287
316
  no('codex', `codex mcp add 가 실패했습니다 (${(r.stderr || r.stdout || '').trim().split('\n')[0] || `exit ${r.status}`})`)
288
317
  snippetToml()
318
+ return false
289
319
  }
290
320
  }
291
321
 
@@ -339,7 +369,7 @@ function main() {
339
369
  any = true
340
370
  if (a.id === 'claude') doClaude(bin, root)
341
371
  else if (a.id === 'agy') doAgy()
342
- else if (a.id === 'codex') doCodex(bin)
372
+ else if (a.id === 'codex' && !doCodex(bin)) return 1
343
373
  }
344
374
 
345
375
  if (!any) hm('CLI', '이 PC 에서 AI CLI 를 찾지 못했습니다 — 나중에 다시 실행하세요')
@@ -350,4 +380,4 @@ function main() {
350
380
  return 0
351
381
  }
352
382
 
353
- process.exit(main())
383
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exit(main())