axmap-cli 1.7.0 → 1.8.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.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: axMap 세션·선점·받은 편지함을 터미널 카드로 보여 줍니다
3
+ ---
4
+
5
+ 현재 Git 저장소를 대상으로 별도의 대화형 터미널에서 `axmap view --cwd <현재 저장소 절대경로>`를 실행하도록 안내한다. 이 명령은 터미널에서 직접 갱신되며 `q`로 닫는다. 도구가 대화형 터미널을 제공하지 않는다면 `axmap view --cwd <현재 저장소 절대경로> --once`의 결과를 스냅샷으로 보여 주고 실시간 화면이라고 주장하지 않는다. 저장소가 모호하면 다른 저장소를 임의로 선택하지 않는다.
6
+
7
+ 관측은 작업을 새로 시작하거나 메시지를 회수·전송하지 않는다. 실제 등록·관측된 상태만 보여 주며 미확인 값을 추정하지 않는다.
8
+
9
+ 개발 중인 axMap 소스 체크아웃에서는 설치본이 구버전일 수 있다. `axmap view --help`를 확인하고 해당 명령이 없으면 소스 체크아웃의 `node bin/axmap.mjs view --cwd <현재 저장소 절대경로>`를 사용한다.
package/README.ko.md CHANGED
@@ -23,6 +23,8 @@ axmap setup
23
23
 
24
24
  지원되는 MCP Apps 호스트에서는 대화 안에서 파이프라인을 확인하고, Claude Code·agy에서는 기존 상태줄에 진행을 추가할 수 있습니다. 호스트별 지원이 다르며 ChatGPT·Claude 앱의 실제 임베디드 화면은 아직 검증하지 않았습니다. [클라이언트 설정과 제한](docs/PIPELINE-CLIENTS.md). 선택적으로 별도 터미널의 `axmap monitor`도 제공합니다([사용법](docs/MONITOR.md)).
25
25
 
26
+ 터미널에서 `axmap view --cwd <저장소>`를 실행하면 관측된 로컬 세션·선점·대기 메시지·최근 이벤트를 카드로 볼 수 있습니다. 화면은 자동 갱신되며 `q` 또는 Esc로 닫습니다. `--once`는 한 번만 출력하고 `--json`은 구조화된 결과를 제공합니다. 등록되지 않은 모든 에이전트나 GitLab 활동까지 표시하는 것은 아닙니다. [화면 사용법](docs/VIEW.md).
27
+
26
28
  ---
27
29
 
28
30
  ## 변경 기록
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # axMap
2
2
 
3
+ Run `axmap view --cwd <repository>` to see observed local sessions, claims, pending mailbox messages and recent events in a terminal dashboard. The view refreshes interactively; press `q` or Esc to exit. Use `--once` for a snapshot or `--json` for structured output. See [the view guide](docs/VIEW.md). The display reflects available observations, not a complete view of every agent or GitLab activity.
4
+
3
5
  **Several AI agents. One repository. Clear ownership before every edit.**
4
6
 
5
7
  axMap is a collaboration CLI that claims files and folders before work begins and rejects overlapping claims.
package/bin/axmap.mjs CHANGED
@@ -2625,6 +2625,7 @@ const RUNNERS = {
2625
2625
  run: ['tools/run.mjs', '사용자가 여는 AI CLI를 연결한다'],
2626
2626
  bridge: ['tools/bridge.mjs', '열린 로컬 AI 세션에 작업을 전달한다'],
2627
2627
  monitor: ['tools/monitor.mjs', '세션과 요청 파이프라인을 실시간으로 표시한다'],
2628
+ view: ['tools/view.mjs', '세션·선점·받은 편지함을 터미널 카드로 표시한다'],
2628
2629
  pipeline: ['tools/pipeline.mjs', '대화 안 파이프라인 표시와 CLI 상태줄을 연결한다'],
2629
2630
  gate: ['governance/gate.mjs', '이 MR 이 정족수를 채웠는가'],
2630
2631
  vote: ['governance/vote.mjs', '표를 던진다'],
@@ -2837,6 +2838,7 @@ const HELP = `axmap - AI 에이전트 작업 선점 프로토콜
2837
2838
  axmap bus 에이전트 사이 쪽지함 (post · list · read · reply)
2838
2839
  axmap bridge 로컬 세션·자동 대화 (serve · sessions · send · chat · dialogue)
2839
2840
  axmap monitor 에이전트·요청 파이프라인·최근 이벤트 실시간 화면
2841
+ axmap view 세션·선점·받은 편지함을 카드로 실시간 표시
2840
2842
  axmap pipeline 대화 안 표시·Claude/agy 상태줄 연결 (--help)
2841
2843
  axmap run agy [인수...] 연결된 Antigravity CLI를 직접 연다
2842
2844
  axmap mr-target 이 브랜치가 저 브랜치로 갈 수 있는가
@@ -2881,6 +2883,7 @@ switch (cmd) {
2881
2883
  case 'setup':
2882
2884
  case 'bridge':
2883
2885
  case 'monitor':
2886
+ case 'view':
2884
2887
  case 'pipeline':
2885
2888
  case 'run':
2886
2889
  case 'gate':
package/docs/VIEW.md ADDED
@@ -0,0 +1,19 @@
1
+ # 터미널에서 axMap 보기
2
+
3
+ 설치한 패키지에서 다음 명령으로 실행한다.
4
+
5
+ ```powershell
6
+ axmap view --cwd C:\work\my-repo
7
+ ```
8
+
9
+ 소스 체크아웃에서 확인할 때는 `node bin/axmap.mjs view --cwd <repo>`를 사용한다.
10
+
11
+ 연결된 세션을 우선해 카드로 보여 주고 현재 선점, 등록된 세션의 대기 중인 로컬 편지와 최근 관측 이벤트를 표시한다. 실시간 갱신 중 `q`, Esc 또는 Ctrl+C로 화면을 닫는다. 진행 중인 작업은 계속된다. 창 크기에 따라 카드가 한 줄 또는 여러 줄로 배치된다.
12
+
13
+ `--once`는 한 번만 출력하며, `--json`은 같은 관측 자료를 JSON으로 출력한다. `--interval 1000`은 수집 간격이고 `--no-color` 또는 `NO_COLOR`는 색을 끈다. 파이프나 리디렉션에서는 스냅샷만 출력한다. 터미널 글꼴은 사용자의 터미널 설정을 따른다.
14
+
15
+ 수신자별 편지함을 읽기만 한다. 읽음 처리, 만료 상태 저장, 메시지 전송, 모델 요청 및 승인 조작은 하지 않는다. 만료 시각이 지난 대기 메시지는 화면 집계에서 제외하고 파일은 수정하지 않는다. 편지함을 읽을 수 없으면 빈 편지함으로 단정하지 않고 오류를 보여 준다. 기존 모니터와 마찬가지로 요청의 관측 결과 확인 과정은 기존 요청 기록을 갱신할 수 있다. 출력에 메시지 주제 또는 내용이 포함될 수 있으니 공유할 때 범위를 확인한다.
16
+
17
+ 포트폴리오 그림은 설명용 애니메이션이며 이 명령은 실제 관측값을 보여 준다. 확인하지 못한 작업률·토큰·연결 상태를 임의로 만들지 않는다. 등록하지 않은 AI 대화는 표시되지 않는다.
18
+
19
+ Claude Code에서 프로젝트 명령을 설치한 환경은 `/axmap-view`로 안내를 부를 수 있다. Codex 등 다른 호스트는 `skills/axmap-view/SKILL.md`를 해당 호스트의 방법으로 설치해야 하며, 모든 터미널에서 `/axmap-view` 자체가 실행되는 것은 아니다. 확실한 실행 방법은 `axmap view`다.
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.7.0",
4
+ "version": "1.8.0",
5
5
  "//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
6
6
  "//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
7
7
  "publishConfig": {
@@ -34,6 +34,7 @@
34
34
  "tools/",
35
35
  "skills/",
36
36
  "governance/",
37
+ "docs/VIEW.md",
37
38
  ".claude/",
38
39
  "CLAUDE.md"
39
40
  ],
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: axmap-view
3
+ description: Show the current Git repository's axMap sessions, active claims, pending mailbox and recent events in a terminal view.
4
+ ---
5
+
6
+ Use the current Git repository or the user's explicitly designated repository. Run `axmap view --cwd <absolute-repository>` in an interactive terminal; `q`, Esc or Ctrl+C closes the view. If the host cannot provide an interactive terminal, run `axmap view --cwd <absolute-repository> --once` and identify it as a snapshot. Slash-menu availability depends on the host and installed skill; do not imply that a bare slash command works in every terminal.
7
+
8
+ The display reads local evidence only. It does not start an AI session, submit work, send or collect mail, or imply that an unobserved session is idle. Never invent progress percentages or token counts.
@@ -3,12 +3,13 @@ import path from 'node:path';
3
3
  import { createMonitor, renderMonitor } from './monitor.mjs';
4
4
  import { errorMessage } from './session-types.mjs';
5
5
  import { renderPipelineGraph } from './pipeline-graph.mjs';
6
+ import { createView, renderView } from './view.mjs';
6
7
  export async function runMonitor(args) {
7
8
  if (args.includes('--help') || args.includes('-h')) {
8
9
  console.log('axmap monitor [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n세션·요청 파이프라인·자동 대화·선점·최근 이벤트를 실시간으로 표시합니다.\nTTY에서는 계속 갱신하며, 파이프/리디렉션에서는 한 번만 출력합니다. q / Esc / Ctrl+C 종료.');
9
10
  return;
10
11
  }
11
- const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color', '--graph']);
12
+ const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color', '--graph', '--view']);
12
13
  for (let i = 0; i < args.length; i++) {
13
14
  if (!allowed.has(args[i]))
14
15
  throw new Error(`알 수 없는 옵션: ${args[i]}`);
@@ -22,8 +23,9 @@ export async function runMonitor(args) {
22
23
  const interval = Number(option('--interval') || 1000);
23
24
  if (!Number.isInteger(interval) || interval < 250 || interval > 60000)
24
25
  throw new Error('interval은 250~60000ms 정수여야 합니다.');
25
- const collect = createMonitor(path.resolve(option('--cwd') || process.cwd()));
26
- const render = args.includes('--graph') ? renderPipelineGraph : renderMonitor;
26
+ const cwd = path.resolve(option('--cwd') || process.cwd());
27
+ const collect = args.includes('--view') ? createView(cwd) : createMonitor(cwd);
28
+ const render = args.includes('--view') ? renderView : args.includes('--graph') ? renderPipelineGraph : renderMonitor;
27
29
  const live = !!process.stdout.isTTY && !!process.stdin.isTTY && process.env.TERM !== 'dumb' && !args.includes('--once') && !args.includes('--json');
28
30
  const color = !!process.stdout.isTTY && !args.includes('--no-color') && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb';
29
31
  if (!live) {
@@ -3,13 +3,14 @@ import { createMonitor, renderMonitor } from './monitor.mjs'
3
3
  import type { MonitorSnapshot } from './monitor.mjs'
4
4
  import { errorMessage } from './session-types.mjs'
5
5
  import { renderPipelineGraph } from './pipeline-graph.mjs'
6
+ import { createView, renderView } from './view.mjs'
6
7
 
7
8
  export async function runMonitor(args: string[]): Promise<void> {
8
9
  if (args.includes('--help') || args.includes('-h')) {
9
10
  console.log('axmap monitor [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n세션·요청 파이프라인·자동 대화·선점·최근 이벤트를 실시간으로 표시합니다.\nTTY에서는 계속 갱신하며, 파이프/리디렉션에서는 한 번만 출력합니다. q / Esc / Ctrl+C 종료.')
10
11
  return
11
12
  }
12
- const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color', '--graph'])
13
+ const allowed = new Set(['--cwd', '--interval', '--once', '--json', '--no-color', '--graph', '--view'])
13
14
  for (let i = 0; i < args.length; i++) {
14
15
  if (!allowed.has(args[i]!)) throw new Error(`알 수 없는 옵션: ${args[i]}`)
15
16
  if (args[i] === '--cwd' || args[i] === '--interval') {
@@ -20,8 +21,9 @@ export async function runMonitor(args: string[]): Promise<void> {
20
21
  const option = (name: string) => { const index = args.indexOf(name); return index < 0 ? undefined : args[index + 1] }
21
22
  const interval = Number(option('--interval') || 1000)
22
23
  if (!Number.isInteger(interval) || interval < 250 || interval > 60000) throw new Error('interval은 250~60000ms 정수여야 합니다.')
23
- const collect = createMonitor(path.resolve(option('--cwd') || process.cwd()))
24
- const render = args.includes('--graph') ? renderPipelineGraph : renderMonitor
24
+ const cwd = path.resolve(option('--cwd') || process.cwd())
25
+ const collect = args.includes('--view') ? createView(cwd) : createMonitor(cwd)
26
+ const render = args.includes('--view') ? renderView : args.includes('--graph') ? renderPipelineGraph : renderMonitor
25
27
  const live = !!process.stdout.isTTY && !!process.stdin.isTTY && process.env.TERM !== 'dumb' && !args.includes('--once') && !args.includes('--json')
26
28
  const color = !!process.stdout.isTTY && !args.includes('--no-color') && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'
27
29
  if (!live) {
package/src/view.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // Generated from src/view.mts. Edit the .mts source and run npm run build.
2
+ /** Read-only terminal view of observed sessions, claims, mail and events. */
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { bridgeHome } from './bridge.mjs';
6
+ import { createMonitor, fitTerminal, terminalText } from './monitor.mjs';
7
+ const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
8
+ export function createView(cwd, options = {}) {
9
+ const collect = createMonitor(cwd, options);
10
+ return async () => {
11
+ const snapshot = await collect(), mail = [], mailIssues = [];
12
+ if (snapshot.issues.some(issue => issue.startsWith('세션 확인:')))
13
+ mailIssues.push('받은 편지함: 세션 목록을 확인하지 못함');
14
+ // Reading does not create mailboxes, expire records, collect messages or wake a model.
15
+ for (const session of snapshot.sessions.slice(0, 100)) {
16
+ if (!uuid.test(session.id)) {
17
+ mailIssues.push('받은 편지함: 세션 ID 확인 필요');
18
+ continue;
19
+ }
20
+ const file = path.join(bridgeHome(), 'mailboxes', `${session.id}.json`);
21
+ try {
22
+ if (fs.statSync(file).size > 1024 * 1024)
23
+ throw new Error('파일 크기 초과');
24
+ const box = JSON.parse(fs.readFileSync(file, 'utf8'));
25
+ if (!box || typeof box !== 'object' || box.version !== 1 || !Array.isArray(box.messages))
26
+ throw new Error('형식 오류');
27
+ for (const m of box.messages) {
28
+ if (!m || typeof m !== 'object')
29
+ throw new Error('메시지 형식 오류');
30
+ const entry = m;
31
+ if (entry.repo !== snapshot.repo || entry.state !== 'queued')
32
+ continue;
33
+ if (entry.to !== session.id || typeof entry.from !== 'string' || typeof entry.text !== 'string' || typeof entry.expiresAt !== 'string' || !Number.isFinite(Date.parse(entry.expiresAt)))
34
+ throw new Error('메시지 형식 오류');
35
+ if (Date.parse(entry.expiresAt) <= Date.now())
36
+ continue;
37
+ mail.push({ to: session.id, from: entry.from, topic: typeof entry.topic === 'string' ? entry.topic : '', text: entry.text });
38
+ }
39
+ }
40
+ catch (error) {
41
+ if (error.code !== 'ENOENT')
42
+ mailIssues.push(`받은 편지함 ${session.id.slice(0, 8)}: 읽기 실패`);
43
+ }
44
+ }
45
+ if (snapshot.sessions.length > 100)
46
+ mailIssues.push('받은 편지함: 앞의 세션 100개만 확인');
47
+ return { ...snapshot, mail, mailIssues };
48
+ };
49
+ }
50
+ const labels = { idle: '대기', running: '작업 중', closed: '종료', awaiting_user: '승인 대기', unknown: '상태 미확인', starting: '시작 중', settling: '대기 전환 중' };
51
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
52
+ function cells(value) {
53
+ return [...segmenter.segment(value)].reduce((n, { segment }) => {
54
+ const cp = segment.codePointAt(0) || 0;
55
+ return n + (/\p{Extended_Pictographic}/u.test(segment) || cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff || cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0x20000) ? 2 : /^\p{Mark}+$/u.test(segment) ? 0 : 1);
56
+ }, 0);
57
+ }
58
+ function pad(value, width) {
59
+ const text = fitTerminal(value, Math.max(0, width));
60
+ return text + ' '.repeat(Math.max(0, width - cells(text)));
61
+ }
62
+ export function renderView(snapshot, { columns = 100, rows = 36, color = false, frame = 0 } = {}) {
63
+ const width = Math.max(1, Math.floor(columns)), height = Math.max(1, Math.floor(rows)), lines = [];
64
+ if (width < 20)
65
+ return [fitTerminal('axMap · 화면이 좁습니다', width), fitTerminal('q 종료', width)].slice(0, height).join('\n');
66
+ const view = snapshot;
67
+ const palette = {
68
+ text: '194;197;205', muted: '137;140;150', border: '78;82;94', accent: '172;158;199',
69
+ claude: '207;157;134', codex: '143;174;214', agy: '139;187;165', warning: '212;171;125',
70
+ };
71
+ const paint = (text, tone) => color ? `\x1b[38;2;${palette[tone]}m${text}\x1b[0m` : text;
72
+ const line = (text, tone = 'text') => lines.push(paint(fitTerminal(text, width), tone));
73
+ const paintBoxLine = (text, index, last, tone, colorStatus = false) => {
74
+ if (index === 0 || index === last)
75
+ return paint(text, 'border');
76
+ return paint(text.slice(0, 1), 'border') + paint(text.slice(1, -1), index === 1 || colorStatus && index === 3 ? tone : 'text') + paint(text.slice(-1), 'border');
77
+ };
78
+ const box = (title, content, w) => {
79
+ const n = Math.max(1, w - 2);
80
+ return [`┌${'─'.repeat(n)}┐`, ...[title, ...content].map(s => `│${pad(' ' + terminalText(s), n)}│`), `└${'─'.repeat(n)}┘`];
81
+ };
82
+ line('axMap VIEW · 실제 로컬 관측', 'accent');
83
+ line(`${snapshot.cwd} · ${snapshot.checkedAt}`, 'muted');
84
+ const sessions = [...snapshot.sessions].sort((a, b) => Number(b.connection === 'connected') - Number(a.connection === 'connected')).slice(0, 3);
85
+ const cols = width >= 90 ? 3 : width >= 58 ? 2 : 1;
86
+ const cardWidth = Math.max(3, Math.floor((width - (cols - 1) * 2) / cols));
87
+ const cards = sessions.map(s => {
88
+ const active = snapshot.requests.some(r => r.to === s.id && ['sending', 'submitted'].includes(r.state));
89
+ const connection = { closed: '연결 종료', unreachable: '연결 불가', unverified: '연결 미확인' };
90
+ const state = s.connection === 'connected' ? labels[s.state] || '상태 미확인' : connection[s.connection] || '연결 미확인';
91
+ return box(`${s.provider.toUpperCase()} · ${s.id.slice(0, 8)}`, [s.role || '역할 미확인', `${active ? ['◐', '◓', '◑', '◒'][frame % 4] : '○'} ${state}`, s.model || '모델 미확인'], cardWidth);
92
+ });
93
+ for (let start = 0; start < cards.length; start += cols) {
94
+ const group = cards.slice(start, start + cols);
95
+ for (let i = 0; i < group[0].length; i++)
96
+ lines.push(group.map((card, k) => {
97
+ const provider = sessions[start + k].provider.toLowerCase();
98
+ const tone = provider === 'codex' ? 'codex' : provider === 'agy' ? 'agy' : provider === 'claude' ? 'claude' : 'accent';
99
+ return paintBoxLine(card[i], i, card.length - 1, tone, true);
100
+ }).join(' '));
101
+ }
102
+ if (!sessions.length)
103
+ line('등록된 세션 없음 · bridge 연결 후 표시', 'muted');
104
+ const claims = snapshot.claims.flatMap(c => c.paths.map(p => `${terminalText(c.agent)} · ${terminalText(p)}`));
105
+ const mails = view.mail?.map(m => `${terminalText(m.from)} → ${m.to.slice(0, 8)} · ${terminalText(m.topic || m.text)}`) || [];
106
+ const left = [`LEDGER · 현재 선점 ${snapshot.claims.length}건`, ...(claims.slice(0, 4).length ? claims.slice(0, 4) : ['현재 선점 없음'])];
107
+ const right = [`MAILBOX · ${view.mailIssues?.length ? '일부 확인 실패' : `대기 ${view.mail?.length ?? 0}건`}`, ...(mails.slice(0, 4).length ? mails.slice(0, 4) : [view.mailIssues?.length ? '대기 건수 미확인' : '대기 메시지 없음'])];
108
+ if (width >= 58) {
109
+ const lw = Math.floor((width - 2) / 2), rw = width - lw - 2, count = Math.max(left.length, right.length);
110
+ const a = box(left[0], Array.from({ length: count - 1 }, (_, i) => left[i + 1] || ''), lw);
111
+ const b = box(right[0], Array.from({ length: count - 1 }, (_, i) => right[i + 1] || ''), rw);
112
+ a.forEach((v, i) => lines.push(paintBoxLine(v, i, a.length - 1, 'accent') + ' ' + paintBoxLine(b[i], i, b.length - 1, 'accent')));
113
+ }
114
+ else {
115
+ const a = box(left[0], left.slice(1), Math.max(3, width));
116
+ const b = box(right[0], right.slice(1), Math.max(3, width));
117
+ lines.push(...a.map((x, i) => paintBoxLine(x, i, a.length - 1, 'accent')));
118
+ lines.push(...b.map((x, i) => paintBoxLine(x, i, b.length - 1, 'accent')));
119
+ }
120
+ line('── 최근 관측 이벤트 ──', 'muted');
121
+ for (const e of snapshot.events.slice(0, 4))
122
+ line(`${terminalText(e.at.slice(11, 19))} ${terminalText(e.label)}`);
123
+ if (!snapshot.events.length)
124
+ line('기록된 이벤트 없음', 'muted');
125
+ for (const issue of [...snapshot.issues, ...(view.mailIssues || [])].slice(0, 2))
126
+ line(`확인 필요: ${issue}`, 'warning');
127
+ const footer = paint(fitTerminal('q / Esc 종료 · 작업은 계속됨 · 모델 호출 없음', width), 'muted');
128
+ return [...lines.slice(0, Math.max(0, height - 1)), footer].join('\n');
129
+ }
package/src/view.mts ADDED
@@ -0,0 +1,115 @@
1
+ /** Read-only terminal view of observed sessions, claims, mail and events. */
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { bridgeHome } from './bridge.mjs'
5
+ import { createMonitor, fitTerminal, terminalText } from './monitor.mjs'
6
+ import type { MonitorSnapshot } from './monitor.mjs'
7
+
8
+ interface ViewMail { to: string; from: string; topic: string; text: string }
9
+ export interface ViewSnapshot extends MonitorSnapshot { mail: ViewMail[]; mailIssues: string[] }
10
+ const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
11
+
12
+ export function createView(cwd: string, options: Parameters<typeof createMonitor>[1] = {}): () => Promise<ViewSnapshot> {
13
+ const collect = createMonitor(cwd, options)
14
+ return async () => {
15
+ const snapshot = await collect(), mail: ViewMail[] = [], mailIssues: string[] = []
16
+ if (snapshot.issues.some(issue => issue.startsWith('세션 확인:'))) mailIssues.push('받은 편지함: 세션 목록을 확인하지 못함')
17
+ // Reading does not create mailboxes, expire records, collect messages or wake a model.
18
+ for (const session of snapshot.sessions.slice(0, 100)) {
19
+ if (!uuid.test(session.id)) { mailIssues.push('받은 편지함: 세션 ID 확인 필요'); continue }
20
+ const file = path.join(bridgeHome(), 'mailboxes', `${session.id}.json`)
21
+ try {
22
+ if (fs.statSync(file).size > 1024 * 1024) throw new Error('파일 크기 초과')
23
+ const box: unknown = JSON.parse(fs.readFileSync(file, 'utf8'))
24
+ if (!box || typeof box !== 'object' || (box as { version?: unknown }).version !== 1 || !Array.isArray((box as { messages?: unknown }).messages)) throw new Error('형식 오류')
25
+ for (const m of (box as { messages: unknown[] }).messages) {
26
+ if (!m || typeof m !== 'object') throw new Error('메시지 형식 오류')
27
+ const entry = m as Record<string, unknown>
28
+ if (entry.repo !== snapshot.repo || entry.state !== 'queued') continue
29
+ if (entry.to !== session.id || typeof entry.from !== 'string' || typeof entry.text !== 'string' || typeof entry.expiresAt !== 'string' || !Number.isFinite(Date.parse(entry.expiresAt))) throw new Error('메시지 형식 오류')
30
+ if (Date.parse(entry.expiresAt) <= Date.now()) continue
31
+ mail.push({ to: session.id, from: entry.from, topic: typeof entry.topic === 'string' ? entry.topic : '', text: entry.text })
32
+ }
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') mailIssues.push(`받은 편지함 ${session.id.slice(0, 8)}: 읽기 실패`)
35
+ }
36
+ }
37
+ if (snapshot.sessions.length > 100) mailIssues.push('받은 편지함: 앞의 세션 100개만 확인')
38
+ return { ...snapshot, mail, mailIssues }
39
+ }
40
+ }
41
+
42
+ const labels: Record<string, string> = { idle: '대기', running: '작업 중', closed: '종료', awaiting_user: '승인 대기', unknown: '상태 미확인', starting: '시작 중', settling: '대기 전환 중' }
43
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
44
+ function cells(value: string): number {
45
+ return [...segmenter.segment(value)].reduce((n, { segment }) => {
46
+ const cp = segment.codePointAt(0) || 0
47
+ return n + (/\p{Extended_Pictographic}/u.test(segment) || cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf || cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff || cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff01 && cp <= 0xff60 || cp >= 0x20000) ? 2 : /^\p{Mark}+$/u.test(segment) ? 0 : 1)
48
+ }, 0)
49
+ }
50
+ function pad(value: unknown, width: number): string {
51
+ const text = fitTerminal(value, Math.max(0, width))
52
+ return text + ' '.repeat(Math.max(0, width - cells(text)))
53
+ }
54
+
55
+ export function renderView(snapshot: MonitorSnapshot, { columns = 100, rows = 36, color = false, frame = 0 } = {}): string {
56
+ const width = Math.max(1, Math.floor(columns)), height = Math.max(1, Math.floor(rows)), lines: string[] = []
57
+ if (width < 20) return [fitTerminal('axMap · 화면이 좁습니다', width), fitTerminal('q 종료', width)].slice(0, height).join('\n')
58
+ const view = snapshot as Partial<ViewSnapshot>
59
+ const palette = {
60
+ text: '194;197;205', muted: '137;140;150', border: '78;82;94', accent: '172;158;199',
61
+ claude: '207;157;134', codex: '143;174;214', agy: '139;187;165', warning: '212;171;125',
62
+ }
63
+ type Tone = keyof typeof palette
64
+ const paint = (text: string, tone: Tone) => color ? `\x1b[38;2;${palette[tone]}m${text}\x1b[0m` : text
65
+ const line = (text: string, tone: Tone = 'text') => lines.push(paint(fitTerminal(text, width), tone))
66
+ const paintBoxLine = (text: string, index: number, last: number, tone: Tone, colorStatus = false) => {
67
+ if (index === 0 || index === last) return paint(text, 'border')
68
+ return paint(text.slice(0, 1), 'border') + paint(text.slice(1, -1), index === 1 || colorStatus && index === 3 ? tone : 'text') + paint(text.slice(-1), 'border')
69
+ }
70
+ const box = (title: string, content: string[], w: number): string[] => {
71
+ const n = Math.max(1, w - 2)
72
+ return [`┌${'─'.repeat(n)}┐`, ...[title, ...content].map(s => `│${pad(' ' + terminalText(s), n)}│`), `└${'─'.repeat(n)}┘`]
73
+ }
74
+ line('axMap VIEW · 실제 로컬 관측', 'accent')
75
+ line(`${snapshot.cwd} · ${snapshot.checkedAt}`, 'muted')
76
+ const sessions = [...snapshot.sessions].sort((a, b) => Number(b.connection === 'connected') - Number(a.connection === 'connected')).slice(0, 3)
77
+ const cols = width >= 90 ? 3 : width >= 58 ? 2 : 1
78
+ const cardWidth = Math.max(3, Math.floor((width - (cols - 1) * 2) / cols))
79
+ const cards = sessions.map(s => {
80
+ const active = snapshot.requests.some(r => r.to === s.id && ['sending', 'submitted'].includes(r.state))
81
+ const connection: Record<string, string> = { closed: '연결 종료', unreachable: '연결 불가', unverified: '연결 미확인' }
82
+ const state = s.connection === 'connected' ? labels[s.state] || '상태 미확인' : connection[s.connection] || '연결 미확인'
83
+ return box(`${s.provider.toUpperCase()} · ${s.id.slice(0, 8)}`, [s.role || '역할 미확인', `${active ? ['◐', '◓', '◑', '◒'][frame % 4] : '○'} ${state}`, s.model || '모델 미확인'], cardWidth)
84
+ })
85
+ for (let start = 0; start < cards.length; start += cols) {
86
+ const group = cards.slice(start, start + cols)
87
+ for (let i = 0; i < group[0]!.length; i++) lines.push(group.map((card, k) => {
88
+ const provider = sessions[start + k]!.provider.toLowerCase()
89
+ const tone: Tone = provider === 'codex' ? 'codex' : provider === 'agy' ? 'agy' : provider === 'claude' ? 'claude' : 'accent'
90
+ return paintBoxLine(card[i]!, i, card.length - 1, tone, true)
91
+ }).join(' '))
92
+ }
93
+ if (!sessions.length) line('등록된 세션 없음 · bridge 연결 후 표시', 'muted')
94
+ const claims = snapshot.claims.flatMap(c => c.paths.map(p => `${terminalText(c.agent)} · ${terminalText(p)}`))
95
+ const mails = view.mail?.map(m => `${terminalText(m.from)} → ${m.to.slice(0, 8)} · ${terminalText(m.topic || m.text)}`) || []
96
+ const left = [`LEDGER · 현재 선점 ${snapshot.claims.length}건`, ...(claims.slice(0, 4).length ? claims.slice(0, 4) : ['현재 선점 없음'])]
97
+ const right = [`MAILBOX · ${view.mailIssues?.length ? '일부 확인 실패' : `대기 ${view.mail?.length ?? 0}건`}`, ...(mails.slice(0, 4).length ? mails.slice(0, 4) : [view.mailIssues?.length ? '대기 건수 미확인' : '대기 메시지 없음'])]
98
+ if (width >= 58) {
99
+ const lw = Math.floor((width - 2) / 2), rw = width - lw - 2, count = Math.max(left.length, right.length)
100
+ const a = box(left[0]!, Array.from({ length: count - 1 }, (_, i) => left[i + 1] || ''), lw)
101
+ const b = box(right[0]!, Array.from({ length: count - 1 }, (_, i) => right[i + 1] || ''), rw)
102
+ a.forEach((v, i) => lines.push(paintBoxLine(v, i, a.length - 1, 'accent') + ' ' + paintBoxLine(b[i]!, i, b.length - 1, 'accent')))
103
+ } else {
104
+ const a = box(left[0]!, left.slice(1), Math.max(3, width))
105
+ const b = box(right[0]!, right.slice(1), Math.max(3, width))
106
+ lines.push(...a.map((x, i) => paintBoxLine(x, i, a.length - 1, 'accent')))
107
+ lines.push(...b.map((x, i) => paintBoxLine(x, i, b.length - 1, 'accent')))
108
+ }
109
+ line('── 최근 관측 이벤트 ──', 'muted')
110
+ for (const e of snapshot.events.slice(0, 4)) line(`${terminalText(e.at.slice(11, 19))} ${terminalText(e.label)}`)
111
+ if (!snapshot.events.length) line('기록된 이벤트 없음', 'muted')
112
+ for (const issue of [...snapshot.issues, ...(view.mailIssues || [])].slice(0, 2)) line(`확인 필요: ${issue}`, 'warning')
113
+ const footer = paint(fitTerminal('q / Esc 종료 · 작업은 계속됨 · 모델 호출 없음', width), 'muted')
114
+ return [...lines.slice(0, Math.max(0, height - 1)), footer].join('\n')
115
+ }
@@ -40,7 +40,9 @@ if (files.length === 0) {
40
40
  // Windows ConPTY owns native console workers. Exercise it after other test
41
41
  // processes have exited so temporary console teardown cannot race the suite's
42
42
  // many concurrent subprocess fixtures. Both batches remain mandatory.
43
- const nativeNames = new Set(['terminal-bridge.test.mjs', 'monitor-cli.test.mjs'])
43
+ // The lock test also asserts an elapsed-time ceiling, so concurrent fixtures
44
+ // can make it fail even when the lock retry path was never entered.
45
+ const nativeNames = new Set(['terminal-bridge.test.mjs', 'monitor-cli.test.mjs', 'view.test.mjs', 'ledgerlock.test.mjs'])
44
46
  const native = process.platform === 'win32' ? files.filter(f => nativeNames.has(path.basename(f))) : []
45
47
  const ordinary = files.filter(f => !native.includes(f))
46
48
  for (const batch of [ordinary, ...native.map(f => [f])].filter(batch => batch.length)) {
package/tools/view.mjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runMonitor } from '../src/monitor-cli.mjs'
3
+ import { errorMessage } from '../src/session-types.mjs'
4
+
5
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
6
+ console.log('axmap view [--cwd <repo>] [--interval 1000] [--once | --json] [--no-color]\n실제 세션·선점·대기 메시지·최근 이벤트를 터미널에서 표시합니다. q / Esc / Ctrl+C 종료.')
7
+ } else {
8
+ try { await runMonitor(['--view', ...process.argv.slice(2)]) }
9
+ catch (e) { console.error(`axMap view: ${errorMessage(e)}`); process.exitCode = 1 }
10
+ }