axmap-cli 1.5.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.
Files changed (53) hide show
  1. package/.claude/commands/pipeline.md +9 -0
  2. package/README.ko.md +37 -0
  3. package/README.md +37 -0
  4. package/bin/axmap.mjs +6 -0
  5. package/mcp/README.md +9 -0
  6. package/mcp/pipeline.html +184 -0
  7. package/mcp/server.mjs +161 -20
  8. package/package.json +12 -3
  9. package/skills/pipeline/SKILL.md +14 -0
  10. package/src/agentcli.mjs +17 -14
  11. package/src/agentcli.mts +19 -0
  12. package/src/bridge.mjs +297 -219
  13. package/src/bridge.mts +282 -0
  14. package/src/codex-connection.mjs +151 -0
  15. package/src/codex-connection.mts +83 -0
  16. package/src/external-console.mjs +140 -97
  17. package/src/external-console.mts +128 -0
  18. package/src/managed-session.mjs +325 -169
  19. package/src/managed-session.mts +242 -0
  20. package/src/monitor-cli.mjs +89 -0
  21. package/src/monitor-cli.mts +66 -0
  22. package/src/monitor.mjs +245 -0
  23. package/src/monitor.mts +199 -0
  24. package/src/node-pty.d.ts +13 -0
  25. package/src/pipeline-graph.mjs +55 -0
  26. package/src/pipeline-graph.mts +51 -0
  27. package/src/pipeline-mcp.mjs +111 -0
  28. package/src/pipeline-mcp.mts +105 -0
  29. package/src/pipeline-statusline.mjs +135 -0
  30. package/src/pipeline-statusline.mts +106 -0
  31. package/src/pipeline-web.mjs +56 -0
  32. package/src/pipeline-web.mts +31 -0
  33. package/src/session-dialogue.mjs +168 -116
  34. package/src/session-dialogue.mts +136 -0
  35. package/src/session-mailbox.mjs +190 -0
  36. package/src/session-mailbox.mts +157 -0
  37. package/src/session-status.mjs +47 -41
  38. package/src/session-status.mts +50 -0
  39. package/src/session-types.mjs +7 -0
  40. package/src/session-types.mts +119 -0
  41. package/src/session-usage.mjs +33 -28
  42. package/src/session-usage.mts +39 -0
  43. package/src/terminal-bridge.mjs +293 -209
  44. package/src/terminal-bridge.mts +235 -0
  45. package/src/windows-console.mjs +42 -0
  46. package/src/windows-console.mts +29 -0
  47. package/tools/bench-console.mjs +70 -0
  48. package/tools/bridge.mjs +41 -6
  49. package/tools/build.mjs +39 -0
  50. package/tools/monitor.mjs +5 -0
  51. package/tools/pipeline.mjs +34 -0
  52. package/tools/run-tests.mjs +3 -2
  53. package/tools/windows-console.cs +116 -0
@@ -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
+ }
@@ -1,48 +1,54 @@
1
- import { sessions } from './bridge.mjs'
2
- import { sendTerminal } from './terminal-bridge.mjs'
3
- import { externalStatus } from './external-console.mjs'
4
-
1
+ // Generated from src/session-status.mts. Edit the .mts source and run npm run build.
2
+ import { sessions } from './bridge.mjs';
3
+ import { sendTerminal } from './terminal-bridge.mjs';
4
+ import { externalStatus } from './external-console.mjs';
5
5
  /** Probe only the authenticated local control pipe. A saved idle/running record
6
6
  * cannot establish that its host still exists; no model request is submitted. */
7
7
  export async function sessionStatus(cwd = process.cwd()) {
8
- const listed = sessions(cwd)
9
- return Promise.all(listed.map(async session => {
10
- const base = { ...session, lastKnownState: session.state ?? null }
11
- const snapshot = (state, connection, extra = {}) => ({ ...base, ...extra, state, connection,
12
- fullyIdle: connection === 'connected' && state === 'idle', checkedAt: new Date().toISOString() })
13
- if (session.state === 'closed') return snapshot('closed', 'closed')
14
- if (session.transport === 'console') {
15
- try {
16
- const live = externalStatus(session)
17
- return snapshot(live.state, 'connected')
18
- } catch { return snapshot('unknown', 'unreachable') }
19
- }
20
- if (!['pty', 'stdio'].includes(session.transport)) return snapshot('unknown', 'unverified')
21
- try {
22
- const live = await sendTerminal(session.id, { action: 'status' }, null, { timeoutMs: 1000 })
23
- if (live?.id !== session.id || !['starting', 'running', 'idle', 'awaiting_user', 'settling', 'closed'].includes(live.state)) {
24
- return snapshot('unknown', 'unreachable')
25
- }
26
- return snapshot(live.state, 'connected', {
27
- usage: live.usage === undefined ? session.usage : live.usage,
28
- pendingApproval: live.pendingApproval,
29
- })
30
- } catch {
31
- // A disconnected host or a timeout is not proof of a completed task.
32
- return snapshot('unknown', 'unreachable')
33
- }
34
- }))
8
+ const listed = sessions(cwd);
9
+ return Promise.all(listed.map(async (session) => {
10
+ const base = { ...session, lastKnownState: session.state ?? null };
11
+ const snapshot = (state, connection, extra = {}) => ({ ...base, ...extra, state, connection,
12
+ fullyIdle: connection === 'connected' && state === 'idle', checkedAt: new Date().toISOString() });
13
+ if (session.state === 'closed')
14
+ return snapshot('closed', 'closed');
15
+ if (session.transport === 'console') {
16
+ try {
17
+ const live = externalStatus(session);
18
+ return snapshot(live.state, 'connected');
19
+ }
20
+ catch {
21
+ return snapshot('unknown', 'unreachable');
22
+ }
23
+ }
24
+ if (!['pty', 'stdio'].includes(session.transport || ''))
25
+ return snapshot('unknown', 'unverified');
26
+ try {
27
+ const raw = await sendTerminal(session.id, { action: 'status' }, null, { timeoutMs: 1000 });
28
+ const live = raw;
29
+ if (live?.id !== session.id || !live.state || !['starting', 'running', 'idle', 'awaiting_user', 'settling', 'closed'].includes(live.state)) {
30
+ return snapshot('unknown', 'unreachable');
31
+ }
32
+ return snapshot(live.state, 'connected', {
33
+ usage: live.usage === undefined ? session.usage : live.usage,
34
+ pendingApproval: live.pendingApproval,
35
+ });
36
+ }
37
+ catch {
38
+ // A disconnected host or a timeout is not proof of a completed task.
39
+ return snapshot('unknown', 'unreachable');
40
+ }
41
+ }));
35
42
  }
36
-
37
43
  const labels = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '승인 대기',
38
- settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' }
39
- const connections = { connected: '연결됨', closed: '종료됨', unreachable: '연결 확인 불가', unverified: '확인 미지원' }
40
- const safe = value => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
41
-
44
+ settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' };
45
+ const connections = { connected: '연결됨', closed: '종료됨', unreachable: '연결 확인 불가', unverified: '확인 미지원' };
46
+ const safe = (value) => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
42
47
  export function formatSessionStatus(listed) {
43
- if (!listed.length) return '이 저장소에 등록된 로컬 세션이 없습니다.'
44
- return ['axMap 세션 상태', ...listed.map(s => {
45
- const usage = s.usage?.totalTokens == null ? '미확인' : `${s.usage.totalTokens}${s.maxTokens ? ` / ${s.maxTokens}` : ''}`
46
- return `${safe(s.provider)} ${safe(s.id)}\n ${connections[s.connection] || '상태 미확인'} · ${labels[s.state] || '상태 미확인'}${s.role ? ` · ${safe(s.role)}` : ''}\n 확인된 누적 처리 토큰: ${usage}`
47
- })].join('\n')
48
+ if (!listed.length)
49
+ return '이 저장소에 등록된 로컬 세션이 없습니다.';
50
+ return ['axMap 세션 상태', ...listed.map(s => {
51
+ const usage = s.usage?.totalTokens == null ? '미확인' : `${s.usage.totalTokens}${s.maxTokens ? ` / ${s.maxTokens}` : ''}`;
52
+ return `${safe(s.provider)} ${safe(s.id)}\n ${connections[s.connection] || '상태 미확인'} · ${labels[s.state] || '상태 미확인'}${s.role ? ` · ${safe(s.role)}` : ''}\n 확인된 누적 처리 토큰: ${usage}`;
53
+ })].join('\n');
48
54
  }
@@ -0,0 +1,50 @@
1
+ import { sessions } from './bridge.mjs'
2
+ import { sendTerminal } from './terminal-bridge.mjs'
3
+ import { externalStatus } from './external-console.mjs'
4
+ import type { PublicSession, SessionSnapshot, SessionState, ConnectionState } from './session-types.mjs'
5
+
6
+ /** Probe only the authenticated local control pipe. A saved idle/running record
7
+ * cannot establish that its host still exists; no model request is submitted. */
8
+ export async function sessionStatus(cwd = process.cwd()): Promise<SessionSnapshot[]> {
9
+ const listed = sessions(cwd)
10
+ return Promise.all(listed.map(async session => {
11
+ const base = { ...session, lastKnownState: session.state ?? null }
12
+ const snapshot = (state: SessionState, connection: ConnectionState, extra: Partial<PublicSession> = {}): SessionSnapshot => ({ ...base, ...extra, state, connection,
13
+ fullyIdle: connection === 'connected' && state === 'idle', checkedAt: new Date().toISOString() })
14
+ if (session.state === 'closed') return snapshot('closed', 'closed')
15
+ if (session.transport === 'console') {
16
+ try {
17
+ const live = externalStatus(session)
18
+ return snapshot(live.state, 'connected')
19
+ } catch { return snapshot('unknown', 'unreachable') }
20
+ }
21
+ if (!['pty', 'stdio'].includes(session.transport || '')) return snapshot('unknown', 'unverified')
22
+ try {
23
+ const raw: unknown = await sendTerminal(session.id, { action: 'status' }, null, { timeoutMs: 1000 })
24
+ const live = raw as Partial<PublicSession> | null
25
+ if (live?.id !== session.id || !live.state || !['starting', 'running', 'idle', 'awaiting_user', 'settling', 'closed'].includes(live.state)) {
26
+ return snapshot('unknown', 'unreachable')
27
+ }
28
+ return snapshot(live.state, 'connected', {
29
+ usage: live.usage === undefined ? session.usage : live.usage,
30
+ pendingApproval: live.pendingApproval,
31
+ })
32
+ } catch {
33
+ // A disconnected host or a timeout is not proof of a completed task.
34
+ return snapshot('unknown', 'unreachable')
35
+ }
36
+ }))
37
+ }
38
+
39
+ const labels = { starting: '시작 중', running: '작업 중', idle: '대기', awaiting_user: '승인 대기',
40
+ settling: '대기 전환 중', closed: '종료', unknown: '상태 미확인' }
41
+ const connections = { connected: '연결됨', closed: '종료됨', unreachable: '연결 확인 불가', unverified: '확인 미지원' }
42
+ const safe = (value: unknown) => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
43
+
44
+ export function formatSessionStatus(listed: readonly SessionSnapshot[]): string {
45
+ if (!listed.length) return '이 저장소에 등록된 로컬 세션이 없습니다.'
46
+ return ['axMap 세션 상태', ...listed.map(s => {
47
+ const usage = s.usage?.totalTokens == null ? '미확인' : `${s.usage.totalTokens}${s.maxTokens ? ` / ${s.maxTokens}` : ''}`
48
+ return `${safe(s.provider)} ${safe(s.id)}\n ${connections[s.connection] || '상태 미확인'} · ${labels[s.state] || '상태 미확인'}${s.role ? ` · ${safe(s.role)}` : ''}\n 확인된 누적 처리 토큰: ${usage}`
49
+ })].join('\n')
50
+ }
@@ -0,0 +1,7 @@
1
+ // Generated from src/session-types.mts. Edit the .mts source and run npm run build.
2
+ export function errorMessage(error) {
3
+ return error instanceof Error ? error.message : String(error);
4
+ }
5
+ export function errorCode(error) {
6
+ return error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
7
+ }
@@ -0,0 +1,119 @@
1
+ /** Shared contracts for the local bridge. Runtime input checks still guard external data. */
2
+ export type Provider = 'codex' | 'agy'
3
+ export type Transport = 'console' | 'stdio' | 'pty' | 'agentapi'
4
+ export type SessionState = 'starting' | 'running' | 'idle' | 'awaiting_user' | 'settling' | 'closed' | 'unknown'
5
+ export type RequestState = 'sending' | 'submitted' | 'unknown' | 'completed' | 'failed' | 'superseded'
6
+ export type ConnectionState = 'connected' | 'closed' | 'unreachable' | 'unverified'
7
+ export type HookEvent = 'SessionStart' | 'PreInvocation' | 'Stop'
8
+
9
+ export interface Usage {
10
+ inputTokens: number
11
+ cachedInputTokens: number
12
+ outputTokens: number
13
+ reasoningTokens: number
14
+ totalTokens: number
15
+ providerTotalTokens: number
16
+ }
17
+ export interface PendingApproval { id: string | number; method?: string; [key: string]: unknown }
18
+ export interface SessionRecord {
19
+ id: string
20
+ provider: Provider
21
+ transport?: Transport
22
+ workspaces: string[]
23
+ repos: string[]
24
+ state?: SessionState
25
+ pid?: number
26
+ startedAt?: string
27
+ registeredAt: string
28
+ event?: string
29
+ fullyIdle?: boolean
30
+ conversationId?: string
31
+ transcript?: string
32
+ model?: string | null
33
+ role?: string
34
+ usage?: Usage | null
35
+ usageKnown?: boolean
36
+ maxTokens?: number
37
+ pendingApproval?: PendingApproval
38
+ executable?: string
39
+ address?: string
40
+ token?: string
41
+ }
42
+ export type PublicSession = Pick<SessionRecord, 'id' | 'provider' | 'workspaces' | 'model' | 'transport' |
43
+ 'state' | 'pid' | 'registeredAt' | 'event' | 'fullyIdle' | 'role' | 'usage' | 'usageKnown' | 'maxTokens' | 'pendingApproval' | 'conversationId'>
44
+ export interface SessionSnapshot extends PublicSession {
45
+ lastKnownState: SessionState | null
46
+ state: SessionState
47
+ connection: ConnectionState
48
+ checkedAt: string
49
+ }
50
+ export interface RequestRecord {
51
+ id: string
52
+ to: string
53
+ from: string
54
+ repo: string
55
+ prompt: string
56
+ createdAt: string
57
+ baseline: number | null
58
+ state: RequestState
59
+ receipt?: unknown
60
+ result?: unknown
61
+ error?: string
62
+ completedAt?: string
63
+ conversationId?: string
64
+ usage?: Usage | null
65
+ }
66
+ export interface DispatchOptions { to: string; prompt: string; cwd: string; from?: string; dialogueId?: string | null }
67
+ export interface HookInput {
68
+ conversationId?: string
69
+ workspacePaths?: string[]
70
+ transcriptPath?: string
71
+ modelName?: string
72
+ fullyIdle?: boolean
73
+ model?: string
74
+ session_id?: string
75
+ prompt?: string
76
+ last_assistant_message?: unknown
77
+ }
78
+ export type LocalRequest = Pick<RequestRecord, 'id' | 'to' | 'prompt'> & Partial<Pick<RequestRecord, 'from'>>
79
+ export type LocalMessage = { token?: string } & (
80
+ { action: 'status' } | { action: 'result'; id: string } |
81
+ { action: 'answer'; id: string | number; decision: string } |
82
+ { action: 'submit'; request: LocalRequest } |
83
+ { action: 'notice'; text: string } |
84
+ { action: 'hook'; event: string; input: HookInput }
85
+ )
86
+ export interface JobRecord {
87
+ id: string; to: string; state: RequestState; result?: unknown; error?: string; completedAt?: string
88
+ baseline?: Usage | null; baselineKnown?: boolean; usage?: Usage | null; conversationId?: string
89
+ turnId?: string; rawUsage?: unknown; observed?: boolean; interrupted?: boolean
90
+ }
91
+ export interface TranscriptEntry { type?: string; source?: string; status?: string; content?: unknown }
92
+ export interface ConsoleSnapshot { pid: number; startedAt: string; screen: string; ignored?: false }
93
+ export interface DialogueTurn {
94
+ index: number; to: string; from: string; requestId: string; state: RequestState
95
+ usage?: Usage | null; conversationId?: string; error?: string; message?: string; done?: boolean
96
+ }
97
+ export type DialogueState = 'queued' | 'running' | 'stopping' | 'failed' | 'cancelled' | 'token_limit' |
98
+ 'unknown' | 'usage_unavailable' | 'message_limit' | 'invalid_response' | 'completed' | 'turn_limit'
99
+ export interface DialogueRecord {
100
+ id: string; repo: string; left?: string; right?: string; state: DialogueState; createdAt?: string
101
+ limits?: { maxTurns: number; maxTokens: number; maxMessageChars: number; timeoutMs: number }
102
+ turns: DialogueTurn[]; usage?: Usage | null; knownUsage?: Usage | null; usagePending?: boolean
103
+ reason?: string; endedAt?: string
104
+ }
105
+ export interface DialogueOptions {
106
+ id?: string; left: string; right: string; prompt: string; cwd?: string
107
+ maxTurns?: number; maxTokens?: number; maxMessageChars?: number; timeoutMs?: number
108
+ onEvent?: (event: Record<string, unknown>) => void
109
+ dispatch?: (options: DispatchOptions) => RequestRecord
110
+ result?: (id: string, cwd: string) => RequestRecord
111
+ inventory?: (cwd: string) => PublicSession[]
112
+ }
113
+
114
+ export function errorMessage(error: unknown): string {
115
+ return error instanceof Error ? error.message : String(error)
116
+ }
117
+ export function errorCode(error: unknown): string | undefined {
118
+ return error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : undefined
119
+ }