axmap-cli 1.6.0 → 1.7.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/pipeline.md +9 -0
- package/README.ko.md +17 -0
- package/README.md +17 -0
- package/mcp/README.md +3 -0
- package/mcp/pipeline.html +34 -7
- package/mcp/server.mjs +34 -3
- package/package.json +2 -1
- 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 +5 -3
- package/src/monitor-cli.mts +5 -3
- 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/tools/bridge.mjs +32 -0
- package/tools/pipeline.mjs +10 -2
|
@@ -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/tools/bridge.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { runDialogue, startBackgroundDialogue, dialogueResult, stopDialogue } fr
|
|
|
8
8
|
import { sendTerminal } from '../src/terminal-bridge.mjs'
|
|
9
9
|
import { sessionStatus, formatSessionStatus } from '../src/session-status.mjs'
|
|
10
10
|
import { attachConsole } from '../src/external-console.mjs'
|
|
11
|
+
import { postMail, collectMail, flushMail } from '../src/session-mailbox.mjs'
|
|
11
12
|
|
|
12
13
|
const args = process.argv.slice(2)
|
|
13
14
|
const [command] = args
|
|
@@ -15,14 +16,31 @@ const option = (name) => { const i = args.indexOf(`--${name}`); return i < 0 ? u
|
|
|
15
16
|
const output = (value) => console.log(JSON.stringify(value, null, 2))
|
|
16
17
|
try {
|
|
17
18
|
switch (command) {
|
|
19
|
+
case 'message': {
|
|
20
|
+
const r = postMail({ to: option('to'), cwd: process.cwd(), text: fs.readFileSync(0, 'utf8'),
|
|
21
|
+
priority: option('priority') || 'boundary', reason: option('reason'), topic: option('topic'), key: option('key'),
|
|
22
|
+
ttlSeconds: Number(option('ttl-seconds') || 3600) })
|
|
23
|
+
output(r)
|
|
24
|
+
if (r.delivery && !['submitted', 'empty'].includes(r.delivery.state)) process.exitCode = 2
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
case 'mailbox': output(collectMail({ to: option('to'), cwd: process.cwd(), at: option('at'), take: args.includes('--take'), all: args.includes('--all') })); break
|
|
28
|
+
case 'flush': {
|
|
29
|
+
const r = flushMail({ to: option('to'), cwd: process.cwd(), at: option('at') }); output(r)
|
|
30
|
+
if (['unknown', 'deferred'].includes(r.state)) process.exitCode = 2
|
|
31
|
+
break
|
|
32
|
+
}
|
|
18
33
|
case 'attach': output(attachConsole({ pid: Number(option('pid')), cwd: process.cwd() })); break
|
|
34
|
+
case 'connect':
|
|
19
35
|
case 'serve': {
|
|
20
36
|
const provider = args[1]
|
|
21
37
|
if (!['codex', 'agy'].includes(provider)) throw new Error('axmap bridge serve codex|agy를 사용하세요.')
|
|
22
38
|
const binary = resolveBin(provider)
|
|
23
39
|
if (!binary) throw new Error(`${provider} 실행 파일을 찾지 못했습니다.`)
|
|
24
40
|
const [executable, ...prefix] = provider === 'codex' ? codexInvocation(binary) : [binary]
|
|
41
|
+
if (command === 'connect' && (!option('thread') || !option('socket'))) throw new Error('bridge connect codex --thread <기존 UUID> --socket <로컬 경로>가 필요합니다.')
|
|
25
42
|
const session = await startManagedSession({ provider, executable, prefix, model: option('model'), role: option('role'),
|
|
43
|
+
...(command === 'connect' ? { existingThreadId: option('thread'), socketPath: option('socket'), observeActive: args.includes('--observe') } : {}),
|
|
26
44
|
maxTokens: Number(option('max-tokens') || 100000), onEvent: output })
|
|
27
45
|
output({ event: 'connected', ...session, exited: undefined, close: undefined })
|
|
28
46
|
process.once('SIGINT', session.close); process.once('SIGTERM', session.close)
|
|
@@ -41,6 +59,7 @@ try {
|
|
|
41
59
|
break
|
|
42
60
|
}
|
|
43
61
|
case 'dialogue': output(dialogueResult(args[1])); break
|
|
62
|
+
case 'notice': output(await sendTerminal(option('to'), { action: 'notice', text: fs.readFileSync(0, 'utf8') })); break
|
|
44
63
|
case 'stop': output(stopDialogue(args[1])); break
|
|
45
64
|
case 'answer': output(await sendTerminal(option('to'), { action: 'answer', id: option('request'), decision: option('decision') })); break
|
|
46
65
|
case 'install':
|
|
@@ -78,6 +97,9 @@ try {
|
|
|
78
97
|
console.log(`axMap 로컬 세션 연결 — 관리형 Codex/agy와 제한된 자동 대화
|
|
79
98
|
axmap bridge serve codex|agy [--model 이름] [--role 역할] [--max-tokens 100000]
|
|
80
99
|
명시적으로 새 관리형 CLI를 시작합니다. 기존 창에 임의로 붙지 않습니다.
|
|
100
|
+
axmap bridge connect codex --thread <기존 UUID> --socket <로컬 socket> [--role 역할] [--observe]
|
|
101
|
+
실행 중인 공식 Codex daemon의 기존 idle 대화에 연결합니다. 새 모델/대화를 만들지 않습니다.
|
|
102
|
+
--observe는 active 대화에도 관측만 연결하며 idle 전환 전에는 새 요청을 거절합니다.
|
|
81
103
|
axmap bridge chat --left <UUID> --right <UUID> [--max-turns 4] [--max-tokens 50000]
|
|
82
104
|
stdin 작업을 자동 중계합니다. 기본 메시지 한도 2000자, 대기 중 모델 호출 없음.
|
|
83
105
|
axmap bridge answer --to <UUID> --request <ID> --decision accept|decline|cancel
|
|
@@ -88,9 +110,19 @@ axmap bridge stop <대화 UUID> 추가 중계 중단 (이
|
|
|
88
110
|
axmap bridge install --experimental --executable <agy 절대 경로> 연결 훅 실험
|
|
89
111
|
axmap bridge uninstall 추가한 연결 훅 해제
|
|
90
112
|
axmap bridge sessions 이 repo에 연결된 세션
|
|
113
|
+
axmap bridge notice --to <UUID> stdin 안내를 기존 Codex 문맥에 기록 (모델 호출 없음)
|
|
91
114
|
axmap bridge attach --pid <PID> 이미 실행한 Windows agy 콘솔을 현재 repo에 연결
|
|
92
115
|
axmap bridge status [--json] 로컬 연결·작업·승인 대기·종료 상태 조회 (모델 호출 없음)
|
|
93
116
|
axmap bridge send --to <세션 UUID> [--wait-ms 30000] 본문은 stdin. 전달 후 지정 시간까지 로컬에서 회신 대기
|
|
117
|
+
send는 실행용 저수준 명령입니다. 일반 협업 알림에는 message를 사용하세요.
|
|
118
|
+
axmap bridge message --to <UUID> [--topic <갱신 주제>] [--key <중복 방지 키>] [--ttl-seconds 3600]
|
|
119
|
+
stdin 일반 메시지를 큐에 저장합니다. 모델 호출·새 턴 없음. 같은 발신자의 대기 중 topic만 최신으로 합칩니다.
|
|
120
|
+
axmap bridge message --to <UUID> --priority immediate --reason stop|data-loss|work-invalidated
|
|
121
|
+
긴급 사유를 보존하고 즉시 전달을 시도합니다. busy면 deferred, unknown이면 재전송 금지.
|
|
122
|
+
axmap bridge mailbox --to <UUID> [--all]
|
|
123
|
+
읽기만 합니다. --take --at checkpoint로 작업 경계에서 묶음 회수 (모델 호출 없음).
|
|
124
|
+
axmap bridge flush --to <UUID> --at task-complete|test-complete|experiment-complete|checkpoint
|
|
125
|
+
필요할 때만 idle 수신자에게 큐를 한 요청으로 전달. 자동 감시·중단·재전송 없음.
|
|
94
126
|
axmap bridge result <요청 UUID> [--wait-ms 30000] 제출/완료/불명 상태와 결과. 대기 중 모델 호출 없음
|
|
95
127
|
관리형 stdio: Codex App Server / agy stream-json. PTY와 agentapi는 별도 실험 경로입니다.`)
|
|
96
128
|
break
|
package/tools/pipeline.mjs
CHANGED
|
@@ -3,14 +3,22 @@ import fs from 'node:fs'
|
|
|
3
3
|
import { runPipelineOperation } from '../src/pipeline-mcp.mjs'
|
|
4
4
|
import { errorMessage } from '../src/session-types.mjs'
|
|
5
5
|
import { installPipelineStatusline, pipelineStatusline } from '../src/pipeline-statusline.mjs'
|
|
6
|
+
import { runMonitor } from '../src/monitor-cli.mjs'
|
|
7
|
+
import { startPipelineWeb } from '../src/pipeline-web.mjs'
|
|
6
8
|
const output = value => process.stdout.write(JSON.stringify(value) + '\n')
|
|
7
9
|
let cancelled = false
|
|
8
10
|
process.on('message', message => { if (message?.type === 'cancel-observation') cancelled = true })
|
|
9
11
|
try {
|
|
10
12
|
const action = process.argv[2]
|
|
11
13
|
const option = name => { const at = process.argv.indexOf(`--${name}`); return at < 0 ? undefined : process.argv[at + 1] }
|
|
12
|
-
if (
|
|
13
|
-
|
|
14
|
+
if (process.argv.includes('--web')) {
|
|
15
|
+
const web = await startPipelineWeb(option('cwd') || process.cwd(), Number(option('port') || 0))
|
|
16
|
+
console.log(JSON.stringify({ url: web.url, mode: 'read-only-local-pipeline' }))
|
|
17
|
+
const close = () => { void web.close() }; process.once('SIGINT', close); process.once('SIGTERM', close)
|
|
18
|
+
} else if (!action || action === 'view' || action.startsWith('--') && !['--help', '-h'].includes(action)) {
|
|
19
|
+
await runMonitor(['--graph', ...process.argv.slice(action === 'view' ? 3 : 2)])
|
|
20
|
+
} else if (action === '--help' || action === '-h') {
|
|
21
|
+
console.log('axmap pipeline [view] [--cwd <repo>] [--once | --json]\naxmap pipeline --web [--cwd <repo>] [--port <local-port>]\n세션 그래프 · 역할 · 실제 요청/응답 · 토큰 · 이벤트를 표시합니다. TTY에서는 실시간 갱신합니다.\naxmap pipeline install-statusline --provider claude|agy\naxmap pipeline uninstall-statusline --provider claude|agy\n상태줄 설치는 기존 명령을 보존하며 새 창은 열지 않습니다.\n대화 안 패널은 MCP ax_pipeline, 진행 알림은 ax_pipeline_watch를 사용하세요.')
|
|
14
22
|
} else if (action === 'install-statusline' || action === 'uninstall-statusline') {
|
|
15
23
|
console.log(JSON.stringify(installPipelineStatusline(option('provider'), option('home'), action === 'uninstall-statusline'), null, 2))
|
|
16
24
|
} else if (action === 'statusline') {
|