axmap-cli 1.6.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.
- package/.claude/commands/axmap-view.md +9 -0
- package/.claude/commands/pipeline.md +9 -0
- package/README.ko.md +19 -0
- package/README.md +19 -0
- package/bin/axmap.mjs +3 -0
- package/docs/VIEW.md +19 -0
- package/mcp/README.md +3 -0
- package/mcp/pipeline.html +34 -7
- package/mcp/server.mjs +34 -3
- package/package.json +3 -1
- package/skills/axmap-view/SKILL.md +8 -0
- package/skills/pipeline/SKILL.md +14 -0
- package/src/bridge.mjs +1 -0
- package/src/bridge.mts +1 -0
- package/src/codex-connection.mjs +151 -0
- package/src/codex-connection.mts +83 -0
- package/src/managed-session.mjs +76 -14
- package/src/managed-session.mts +57 -13
- package/src/monitor-cli.mjs +8 -4
- package/src/monitor-cli.mts +8 -4
- package/src/monitor.mjs +5 -0
- package/src/monitor.mts +4 -0
- package/src/pipeline-graph.mjs +55 -0
- package/src/pipeline-graph.mts +51 -0
- package/src/pipeline-mcp.mjs +3 -1
- package/src/pipeline-mcp.mts +3 -1
- package/src/pipeline-web.mjs +56 -0
- package/src/pipeline-web.mts +31 -0
- package/src/session-mailbox.mjs +190 -0
- package/src/session-mailbox.mts +157 -0
- package/src/session-types.mts +2 -1
- package/src/view.mjs +129 -0
- package/src/view.mts +115 -0
- package/tools/bridge.mjs +32 -0
- package/tools/pipeline.mjs +10 -2
- package/tools/run-tests.mjs +3 -1
- package/tools/view.mjs +10 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Generated from src/pipeline-web.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Read-only loopback view for clients that cannot embed MCP Apps. */
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { createMonitor } from './monitor.mjs';
|
|
6
|
+
import { pipelineView } from './pipeline-mcp.mjs';
|
|
7
|
+
export async function startPipelineWeb(cwd, port = 0) {
|
|
8
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
9
|
+
throw new Error('port must be 0..65535');
|
|
10
|
+
const collect = createMonitor(cwd);
|
|
11
|
+
const html = fs.readFileSync(new URL('../mcp/pipeline.html', import.meta.url), 'utf8')
|
|
12
|
+
.replace('<html lang="ko">', '<html lang="ko" data-axmap-local="true">')
|
|
13
|
+
.replace('<script>', '<script>window.axmapLocalRead=()=>fetch("/api/snapshot",{cache:"no-store"}).then(r=>{if(!r.ok)throw new Error("Local observation failed");return r.json()});</script><script>');
|
|
14
|
+
let collecting = null, url = '';
|
|
15
|
+
const server = http.createServer(async (req, res) => {
|
|
16
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
17
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
18
|
+
if (req.headers.host !== new URL(url).host || req.headers.origin && req.headers.origin !== url || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
19
|
+
res.writeHead(403);
|
|
20
|
+
res.end('Local origin required');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (req.method !== 'GET') {
|
|
24
|
+
res.writeHead(405);
|
|
25
|
+
res.end('Read-only');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (req.url === '/' || req.url === '/pipeline') {
|
|
29
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
30
|
+
res.end(html);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (req.url !== '/api/snapshot') {
|
|
34
|
+
res.writeHead(404);
|
|
35
|
+
res.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
// Concurrent browser refreshes share observation; none submit a model turn.
|
|
40
|
+
collecting ??= collect().finally(() => { collecting = null; });
|
|
41
|
+
const snapshot = await collecting;
|
|
42
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
43
|
+
res.end(JSON.stringify({ structuredContent: pipelineView(snapshot), isError: false }));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
res.writeHead(503);
|
|
47
|
+
res.end(JSON.stringify({ isError: true, content: [{ type: 'text', text: 'Local observation failed' }] }));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
51
|
+
const address = server.address();
|
|
52
|
+
if (!address || typeof address === 'string')
|
|
53
|
+
throw new Error('Local listener unavailable');
|
|
54
|
+
url = `http://127.0.0.1:${address.port}`;
|
|
55
|
+
return { url: `${url}/pipeline`, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections(); }) };
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Read-only loopback view for clients that cannot embed MCP Apps. */
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import { createMonitor } from './monitor.mjs'
|
|
5
|
+
import { pipelineView } from './pipeline-mcp.mjs'
|
|
6
|
+
export async function startPipelineWeb(cwd: string, port = 0) {
|
|
7
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('port must be 0..65535')
|
|
8
|
+
const collect = createMonitor(cwd)
|
|
9
|
+
const html = fs.readFileSync(new URL('../mcp/pipeline.html', import.meta.url), 'utf8')
|
|
10
|
+
.replace('<html lang="ko">', '<html lang="ko" data-axmap-local="true">')
|
|
11
|
+
.replace('<script>', '<script>window.axmapLocalRead=()=>fetch("/api/snapshot",{cache:"no-store"}).then(r=>{if(!r.ok)throw new Error("Local observation failed");return r.json()});</script><script>')
|
|
12
|
+
let collecting: ReturnType<typeof collect> | null = null, url = ''
|
|
13
|
+
const server = http.createServer(async (req, res) => {
|
|
14
|
+
res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
15
|
+
if (req.headers.host !== new URL(url).host || req.headers.origin && req.headers.origin !== url || req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end('Local origin required'); return }
|
|
16
|
+
if (req.method !== 'GET') { res.writeHead(405); res.end('Read-only'); return }
|
|
17
|
+
if (req.url === '/' || req.url === '/pipeline') { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(html); return }
|
|
18
|
+
if (req.url !== '/api/snapshot') { res.writeHead(404); res.end(); return }
|
|
19
|
+
try {
|
|
20
|
+
// Concurrent browser refreshes share observation; none submit a model turn.
|
|
21
|
+
collecting ??= collect().finally(() => { collecting = null })
|
|
22
|
+
const snapshot = await collecting
|
|
23
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ structuredContent: pipelineView(snapshot), isError: false }))
|
|
24
|
+
} catch { res.writeHead(503); res.end(JSON.stringify({ isError: true, content: [{ type: 'text', text: 'Local observation failed' }] })) }
|
|
25
|
+
})
|
|
26
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve) })
|
|
27
|
+
const address = server.address()
|
|
28
|
+
if (!address || typeof address === 'string') throw new Error('Local listener unavailable')
|
|
29
|
+
url = `http://127.0.0.1:${address.port}`
|
|
30
|
+
return { url: `${url}/pipeline`, close: () => new Promise<void>((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections() }) }
|
|
31
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Generated from src/session-mailbox.mts. Edit the .mts source and run npm run build.
|
|
2
|
+
/** Collaboration mail is durable data, not a user instruction or a model turn.
|
|
3
|
+
* Receivers collect it at explicit safe boundaries; only urgent mail may send now. */
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest } from './bridge.mjs';
|
|
8
|
+
import { withRepoLock } from './repolock.mjs';
|
|
9
|
+
import { errorMessage } from './session-types.mjs';
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const reasons = ['stop', 'data-loss', 'work-invalidated'];
|
|
12
|
+
const boundaries = ['task-complete', 'test-complete', 'experiment-complete', 'checkpoint'];
|
|
13
|
+
function box(to, cwd) {
|
|
14
|
+
if (!UUID.test(to || '') || !sessions(cwd).some(s => s.id === to))
|
|
15
|
+
throw new Error('현재 저장소에 등록된 수신 세션 UUID가 필요합니다.');
|
|
16
|
+
const repo = repoKey(cwd);
|
|
17
|
+
const dir = path.join(bridgeHome(), 'mailboxes');
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
19
|
+
const target = path.join(dir, `${to}.json`);
|
|
20
|
+
return { repo, target, lock: `${target}.lock` };
|
|
21
|
+
}
|
|
22
|
+
function load(target) {
|
|
23
|
+
if (!fs.existsSync(target))
|
|
24
|
+
return { version: 1, messages: [], batches: [] };
|
|
25
|
+
const b = readJSON(target);
|
|
26
|
+
if (b.version !== 1 || !Array.isArray(b.messages) || !Array.isArray(b.batches))
|
|
27
|
+
throw new Error('받은 편지함 형식을 확인하지 못했습니다.');
|
|
28
|
+
const states = ['queued', 'sending', 'submitted', 'unknown', 'collected', 'expired', 'superseded'];
|
|
29
|
+
if (b.messages.some(m => !m || !UUID.test(m.id || '') || m.to !== path.basename(target, '.json') ||
|
|
30
|
+
typeof m.repo !== 'string' || typeof m.from !== 'string' || typeof m.text !== 'string' || m.source !== 'agent' ||
|
|
31
|
+
!['boundary', 'immediate'].includes(m.priority) || !states.includes(m.state) ||
|
|
32
|
+
!Number.isFinite(Date.parse(m.createdAt)) || !Number.isFinite(Date.parse(m.expiresAt))) ||
|
|
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)))
|
|
35
|
+
throw new Error('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.');
|
|
36
|
+
return b;
|
|
37
|
+
}
|
|
38
|
+
function expire(b) {
|
|
39
|
+
for (const m of b.messages)
|
|
40
|
+
if (m.state === 'queued' && Date.parse(m.expiresAt) <= Date.now())
|
|
41
|
+
m.state = 'expired';
|
|
42
|
+
}
|
|
43
|
+
function boundary(value) {
|
|
44
|
+
if (!boundaries.includes(value || ''))
|
|
45
|
+
throw new Error(`안전한 작업 경계를 명시하세요: ${boundaries.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
export function postMail({ to, cwd, text, from = process.env.AXMAP_SESSION || process.env.AXMAP_AGENT || 'axmap', priority = 'boundary', reason, topic, key, ttlSeconds = 3600 }) {
|
|
48
|
+
if (typeof text !== 'string' || !text.trim() || Buffer.byteLength(text) > 24000)
|
|
49
|
+
throw new Error('메시지는 1~24000바이트여야 합니다.');
|
|
50
|
+
if (Buffer.byteLength(JSON.stringify(text)) > 28000)
|
|
51
|
+
throw new Error('JSON으로 인코딩한 메시지가 묶음 한도를 넘습니다.');
|
|
52
|
+
if (!['boundary', 'immediate'].includes(priority))
|
|
53
|
+
throw new Error('priority는 boundary 또는 immediate입니다.');
|
|
54
|
+
if (priority === 'immediate' ? !reasons.includes(reason || '') : reason !== undefined)
|
|
55
|
+
throw new Error('즉시 전달 사유는 stop, data-loss, work-invalidated 중 하나입니다. 일반 알림에는 사유를 넣지 마세요.');
|
|
56
|
+
for (const v of [from, topic, key])
|
|
57
|
+
if (v !== undefined && (typeof v !== 'string' || !v.trim() || v.length > 200))
|
|
58
|
+
throw new Error('발신자·topic·key는 1~200자입니다.');
|
|
59
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 86400)
|
|
60
|
+
throw new Error('ttl-seconds는 1~86400 정수입니다.');
|
|
61
|
+
const { repo, target, lock } = box(to, cwd);
|
|
62
|
+
const saved = withRepoLock(lock, () => {
|
|
63
|
+
const b = load(target);
|
|
64
|
+
expire(b);
|
|
65
|
+
if (key) {
|
|
66
|
+
const old = b.messages.find(m => m.repo === repo && m.from === from && m.key === key);
|
|
67
|
+
if (old) {
|
|
68
|
+
if (old.text !== text || old.priority !== priority || old.reason !== reason || old.topic !== topic)
|
|
69
|
+
throw new Error('같은 메시지 key에 다른 내용이 있습니다.');
|
|
70
|
+
writeJSON(target, b);
|
|
71
|
+
return { mail: old, replayed: true };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (b.messages.filter(m => m.repo === repo && m.state === 'queued').length >= 1000)
|
|
75
|
+
throw new Error('받은 편지함이 가득 찼습니다. 작업 경계에서 먼저 회수하세요.');
|
|
76
|
+
const m = { id: randomUUID(), repo, to, from, source: 'agent', text, priority, reason, topic, key,
|
|
77
|
+
createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), state: 'queued' };
|
|
78
|
+
// Coalesce only explicitly related routine updates by the SAME sender.
|
|
79
|
+
// Urgent messages and messages already handed off remain immutable.
|
|
80
|
+
if (topic && priority === 'boundary')
|
|
81
|
+
for (const old of b.messages) {
|
|
82
|
+
if (old.repo === repo && old.from === from && old.topic === topic && old.priority === 'boundary' && old.state === 'queued') {
|
|
83
|
+
old.state = 'superseded';
|
|
84
|
+
old.supersededBy = m.id;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
b.messages.push(m);
|
|
88
|
+
writeJSON(target, b);
|
|
89
|
+
return { mail: m, replayed: false };
|
|
90
|
+
});
|
|
91
|
+
if (priority === 'immediate' && !saved.replayed)
|
|
92
|
+
return { ...saved, delivery: flushMail({ to, cwd, immediateId: saved.mail.id }) };
|
|
93
|
+
if (priority === 'immediate')
|
|
94
|
+
return { ...saved, modelInvoked: false, delivery: {
|
|
95
|
+
state: saved.mail.state === 'queued' ? 'deferred' : saved.mail.state,
|
|
96
|
+
requestId: saved.mail.requestId, replayed: true, modelInvoked: false,
|
|
97
|
+
} };
|
|
98
|
+
return { ...saved, modelInvoked: false };
|
|
99
|
+
}
|
|
100
|
+
/** Reading/collecting is local only. Collected means handed to the caller, NOT executed. */
|
|
101
|
+
export function collectMail({ to, cwd, at, take = false, all = false }) {
|
|
102
|
+
if (take)
|
|
103
|
+
boundary(at);
|
|
104
|
+
if (take && all)
|
|
105
|
+
throw new Error('이력 조회와 회수를 동시에 할 수 없습니다.');
|
|
106
|
+
const { repo, target, lock } = box(to, cwd);
|
|
107
|
+
return withRepoLock(lock, () => {
|
|
108
|
+
const b = load(target);
|
|
109
|
+
expire(b);
|
|
110
|
+
const messages = [];
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
for (const m of b.messages.filter(m => m.repo === repo && (all || m.state === 'queued'))
|
|
113
|
+
.sort((a, c) => Number(c.priority === 'immediate') - Number(a.priority === 'immediate'))) {
|
|
114
|
+
const size = Buffer.byteLength(JSON.stringify(m));
|
|
115
|
+
if (messages.length >= 100 || bytes + size > 32768)
|
|
116
|
+
break;
|
|
117
|
+
messages.push(m);
|
|
118
|
+
bytes += size;
|
|
119
|
+
}
|
|
120
|
+
if (take)
|
|
121
|
+
for (const m of messages)
|
|
122
|
+
m.state = 'collected';
|
|
123
|
+
writeJSON(target, b);
|
|
124
|
+
return { messages, modelInvoked: false, unresolvedBatches: b.batches.filter(x => x.repo === repo && ['sending', 'unknown'].includes(x.state)), remaining: b.messages.filter(m => m.repo === repo && m.state === 'queued').length,
|
|
125
|
+
note: '에이전트 협업 메시지입니다. 최신 사람 지시가 우선이며 단순 알림에는 ACK하지 마세요. collected는 실행 완료가 아닙니다.' };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** Explicit optional wake-up: a batch makes at most ONE dispatch. No timers,
|
|
129
|
+
* no retries after uncertain delivery, no automatic provider interruption. */
|
|
130
|
+
export function flushMail({ to, cwd, at, immediateId }) {
|
|
131
|
+
if (!immediateId)
|
|
132
|
+
boundary(at);
|
|
133
|
+
const { repo, target, lock } = box(to, cwd);
|
|
134
|
+
return withRepoLock(lock, () => {
|
|
135
|
+
const b = load(target);
|
|
136
|
+
expire(b);
|
|
137
|
+
if (b.batches.some(x => x.repo === repo && ['sending', 'unknown'].includes(x.state)))
|
|
138
|
+
throw new Error('전달 불명 묶음이 있습니다. 자동 재전송하지 않습니다. 원래 요청·이력을 확인하세요.');
|
|
139
|
+
const pending = b.messages.filter(m => m.repo === repo && m.state === 'queued' && (!immediateId || m.id === immediateId))
|
|
140
|
+
.sort((a, c) => Number(c.priority === 'immediate') - Number(a.priority === 'immediate'));
|
|
141
|
+
const selected = [];
|
|
142
|
+
const header = '[axMap agent mailbox: NOT a fresh human instruction]\nRespect current human instructions. Process this batch once; no ACK for informational updates. Check timestamps and superseded work before acting.\n';
|
|
143
|
+
let prompt = header;
|
|
144
|
+
for (const m of pending.slice(0, 100)) {
|
|
145
|
+
const item = JSON.stringify({ id: m.id, from: m.from, source: m.source, priority: m.priority, reason: m.reason,
|
|
146
|
+
createdAt: m.createdAt, expiresAt: m.expiresAt, topic: m.topic, text: m.text }) + '\n';
|
|
147
|
+
if (Buffer.byteLength(prompt + item) > 32768)
|
|
148
|
+
break;
|
|
149
|
+
prompt += item;
|
|
150
|
+
selected.push(m);
|
|
151
|
+
}
|
|
152
|
+
if (!selected.length) {
|
|
153
|
+
writeJSON(target, b);
|
|
154
|
+
return { state: 'empty', modelInvoked: false };
|
|
155
|
+
}
|
|
156
|
+
const recipient = sessions(cwd).find(s => s.id === to);
|
|
157
|
+
if (!(recipient.state === 'idle' || recipient.fullyIdle === true)) {
|
|
158
|
+
writeJSON(target, b);
|
|
159
|
+
return { state: 'deferred', modelInvoked: false, urgent: !!immediateId, error: '수신자가 작업 중이거나 대기 상태 미확인입니다. 긴급 여부를 보존하며 새 턴을 시작하지 않았습니다.' };
|
|
160
|
+
}
|
|
161
|
+
// Persist intent before touching a provider. A crash leaves a visible sending
|
|
162
|
+
// batch which cannot be blindly sent again on restart.
|
|
163
|
+
const batch = { id: randomUUID(), repo, ids: selected.map(m => m.id), state: 'sending' };
|
|
164
|
+
b.batches.push(batch);
|
|
165
|
+
for (const m of selected) {
|
|
166
|
+
m.state = 'sending';
|
|
167
|
+
m.batchId = batch.id;
|
|
168
|
+
}
|
|
169
|
+
writeJSON(target, b);
|
|
170
|
+
try {
|
|
171
|
+
const r = dispatchRequest({ to, cwd, from: 'axmap-agent-mailbox', prompt });
|
|
172
|
+
batch.state = r.state === 'submitted' ? 'submitted' : 'unknown';
|
|
173
|
+
batch.requestId = r.id;
|
|
174
|
+
batch.error = r.error;
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
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';
|
|
180
|
+
batch.error = errorMessage(e);
|
|
181
|
+
}
|
|
182
|
+
for (const m of selected) {
|
|
183
|
+
m.state = batch.state;
|
|
184
|
+
m.requestId = batch.requestId;
|
|
185
|
+
m.error = batch.error;
|
|
186
|
+
}
|
|
187
|
+
writeJSON(target, b);
|
|
188
|
+
return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Collaboration mail is durable data, not a user instruction or a model turn.
|
|
2
|
+
* Receivers collect it at explicit safe boundaries; only urgent mail may send now. */
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest } from './bridge.mjs'
|
|
7
|
+
import { withRepoLock } from './repolock.mjs'
|
|
8
|
+
import { errorMessage } from './session-types.mjs'
|
|
9
|
+
|
|
10
|
+
type Priority = 'boundary' | 'immediate'
|
|
11
|
+
type State = 'queued' | 'sending' | 'submitted' | 'unknown' | 'collected' | 'expired' | 'superseded'
|
|
12
|
+
export interface Mail {
|
|
13
|
+
id: string; repo: string; to: string; from: string; source: 'agent'; text: string
|
|
14
|
+
priority: Priority; reason?: string; topic?: string; key?: string
|
|
15
|
+
createdAt: string; expiresAt: string; state: State
|
|
16
|
+
supersededBy?: string; batchId?: string; requestId?: string; error?: string
|
|
17
|
+
}
|
|
18
|
+
interface Batch { id: string; repo: string; ids: string[]; state: 'sending' | 'submitted' | 'unknown'; requestId?: string; error?: string }
|
|
19
|
+
interface Box { version: 1; messages: Mail[]; batches: Batch[] }
|
|
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
|
+
const reasons = ['stop', 'data-loss', 'work-invalidated']
|
|
22
|
+
const boundaries = ['task-complete', 'test-complete', 'experiment-complete', 'checkpoint']
|
|
23
|
+
|
|
24
|
+
function box(to: string, cwd: string) {
|
|
25
|
+
if (!UUID.test(to || '') || !sessions(cwd).some(s => s.id === to)) throw new Error('현재 저장소에 등록된 수신 세션 UUID가 필요합니다.')
|
|
26
|
+
const repo = repoKey(cwd)
|
|
27
|
+
const dir = path.join(bridgeHome(), 'mailboxes'); fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
28
|
+
const target = path.join(dir, `${to}.json`)
|
|
29
|
+
return { repo, target, lock: `${target}.lock` }
|
|
30
|
+
}
|
|
31
|
+
function load(target: string): Box {
|
|
32
|
+
if (!fs.existsSync(target)) return { version: 1, messages: [], batches: [] }
|
|
33
|
+
const b = readJSON<Box>(target)
|
|
34
|
+
if (b.version !== 1 || !Array.isArray(b.messages) || !Array.isArray(b.batches)) throw new Error('받은 편지함 형식을 확인하지 못했습니다.')
|
|
35
|
+
const states = ['queued', 'sending', 'submitted', 'unknown', 'collected', 'expired', 'superseded']
|
|
36
|
+
if (b.messages.some(m => !m || !UUID.test(m.id || '') || m.to !== path.basename(target, '.json') ||
|
|
37
|
+
typeof m.repo !== 'string' || typeof m.from !== 'string' || typeof m.text !== 'string' || m.source !== 'agent' ||
|
|
38
|
+
!['boundary', 'immediate'].includes(m.priority) || !states.includes(m.state) ||
|
|
39
|
+
!Number.isFinite(Date.parse(m.createdAt)) || !Number.isFinite(Date.parse(m.expiresAt))) ||
|
|
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('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.')
|
|
42
|
+
return b
|
|
43
|
+
}
|
|
44
|
+
function expire(b: Box) {
|
|
45
|
+
for (const m of b.messages) if (m.state === 'queued' && Date.parse(m.expiresAt) <= Date.now()) m.state = 'expired'
|
|
46
|
+
}
|
|
47
|
+
function boundary(value?: string) {
|
|
48
|
+
if (!boundaries.includes(value || '')) throw new Error(`안전한 작업 경계를 명시하세요: ${boundaries.join(', ')}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function postMail({ to, cwd, text, from = process.env.AXMAP_SESSION || process.env.AXMAP_AGENT || 'axmap',
|
|
52
|
+
priority = 'boundary', reason, topic, key, ttlSeconds = 3600 }:
|
|
53
|
+
{ to: string; cwd: string; text: string; from?: string; priority?: Priority; reason?: string; topic?: string; key?: string; ttlSeconds?: number }) {
|
|
54
|
+
if (typeof text !== 'string' || !text.trim() || Buffer.byteLength(text) > 24000) throw new Error('메시지는 1~24000바이트여야 합니다.')
|
|
55
|
+
if (Buffer.byteLength(JSON.stringify(text)) > 28000) throw new Error('JSON으로 인코딩한 메시지가 묶음 한도를 넘습니다.')
|
|
56
|
+
if (!['boundary', 'immediate'].includes(priority)) throw new Error('priority는 boundary 또는 immediate입니다.')
|
|
57
|
+
if (priority === 'immediate' ? !reasons.includes(reason || '') : reason !== undefined) throw new Error('즉시 전달 사유는 stop, data-loss, work-invalidated 중 하나입니다. 일반 알림에는 사유를 넣지 마세요.')
|
|
58
|
+
for (const v of [from, topic, key]) if (v !== undefined && (typeof v !== 'string' || !v.trim() || v.length > 200)) throw new Error('발신자·topic·key는 1~200자입니다.')
|
|
59
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 86400) throw new Error('ttl-seconds는 1~86400 정수입니다.')
|
|
60
|
+
const { repo, target, lock } = box(to, cwd)
|
|
61
|
+
const saved = withRepoLock(lock, () => {
|
|
62
|
+
const b = load(target); expire(b)
|
|
63
|
+
if (key) {
|
|
64
|
+
const old = b.messages.find(m => m.repo === repo && m.from === from && m.key === key)
|
|
65
|
+
if (old) {
|
|
66
|
+
if (old.text !== text || old.priority !== priority || old.reason !== reason || old.topic !== topic) throw new Error('같은 메시지 key에 다른 내용이 있습니다.')
|
|
67
|
+
writeJSON(target, b); return { mail: old, replayed: true }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (b.messages.filter(m => m.repo === repo && m.state === 'queued').length >= 1000) throw new Error('받은 편지함이 가득 찼습니다. 작업 경계에서 먼저 회수하세요.')
|
|
71
|
+
const m: Mail = { id: randomUUID(), repo, to, from, source: 'agent', text, priority, reason, topic, key,
|
|
72
|
+
createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), state: 'queued' }
|
|
73
|
+
// Coalesce only explicitly related routine updates by the SAME sender.
|
|
74
|
+
// Urgent messages and messages already handed off remain immutable.
|
|
75
|
+
if (topic && priority === 'boundary') for (const old of b.messages) {
|
|
76
|
+
if (old.repo === repo && old.from === from && old.topic === topic && old.priority === 'boundary' && old.state === 'queued') {
|
|
77
|
+
old.state = 'superseded'; old.supersededBy = m.id
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
b.messages.push(m); writeJSON(target, b); return { mail: m, replayed: false }
|
|
81
|
+
})
|
|
82
|
+
if (priority === 'immediate' && !saved.replayed) return { ...saved, delivery: flushMail({ to, cwd, immediateId: saved.mail.id }) }
|
|
83
|
+
if (priority === 'immediate') return { ...saved, modelInvoked: false, delivery: {
|
|
84
|
+
state: saved.mail.state === 'queued' ? 'deferred' : saved.mail.state,
|
|
85
|
+
requestId: saved.mail.requestId, replayed: true, modelInvoked: false,
|
|
86
|
+
} }
|
|
87
|
+
return { ...saved, modelInvoked: false }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reading/collecting is local only. Collected means handed to the caller, NOT executed. */
|
|
91
|
+
export function collectMail({ to, cwd, at, take = false, all = false }:
|
|
92
|
+
{ to: string; cwd: string; at?: string; take?: boolean; all?: boolean }) {
|
|
93
|
+
if (take) boundary(at)
|
|
94
|
+
if (take && all) throw new Error('이력 조회와 회수를 동시에 할 수 없습니다.')
|
|
95
|
+
const { repo, target, lock } = box(to, cwd)
|
|
96
|
+
return withRepoLock(lock, () => {
|
|
97
|
+
const b = load(target); expire(b)
|
|
98
|
+
const messages: Mail[] = []
|
|
99
|
+
let bytes = 0
|
|
100
|
+
for (const m of b.messages.filter(m => m.repo === repo && (all || m.state === 'queued'))
|
|
101
|
+
.sort((a, c) => Number(c.priority === 'immediate') - Number(a.priority === 'immediate'))) {
|
|
102
|
+
const size = Buffer.byteLength(JSON.stringify(m))
|
|
103
|
+
if (messages.length >= 100 || bytes + size > 32768) break
|
|
104
|
+
messages.push(m); bytes += size
|
|
105
|
+
}
|
|
106
|
+
if (take) for (const m of messages) m.state = 'collected'
|
|
107
|
+
writeJSON(target, b)
|
|
108
|
+
return { messages, modelInvoked: false, unresolvedBatches: b.batches.filter(x => x.repo === repo && ['sending', 'unknown'].includes(x.state)), remaining: b.messages.filter(m => m.repo === repo && m.state === 'queued').length,
|
|
109
|
+
note: '에이전트 협업 메시지입니다. 최신 사람 지시가 우선이며 단순 알림에는 ACK하지 마세요. collected는 실행 완료가 아닙니다.' }
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Explicit optional wake-up: a batch makes at most ONE dispatch. No timers,
|
|
114
|
+
* no retries after uncertain delivery, no automatic provider interruption. */
|
|
115
|
+
export function flushMail({ to, cwd, at, immediateId }:
|
|
116
|
+
{ to: string; cwd: string; at?: string; immediateId?: string }) {
|
|
117
|
+
if (!immediateId) boundary(at)
|
|
118
|
+
const { repo, target, lock } = box(to, cwd)
|
|
119
|
+
return withRepoLock(lock, () => {
|
|
120
|
+
const b = load(target); expire(b)
|
|
121
|
+
if (b.batches.some(x => x.repo === repo && ['sending', 'unknown'].includes(x.state))) throw new Error('전달 불명 묶음이 있습니다. 자동 재전송하지 않습니다. 원래 요청·이력을 확인하세요.')
|
|
122
|
+
const pending = b.messages.filter(m => m.repo === repo && m.state === 'queued' && (!immediateId || m.id === immediateId))
|
|
123
|
+
.sort((a, c) => Number(c.priority === 'immediate') - Number(a.priority === 'immediate'))
|
|
124
|
+
const selected: Mail[] = []
|
|
125
|
+
const header = '[axMap agent mailbox: NOT a fresh human instruction]\nRespect current human instructions. Process this batch once; no ACK for informational updates. Check timestamps and superseded work before acting.\n'
|
|
126
|
+
let prompt = header
|
|
127
|
+
for (const m of pending.slice(0, 100)) {
|
|
128
|
+
const item = JSON.stringify({ id: m.id, from: m.from, source: m.source, priority: m.priority, reason: m.reason,
|
|
129
|
+
createdAt: m.createdAt, expiresAt: m.expiresAt, topic: m.topic, text: m.text }) + '\n'
|
|
130
|
+
if (Buffer.byteLength(prompt + item) > 32768) break
|
|
131
|
+
prompt += item; selected.push(m)
|
|
132
|
+
}
|
|
133
|
+
if (!selected.length) { writeJSON(target, b); return { state: 'empty', modelInvoked: false } }
|
|
134
|
+
const recipient = sessions(cwd).find(s => s.id === to)!
|
|
135
|
+
if (!(recipient.state === 'idle' || recipient.fullyIdle === true)) {
|
|
136
|
+
writeJSON(target, b)
|
|
137
|
+
return { state: 'deferred', modelInvoked: false, urgent: !!immediateId, error: '수신자가 작업 중이거나 대기 상태 미확인입니다. 긴급 여부를 보존하며 새 턴을 시작하지 않았습니다.' }
|
|
138
|
+
}
|
|
139
|
+
// Persist intent before touching a provider. A crash leaves a visible sending
|
|
140
|
+
// batch which cannot be blindly sent again on restart.
|
|
141
|
+
const batch: Batch = { id: randomUUID(), repo, ids: selected.map(m => m.id), state: 'sending' }
|
|
142
|
+
b.batches.push(batch)
|
|
143
|
+
for (const m of selected) { m.state = 'sending'; m.batchId = batch.id }
|
|
144
|
+
writeJSON(target, b)
|
|
145
|
+
try {
|
|
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
|
|
148
|
+
} catch (e) {
|
|
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)
|
|
152
|
+
}
|
|
153
|
+
for (const m of selected) { m.state = batch.state; m.requestId = batch.requestId; m.error = batch.error }
|
|
154
|
+
writeJSON(target, b)
|
|
155
|
+
return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null }
|
|
156
|
+
})
|
|
157
|
+
}
|
package/src/session-types.mts
CHANGED
|
@@ -40,7 +40,7 @@ export interface SessionRecord {
|
|
|
40
40
|
token?: string
|
|
41
41
|
}
|
|
42
42
|
export type PublicSession = Pick<SessionRecord, 'id' | 'provider' | 'workspaces' | 'model' | 'transport' |
|
|
43
|
-
'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval'>
|
|
43
|
+
'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId'>
|
|
44
44
|
export interface SessionSnapshot extends PublicSession {
|
|
45
45
|
lastKnownState: SessionState | null
|
|
46
46
|
state: SessionState
|
|
@@ -80,6 +80,7 @@ export type LocalMessage = { token?: string } & (
|
|
|
80
80
|
{ action: 'status' } | { action: 'result'; id: string } |
|
|
81
81
|
{ action: 'answer'; id: string | number; decision: string } |
|
|
82
82
|
{ action: 'submit'; request: LocalRequest } |
|
|
83
|
+
{ action: 'notice'; text: string } |
|
|
83
84
|
{ action: 'hook'; event: string; input: HookInput }
|
|
84
85
|
)
|
|
85
86
|
export interface JobRecord {
|
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
|
+
}
|