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 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: '작업 요청의 제출/완료/불명 상태, 결과와 측정 사용량을 확인한다. 관리형 세션은 CLI의 구조화된 실행 완료 이벤트, 실험용 터미널은 해당 요청의 종료 훅으로 판정한다.',
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.0",
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
- const s = sessionFor(request.to, cwd)
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 (!['pty', 'stdio'].includes(s.transport)) {
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
+ }
@@ -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(10000, () => fail(new Error('로컬 세션 응답 시간 초과. 자동 재전송하지 마세요.')))
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
- // Installed once, harmless for sessions not launched through axMap.
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: JSON.parse(fs.readFileSync(0, 'utf8')) }, process.env.AXMAP_TERMINAL_TOKEN)
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('세션 연결', 'axmap run agy로 여는 CLI가 자동 연결됩니다')
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
+ }