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.
@@ -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
+ }
@@ -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
@@ -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 (!action || action === '--help' || action === '-h') {
13
- console.log('axmap pipeline install-statusline --provider claude|agy\naxmap pipeline uninstall-statusline --provider claude|agy\n현재 CLI의 상태줄에 axMap 진행을 표시합니다. 기존 명령은 보존하며 새 창은 열지 않습니다.\n대화 안 패널은 MCP ax_pipeline, 진행 알림은 ax_pipeline_watch를 사용하세요.')
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') {