axmap-cli 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bridge.mjs CHANGED
@@ -9,7 +9,8 @@ import { spawnSync } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { withRepoLock } from './repolock.mjs';
11
11
  import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs';
12
- import { errorCode, errorMessage } from './session-types.mjs';
12
+ import { errorCode, errorMessage, RPC_NOT_DELIVERED_EXIT } from './session-types.mjs';
13
+ import { commonKey, resolveTarget } from './workspace.mjs';
13
14
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
15
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
16
  const bridgeHome = () => path.join(process.env.AXMAP_STATE_DIR || path.join(os.homedir(), '.axmap'), 'bridge');
@@ -45,14 +46,23 @@ function writeJSON(p, data) {
45
46
  fs.unlinkSync(tmp);
46
47
  }
47
48
  }
48
- function repoKey(cwd) {
49
+ function commonDirKey(cwd) {
49
50
  const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
50
51
  cwd, encoding: 'utf8', windowsHide: true,
51
52
  });
52
- if (r.status !== 0)
53
+ return r.status === 0 ? commonKey(fs.realpathSync(r.stdout.trim())) : null;
54
+ }
55
+ function repoKey(cwd) {
56
+ // Git 안이면 지금까지와 같은 한 번의 git 호출로 끝낸다. 실패했을 때만 작업 폴더
57
+ // 규칙(수동 설정 → 자식 저장소 자동 탐색 → 모호하면 오류)으로 대상 저장소를 정한다.
58
+ const direct = commonDirKey(cwd);
59
+ if (direct)
60
+ return direct;
61
+ const repo = resolveTarget(cwd);
62
+ const key = repo && commonDirKey(repo);
63
+ if (!key)
53
64
  throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`);
54
- const key = fs.realpathSync(r.stdout.trim());
55
- return process.platform === 'win32' ? key.toLowerCase() : key;
65
+ return key;
56
66
  }
57
67
  function addressError(address) {
58
68
  // Only a loopback address exported by a local provider hook is accepted.
@@ -98,20 +108,96 @@ function transcript(session) {
98
108
  lines.pop();
99
109
  return lines.filter(s => s.trim()).map(s => JSON.parse(s));
100
110
  }
101
- function publicSession(s) {
111
+ function publicSession(s, liveness) {
102
112
  return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
103
113
  conversationId: s.conversationId,
104
114
  transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
105
- registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
106
- role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval };
115
+ registeredAt: s.registeredAt, event: s.event,
116
+ // 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
117
+ fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
118
+ role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
119
+ ...(liveness ? { liveness } : {}) };
120
+ }
121
+ /** 관리형 세션(pty·stdio)의 로컬 제어 파이프. 세션 호스트가 살아 있는 동안만 존재한다. */
122
+ export const hostPipe = (id) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
123
+ /** Windows 의 열린 파이프 이름 목록. 읽지 못하면 오류 자체를 돌려준다 —
124
+ * "목록에 없음"과 "목록을 못 읽음"을 같은 것으로 보면 산 세션을 닫힘으로 적는다. */
125
+ function openPipes() {
126
+ try {
127
+ return new Set(fs.readdirSync('\\\\.\\pipe\\').map(n => n.toLowerCase()));
128
+ }
129
+ catch (e) {
130
+ return e instanceof Error ? e : new Error(String(e));
131
+ }
132
+ }
133
+ /**
134
+ * 저장된 상태가 아니라 호스트가 실제로 살아 있는지. 연결 요청은 보내지 않는다.
135
+ *
136
+ * `bridge serve` 가 강제 종료되면 종료 처리(state: closed)를 쓰지 못해 기록이
137
+ * 영원히 `idle` 로 남는다 (실측: 세션 d3cde187, 파이프 없음). 파이프는 호스트가
138
+ * listen 한 뒤에만 기록을 쓰므로, 파이프가 없다는 것은 호스트가 끝났다는 뜻이다.
139
+ * Windows : 파이프 이름 목록에 없으면 dead, 목록을 못 읽으면 unverified
140
+ * 그 밖 : 소켓 파일이 없으면 dead. 있으면 기록된 호스트 pid 로 판단하고,
141
+ * pid 가 없으면(이전 버전 기록) unverified — 남은 소켓 파일만으로는 모른다.
142
+ */
143
+ function hostLiveness(s, pipes) {
144
+ if (process.platform === 'win32') {
145
+ const listed = pipes();
146
+ if (listed instanceof Error)
147
+ return 'unverified';
148
+ return listed.has(`axmap-${s.id}`.toLowerCase()) ? 'alive' : 'dead';
149
+ }
150
+ try {
151
+ fs.statSync(hostPipe(s.id));
152
+ }
153
+ catch (e) {
154
+ return errorCode(e) === 'ENOENT' ? 'dead' : 'unverified';
155
+ }
156
+ if (!Number.isSafeInteger(s.hostPid) || s.hostPid <= 0)
157
+ return 'unverified';
158
+ try {
159
+ process.kill(s.hostPid, 0);
160
+ return 'alive';
161
+ }
162
+ catch (e) {
163
+ return errorCode(e) === 'ESRCH' ? 'dead' : errorCode(e) === 'EPERM' ? 'alive' : 'unverified';
164
+ }
165
+ }
166
+ function markClosed(target, s) {
167
+ // 호스트가 끝난 뒤 기록을 바꾸는 쪽은 여기뿐이지만, 막 종료 처리 중인 호스트가 더
168
+ // 최신 기록(사용량 등)을 썼을 수 있어 다시 읽고 바꾼다.
169
+ let latest = s;
170
+ try {
171
+ latest = readJSON(target);
172
+ }
173
+ catch { /* 아래에서 읽은 기록으로 표시만 한다 */ }
174
+ if (latest.state === 'closed')
175
+ return latest;
176
+ const closed = { ...latest, state: 'closed', fullyIdle: false,
177
+ closedReason: '세션 호스트의 로컬 연결 파이프가 없습니다. 정상 종료 처리 없이 끝난 것으로 보입니다.' };
178
+ // 저장에 실패해도 이번 조회에는 닫힘으로 보고한다. 다음 조회가 다시 시도한다.
179
+ try {
180
+ writeJSON(target, closed);
181
+ }
182
+ catch { /* 위와 같다 */ }
183
+ return closed;
107
184
  }
108
185
  export function sessions(cwd) {
109
186
  const dir = path.join(bridgeHome(), 'sessions');
110
187
  if (!fs.existsSync(dir))
111
188
  return [];
112
189
  const repo = repoKey(cwd);
190
+ let pipes;
191
+ const listPipes = () => (pipes ??= openPipes());
113
192
  return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
114
- .map(n => readJSON(path.join(dir, n))).filter(s => s.repos.includes(repo)).map(publicSession);
193
+ .map(n => [path.join(dir, n), readJSON(path.join(dir, n))])
194
+ .filter(([, s]) => s.repos.includes(repo))
195
+ .map(([target, s]) => {
196
+ if (!['pty', 'stdio'].includes(s.transport || '') || s.state === 'closed')
197
+ return publicSession(s);
198
+ const liveness = hostLiveness(s, listPipes);
199
+ return liveness === 'dead' ? publicSession(markClosed(target, s), 'dead') : publicSession(s, liveness);
200
+ });
115
201
  }
116
202
  function sessionFor(id, cwd) {
117
203
  const s = readJSON(file('sessions', id));
@@ -217,59 +303,85 @@ export function sessionLeasePath(id) {
217
303
  return path.join(bridgeHome(), 'dialogues', `session-${id}.lock`);
218
304
  }
219
305
  export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }) {
220
- if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768)
221
- throw new Error('요청 본문은 1~32768바이트여야 합니다.');
222
- const s = sessionFor(to, cwd);
223
- const lease = sessionLeasePath(to);
224
- if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
225
- throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.');
226
- const dir = path.join(bridgeHome(), 'requests');
227
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
228
- return withRepoLock(path.join(dir, `${to}.lock`), () => {
306
+ // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
307
+ // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
308
+ let sending = false;
309
+ try {
310
+ if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768)
311
+ throw new Error('요청 본문은 1~32768바이트여야 합니다.');
312
+ const s = sessionFor(to, cwd);
313
+ const lease = sessionLeasePath(to);
229
314
  if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
230
- throw new Error('이 세션은 자동 대화가 사용 중입니다.');
231
- for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
232
- const previous = readJSON(path.join(dir, n));
233
- if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state))
234
- continue;
235
- const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd);
236
- if (['sending', 'submitted', 'unknown'].includes(latest.state))
237
- throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`);
238
- }
239
- // No fallback to `agy -p` or --conversation: both would start a separate process.
240
- let consolePreflight;
241
- if (s.transport === 'console') {
242
- validateConsolePrompt(prompt, from);
243
- consolePreflight = checkConsole(s);
244
- }
245
- if (!['pty', 'stdio', 'console'].includes(s.transport || '')) {
246
- runAPI(s, ['get-conversation-metadata', to]);
247
- if (!s.fullyIdle || s.event !== 'Stop')
248
- throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.');
249
- }
250
- const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
251
- createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' };
252
- const target = file('requests', request.id);
253
- writeJSON(target, request);
254
- try {
255
- request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
256
- ? terminalRPC(to, { action: 'submit', request })
257
- : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`]);
258
- request.state = 'submitted';
259
- }
260
- catch (e) {
261
- // A timeout may occur after delivery. Never retry automatically.
262
- request.state = 'unknown';
263
- request.error = errorMessage(e);
264
- }
265
- writeJSON(target, request);
266
- return request;
267
- });
315
+ throw new Error('이 세션은 자동 대화가 사용 중입니다. 해당 대화가 끝난 뒤 요청하세요.');
316
+ const dir = path.join(bridgeHome(), 'requests');
317
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
318
+ return withRepoLock(path.join(dir, `${to}.lock`), () => {
319
+ if (fs.existsSync(lease) && readJSON(lease).dialogueId !== dialogueId)
320
+ throw new Error('이 세션은 자동 대화가 사용 중입니다.');
321
+ for (const n of fs.readdirSync(dir).filter(n => n.endsWith('.json'))) {
322
+ const previous = readJSON(path.join(dir, n));
323
+ if (previous.to !== to || !['sending', 'submitted', 'unknown'].includes(previous.state))
324
+ continue;
325
+ const latest = previous.state === 'sending' ? previous : requestResult(previous.id, cwd);
326
+ if (['sending', 'submitted', 'unknown'].includes(latest.state))
327
+ throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`);
328
+ }
329
+ // No fallback to `agy -p` or --conversation: both would start a separate process.
330
+ let consolePreflight;
331
+ if (s.transport === 'console') {
332
+ validateConsolePrompt(prompt, from);
333
+ consolePreflight = checkConsole(s);
334
+ }
335
+ if (!['pty', 'stdio', 'console'].includes(s.transport || '')) {
336
+ runAPI(s, ['get-conversation-metadata', to]);
337
+ if (!s.fullyIdle || s.event !== 'Stop')
338
+ throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.');
339
+ }
340
+ const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
341
+ createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' };
342
+ const target = file('requests', request.id);
343
+ writeJSON(target, request);
344
+ sending = true;
345
+ try {
346
+ request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
347
+ ? terminalRPC(to, { action: 'submit', request })
348
+ : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`]);
349
+ request.state = 'submitted';
350
+ }
351
+ catch (e) {
352
+ if (notDelivered(e)) {
353
+ // 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
354
+ // 않았으므로 확실히 전달되지 않았다. unknown 으로 두면 이 세션의 다음 요청이
355
+ // 전부 막힌다. 자동 재전송은 여전히 하지 않는다.
356
+ request.state = 'failed';
357
+ request.error = `전달되지 않았습니다: ${errorMessage(e)}`;
358
+ }
359
+ else {
360
+ // A timeout may occur after delivery. Never retry automatically.
361
+ request.state = 'unknown';
362
+ request.error = errorMessage(e);
363
+ }
364
+ }
365
+ writeJSON(target, request);
366
+ return request;
367
+ });
368
+ }
369
+ catch (e) {
370
+ if (!sending && e instanceof Error)
371
+ Object.assign(e, { notDelivered: true });
372
+ throw e;
373
+ }
268
374
  }
375
+ /** 연결 전에 실패해 아무것도 보내지 않은 오류만 true. 문구가 아니라 종료 코드나 보내기 전 단계로 가른다. */
376
+ export const notDelivered = (e) => e instanceof Error && e.notDelivered === true;
269
377
  function terminalRPC(id, message) {
270
378
  const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
271
379
  input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
272
380
  });
381
+ // 시간 초과(r.error)나 그 밖의 실패는 연결 뒤에 났을 수 있어 전달 여부를 모른다.
382
+ if (!r.error && r.status === RPC_NOT_DELIVERED_EXIT) {
383
+ throw Object.assign(new Error(r.stderr?.trim() || '로컬 세션 파이프에 연결하지 못했습니다.'), { notDelivered: true });
384
+ }
273
385
  if (r.error || r.status !== 0)
274
386
  throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.');
275
387
  return JSON.parse(r.stdout);
package/src/bridge.mts CHANGED
@@ -8,7 +8,9 @@ import { spawnSync } from 'node:child_process'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { withRepoLock } from './repolock.mjs'
10
10
  import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs'
11
- import { errorCode, errorMessage } from './session-types.mjs'
11
+ import { errorCode, errorMessage, RPC_NOT_DELIVERED_EXIT } from './session-types.mjs'
12
+ import { commonKey, resolveTarget } from './workspace.mjs'
13
+ import type { HostLiveness } from './session-types.mjs'
12
14
  import type { SessionRecord, PublicSession, RequestRecord, DispatchOptions, HookInput, TranscriptEntry } from './session-types.mjs'
13
15
 
14
16
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -37,13 +39,21 @@ function writeJSON(p: string, data: unknown): void {
37
39
  }
38
40
  } finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp) }
39
41
  }
40
- function repoKey(cwd: string): string {
42
+ function commonDirKey(cwd: string): string | null {
41
43
  const r = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
42
44
  cwd, encoding: 'utf8', windowsHide: true,
43
45
  })
44
- if (r.status !== 0) throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`)
45
- const key = fs.realpathSync(r.stdout.trim())
46
- return process.platform === 'win32' ? key.toLowerCase() : key
46
+ return r.status === 0 ? commonKey(fs.realpathSync(r.stdout.trim())) : null
47
+ }
48
+ function repoKey(cwd: string): string {
49
+ // Git 안이면 지금까지와 같은 한 번의 git 호출로 끝낸다. 실패했을 때만 작업 폴더
50
+ // 규칙(수동 설정 → 자식 저장소 자동 탐색 → 모호하면 오류)으로 대상 저장소를 정한다.
51
+ const direct = commonDirKey(cwd)
52
+ if (direct) return direct
53
+ const repo = resolveTarget(cwd)
54
+ const key = repo && commonDirKey(repo)
55
+ if (!key) throw new Error(`Git 작업 저장소를 확인하지 못했습니다: ${cwd}`)
56
+ return key
47
57
  }
48
58
  function addressError(address: unknown): asserts address is string {
49
59
  // Only a loopback address exported by a local provider hook is accepted.
@@ -79,19 +89,77 @@ function transcript(session: SessionRecord): TranscriptEntry[] {
79
89
  lines.pop()
80
90
  return lines.filter(s => s.trim()).map(s => JSON.parse(s) as TranscriptEntry)
81
91
  }
82
- function publicSession(s: SessionRecord): PublicSession {
92
+ function publicSession(s: SessionRecord, liveness?: HostLiveness): PublicSession {
83
93
  return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
84
94
  conversationId: s.conversationId,
85
95
  transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
86
- registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
87
- role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval }
96
+ registeredAt: s.registeredAt, event: s.event,
97
+ // 호스트가 살아 있는지 확인하지 못했으면 저장된 "대기"를 대기로 보고하지 않는다.
98
+ fullyIdle: liveness === 'unverified' ? false : s.fullyIdle,
99
+ role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval,
100
+ ...(liveness ? { liveness } : {}) }
101
+ }
102
+
103
+ /** 관리형 세션(pty·stdio)의 로컬 제어 파이프. 세션 호스트가 살아 있는 동안만 존재한다. */
104
+ export const hostPipe = (id: string) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
105
+
106
+ /** Windows 의 열린 파이프 이름 목록. 읽지 못하면 오류 자체를 돌려준다 —
107
+ * "목록에 없음"과 "목록을 못 읽음"을 같은 것으로 보면 산 세션을 닫힘으로 적는다. */
108
+ function openPipes(): Set<string> | Error {
109
+ try { return new Set(fs.readdirSync('\\\\.\\pipe\\').map(n => n.toLowerCase())) }
110
+ catch (e) { return e instanceof Error ? e : new Error(String(e)) }
88
111
  }
112
+
113
+ /**
114
+ * 저장된 상태가 아니라 호스트가 실제로 살아 있는지. 연결 요청은 보내지 않는다.
115
+ *
116
+ * `bridge serve` 가 강제 종료되면 종료 처리(state: closed)를 쓰지 못해 기록이
117
+ * 영원히 `idle` 로 남는다 (실측: 세션 d3cde187, 파이프 없음). 파이프는 호스트가
118
+ * listen 한 뒤에만 기록을 쓰므로, 파이프가 없다는 것은 호스트가 끝났다는 뜻이다.
119
+ * Windows : 파이프 이름 목록에 없으면 dead, 목록을 못 읽으면 unverified
120
+ * 그 밖 : 소켓 파일이 없으면 dead. 있으면 기록된 호스트 pid 로 판단하고,
121
+ * pid 가 없으면(이전 버전 기록) unverified — 남은 소켓 파일만으로는 모른다.
122
+ */
123
+ function hostLiveness(s: SessionRecord, pipes: () => Set<string> | Error): HostLiveness {
124
+ if (process.platform === 'win32') {
125
+ const listed = pipes()
126
+ if (listed instanceof Error) return 'unverified'
127
+ return listed.has(`axmap-${s.id}`.toLowerCase()) ? 'alive' : 'dead'
128
+ }
129
+ try { fs.statSync(hostPipe(s.id)) } catch (e) { return errorCode(e) === 'ENOENT' ? 'dead' : 'unverified' }
130
+ if (!Number.isSafeInteger(s.hostPid) || (s.hostPid as number) <= 0) return 'unverified'
131
+ try { process.kill(s.hostPid as number, 0); return 'alive' } catch (e) {
132
+ return errorCode(e) === 'ESRCH' ? 'dead' : errorCode(e) === 'EPERM' ? 'alive' : 'unverified'
133
+ }
134
+ }
135
+
136
+ function markClosed(target: string, s: SessionRecord): SessionRecord {
137
+ // 호스트가 끝난 뒤 기록을 바꾸는 쪽은 여기뿐이지만, 막 종료 처리 중인 호스트가 더
138
+ // 최신 기록(사용량 등)을 썼을 수 있어 다시 읽고 바꾼다.
139
+ let latest = s
140
+ try { latest = readJSON<SessionRecord>(target) } catch { /* 아래에서 읽은 기록으로 표시만 한다 */ }
141
+ if (latest.state === 'closed') return latest
142
+ const closed: SessionRecord = { ...latest, state: 'closed', fullyIdle: false,
143
+ closedReason: '세션 호스트의 로컬 연결 파이프가 없습니다. 정상 종료 처리 없이 끝난 것으로 보입니다.' }
144
+ // 저장에 실패해도 이번 조회에는 닫힘으로 보고한다. 다음 조회가 다시 시도한다.
145
+ try { writeJSON(target, closed) } catch { /* 위와 같다 */ }
146
+ return closed
147
+ }
148
+
89
149
  export function sessions(cwd: string): PublicSession[] {
90
150
  const dir = path.join(bridgeHome(), 'sessions')
91
151
  if (!fs.existsSync(dir)) return []
92
152
  const repo = repoKey(cwd)
153
+ let pipes: Set<string> | Error | undefined
154
+ const listPipes = () => (pipes ??= openPipes())
93
155
  return fs.readdirSync(dir).filter(n => UUID.test(n.replace(/\.json$/, '')) && n.endsWith('.json'))
94
- .map(n => readJSON<SessionRecord>(path.join(dir, n))).filter(s => s.repos.includes(repo)).map(publicSession)
156
+ .map(n => [path.join(dir, n), readJSON<SessionRecord>(path.join(dir, n))] as const)
157
+ .filter(([, s]) => s.repos.includes(repo))
158
+ .map(([target, s]) => {
159
+ if (!['pty', 'stdio'].includes(s.transport || '') || s.state === 'closed') return publicSession(s)
160
+ const liveness = hostLiveness(s, listPipes)
161
+ return liveness === 'dead' ? publicSession(markClosed(target, s), 'dead') : publicSession(s, liveness)
162
+ })
95
163
  }
96
164
  function sessionFor(id: string, cwd: string): SessionRecord {
97
165
  const s = readJSON<SessionRecord>(file('sessions', id))
@@ -180,6 +248,10 @@ export function sessionLeasePath(id: string): string {
180
248
  }
181
249
 
182
250
  export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId = null }: DispatchOptions): RequestRecord {
251
+ // 보내기 시작 전에 난 오류(이전 요청 미확정, 대기 미확인 등)는 확실히 전달되지 않은 것이다.
252
+ // 받은 편지함이 이를 "전달 불명"과 구별해야 쪽지를 다시 대기열로 돌릴 수 있다.
253
+ let sending = false
254
+ try {
183
255
  if (typeof prompt !== 'string' || !prompt.trim() || Buffer.byteLength(prompt) > 32768) throw new Error('요청 본문은 1~32768바이트여야 합니다.')
184
256
  const s = sessionFor(to, cwd)
185
257
  const lease = sessionLeasePath(to)
@@ -205,25 +277,45 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
205
277
  createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport || '') ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
206
278
  const target = file('requests', request.id)
207
279
  writeJSON(target, request)
280
+ sending = true
208
281
  try {
209
282
  request.receipt = s.transport === 'console' ? submitConsole(s, request, consolePreflight) : ['pty', 'stdio'].includes(s.transport || '')
210
283
  ? terminalRPC(to, { action: 'submit', request })
211
284
  : runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
212
285
  request.state = 'submitted'
213
286
  } catch (e) {
214
- // A timeout may occur after delivery. Never retry automatically.
215
- request.state = 'unknown'
216
- request.error = errorMessage(e)
287
+ if (notDelivered(e)) {
288
+ // 로컬 파이프에 연결조차 못 했다(ENOENT·ECONNREFUSED). 한 바이트도 보내지
289
+ // 않았으므로 확실히 전달되지 않았다. unknown 으로 두면 이 세션의 다음 요청이
290
+ // 전부 막힌다. 자동 재전송은 여전히 하지 않는다.
291
+ request.state = 'failed'
292
+ request.error = `전달되지 않았습니다: ${errorMessage(e)}`
293
+ } else {
294
+ // A timeout may occur after delivery. Never retry automatically.
295
+ request.state = 'unknown'
296
+ request.error = errorMessage(e)
297
+ }
217
298
  }
218
299
  writeJSON(target, request)
219
300
  return request
220
301
  })
302
+ } catch (e) {
303
+ if (!sending && e instanceof Error) Object.assign(e, { notDelivered: true })
304
+ throw e
305
+ }
221
306
  }
222
307
 
308
+ /** 연결 전에 실패해 아무것도 보내지 않은 오류만 true. 문구가 아니라 종료 코드나 보내기 전 단계로 가른다. */
309
+ export const notDelivered =(e: unknown) => e instanceof Error && (e as Error & { notDelivered?: boolean }).notDelivered === true
310
+
223
311
  function terminalRPC(id: string, message: unknown): unknown {
224
312
  const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'run.mjs'), 'rpc', id], {
225
313
  input: JSON.stringify(message), encoding: 'utf8', windowsHide: true, timeout: 15000,
226
314
  })
315
+ // 시간 초과(r.error)나 그 밖의 실패는 연결 뒤에 났을 수 있어 전달 여부를 모른다.
316
+ if (!r.error && r.status === RPC_NOT_DELIVERED_EXIT) {
317
+ throw Object.assign(new Error(r.stderr?.trim() || '로컬 세션 파이프에 연결하지 못했습니다.'), { notDelivered: true })
318
+ }
227
319
  if (r.error || r.status !== 0) throw new Error(r.error?.message || r.stderr?.trim() || '터미널 세션에 연결하지 못했습니다.')
228
320
  return JSON.parse(r.stdout)
229
321
  }
@@ -6,11 +6,12 @@ import path from 'node:path';
6
6
  import net from 'node:net';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
9
- import { bridgeHome, repoKey, writeJSON } from './bridge.mjs';
9
+ import { bridgeHome, hostPipe, repoKey, writeJSON } from './bridge.mjs';
10
10
  import { normalizeUsage, usageDelta } from './session-usage.mjs';
11
11
  import { errorMessage } from './session-types.mjs';
12
12
  import { connectCodexProxy } from './codex-connection.mjs';
13
- const pipeFor = (id) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`);
13
+ // 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
14
+ const pipeFor = hostPipe;
14
15
  const uuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '');
15
16
  export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false, cwd = process.cwd(), maxTokens = 100000, timeoutMs = 120000, onEvent = () => { } }) {
16
17
  if (!['codex', 'agy'].includes(provider))
@@ -24,7 +25,7 @@ export async function startManagedSession({ provider, executable = provider, pre
24
25
  const id = randomUUID(), token = randomBytes(32).toString('hex');
25
26
  const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`);
26
27
  const s = { id, token, provider, transport: 'stdio', role, model: model || null,
27
- workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
28
+ workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false, hostPid: process.pid,
28
29
  registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens };
29
30
  const jobs = new Map();
30
31
  let attachedUsageOrigin = null;
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  import net from 'node:net'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto'
8
- import { bridgeHome, repoKey, writeJSON } from './bridge.mjs'
8
+ import { bridgeHome, hostPipe, repoKey, writeJSON } from './bridge.mjs'
9
9
  import { normalizeUsage, usageDelta } from './session-usage.mjs'
10
10
  import { errorMessage } from './session-types.mjs'
11
11
  import { connectCodexProxy } from './codex-connection.mjs'
@@ -31,7 +31,8 @@ interface ProviderMessage {
31
31
  }
32
32
  interface AgyResult { conversation_id: string; status: string; response?: unknown; error?: string; usage?: unknown }
33
33
 
34
- const pipeFor = (id: string) => process.platform === 'win32' ? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
34
+ // 생존 확인(bridge.sessions)이 같은 이름을 보도록 한 곳에서 만든다.
35
+ const pipeFor = hostPipe
35
36
  const uuid = (value: string | undefined) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '')
36
37
 
37
38
  export async function startManagedSession({ provider, executable = provider, prefix = [], args = null, model, role = '', existingThreadId, socketPath, observeActive = false,
@@ -43,7 +44,7 @@ export async function startManagedSession({ provider, executable = provider, pre
43
44
  const id = randomUUID(), token = randomBytes(32).toString('hex')
44
45
  const sessionPath = path.join(bridgeHome(), 'sessions', `${id}.json`)
45
46
  const s: SessionRecord = { id, token, provider, transport: 'stdio', role, model: model || null,
46
- workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false,
47
+ workspaces: [path.resolve(cwd)], repos: [repoKey(cwd)], state: 'starting', fullyIdle: false, hostPid: process.pid,
47
48
  registeredAt: new Date().toISOString(), usage: null, usageKnown: true, maxTokens }
48
49
  const jobs = new Map<string, JobRecord>()
49
50
  let attachedUsageOrigin: Usage | null = null
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest } from './bridge.mjs';
7
+ import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest, notDelivered } from './bridge.mjs';
8
8
  import { withRepoLock } from './repolock.mjs';
9
9
  import { errorMessage } from './session-types.mjs';
10
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -31,7 +31,7 @@ function load(target) {
31
31
  !['boundary', 'immediate'].includes(m.priority) || !states.includes(m.state) ||
32
32
  !Number.isFinite(Date.parse(m.createdAt)) || !Number.isFinite(Date.parse(m.expiresAt))) ||
33
33
  b.batches.some(x => !x || !UUID.test(x.id || '') || typeof x.repo !== 'string' || !Array.isArray(x.ids) ||
34
- !['sending', 'submitted', 'unknown'].includes(x.state)))
34
+ !['sending', 'submitted', 'unknown', 'failed'].includes(x.state)))
35
35
  throw new Error('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.');
36
36
  return b;
37
37
  }
@@ -169,22 +169,32 @@ export function flushMail({ to, cwd, at, immediateId }) {
169
169
  writeJSON(target, b);
170
170
  try {
171
171
  const r = dispatchRequest({ to, cwd, from: 'axmap-agent-mailbox', prompt });
172
- batch.state = r.state === 'submitted' ? 'submitted' : 'unknown';
172
+ batch.state = r.state === 'submitted' ? 'submitted' : r.state === 'failed' ? 'failed' : 'unknown';
173
173
  batch.requestId = r.id;
174
174
  batch.error = r.error;
175
175
  }
176
176
  catch (e) {
177
177
  // A storage error can happen after the provider accepted a request. Even
178
- // thrown errors must not put this batch back into an executable queue.
179
- batch.state = 'unknown';
178
+ // thrown errors must not put this batch back into an executable queue —
179
+ // unless the error came before anything was sent.
180
+ batch.state = notDelivered(e) ? 'failed' : 'unknown';
180
181
  batch.error = errorMessage(e);
181
182
  }
182
- for (const m of selected) {
183
- m.state = batch.state;
184
- m.requestId = batch.requestId;
185
- m.error = batch.error;
186
- }
183
+ // 확실히 전달되지 않은 묶음의 쪽지는 대기열로 돌려 다음 경계에서 다시 보낼 수 있게 한다.
184
+ // 자동으로 다시 보내지는 않는다. 묶음 기록은 이력으로 남는다.
185
+ if (batch.state === 'failed')
186
+ for (const m of selected) {
187
+ m.state = 'queued';
188
+ m.error = batch.error;
189
+ }
190
+ else
191
+ for (const m of selected) {
192
+ m.state = batch.state;
193
+ m.requestId = batch.requestId;
194
+ m.error = batch.error;
195
+ }
187
196
  writeJSON(target, b);
188
- return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null };
197
+ return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null,
198
+ ...(batch.state === 'failed' ? { requeued: selected.length } : {}) };
189
199
  });
190
200
  }
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { randomUUID } from 'node:crypto'
6
- import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest } from './bridge.mjs'
6
+ import { bridgeHome, repoKey, sessions, readJSON, writeJSON, dispatchRequest, notDelivered } from './bridge.mjs'
7
7
  import { withRepoLock } from './repolock.mjs'
8
8
  import { errorMessage } from './session-types.mjs'
9
9
 
@@ -15,7 +15,7 @@ export interface Mail {
15
15
  createdAt: string; expiresAt: string; state: State
16
16
  supersededBy?: string; batchId?: string; requestId?: string; error?: string
17
17
  }
18
- interface Batch { id: string; repo: string; ids: string[]; state: 'sending' | 'submitted' | 'unknown'; requestId?: string; error?: string }
18
+ interface Batch { id: string; repo: string; ids: string[]; state: 'sending' | 'submitted' | 'unknown' | 'failed'; requestId?: string; error?: string }
19
19
  interface Box { version: 1; messages: Mail[]; batches: Batch[] }
20
20
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
21
21
  const reasons = ['stop', 'data-loss', 'work-invalidated']
@@ -38,7 +38,7 @@ function load(target: string): Box {
38
38
  !['boundary', 'immediate'].includes(m.priority) || !states.includes(m.state) ||
39
39
  !Number.isFinite(Date.parse(m.createdAt)) || !Number.isFinite(Date.parse(m.expiresAt))) ||
40
40
  b.batches.some(x => !x || !UUID.test(x.id || '') || typeof x.repo !== 'string' || !Array.isArray(x.ids) ||
41
- !['sending', 'submitted', 'unknown'].includes(x.state))) throw new Error('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.')
41
+ !['sending', 'submitted', 'unknown', 'failed'].includes(x.state))) throw new Error('받은 편지함 기록이 손상됐습니다. 빈 큐로 바꾸지 않습니다.')
42
42
  return b
43
43
  }
44
44
  function expire(b: Box) {
@@ -144,14 +144,19 @@ export function flushMail({ to, cwd, at, immediateId }:
144
144
  writeJSON(target, b)
145
145
  try {
146
146
  const r = dispatchRequest({ to, cwd, from: 'axmap-agent-mailbox', prompt })
147
- batch.state = r.state === 'submitted' ? 'submitted' : 'unknown'; batch.requestId = r.id; batch.error = r.error
147
+ batch.state = r.state === 'submitted' ? 'submitted' : r.state === 'failed' ? 'failed' : 'unknown'; batch.requestId = r.id; batch.error = r.error
148
148
  } catch (e) {
149
149
  // A storage error can happen after the provider accepted a request. Even
150
- // thrown errors must not put this batch back into an executable queue.
151
- batch.state = 'unknown'; batch.error = errorMessage(e)
150
+ // thrown errors must not put this batch back into an executable queue —
151
+ // unless the error came before anything was sent.
152
+ batch.state = notDelivered(e) ? 'failed' : 'unknown'; batch.error = errorMessage(e)
152
153
  }
153
- for (const m of selected) { m.state = batch.state; m.requestId = batch.requestId; m.error = batch.error }
154
+ // 확실히 전달되지 않은 묶음의 쪽지는 대기열로 돌려 다음 경계에서 다시 보낼 수 있게 한다.
155
+ // 자동으로 다시 보내지는 않는다. 묶음 기록은 이력으로 남는다.
156
+ if (batch.state === 'failed') for (const m of selected) { m.state = 'queued'; m.error = batch.error }
157
+ else for (const m of selected) { m.state = batch.state as State; m.requestId = batch.requestId; m.error = batch.error }
154
158
  writeJSON(target, b)
155
- return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null }
159
+ return { ...batch, modelInvocationRequested: batch.state === 'submitted', modelInvoked: null,
160
+ ...(batch.state === 'failed' ? { requeued: selected.length } : {}) }
156
161
  })
157
162
  }
@@ -1,4 +1,7 @@
1
1
  // Generated from src/session-types.mts. Edit the .mts source and run npm run build.
2
+ /** `run.mjs rpc` 가 로컬 파이프에 연결조차 못 했을 때(ENOENT·ECONNREFUSED)의 종료 코드.
3
+ * 이 경우에만 요청이 확실히 전달되지 않았다고 판단한다. */
4
+ export const RPC_NOT_DELIVERED_EXIT = 3;
2
5
  export function errorMessage(error) {
3
6
  return error instanceof Error ? error.message : String(error);
4
7
  }