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/.claude/commands/ax-update.md +0 -3
- package/README.ko.md +15 -0
- package/README.md +15 -0
- package/bin/axmap.mjs +149 -88
- package/governance/vote.mjs +6 -69
- package/mcp/README.md +2 -0
- package/mcp/server.mjs +22 -7
- package/package.json +1 -1
- package/src/bridge.mjs +168 -56
- package/src/bridge.mts +104 -12
- package/src/managed-session.mjs +4 -3
- package/src/managed-session.mts +4 -3
- package/src/session-mailbox.mjs +21 -11
- package/src/session-mailbox.mts +13 -8
- package/src/session-types.mjs +3 -0
- package/src/session-types.mts +9 -1
- package/src/terminal-bridge.mjs +13 -7
- package/src/terminal-bridge.mts +12 -7
- package/src/update.mjs +3 -20
- package/src/workspace.mjs +247 -0
- package/src/workspace.mts +220 -0
- package/tools/build.mjs +1 -1
- package/tools/bus.mjs +1 -1
- package/tools/run.mjs +8 -1
- package/src/closure.mjs +0 -343
- package/tools/vendor.mjs +0 -382
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
106
|
-
|
|
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 =>
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
request
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
request
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
87
|
-
|
|
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))
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
}
|
package/src/managed-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/managed-session.mts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/session-mailbox.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
m
|
|
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
|
}
|
package/src/session-mailbox.mts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/session-types.mjs
CHANGED
|
@@ -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
|
}
|