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.
- package/.claude/commands/ax-update.md +0 -3
- package/.claude/commands/axmap-view.md +9 -0
- package/README.ko.md +17 -0
- package/README.md +17 -0
- package/bin/axmap.mjs +152 -88
- package/docs/VIEW.md +19 -0
- package/governance/vote.mjs +6 -69
- package/mcp/README.md +2 -0
- package/mcp/server.mjs +22 -7
- package/package.json +2 -1
- package/skills/axmap-view/SKILL.md +8 -0
- package/src/bridge.mjs +168 -56
- package/src/bridge.mts +104 -12
- package/src/managed-session.mjs +4 -3
- package/src/managed-session.mts +4 -3
- package/src/monitor-cli.mjs +5 -3
- package/src/monitor-cli.mts +5 -3
- package/src/session-mailbox.mjs +21 -11
- package/src/session-mailbox.mts +13 -8
- package/src/session-types.mjs +3 -0
- package/src/session-types.mts +9 -1
- package/src/terminal-bridge.mjs +13 -7
- package/src/terminal-bridge.mts +12 -7
- package/src/update.mjs +3 -20
- package/src/view.mjs +129 -0
- package/src/view.mts +115 -0
- package/src/workspace.mjs +247 -0
- package/src/workspace.mts +220 -0
- package/tools/build.mjs +1 -1
- package/tools/bus.mjs +1 -1
- package/tools/run-tests.mjs +3 -1
- package/tools/run.mjs +8 -1
- package/tools/view.mjs +10 -0
- package/src/closure.mjs +0 -343
- package/tools/vendor.mjs +0 -382
package/src/monitor-cli.mts
CHANGED
|
@@ -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
|
|
24
|
-
const
|
|
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/session-mailbox.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
m
|
|
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
|
}
|
package/src/session-mailbox.mts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/session-types.mjs
CHANGED
|
@@ -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
|
}
|
package/src/session-types.mts
CHANGED
|
@@ -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
|
package/src/terminal-bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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',
|
|
35
|
-
|
|
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); };
|
package/src/terminal-bridge.mts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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',
|
|
42
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|