axmap-cli 1.7.0 → 1.9.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.
@@ -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) {
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest } from './bridge.mjs';
7
+ import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest, notDelivered } from './bridge.mjs';
8
8
  import { withRepoLock } from './repolock.mjs';
9
9
  import { errorMessage } from './session-types.mjs';
10
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -31,7 +31,7 @@ function load(target) {
31
31
  !['boundary', 'immediate'].includes(m.priority) || !states.includes(m.state) ||
32
32
  !Number.isFinite(Date.parse(m.createdAt)) || !Number.isFinite(Date.parse(m.expiresAt))) ||
33
33
  b.batches.some(x => !x || !UUID.test(x.id || '') || typeof x.repo !== 'string' || !Array.isArray(x.ids) ||
34
- !['sending', 'submitted', 'unknown'].includes(x.state)))
34
+ !['sending', 'submitted', 'unknown', 'failed'].includes(x.state)))
35
35
  throw new Error('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.');
36
36
  return b;
37
37
  }
@@ -169,22 +169,32 @@ export function flushMail({ to, cwd, at, immediateId }) {
169
169
  writeJSON(target, b);
170
170
  try {
171
171
  const r = dispatchRequest({ to, cwd, from: 'axmap-agent-mailbox', prompt });
172
- batch.state = r.state === 'submitted' ? 'submitted' : 'unknown';
172
+ batch.state = r.state === 'submitted' ? 'submitted' : r.state === 'failed' ? 'failed' : 'unknown';
173
173
  batch.requestId = r.id;
174
174
  batch.error = r.error;
175
175
  }
176
176
  catch (e) {
177
177
  // A storage error can happen after the provider accepted a request. Even
178
- // thrown errors must not put this batch back into an executable queue.
179
- batch.state = 'unknown';
178
+ // thrown errors must not put this batch back into an executable queue —
179
+ // unless the error came before anything was sent.
180
+ batch.state = notDelivered(e) ? 'failed' : 'unknown';
180
181
  batch.error = errorMessage(e);
181
182
  }
182
- for (const m of selected) {
183
- m.state = batch.state;
184
- m.requestId = batch.requestId;
185
- m.error = batch.error;
186
- }
183
+ // 확실히 전달되지 않은 묶음의 쪽지는 대기열로 돌려 다음 경계에서 다시 보낼 수 있게 한다.
184
+ // 자동으로 다시 보내지는 않는다. 묶음 기록은 이력으로 남는다.
185
+ if (batch.state === 'failed')
186
+ for (const m of selected) {
187
+ m.state = 'queued';
188
+ m.error = batch.error;
189
+ }
190
+ else
191
+ for (const m of selected) {
192
+ m.state = batch.state;
193
+ m.requestId = batch.requestId;
194
+ m.error = batch.error;
195
+ }
187
196
  writeJSON(target, b);
188
- return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null };
197
+ return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null,
198
+ ...(batch.state === 'failed' ? { requeued: selected.length } : {}) };
189
199
  });
190
200
  }
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { randomUUID } from 'node:crypto'
6
- import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest } from './bridge.mjs'
6
+ import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest, notDelivered } from './bridge.mjs'
7
7
  import { withRepoLock } from './repolock.mjs'
8
8
  import { errorMessage } from './session-types.mjs'
9
9
 
@@ -15,7 +15,7 @@ export interface Mail {
15
15
  createdAt: string; expiresAt: string; state: State
16
16
  supersededBy?: string; batchId?: string; requestId?: string; error?: string
17
17
  }
18
- interface Batch { id: string; repo: string; ids: string[]; state: 'sending' | 'submitted' | 'unknown'; requestId?: string; error?: string }
18
+ interface Batch { id: string; repo: string; ids: string[]; state: 'sending' | 'submitted' | 'unknown' | 'failed'; requestId?: string; error?: string }
19
19
  interface Box { version: 1; messages: Mail[]; batches: Batch[] }
20
20
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
21
21
  const reasons = ['stop', 'data-loss', 'work-invalidated']
@@ -38,7 +38,7 @@ function load(target: string): Box {
38
38
  !['boundary', 'immediate'].includes(m.priority) || !states.includes(m.state) ||
39
39
  !Number.isFinite(Date.parse(m.createdAt)) || !Number.isFinite(Date.parse(m.expiresAt))) ||
40
40
  b.batches.some(x => !x || !UUID.test(x.id || '') || typeof x.repo !== 'string' || !Array.isArray(x.ids) ||
41
- !['sending', 'submitted', 'unknown'].includes(x.state))) throw new Error('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.')
41
+ !['sending', 'submitted', 'unknown', 'failed'].includes(x.state))) throw new Error('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.')
42
42
  return b
43
43
  }
44
44
  function expire(b: Box) {
@@ -144,14 +144,19 @@ export function flushMail({ to, cwd, at, immediateId }:
144
144
  writeJSON(target, b)
145
145
  try {
146
146
  const r = dispatchRequest({ to, cwd, from: 'axmap-agent-mailbox', prompt })
147
- batch.state = r.state === 'submitted' ? 'submitted' : 'unknown'; batch.requestId = r.id; batch.error = r.error
147
+ batch.state = r.state === 'submitted' ? 'submitted' : r.state === 'failed' ? 'failed' : 'unknown'; batch.requestId = r.id; batch.error = r.error
148
148
  } catch (e) {
149
149
  // A storage error can happen after the provider accepted a request. Even
150
- // thrown errors must not put this batch back into an executable queue.
151
- batch.state = 'unknown'; batch.error = errorMessage(e)
150
+ // thrown errors must not put this batch back into an executable queue —
151
+ // unless the error came before anything was sent.
152
+ batch.state = notDelivered(e) ? 'failed' : 'unknown'; batch.error = errorMessage(e)
152
153
  }
153
- for (const m of selected) { m.state = batch.state; m.requestId = batch.requestId; m.error = batch.error }
154
+ // 확실히 전달되지 않은 묶음의 쪽지는 대기열로 돌려 다음 경계에서 다시 보낼 수 있게 한다.
155
+ // 자동으로 다시 보내지는 않는다. 묶음 기록은 이력으로 남는다.
156
+ if (batch.state === 'failed') for (const m of selected) { m.state = 'queued'; m.error = batch.error }
157
+ else for (const m of selected) { m.state = batch.state as State; m.requestId = batch.requestId; m.error = batch.error }
154
158
  writeJSON(target, b)
155
- return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null }
159
+ return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null,
160
+ ...(batch.state === 'failed' ? { requeued: selected.length } : {}) }
156
161
  })
157
162
  }
@@ -1,4 +1,7 @@
1
1
  // Generated from src/session-types.mts. Edit the .mts source and run npm run build.
2
+ /** `run.mjs rpc` 가 로컬 파이프에 연결조차 못 했을 때(ENOENT·ECONNREFUSED)의 종료 코드.
3
+ * 이 경우에만 요청이 확실히 전달되지 않았다고 판단한다. */
4
+ export const RPC_NOT_DELIVERED_EXIT = 3;
2
5
  export function errorMessage(error) {
3
6
  return error instanceof Error ? error.message : String(error);
4
7
  }
@@ -4,6 +4,11 @@ export type Transport = 'console' | 'stdio' | 'pty' | 'agentapi'
4
4
  export type SessionState = 'starting' | 'running' | 'idle' | 'awaiting_user' | 'settling' | 'closed' | 'unknown'
5
5
  export type RequestState = 'sending' | 'submitted' | 'unknown' | 'completed' | 'failed' | 'superseded'
6
6
  export type ConnectionState = 'connected' | 'closed' | 'unreachable' | 'unverified'
7
+ /** 관리형 세션 호스트의 생존. unverified 는 "확인하지 못함"이며 살아 있음도 죽음도 아니다. */
8
+ export type HostLiveness = 'alive' | 'dead' | 'unverified'
9
+ /** `run.mjs rpc` 가 로컬 파이프에 연결조차 못 했을 때(ENOENT·ECONNREFUSED)의 종료 코드.
10
+ * 이 경우에만 요청이 확실히 전달되지 않았다고 판단한다. */
11
+ export const RPC_NOT_DELIVERED_EXIT = 3
7
12
  export type HookEvent = 'SessionStart' | 'PreInvocation' | 'Stop'
8
13
 
9
14
  export interface Usage {
@@ -38,9 +43,12 @@ export interface SessionRecord {
38
43
  executable?: string
39
44
  address?: string
40
45
  token?: string
46
+ /** 로컬 파이프를 연 `bridge serve`·`run` 프로세스. 비 Windows 생존 확인에 쓴다. */
47
+ hostPid?: number
48
+ closedReason?: string
41
49
  }
42
50
  export type PublicSession = Pick<SessionRecord, 'id' | 'provider' | 'workspaces' | 'model' | 'transport' |
43
- 'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId'>
51
+ 'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId'> & { liveness?: HostLiveness }
44
52
  export interface SessionSnapshot extends PublicSession {
45
53
  lastKnownState: SessionState | null
46
54
  state: SessionState
@@ -8,7 +8,7 @@ import net from 'node:net';
8
8
  import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { createRequire } from 'node:module';
11
- import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs';
11
+ import { bridgeHome, hostPipe, repoKey, readJSON, writeJSON } from './bridge.mjs';
12
12
  import { errorCode, errorMessage } from './session-types.mjs';
13
13
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
14
  const validID = (id) => /^[0-9a-f-]{36}$/i.test(id || '');
@@ -17,8 +17,8 @@ const sessionFile = (id) => {
17
17
  throw new Error('잘못된 터미널 세션 ID입니다.');
18
18
  return path.join(bridgeHome(), 'sessions', `${id}.json`);
19
19
  };
20
- const pipeFor = (id) => process.platform === 'win32'
21
- ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
20
+ // 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
21
+ const pipeFor = hostPipe;
22
22
  export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } = {}) {
23
23
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
24
24
  throw new Error('양의 응답 시간 한도가 필요합니다.');
@@ -28,11 +28,17 @@ export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } =
28
28
  return new Promise((resolve, reject) => {
29
29
  const socket = net.createConnection(pipeFor(id));
30
30
  socket.setEncoding('utf8');
31
- let buffer = '';
31
+ let buffer = '', connected = false;
32
32
  const fail = (e) => { socket.destroy(); reject(e); };
33
33
  socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')));
34
- socket.on('error', fail);
35
- socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'));
34
+ socket.on('error', e => {
35
+ // 연결 전의 ENOENT(파이프·소켓 없음)·ECONNREFUSED(듣는 쪽 없음)는 아무것도 쓰지
36
+ // 않았다는 증거다. 연결 뒤의 오류는 요청이 이미 건너갔을 수 있어 구별해 둔다.
37
+ if (!connected && ['ENOENT', 'ECONNREFUSED'].includes(errorCode(e) || ''))
38
+ Object.assign(e, { notDelivered: true });
39
+ fail(e);
40
+ });
41
+ socket.on('connect', () => { connected = true; socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'); });
36
42
  socket.on('data', chunk => {
37
43
  buffer += chunk;
38
44
  if (Buffer.byteLength(buffer) > 1024 * 1024)
@@ -60,7 +66,7 @@ export async function startTerminal({ executable, args = [], provider, cwd = pro
60
66
  const { spawn } = await import('node-pty');
61
67
  const id = randomUUID(), token = randomBytes(32).toString('hex');
62
68
  const s = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
63
- state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' };
69
+ state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch', hostPid: process.pid };
64
70
  const jobs = new Map();
65
71
  let pending = null, draft = false, term, closed = false, stopTimer;
66
72
  const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s); };
@@ -7,7 +7,7 @@ import net from 'node:net'
7
7
  import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { createRequire } from 'node:module'
10
- import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
10
+ import { bridgeHome, hostPipe, repoKey, readJSON, writeJSON } from './bridge.mjs'
11
11
  import { errorCode, errorMessage } from './session-types.mjs'
12
12
  import type { IPty } from 'node-pty'
13
13
  import type { Provider, SessionRecord, LocalMessage, JobRecord, RequestState, TranscriptEntry } from './session-types.mjs'
@@ -25,8 +25,8 @@ const sessionFile = (id: string) => {
25
25
  if (!validID(id)) throw new Error('잘못된 터미널 세션 ID입니다.')
26
26
  return path.join(bridgeHome(), 'sessions', `${id}.json`)
27
27
  }
28
- const pipeFor = (id: string) => process.platform === 'win32'
29
- ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
28
+ // 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
29
+ const pipeFor = hostPipe
30
30
 
31
31
  export function sendTerminal(id: string, message: LocalMessage, token: string | null = null, { timeoutMs = 10000 } = {}): Promise<unknown> {
32
32
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 응답 시간 한도가 필요합니다.')
@@ -35,11 +35,16 @@ export function sendTerminal(id: string, message: LocalMessage, token: string |
35
35
  return new Promise((resolve, reject) => {
36
36
  const socket = net.createConnection(pipeFor(id))
37
37
  socket.setEncoding('utf8')
38
- let buffer = ''
38
+ let buffer = '', connected = false
39
39
  const fail = (e: Error) => { socket.destroy(); reject(e) }
40
40
  socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')))
41
- socket.on('error', fail)
42
- socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'))
41
+ socket.on('error', e => {
42
+ // 연결 전의 ENOENT(파이프·소켓 없음)·ECONNREFUSED(듣는 쪽 없음)는 아무것도 쓰지
43
+ // 않았다는 증거다. 연결 뒤의 오류는 요청이 이미 건너갔을 수 있어 구별해 둔다.
44
+ if (!connected && ['ENOENT', 'ECONNREFUSED'].includes(errorCode(e) || '')) Object.assign(e, { notDelivered: true })
45
+ fail(e)
46
+ })
47
+ socket.on('connect', () => { connected = true; socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n') })
43
48
  socket.on('data', chunk => {
44
49
  buffer += chunk
45
50
  if (Buffer.byteLength(buffer) > 1024 * 1024) return fail(new Error('로컬 응답 크기 제한 초과'))
@@ -59,7 +64,7 @@ export async function startTerminal({ executable, args = [], provider, cwd = pro
59
64
  const { spawn } = await import('node-pty')
60
65
  const id = randomUUID(), token = randomBytes(32).toString('hex')
61
66
  const s: SessionRecord = { id, provider, transport: 'pty', token, workspaces: [cwd], repos: [repoKey(cwd)],
62
- state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch' }
67
+ state: 'starting', fullyIdle: false, registeredAt: new Date().toISOString(), event: 'launch', hostPid: process.pid }
63
68
  const jobs = new Map<string, JobRecord>()
64
69
  let pending: JobRecord | null = null, draft = false, term: IPty, closed = false, stopTimer: NodeJS.Timeout | undefined
65
70
  const save = () => { s.registeredAt = new Date().toISOString(); writeJSON(sessionFile(id), s) }
package/src/update.mjs CHANGED
@@ -11,19 +11,9 @@
11
11
  * 코드가 아니기 때문이다. 적용은 사람이 `axmap update` 를 읽고 한 줄을
12
12
  * 직접 실행하는 것으로만 일어난다.
13
13
  *
14
- * ── 🔴 벤더링된 사본에서는 아무것도 하지 않는다 ─────────────────────────────
15
- *
16
- * 팀 저장소의 `ci/axmap/` 은 이 코드의 **사본**이고(팀 CLAUDE.md 0.3),
17
- * 그 사본이 최신인지는 npm 이 아니라 `tools/vendor.mjs` 가 정한다. 사본이
18
- * 레지스트리를 쳐다보면 두 가지가 한꺼번에 나빠진다.
19
- *
20
- * 1. 팀 CI 가 매 파이프라인마다 바깥 네트워크에 의존하게 된다 — 벤더링이
21
- * 끊으려고 했던 바로 그 연결이다
22
- * 2. "새 버전이 있다" 는 안내가 팀원에게 뜨는데, 그 팀원이 할 수 있는 일은
23
- * 없다. 사본은 axMap 저장소에서 `vendor.mjs` 를 다시 돌려야만 바뀐다
24
- *
25
- * 사본인지 아닌지는 **옆에 `SOURCE.json` 이 있는가**로 가른다. 그 파일은
26
- * `tools/vendor.mjs` 가 사본에만 써 넣는다(원본 저장소 루트에는 없다).
14
+ * 팀 저장소 안에 이 코드의 사본(`ci/axmap/`)을 두던 시절에는 사본이면 확인을
15
+ * 건너뛰는 갈래가 여기 있었다. 2026-10-29 에 사본을 걷어냈으므로(DECISIONS D31)
16
+ * 그 갈래도 없다 — 이 코드가 도는 자리는 npm 설치본과 axMap 저장소뿐이다.
27
17
  */
28
18
 
29
19
  import fs from 'node:fs'
@@ -79,13 +69,6 @@ export function compareVersions(a, b) {
79
69
  return x.pre > y.pre ? 1 : -1
80
70
  }
81
71
 
82
- /**
83
- * 이 axMap 이 **벤더링된 사본**인가. 사본이면 갱신 확인을 통째로 건너뛴다.
84
- *
85
- * @param {string} axmapRoot `bin/` · `src/` 를 담고 있는 폴더
86
- */
87
- export const isVendored = (axmapRoot) => fs.existsSync(path.join(axmapRoot, 'SOURCE.json'))
88
-
89
72
  /**
90
73
  * 확인을 하지 말아야 하는 자리인가.
91
74
  *
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
+ }