axmap-cli 1.4.0 → 1.5.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/README.ko.md +16 -0
- package/README.md +16 -0
- package/mcp/server.mjs +2 -2
- package/package.json +1 -1
- package/src/bridge.mjs +20 -8
- package/src/external-console.mjs +116 -0
- package/src/session-status.mjs +48 -0
- package/src/terminal-bridge.mjs +3 -2
- package/tools/bridge.mjs +11 -0
- package/tools/run.mjs +6 -2
- package/tools/setup.mjs +1 -1
- package/tools/windows-console.ps1 +91 -0
package/README.ko.md
CHANGED
|
@@ -25,6 +25,22 @@ axmap setup
|
|
|
25
25
|
|
|
26
26
|
최신 변경부터 정리합니다.
|
|
27
27
|
|
|
28
|
+
### 1.5.0
|
|
29
|
+
|
|
30
|
+
<sub>2026.10.21</sub>
|
|
31
|
+
|
|
32
|
+
**Windows에서 따로 실행한 Antigravity 대화창을 재시작 없이 연결합니다.**
|
|
33
|
+
|
|
34
|
+
- 일반 `agy`로 직접 연 대화창도 설치된 훅으로 자동 등록합니다. 이미 열린 창은 대상 저장소에서 `axmap bridge attach --pid <PID>`로 연결합니다.
|
|
35
|
+
- `ax_dispatch`와 `ax_result`가 기존 프로세스와 대화를 유지합니다. 빈 입력창의 대기 상태를 확인해 전달하고, 요청 UUID가 있는 대화 기록과 현재 콘솔 상태로 결과를 판정합니다.
|
|
36
|
+
- `axmap bridge status [--json]`으로 모델 작업을 보내지 않고 로컬 연결 상태를 확인합니다.
|
|
37
|
+
- 독립 콘솔 연결은 현재 Windows agy만 지원합니다. 토큰 예산을 검사하는 `ax_chat`은 관리형 stdio 세션을 사용합니다.
|
|
38
|
+
|
|
39
|
+
실제 기존 agy 창의 메시지 왕복과 회귀 테스트 574개 통과를 확인했습니다. 독립 콘솔의 자동 등록, 한글 전달, 입력 보호와 프로세스 식별 검사도 포함합니다.
|
|
40
|
+
[연결 안내 →](docs/SESSION-BRIDGE.md)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
28
44
|
### 1.4.0
|
|
29
45
|
|
|
30
46
|
<sub>2026.10.20</sub>
|
package/README.md
CHANGED
|
@@ -25,6 +25,22 @@ Each Git repository's ledger is prepared on first use. Collaborators must share
|
|
|
25
25
|
|
|
26
26
|
Newest changes first.
|
|
27
27
|
|
|
28
|
+
### 1.5.0
|
|
29
|
+
|
|
30
|
+
<sub>October 21, 2026</sub>
|
|
31
|
+
|
|
32
|
+
**Connect an existing Windows Antigravity conversation without restarting it.**
|
|
33
|
+
|
|
34
|
+
- Interactive `agy` sessions launched directly register through the installed hooks. Use `axmap bridge attach --pid <PID>` from the target repository for an already open console.
|
|
35
|
+
- `ax_dispatch` and `ax_result` preserve the existing process and conversation. Delivery requires an empty, idle input box; results match the request UUID in the transcript and the live console state.
|
|
36
|
+
- `axmap bridge status [--json]` checks local connections without submitting model work.
|
|
37
|
+
- Independent console connections currently support Windows agy only. Token-budgeted `ax_chat` still requires managed stdio sessions.
|
|
38
|
+
|
|
39
|
+
Validated against an existing real agy console and 574 passing regression tests, including independent-console registration, Unicode delivery, input protection and process identity checks.
|
|
40
|
+
[Connection guide →](docs/SESSION-BRIDGE.md)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
28
44
|
### 1.4.0
|
|
29
45
|
|
|
30
46
|
<sub>October 20, 2026</sub>
|
package/mcp/server.mjs
CHANGED
|
@@ -392,7 +392,7 @@ const TOOLS = [
|
|
|
392
392
|
},
|
|
393
393
|
{
|
|
394
394
|
name: 'ax_dispatch',
|
|
395
|
-
description: '사용자가 허용한 작업을 연결된 상대 세션에 전달한다. 기존 맥락을 유지하며 새 모델 프로세스를 만들지 않는다. submitted는 완료가 아니다. unknown이면 자동 재시도하지 말고 ax_result로 확인한다. Codex/agy 관리형 stdio, 실험용 PTY/agentapi 연결을 지원한다.',
|
|
395
|
+
description: '사용자가 허용한 작업을 연결된 상대 세션에 전달한다. 기존 맥락을 유지하며 새 모델 프로세스를 만들지 않는다. submitted는 완료가 아니다. unknown이면 자동 재시도하지 말고 ax_result로 확인한다. Codex/agy 관리형 stdio, 별도로 실행한 Windows agy console, 실험용 PTY/agentapi 연결을 지원한다.',
|
|
396
396
|
inputSchema: { type: 'object', properties: {
|
|
397
397
|
to: { type: 'string', description: 'ax_sessions에서 확인한 상대 세션 UUID' },
|
|
398
398
|
prompt: { type: 'string', description: '상대가 실행할 작업 요청' },
|
|
@@ -400,7 +400,7 @@ const TOOLS = [
|
|
|
400
400
|
},
|
|
401
401
|
{
|
|
402
402
|
name: 'ax_result',
|
|
403
|
-
description: '작업 요청의 제출/완료/불명 상태, 결과와 측정 사용량을 확인한다. 관리형 세션은
|
|
403
|
+
description: '작업 요청의 제출/완료/불명 상태, 결과와 측정 사용량을 확인한다. 관리형 세션은 구조화된 완료 이벤트, PTY는 종료 훅, 독립 Windows console은 요청 UUID가 있는 대화 기록과 현재 빈 입력창의 대기 상태로 판정한다.',
|
|
404
404
|
inputSchema: { type: 'object', properties: {
|
|
405
405
|
id: { type: 'string', description: 'ax_dispatch가 반환한 요청 UUID' },
|
|
406
406
|
}, required: ['id'] },
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//name": "🔴 npm 의 'axmap' 은 2022년부터 남이 쓰고 있는 다른 꾸러미다(leozin_ 의 Map 라이브러리, 1.1.2). 그래서 `npx axmap` 은 우리 것이 아니고, 누구에게도 그렇게 안내하면 안 된다. 2026-08-28 에 axmap-cli 로 정했다 — 스코프(@사용자/이름)를 쓰지 않는 이유는 스코프가 npm 계정 이름에 묶여서 나중에 팀이나 조직으로 옮길 때 이름이 통째로 바뀌기 때문이다. 사람이 치는 **명령** 이름은 그대로 axmap 이다 (아래 bin) — 꾸러미 이름과 명령 이름은 달라도 된다.",
|
|
3
3
|
"name": "axmap-cli",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"//private": "🔴 여기 있던 \"private\": true 를 2026-08-28 에 지웠다. 그 줄이 있는 동안 npm publish 는 거부됐다. 되돌리려면 다시 넣으면 되지만, 이미 올라간 버전은 그래도 안 사라진다 — npm 은 같은 번호를 덮어쓰지 못하고 삭제도 72시간 안에만 된다. 즉 이 줄을 되살리는 것은 '앞으로 안 올린다' 는 뜻이지 '올린 것을 없앤다' 는 뜻이 아니다.",
|
|
6
6
|
"//publishConfig": "axmap-cli 는 스코프가 없어서 기본이 이미 공개다. 즉 지금은 없어도 된다. 그래도 남겨 두는 이유: 나중에 @조직/axmap 처럼 스코프를 붙이는 날 이 줄이 없으면 publish 가 '유료 플랜이 필요하다'는 엉뚱한 말로 실패한다.",
|
|
7
7
|
"publishConfig": {
|
package/src/bridge.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
7
7
|
import { spawnSync } from 'node:child_process'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { withRepoLock } from './repolock.mjs'
|
|
10
|
+
import { submitConsole, checkConsole, validateConsolePrompt, findConsoleTranscript, externalStatus } from './external-console.mjs'
|
|
10
11
|
|
|
11
12
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
12
13
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -59,7 +60,7 @@ function runAPI(session, args) {
|
|
|
59
60
|
return null
|
|
60
61
|
}
|
|
61
62
|
function transcript(session) {
|
|
62
|
-
const expected = path.join(agyHome(), 'brain', session.id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
63
|
+
const expected = path.join(agyHome(), 'brain', session.conversationId || session.id, '.system_generated', 'logs', 'transcript.jsonl')
|
|
63
64
|
if (session.transcript !== expected) throw new Error('등록된 대화 기록 경로가 올바르지 않습니다.')
|
|
64
65
|
const lines = fs.readFileSync(expected, 'utf8').split('\n')
|
|
65
66
|
// A trailing incomplete record may be in flight; malformed complete records fail.
|
|
@@ -68,7 +69,7 @@ function transcript(session) {
|
|
|
68
69
|
}
|
|
69
70
|
function publicSession(s) {
|
|
70
71
|
return { id: s.id, provider: s.provider, workspaces: s.workspaces, model: s.model,
|
|
71
|
-
transport: s.transport || 'agentapi', state: s.state,
|
|
72
|
+
transport: s.transport || 'agentapi', state: s.state, pid: s.pid,
|
|
72
73
|
registeredAt: s.registeredAt, event: s.event, fullyIdle: s.fullyIdle,
|
|
73
74
|
role: s.role, usage: s.usage, usageKnown: s.usageKnown, maxTokens: s.maxTokens, pendingApproval: s.pendingApproval }
|
|
74
75
|
}
|
|
@@ -112,13 +113,23 @@ export function requestResult(id, cwd) {
|
|
|
112
113
|
const request = readJSON(target)
|
|
113
114
|
if (request.repo !== repoKey(cwd)) throw new Error('이 요청은 현재 작업 저장소의 요청이 아닙니다.')
|
|
114
115
|
if (!['submitted', 'unknown'].includes(request.state)) return request
|
|
115
|
-
|
|
116
|
+
let s = sessionFor(request.to, cwd)
|
|
116
117
|
if (['pty', 'stdio'].includes(s.transport)) {
|
|
117
118
|
try { Object.assign(request, terminalRPC(s.id, { action: 'result', id })) }
|
|
118
119
|
catch (e) { request.state = 'unknown'; request.error = e.message }
|
|
119
120
|
writeJSON(target, request)
|
|
120
121
|
return request
|
|
121
122
|
}
|
|
123
|
+
if (s.transport === 'console') {
|
|
124
|
+
try { s.fullyIdle = externalStatus(s).state === 'idle' }
|
|
125
|
+
catch (e) {
|
|
126
|
+
request.state = 'unknown'; request.error = e.message
|
|
127
|
+
writeJSON(target, request)
|
|
128
|
+
return request
|
|
129
|
+
}
|
|
130
|
+
s = findConsoleTranscript(s, request)
|
|
131
|
+
if (!s.transcript) return request
|
|
132
|
+
}
|
|
122
133
|
const entries = transcript(s)
|
|
123
134
|
const marker = `[axMap request ${id}]`
|
|
124
135
|
const start = entries.findIndex((e, i) => i >= request.baseline && e.type === 'USER_INPUT' && String(e.content).includes(marker))
|
|
@@ -127,12 +138,12 @@ export function requestResult(id, cwd) {
|
|
|
127
138
|
if (tail.some(e => e.type === 'USER_INPUT')) {
|
|
128
139
|
request.state = 'superseded'
|
|
129
140
|
request.error = '다른 사용자 입력이 이어져 이 요청의 완료를 확정할 수 없습니다.'
|
|
130
|
-
} else if (s.event === 'Stop' && s.fullyIdle && s.registeredAt > request.createdAt) {
|
|
141
|
+
} else if (s.transport === 'console' ? s.fullyIdle : s.event === 'Stop' && s.fullyIdle && s.registeredAt > request.createdAt) {
|
|
131
142
|
const answer = tail.filter(e => e.source === 'MODEL' && e.type === 'PLANNER_RESPONSE' && e.status === 'DONE').at(-1)
|
|
132
143
|
if (answer) {
|
|
133
144
|
request.state = 'completed'
|
|
134
145
|
request.result = answer.content
|
|
135
|
-
request.completedAt = s.registeredAt
|
|
146
|
+
request.completedAt = s.transport === 'console' ? new Date().toISOString() : s.registeredAt
|
|
136
147
|
}
|
|
137
148
|
}
|
|
138
149
|
if (['completed', 'superseded'].includes(request.state)) writeJSON(target, request)
|
|
@@ -160,16 +171,17 @@ export function dispatchRequest({ to, prompt, cwd, from = 'axmap', dialogueId =
|
|
|
160
171
|
if (['sending', 'submitted', 'unknown'].includes(latest.state)) throw new Error(`이 세션의 이전 요청이 아직 확정되지 않았습니다: ${latest.id}`)
|
|
161
172
|
}
|
|
162
173
|
// No fallback to `agy -p` or --conversation: both would start a separate process.
|
|
163
|
-
if (
|
|
174
|
+
if (s.transport === 'console') { validateConsolePrompt(prompt, from); checkConsole(s) }
|
|
175
|
+
if (!['pty', 'stdio', 'console'].includes(s.transport)) {
|
|
164
176
|
runAPI(s, ['get-conversation-metadata', to])
|
|
165
177
|
if (!s.fullyIdle || s.event !== 'Stop') throw new Error('상대 세션의 대기 상태를 아직 확인하지 못했습니다. 작업 종료 훅을 기다리세요.')
|
|
166
178
|
}
|
|
167
179
|
const request = { id: randomUUID(), to, from, repo: repoKey(cwd), prompt,
|
|
168
|
-
createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport) ? null : transcript(s).length, state: 'sending' }
|
|
180
|
+
createdAt: new Date().toISOString(), baseline: ['pty', 'stdio'].includes(s.transport) ? null : s.transcript ? transcript(s).length : 0, state: 'sending' }
|
|
169
181
|
const target = file('requests', request.id)
|
|
170
182
|
writeJSON(target, request)
|
|
171
183
|
try {
|
|
172
|
-
request.receipt = ['pty', 'stdio'].includes(s.transport)
|
|
184
|
+
request.receipt = s.transport === 'console' ? submitConsole(s, request) : ['pty', 'stdio'].includes(s.transport)
|
|
173
185
|
? terminalRPC(to, { action: 'submit', request })
|
|
174
186
|
: runAPI(s, ['send-message', to, `[axMap request ${request.id}]\n요청자: ${from}\n${prompt}`])
|
|
175
187
|
request.state = 'submitted'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Attach to an existing Windows console; never launch or resume a model. */
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { bridgeHome, repoKey, readJSON, writeJSON } from './bridge.mjs'
|
|
9
|
+
|
|
10
|
+
const helper = fileURLToPath(new URL('../tools/windows-console.ps1', import.meta.url))
|
|
11
|
+
export function consoleCall(pid, action = 'snapshot', payload = {}) {
|
|
12
|
+
if (process.platform !== 'win32') throw new Error('독립 agy 콘솔 연결은 현재 Windows에서 지원합니다.')
|
|
13
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('양의 정수 PID가 필요합니다.')
|
|
14
|
+
const r = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
15
|
+
'-File', helper, '-TargetPid', String(pid), '-Action', action], {
|
|
16
|
+
input: JSON.stringify(payload), encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 1024 * 1024,
|
|
17
|
+
})
|
|
18
|
+
let data
|
|
19
|
+
try { data = JSON.parse(r.stdout) } catch { /* report the underlying failure */ }
|
|
20
|
+
if (r.error || r.status !== 0 || data?.error || !data) throw new Error(data?.error || r.error?.message || r.stderr?.trim() || '기존 콘솔 연결 실패')
|
|
21
|
+
return data
|
|
22
|
+
}
|
|
23
|
+
function identityID({ pid, startedAt }) {
|
|
24
|
+
const hex = createHash('sha256').update(`agy-console:${pid}:${startedAt}`).digest('hex')
|
|
25
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`
|
|
26
|
+
}
|
|
27
|
+
const target = id => path.join(bridgeHome(), 'sessions', `${id}.json`)
|
|
28
|
+
export function consoleIdle(screen) {
|
|
29
|
+
// Use the last input box only. Old answers may contain quoted prompt boxes.
|
|
30
|
+
const borders = [...screen.matchAll(/^[─━]{10,}\r?$/gm)]
|
|
31
|
+
if (borders.length < 2) return false
|
|
32
|
+
const top = borders.at(-2), bottom = borders.at(-1)
|
|
33
|
+
const input = screen.slice(top.index + top[0].length, bottom.index).trim()
|
|
34
|
+
const footer = screen.slice(bottom.index + bottom[0].length)
|
|
35
|
+
return input === '>' && /\? for shortcuts/.test(footer) && !/esc to cancel/i.test(footer)
|
|
36
|
+
}
|
|
37
|
+
export function attachConsole({ pid, cwd }) {
|
|
38
|
+
const live = consoleCall(pid)
|
|
39
|
+
const id = identityID(live)
|
|
40
|
+
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {}
|
|
41
|
+
const repo = repoKey(cwd)
|
|
42
|
+
if (previous.repos && !previous.repos.includes(repo)) throw new Error('이 프로세스는 다른 저장소에 등록되어 있습니다.')
|
|
43
|
+
const idle = consoleIdle(live.screen)
|
|
44
|
+
const s = { ...previous, id, provider: 'agy', transport: 'console', pid, startedAt: live.startedAt,
|
|
45
|
+
workspaces: previous.workspaces || [path.resolve(cwd)], repos: previous.repos || [repo],
|
|
46
|
+
state: idle ? 'idle' : 'unknown', fullyIdle: idle, registeredAt: new Date().toISOString(), event: previous.event || 'attach' }
|
|
47
|
+
writeJSON(target(id), s)
|
|
48
|
+
return { id, provider: s.provider, transport: s.transport, pid, state: s.state, workspaces: s.workspaces }
|
|
49
|
+
}
|
|
50
|
+
export function registerExternalHook(input, event) {
|
|
51
|
+
if (!['SessionStart', 'PreInvocation', 'Stop'].includes(event)) throw new Error('지원하지 않는 훅 이벤트입니다.')
|
|
52
|
+
const identity = consoleCall(process.pid, 'ancestor')
|
|
53
|
+
if (identity.ignored) return {}
|
|
54
|
+
const id = identityID(identity)
|
|
55
|
+
const previous = fs.existsSync(target(id)) ? readJSON(target(id)) : {}
|
|
56
|
+
const workspaces = input.workspacePaths || previous.workspaces
|
|
57
|
+
if (!Array.isArray(workspaces) || !workspaces.length || workspaces.some(p => !path.isAbsolute(p))) throw new Error('훅의 작업 저장소 경로가 없습니다.')
|
|
58
|
+
const conversationId = input.conversationId
|
|
59
|
+
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(conversationId || '')) throw new Error('훅의 대화 UUID가 없습니다.')
|
|
60
|
+
const transcript = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'),
|
|
61
|
+
'brain', conversationId, '.system_generated', 'logs', 'transcript.jsonl')
|
|
62
|
+
if (path.resolve(input.transcriptPath || '') !== transcript) throw new Error('훅의 대화 기록 경로가 다릅니다.')
|
|
63
|
+
const idle = event === 'Stop' && input.fullyIdle === true
|
|
64
|
+
writeJSON(target(id), { ...previous, ...identity, id, provider: 'agy', transport: 'console', conversationId,
|
|
65
|
+
transcript, workspaces, repos: [...new Set(workspaces.map(repoKey))], model: input.modelName || previous.model,
|
|
66
|
+
event, state: idle ? 'idle' : 'running', fullyIdle: idle, registeredAt: new Date().toISOString() })
|
|
67
|
+
return { id }
|
|
68
|
+
}
|
|
69
|
+
export function externalStatus(s) {
|
|
70
|
+
s = readJSON(target(s.id))
|
|
71
|
+
const live = consoleCall(s.pid, 'snapshot', { startedAt: s.startedAt })
|
|
72
|
+
const state = consoleIdle(live.screen) ? 'idle' : 'unknown'
|
|
73
|
+
writeJSON(target(s.id), { ...readJSON(target(s.id)), state, fullyIdle: state === 'idle' })
|
|
74
|
+
return { id: s.id, state }
|
|
75
|
+
}
|
|
76
|
+
export function checkConsole(s) {
|
|
77
|
+
const live = consoleCall(s.pid, 'snapshot', { startedAt: s.startedAt })
|
|
78
|
+
if (!consoleIdle(live.screen)) throw new Error('상대 콘솔이 빈 입력창에서 대기 중이지 않습니다. 입력을 보내지 않았습니다.')
|
|
79
|
+
return live
|
|
80
|
+
}
|
|
81
|
+
export function validateConsolePrompt(prompt, from) {
|
|
82
|
+
if (/[\x00-\x08\x0b-\x1f\x7f]/.test(prompt.replace(/\r\n/g, '\n')) || /[\x00-\x1f\x7f]/.test(from)) throw new Error('콘솔 요청에는 제어 문자를 포함할 수 없습니다.')
|
|
83
|
+
}
|
|
84
|
+
export function findConsoleTranscript(s, request) {
|
|
85
|
+
if (s.transcript) return s
|
|
86
|
+
const brain = path.join(process.env.AXMAP_AGY_HOME || path.join(os.homedir(), '.gemini', 'antigravity-cli'), 'brain')
|
|
87
|
+
if (!fs.existsSync(brain)) return s
|
|
88
|
+
const marker = `[axMap request ${request.id}]`
|
|
89
|
+
const matches = []
|
|
90
|
+
for (const entry of fs.readdirSync(brain, { withFileTypes: true })) {
|
|
91
|
+
if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name)) continue
|
|
92
|
+
const log = path.join(brain, entry.name, '.system_generated', 'logs', 'transcript.jsonl')
|
|
93
|
+
let stat
|
|
94
|
+
try { stat = fs.statSync(log) } catch { continue }
|
|
95
|
+
if (stat.mtimeMs < Date.parse(request.createdAt) || stat.size > 32 * 1024 * 1024) continue
|
|
96
|
+
const records = fs.readFileSync(log, 'utf8').split('\n').slice(0, -1)
|
|
97
|
+
if (records.some(line => {
|
|
98
|
+
try { const e = JSON.parse(line); return e.type === 'USER_INPUT' && String(e.content).includes(marker) }
|
|
99
|
+
catch { return false }
|
|
100
|
+
})) matches.push({ conversationId: entry.name, transcript: log })
|
|
101
|
+
}
|
|
102
|
+
if (matches.length !== 1) return s
|
|
103
|
+
const updated = { ...readJSON(target(s.id)), ...matches[0] }
|
|
104
|
+
writeJSON(target(s.id), updated)
|
|
105
|
+
return updated
|
|
106
|
+
}
|
|
107
|
+
export function submitConsole(s, request) {
|
|
108
|
+
validateConsolePrompt(request.prompt, request.from)
|
|
109
|
+
const live = checkConsole(s)
|
|
110
|
+
// Bracketed paste preserves multiline prompts and prevents embedded Enter keys
|
|
111
|
+
// from becoming separate submissions. Only the final CR submits the message.
|
|
112
|
+
const text = `\x1b[200~[axMap request ${request.id}]\n요청자: ${request.from}\n${request.prompt.replace(/\r\n/g, '\n')}\x1b[201~\r`
|
|
113
|
+
consoleCall(s.pid, 'send', { startedAt: s.startedAt, expectedScreen: live.screen, text })
|
|
114
|
+
writeJSON(target(s.id), { ...readJSON(target(s.id)), state: 'running', fullyIdle: false })
|
|
115
|
+
return { accepted: true, pid: s.pid }
|
|
116
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { sessions } from './bridge.mjs'
|
|
2
|
+
import { sendTerminal } from './terminal-bridge.mjs'
|
|
3
|
+
import { externalStatus } from './external-console.mjs'
|
|
4
|
+
|
|
5
|
+
/** Probe only the authenticated local control pipe. A saved idle/running record
|
|
6
|
+
* cannot establish that its host still exists; no model request is submitted. */
|
|
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
|
+
}))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
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
|
+
|
|
42
|
+
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
|
+
}
|
package/src/terminal-bridge.mjs
CHANGED
|
@@ -18,7 +18,8 @@ const sessionFile = id => {
|
|
|
18
18
|
const pipeFor = id => process.platform === 'win32'
|
|
19
19
|
? `\\\\.\\pipe\\axmap-${id}` : path.join(bridgeHome(), `${id}.sock`)
|
|
20
20
|
|
|
21
|
-
export function sendTerminal(id, message, token = null) {
|
|
21
|
+
export function sendTerminal(id, message, token = null, { timeoutMs = 10000 } = {}) {
|
|
22
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('양의 응답 시간 한도가 필요합니다.')
|
|
22
23
|
const s = readJSON(sessionFile(id))
|
|
23
24
|
if (!['pty', 'stdio'].includes(s.transport)) throw new Error('로컬 관리형 세션이 아닙니다.')
|
|
24
25
|
return new Promise((resolve, reject) => {
|
|
@@ -26,7 +27,7 @@ export function sendTerminal(id, message, token = null) {
|
|
|
26
27
|
socket.setEncoding('utf8')
|
|
27
28
|
let buffer = ''
|
|
28
29
|
const fail = e => { socket.destroy(); reject(e) }
|
|
29
|
-
socket.setTimeout(
|
|
30
|
+
socket.setTimeout(timeoutMs, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')))
|
|
30
31
|
socket.on('error', fail)
|
|
31
32
|
socket.on('connect', () => socket.write(JSON.stringify({ ...message, token: token || s.token }) + '\n'))
|
|
32
33
|
socket.on('data', chunk => {
|
package/tools/bridge.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { dispatchRequest, installHooks, registerHook, requestResult, sessions, u
|
|
|
6
6
|
import { startManagedSession } from '../src/managed-session.mjs'
|
|
7
7
|
import { runDialogue, startBackgroundDialogue, dialogueResult, stopDialogue } from '../src/session-dialogue.mjs'
|
|
8
8
|
import { sendTerminal } from '../src/terminal-bridge.mjs'
|
|
9
|
+
import { sessionStatus, formatSessionStatus } from '../src/session-status.mjs'
|
|
10
|
+
import { attachConsole } from '../src/external-console.mjs'
|
|
9
11
|
|
|
10
12
|
const args = process.argv.slice(2)
|
|
11
13
|
const [command] = args
|
|
@@ -13,6 +15,7 @@ const option = (name) => { const i = args.indexOf(`--${name}`); return i < 0 ? u
|
|
|
13
15
|
const output = (value) => console.log(JSON.stringify(value, null, 2))
|
|
14
16
|
try {
|
|
15
17
|
switch (command) {
|
|
18
|
+
case 'attach': output(attachConsole({ pid: Number(option('pid')), cwd: process.cwd() })); break
|
|
16
19
|
case 'serve': {
|
|
17
20
|
const provider = args[1]
|
|
18
21
|
if (!['codex', 'agy'].includes(provider)) throw new Error('axmap bridge serve codex|agy를 사용하세요.')
|
|
@@ -54,6 +57,12 @@ try {
|
|
|
54
57
|
break
|
|
55
58
|
}
|
|
56
59
|
case 'sessions': output(sessions(process.cwd())); break
|
|
60
|
+
case 'status': {
|
|
61
|
+
const listed = await sessionStatus()
|
|
62
|
+
if (args.includes('--json')) output(listed)
|
|
63
|
+
else console.log(formatSessionStatus(listed))
|
|
64
|
+
break
|
|
65
|
+
}
|
|
57
66
|
case 'send': {
|
|
58
67
|
const r = dispatchRequest({ to: option('to'), prompt: fs.readFileSync(0, 'utf8'), cwd: process.cwd(), from: process.env.AXMAP_AGENT || 'axmap' })
|
|
59
68
|
output(r)
|
|
@@ -76,6 +85,8 @@ axmap bridge stop <대화 UUID> 추가 중계 중단 (이
|
|
|
76
85
|
axmap bridge install --experimental --executable <agy 절대 경로> 연결 훅 실험
|
|
77
86
|
axmap bridge uninstall 추가한 연결 훅 해제
|
|
78
87
|
axmap bridge sessions 이 repo에 연결된 세션
|
|
88
|
+
axmap bridge attach --pid <PID> 이미 실행한 Windows agy 콘솔을 현재 repo에 연결
|
|
89
|
+
axmap bridge status [--json] 로컬 연결·작업·승인 대기·종료 상태 조회 (모델 호출 없음)
|
|
79
90
|
axmap bridge send --to <세션 UUID> 본문은 stdin. 기존 세션에 전달
|
|
80
91
|
axmap bridge result <요청 UUID> 제출/완료/불명 상태와 결과
|
|
81
92
|
관리형 stdio: Codex App Server / agy stream-json. PTY와 agentapi는 별도 실험 경로입니다.`)
|
package/tools/run.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { spawnSync } from 'node:child_process'
|
|
5
5
|
import { startTerminal, sendTerminal, installTerminalHooks, installTerminalRuntime } from '../src/terminal-bridge.mjs'
|
|
6
|
+
import { registerExternalHook } from '../src/external-console.mjs'
|
|
6
7
|
|
|
7
8
|
const [command, ...args] = process.argv.slice(2)
|
|
8
9
|
try {
|
|
@@ -13,9 +14,12 @@ try {
|
|
|
13
14
|
} else if (command === 'rpc') {
|
|
14
15
|
console.log(JSON.stringify(await sendTerminal(args[0], JSON.parse(fs.readFileSync(0, 'utf8')))))
|
|
15
16
|
} else if (command === 'hook') {
|
|
16
|
-
|
|
17
|
+
const input = JSON.parse(fs.readFileSync(0, 'utf8'))
|
|
17
18
|
if (process.env.AXMAP_TERMINAL_SESSION && process.env.AXMAP_TERMINAL_TOKEN) {
|
|
18
|
-
await sendTerminal(process.env.AXMAP_TERMINAL_SESSION, { action: 'hook', event: args[0], input
|
|
19
|
+
await sendTerminal(process.env.AXMAP_TERMINAL_SESSION, { action: 'hook', event: args[0], input }, process.env.AXMAP_TERMINAL_TOKEN)
|
|
20
|
+
} else if (process.platform === 'win32' && input.conversationId) {
|
|
21
|
+
try { registerExternalHook(input, args[0]) }
|
|
22
|
+
catch (e) { console.error(`axMap 독립 콘솔: ${e.message}`) }
|
|
19
23
|
}
|
|
20
24
|
console.log('{}')
|
|
21
25
|
} else if (command === '--help' || !command) {
|
package/tools/setup.mjs
CHANGED
|
@@ -257,7 +257,7 @@ if (!has('--no-register')) {
|
|
|
257
257
|
else {
|
|
258
258
|
const hookRunner = path.join(DEST, 'tools', 'run.mjs')
|
|
259
259
|
const r = spawnSync(process.execPath, [hookRunner, 'install-hooks'], { encoding: 'utf8', windowsHide: true })
|
|
260
|
-
if (r.status === 0) ok('세션 연결', '
|
|
260
|
+
if (r.status === 0) ok('세션 연결', 'Windows에서 직접 여는 agy와 axmap run CLI가 자동 연결됩니다')
|
|
261
261
|
else no('세션 연결', r.stderr?.trim() || '연결 훅 등록 실패')
|
|
262
262
|
}
|
|
263
263
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
param([int]$TargetPid, [string]$Action = 'snapshot')
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
+
[Console]::InputEncoding = $utf8
|
|
5
|
+
[Console]::OutputEncoding = $utf8
|
|
6
|
+
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
7
|
+
# Keep the original pipe writer: AttachConsole replaces the standard handles.
|
|
8
|
+
$reply = [Console]::Out
|
|
9
|
+
if ($Action -eq 'ancestor') {
|
|
10
|
+
for ($i = 0; $i -lt 12 -and $TargetPid -gt 0; $i++) {
|
|
11
|
+
$entry = Get-CimInstance Win32_Process -Filter "ProcessId=$TargetPid"
|
|
12
|
+
if (!$entry) { break }
|
|
13
|
+
if ($entry.Name -eq 'agy.exe') {
|
|
14
|
+
if ($entry.CommandLine -match '--input-format|--print(?:\s|=|$)|(?:^|\s)-p(?:\s|$)') { $reply.WriteLine('{"ignored":true}'); exit 0 }
|
|
15
|
+
$proc = Get-Process -Id $TargetPid
|
|
16
|
+
$reply.WriteLine((@{ pid=$TargetPid; startedAt=$proc.StartTime.ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress))
|
|
17
|
+
exit 0
|
|
18
|
+
}
|
|
19
|
+
$TargetPid = $entry.ParentProcessId
|
|
20
|
+
}
|
|
21
|
+
throw 'No interactive agy ancestor found'
|
|
22
|
+
}
|
|
23
|
+
Add-Type -TypeDefinition @'
|
|
24
|
+
using System;
|
|
25
|
+
using System.Text;
|
|
26
|
+
using System.Runtime.InteropServices;
|
|
27
|
+
using System.ComponentModel;
|
|
28
|
+
public static class AxConsole {
|
|
29
|
+
[StructLayout(LayoutKind.Sequential)] public struct Coord { public short X,Y; }
|
|
30
|
+
[StructLayout(LayoutKind.Sequential)] public struct Rect { public short Left,Top,Right,Bottom; }
|
|
31
|
+
[StructLayout(LayoutKind.Sequential)] public struct Info { public Coord Size,Cursor; public short Attr; public Rect Window; public Coord Max; }
|
|
32
|
+
[StructLayout(LayoutKind.Explicit, CharSet=CharSet.Unicode, Size=20)] public struct Record {
|
|
33
|
+
[FieldOffset(0)] public short Type;
|
|
34
|
+
[FieldOffset(4)] public int Down;
|
|
35
|
+
[FieldOffset(8)] public short Repeat;
|
|
36
|
+
[FieldOffset(10)] public short Key;
|
|
37
|
+
[FieldOffset(14)] public char Char;
|
|
38
|
+
}
|
|
39
|
+
[DllImport("kernel32.dll", SetLastError=true)] static extern bool AttachConsole(uint pid);
|
|
40
|
+
[DllImport("kernel32.dll")] public static extern bool FreeConsole();
|
|
41
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateFile(string name,uint access,uint share,IntPtr sec,uint mode,uint flags,IntPtr template);
|
|
42
|
+
[DllImport("kernel32.dll", SetLastError=true)] static extern bool GetConsoleScreenBufferInfo(IntPtr h,out Info info);
|
|
43
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool ReadConsoleOutputCharacter(IntPtr h,[Out] char[] text,uint count,Coord at,out uint read);
|
|
44
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool WriteConsoleInput(IntPtr h,Record[] input,uint count,out uint written);
|
|
45
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
|
|
46
|
+
static void Check(bool ok) { if(!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); }
|
|
47
|
+
public static void Attach(int pid) { FreeConsole(); Check(AttachConsole((uint)pid)); }
|
|
48
|
+
static IntPtr Open(string name) { var h=CreateFile(name,0xC0000000,3,IntPtr.Zero,3,0,IntPtr.Zero); if(h==new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error()); return h; }
|
|
49
|
+
public static string Snapshot() {
|
|
50
|
+
var h=Open("CONOUT$"); try {
|
|
51
|
+
Info info; Check(GetConsoleScreenBufferInfo(h,out info));
|
|
52
|
+
var text=new StringBuilder();
|
|
53
|
+
for(short y=info.Window.Top;y<=info.Window.Bottom;y++) {
|
|
54
|
+
uint read; var row=new char[info.Size.X]; Check(ReadConsoleOutputCharacter(h,row,(uint)row.Length,new Coord { X=0,Y=y },out read)); text.AppendLine(new string(row,0,(int)read).TrimEnd());
|
|
55
|
+
}
|
|
56
|
+
return text.ToString();
|
|
57
|
+
} finally { CloseHandle(h); }
|
|
58
|
+
}
|
|
59
|
+
public static void Send(string text) {
|
|
60
|
+
var h=Open("CONIN$"); try {
|
|
61
|
+
var records=new Record[text.Length*2];
|
|
62
|
+
for(int i=0;i<text.Length;i++) {
|
|
63
|
+
var r=new Record {Type=1,Down=1,Repeat=1,Char=text[i],Key=(short)(text[i]=='\r'?13:0)};
|
|
64
|
+
records[i*2]=r; r.Down=0; records[i*2+1]=r;
|
|
65
|
+
}
|
|
66
|
+
uint written; Check(WriteConsoleInput(h,records,(uint)records.Length,out written));
|
|
67
|
+
if(written!=records.Length) throw new Exception("Partial console delivery; do not retry automatically.");
|
|
68
|
+
} finally { CloseHandle(h); }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
'@
|
|
72
|
+
try {
|
|
73
|
+
$proc = Get-Process -Id $TargetPid
|
|
74
|
+
if ($proc.ProcessName -ne 'agy') { throw 'Target is not agy.exe' }
|
|
75
|
+
$commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId=$TargetPid").CommandLine
|
|
76
|
+
if ($commandLine -match '--input-format|--print(?:\s|=|$)|(?:^|\s)-p(?:\s|$)') { throw 'Target is not an interactive agy console' }
|
|
77
|
+
if ($payload.startedAt -and $proc.StartTime.ToUniversalTime().ToString('o') -ne $payload.startedAt) { throw 'Process identity changed' }
|
|
78
|
+
[AxConsole]::Attach($TargetPid)
|
|
79
|
+
$screen = [AxConsole]::Snapshot()
|
|
80
|
+
if ($Action -eq 'send') {
|
|
81
|
+
if ($payload.expectedScreen -cne $screen) { throw 'Console changed before delivery; input was not sent' }
|
|
82
|
+
[AxConsole]::Send($payload.text)
|
|
83
|
+
}
|
|
84
|
+
$result = @{ screen = $screen; startedAt = $proc.StartTime.ToUniversalTime().ToString('o'); pid = $TargetPid }
|
|
85
|
+
[AxConsole]::FreeConsole() | Out-Null
|
|
86
|
+
$reply.WriteLine(($result | ConvertTo-Json -Compress))
|
|
87
|
+
} catch {
|
|
88
|
+
[AxConsole]::FreeConsole() | Out-Null
|
|
89
|
+
$reply.WriteLine((@{ error = $_.Exception.Message } | ConvertTo-Json -Compress))
|
|
90
|
+
exit 1
|
|
91
|
+
}
|