dsh-agy-link 0.1.5 → 0.2.1
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/CHANGELOG.md +37 -47
- package/README.md +10 -0
- package/dist/bridge.mjs +128 -0
- package/dist/index.js +551 -19
- package/docs/KNOWN-GAPS.md +15 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,54 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 (2026-08-19)
|
|
4
|
+
|
|
5
|
+
- Cross-platform hardening (Linux / macOS / Windows):
|
|
6
|
+
- binary discovery is platform-aware: agy / agy.exe / .cmd / .bat across
|
|
7
|
+
PATH, ~/.local/bin, /usr/local/bin, /opt/homebrew/bin,
|
|
8
|
+
%LOCALAPPDATA%\Programs, and the npm shim dir; a real executable is
|
|
9
|
+
always preferred over a cmd shim
|
|
10
|
+
- Windows .cmd/.bat shims spawn through cmd.exe with cross-spawn-style
|
|
11
|
+
argument quoting (unit-tested)
|
|
12
|
+
- tree-kill uses taskkill /T /F on Windows (Unix process groups do not
|
|
13
|
+
exist there); detached sessions are POSIX-only so no console window
|
|
14
|
+
flashes on Windows
|
|
15
|
+
- CRLF stdout is normalized (trailing \r stripped per line)
|
|
16
|
+
- the MCP bridge script path resolves via fileURLToPath (URL.pathname
|
|
17
|
+
would yield /C:/... on Windows and break the spawn)
|
|
18
|
+
- 5 new cross-platform tests (56 total).
|
|
19
|
+
|
|
20
|
+
## 0.2.0 (2026-08-19)
|
|
21
|
+
|
|
22
|
+
- Multimodal (path-based): DSH image attachments are staged to a local media
|
|
23
|
+
directory (config `mediaDir`, TTL sweep `mediaTtlMs`, caps `mediaMaxBytes` /
|
|
24
|
+
`mediaMaxImages`) and referenced by absolute path in the agy prompt with
|
|
25
|
+
`--add-dir` - agy views them with its own tools. Env: `DSH_AGY_MEDIA_DIR`,
|
|
26
|
+
`DSH_AGY_MEDIA_TTL_MS`.
|
|
27
|
+
- `agy_ask` gains `readPaths` (inline text files into the one-shot prompt;
|
|
28
|
+
binaries skipped with a note) and `schema` (JSON Schema enforced via
|
|
29
|
+
`--json-schema`).
|
|
30
|
+
- MCP reverse bridge (experimental, `mcpBridge: true` / `DSH_AGY_MCP_BRIDGE=1`):
|
|
31
|
+
agy can call DSH-side tools through a loopback, token-guarded endpoint plus a
|
|
32
|
+
zero-dep stdio MCP server merged into the workspace `.mcp.json`.
|
|
33
|
+
`mcpToolAllowlist` restricts the exposed set; `run_code` / `agy_ask` are
|
|
34
|
+
never bridged.
|
|
35
|
+
- Abort semantics locked by a regression test: everything the model produced
|
|
36
|
+
before the caller hits stop is preserved (open block closed, then failure
|
|
37
|
+
finish).
|
|
38
|
+
- 12 new tests (51 total).
|
|
39
|
+
|
|
3
40
|
## 0.1.5 (2026-08-19)
|
|
4
41
|
|
|
5
42
|
- README restructured into a single bilingual page: Chinese first, then
|
|
6
43
|
English (README.zh.md removed; package files list updated).
|
|
7
44
|
- Releases now publish to npm automatically (NPM_TOKEN secret configured).
|
|
8
|
-
|
|
9
|
-
## 0.1.4 (2026-08-19)
|
|
10
|
-
|
|
11
|
-
- READMEs: added the author promo block (Chinese first, then English).
|
|
12
|
-
- Housekeeping: internal spec documents are no longer part of the repo.
|
|
13
|
-
|
|
14
|
-
## 0.1.3 (2026-08-19)
|
|
15
|
-
|
|
16
|
-
- Fixed the client slot registration: the sidebar footer slot entry must
|
|
17
|
-
register under the slot name `sidebar.footer.action` (not `ui-sidebar`),
|
|
18
|
-
matching the ui-sidebar slot table - the wrong name failed plugin loading
|
|
19
|
-
with: slot ui-sidebar is not declared.
|
|
20
|
-
|
|
21
|
-
## 0.1.2 (2026-08-18)
|
|
22
|
-
|
|
23
|
-
- Fixed prompt assembly for returning sessions: the last-assistant scan now
|
|
24
|
-
stops at the newest assistant message, and the history digest covers exactly
|
|
25
|
-
the foreign turns since the watermark (previously it could re-send stale
|
|
26
|
-
history or swallow intermediate turns into the trailing prompt).
|
|
27
|
-
- Hardened the stream loop: spawn failures release the concurrency slot and
|
|
28
|
-
surface as `PROCESS_EXIT` instead of hanging; the collector is fail-safe.
|
|
29
|
-
- `/agy doctor` now exports the real last-run stdout ring (parser wiring).
|
|
30
|
-
- `agy_ask` one-shot runs pass `--print-timeout` consistent with the watchdog.
|
|
31
|
-
|
|
32
|
-
## 0.1.1 (2026-08-18)
|
|
33
|
-
|
|
34
|
-
- Fixed cordis activation in app-less profiles (optional services are now
|
|
35
|
-
resolved via `ctx.get`, not `?`-suffixed inject entries).
|
|
36
|
-
- Added `providerRetryPolicy`: AUTH failures fail fast (no quota-burning
|
|
37
|
-
retries); transient process failures retry once.
|
|
38
|
-
|
|
39
|
-
## 0.1.0 (2026-08-18)
|
|
40
|
-
|
|
41
|
-
Initial release.
|
|
42
|
-
|
|
43
|
-
- `antigravity` LLM provider route over `agy -p --output-format stream-json`.
|
|
44
|
-
- Streaming text / thinking / tool-activity mapping with token usage.
|
|
45
|
-
- Session ↔ conversation binding with history digest on first contact.
|
|
46
|
-
- Model discovery (`agy models`) with Gemini effort folding and fallback
|
|
47
|
-
catalog.
|
|
48
|
-
- Google login: `/agy auth` + `/agy auth-code` + GUI panel (QR + paste).
|
|
49
|
-
- `/agy` command family: status / auth / auth-code / models / mode /
|
|
50
|
-
effort / clear / doctor / help.
|
|
51
|
-
- `agy_ask` delegation tool (optional).
|
|
52
|
-
- Permission modes: plan / accept-edits / skip with red-flag UI on skip.
|
|
53
|
-
- `/agy doctor` diagnostics export with auth redaction.
|
|
54
|
-
- Auxiliary calls (compaction / session-title) via one-shot plan-mode turns.
|
package/README.md
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
| 🔐 **GUI 内 Google 登录** | `/agy auth` 打印授权 URL;侧栏面板提供二维码 + 授权码粘贴框 |
|
|
35
35
|
| 🤝 **`agy_ask` 工具** | 任何 DSH 模型都可以把一次性任务委托给 Antigravity 模型(AskAntigravity 模式) |
|
|
36
36
|
| ⌨️ **`/agy` 命令族** | `status` / `auth` / `auth-code` / `models` / `mode` / `effort` / `clear` / `doctor` / `help` |
|
|
37
|
+
| 🖼 **图片多模态(v0.2)** | 图片落盘到本地媒体目录(TTL 清理),prompt 以绝对路径引用 + `--add-dir` 授权,agy 用自己的工具看图 |
|
|
38
|
+
| 📎 **文件内联 + 结构化输出(v0.2)** | `agy_ask` 新增 `readPaths`(文本文件内联)与 `schema`(`--json-schema` 强约束答案) |
|
|
39
|
+
| 🌉 **MCP 反向桥(v0.2 实验)** | `mcpBridge: true` 开启后 agy 可直接调 DSH 侧工具(回环 + token 守卫端点 + 零依赖 stdio MCP 服务器,`.mcp.json` 合并写入/禁用还原) |
|
|
37
40
|
| 😴 **休眠安全** | 没装 agy?没登录?插件照常加载并告诉你怎么修 |
|
|
38
41
|
|
|
39
42
|
## 🚀 快速开始
|
|
@@ -56,6 +59,8 @@ dsh plugin --profile web add dsh-agy-link
|
|
|
56
59
|
|
|
57
60
|
> 插件绝不读取/复制/移动 `~/.gemini/antigravity-cli/antigravity-oauth-token`;登录完全通过官方 CLI 自己的流程完成。
|
|
58
61
|
|
|
62
|
+
> 🖥 **跨平台**:Linux / macOS / Windows 均受支持——bin 探测按平台查找 `agy`/`agy.exe`(PATH、`~/.local/bin`、`/usr/local/bin`、`/opt/homebrew/bin`、`%LOCALAPPDATA%\Programs`,npm `.cmd` shim 自动经 cmd.exe 安全引号包裹启动);中断/超时杀树在 Windows 走 `taskkill /T /F`;CRLF 输出统一剥离;媒体目录与 MCP 桥路径全部 `fileURLToPath`/`join` 构造。
|
|
63
|
+
|
|
59
64
|
## ⚙️ 配置
|
|
60
65
|
|
|
61
66
|
配置在 `agy-link` 插件条目里(`/plugin` 或 profile patch 层编辑),环境变量优先:
|
|
@@ -118,6 +123,9 @@ Bring **Google Antigravity models into DeepSeek Harness (DSH)** — chat, thinki
|
|
|
118
123
|
| 🤝 **`agy_ask` tool** | Let any DSH model delegate a one-shot task to an Antigravity model (the AskAntigravity pattern) |
|
|
119
124
|
| ⌨️ **`/agy` commands** | `status`, `auth`, `auth-code`, `models`, `mode`, `effort`, `clear`, `doctor`, `help` |
|
|
120
125
|
| 😴 **Dormant-safe** | No agy binary? Not signed in? The plugin loads anyway and tells you what to fix |
|
|
126
|
+
| 🖼 **Image multimodal (v0.2)** | image attachments staged to a TTL-swept local dir, referenced by absolute path with `--add-dir`; agy views them with its own tools |
|
|
127
|
+
| 📎 **File inlining + structured output (v0.2)** | `agy_ask` gains `readPaths` (inline text files) and `schema` (enforced via `--json-schema`) |
|
|
128
|
+
| 🌉 **MCP reverse bridge (v0.2, experimental)** | with `mcpBridge: true`, agy calls DSH-side tools directly (loopback token-guarded endpoint + zero-dep stdio MCP server merged into `.mcp.json`, restored on disable) |
|
|
121
129
|
|
|
122
130
|
## 🚀 Quick start
|
|
123
131
|
|
|
@@ -139,6 +147,8 @@ dsh plugin --profile web add dsh-agy-link
|
|
|
139
147
|
|
|
140
148
|
> The plugin never reads, copies, or moves `~/.gemini/antigravity-cli/antigravity-oauth-token`; login is driven entirely through the official CLI's own flow.
|
|
141
149
|
|
|
150
|
+
> 🖥 **Cross-platform**: Linux / macOS / Windows — platform-aware binary discovery (`agy` / `agy.exe` across PATH, `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `%LOCALAPPDATA%\Programs`; npm `.cmd` shims spawn through cmd.exe with safe quoting), tree-kill via `taskkill /T /F` on Windows, CRLF output normalized, media and bridge paths built with `fileURLToPath`/`join`.
|
|
151
|
+
|
|
142
152
|
## ⚙️ Configuration
|
|
143
153
|
|
|
144
154
|
Config lives in the `agy-link` plugin entry (edit via `/plugin` or the profile patch layer). Environment variables override the file:
|
package/dist/bridge.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dsh-agy-link MCP bridge script: a minimal stdio MCP server with zero
|
|
3
|
+
// dependencies. agy launches it per the workspace .mcp.json; it forwards
|
|
4
|
+
// tools/list and tools/call to the plugin loopback endpoint whose URL and
|
|
5
|
+
// token arrive via env. Protocol: newline-delimited JSON-RPC 2.0.
|
|
6
|
+
import { request as httpRequest } from 'node:http'
|
|
7
|
+
import process from 'node:process'
|
|
8
|
+
import { createInterface } from 'node:readline'
|
|
9
|
+
|
|
10
|
+
const URL_BASE = process.env.DSH_MCP_URL || ''
|
|
11
|
+
const TOKEN = process.env.DSH_MCP_TOKEN || ''
|
|
12
|
+
const PROTOCOL = '2024-11-05'
|
|
13
|
+
|
|
14
|
+
function post(path, body) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
if (URL_BASE === '' || TOKEN === '') {
|
|
17
|
+
reject(new Error('bridge not configured (missing DSH_MCP_URL/DSH_MCP_TOKEN)'))
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const data = JSON.stringify(body)
|
|
21
|
+
const u = new URL(URL_BASE)
|
|
22
|
+
const req = httpRequest({
|
|
23
|
+
hostname: u.hostname,
|
|
24
|
+
port: u.port || 80,
|
|
25
|
+
method: 'POST',
|
|
26
|
+
path,
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'Content-Length': Buffer.byteLength(data),
|
|
30
|
+
Authorization: 'Bearer ' + TOKEN,
|
|
31
|
+
},
|
|
32
|
+
timeout: 600000,
|
|
33
|
+
});
|
|
34
|
+
req.on('response', (res) => {
|
|
35
|
+
const chunks = []
|
|
36
|
+
res.on('data', (c) => chunks.push(c))
|
|
37
|
+
res.on('end', () => {
|
|
38
|
+
try {
|
|
39
|
+
resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
|
|
40
|
+
} catch (e) {
|
|
41
|
+
reject(e)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
});
|
|
45
|
+
req.on('timeout', () => req.destroy(new Error('bridge request timed out')))
|
|
46
|
+
req.on('error', reject)
|
|
47
|
+
req.end(data)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let toolCache = null;
|
|
52
|
+
|
|
53
|
+
async function listTools() {
|
|
54
|
+
if (toolCache) return toolCache;
|
|
55
|
+
const res = await post('/tools', {})
|
|
56
|
+
if (res.status !== 200) throw new Error('bridge /tools failed: ' + JSON.stringify(res.body))
|
|
57
|
+
toolCache = (res.body.tools || []).map((t) => ({
|
|
58
|
+
name: t.name,
|
|
59
|
+
description: t.description || t.dshName,
|
|
60
|
+
inputSchema: t.inputSchema || { type: 'object' },
|
|
61
|
+
_dshName: t.dshName,
|
|
62
|
+
}))
|
|
63
|
+
return toolCache;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function send(msg) {
|
|
67
|
+
process.stdout.write(JSON.stringify(msg) + '\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function handle(id, method, params) {
|
|
71
|
+
if (method === 'initialize') {
|
|
72
|
+
send({ jsonrpc: '2.0', id, result: { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: 'dsh-agy-link-bridge', version: '0.2.0' } } })
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (method.startsWith('notifications/')) return;
|
|
76
|
+
if (method === 'tools/list') {
|
|
77
|
+
const tools = await listTools()
|
|
78
|
+
send({ jsonrpc: '2.0', id, result: { tools: tools.map(({ _dshName, ...t }) => t) } })
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (method === 'tools/call') {
|
|
82
|
+
const name = params && params.name
|
|
83
|
+
const args = (params && params.arguments) || {}
|
|
84
|
+
const tools = await listTools()
|
|
85
|
+
const hit = tools.find((t) => t.name === name)
|
|
86
|
+
if (!hit) {
|
|
87
|
+
send({ jsonrpc: '2.0', id, error: { code: -32602, message: 'unknown tool: ' + name } })
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const res = await post('/call', { dshName: hit._dshName, arguments: args })
|
|
92
|
+
if (res.status !== 200 || res.body.ok !== true) {
|
|
93
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'ERROR: ' + JSON.stringify(res.body) }], isError: true } })
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: res.body.text || '' }] } })
|
|
97
|
+
} catch (e) {
|
|
98
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'ERROR: ' + String(e) }], isError: true } })
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (method === 'ping') {
|
|
103
|
+
send({ jsonrpc: '2.0', id, result: {} })
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rl = createInterface({ input: process.stdin, terminal: false })
|
|
110
|
+
rl.on('line', (line) => {
|
|
111
|
+
const text = line.trim()
|
|
112
|
+
if (text === '') return
|
|
113
|
+
let msg;
|
|
114
|
+
try {
|
|
115
|
+
msg = JSON.parse(text)
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return
|
|
120
|
+
if (msg.id === undefined) {
|
|
121
|
+
void handle(null, msg.method, msg.params).catch(() => {})
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
void handle(msg.id, msg.method, msg.params).catch((e) => {
|
|
125
|
+
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(e) } })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
process.stdin.on('end', () => process.exit(0))
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { delimiter, dirname, join } from "node:path";
|
|
2
|
-
import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
1
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { execFileSync, spawn } from "node:child_process";
|
|
6
7
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
7
11
|
//#region src/common/types.ts
|
|
8
12
|
const PROVIDER_ID = "antigravity";
|
|
9
13
|
const PLUGIN_ID = "agy-link";
|
|
@@ -46,7 +50,13 @@ function defaultConfig() {
|
|
|
46
50
|
compactionMaxChars: 8e5,
|
|
47
51
|
workspaceRoot: "",
|
|
48
52
|
fallbackModels: DEFAULT_FALLBACK_MODELS,
|
|
49
|
-
askTool: false
|
|
53
|
+
askTool: false,
|
|
54
|
+
mediaDir: "",
|
|
55
|
+
mediaTtlMs: 864e5,
|
|
56
|
+
mediaMaxBytes: 10485760,
|
|
57
|
+
mediaMaxImages: 8,
|
|
58
|
+
mcpBridge: false,
|
|
59
|
+
mcpToolAllowlist: ""
|
|
50
60
|
};
|
|
51
61
|
}
|
|
52
62
|
const Err = {
|
|
@@ -138,7 +148,13 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
|
|
|
138
148
|
compactionMaxChars: asNum(get("compactionMaxChars")) ?? base.compactionMaxChars,
|
|
139
149
|
workspaceRoot: asString(get("workspaceRoot")) ?? base.workspaceRoot,
|
|
140
150
|
fallbackModels: Array.isArray(get("fallbackModels")) ? get("fallbackModels").filter((x) => !!x && typeof x === "object" && typeof x.id === "string") : base.fallbackModels,
|
|
141
|
-
askTool: asBool(get("askTool")) ?? base.askTool
|
|
151
|
+
askTool: asBool(get("askTool")) ?? base.askTool,
|
|
152
|
+
mediaDir: asString(get("mediaDir")) ?? base.mediaDir,
|
|
153
|
+
mediaTtlMs: asNum(get("mediaTtlMs")) ?? base.mediaTtlMs,
|
|
154
|
+
mediaMaxBytes: asNum(get("mediaMaxBytes")) ?? base.mediaMaxBytes,
|
|
155
|
+
mediaMaxImages: asNum(get("mediaMaxImages")) ?? base.mediaMaxImages,
|
|
156
|
+
mcpBridge: asBool(get("mcpBridge")) ?? base.mcpBridge,
|
|
157
|
+
mcpToolAllowlist: asString(get("mcpToolAllowlist")) ?? base.mcpToolAllowlist
|
|
142
158
|
};
|
|
143
159
|
if (env.DSH_AGY_ENABLED !== void 0) cfg.enabled = asBool(env.DSH_AGY_ENABLED) ?? cfg.enabled;
|
|
144
160
|
if (env.DSH_AGY_BIN) cfg.agyBin = env.DSH_AGY_BIN;
|
|
@@ -157,6 +173,16 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
|
|
|
157
173
|
if (t && t > 0) cfg.timeoutMs = t;
|
|
158
174
|
}
|
|
159
175
|
if (env.DSH_AGY_EXTRA_ARGS) cfg.extraArgs = env.DSH_AGY_EXTRA_ARGS.split(/\s+/).filter(Boolean);
|
|
176
|
+
if (env.DSH_AGY_MEDIA_DIR) cfg.mediaDir = env.DSH_AGY_MEDIA_DIR;
|
|
177
|
+
if (env.DSH_AGY_MEDIA_TTL_MS) {
|
|
178
|
+
const t = asNum(env.DSH_AGY_MEDIA_TTL_MS);
|
|
179
|
+
if (t && t > 0) cfg.mediaTtlMs = t;
|
|
180
|
+
}
|
|
181
|
+
if (env.DSH_AGY_MCP_BRIDGE !== void 0) {
|
|
182
|
+
const b = asBool(env.DSH_AGY_MCP_BRIDGE);
|
|
183
|
+
if (b !== void 0) cfg.mcpBridge = b;
|
|
184
|
+
}
|
|
185
|
+
if (env.DSH_AGY_MCP_TOOL_ALLOWLIST) cfg.mcpToolAllowlist = env.DSH_AGY_MCP_TOOL_ALLOWLIST;
|
|
160
186
|
return cfg;
|
|
161
187
|
}
|
|
162
188
|
//#endregion
|
|
@@ -892,20 +918,149 @@ var StreamJsonParser = class {
|
|
|
892
918
|
return ev;
|
|
893
919
|
}
|
|
894
920
|
};
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/host/media.ts
|
|
923
|
+
const EXT = {
|
|
924
|
+
"image/png": "png",
|
|
925
|
+
"image/jpeg": "jpg",
|
|
926
|
+
"image/webp": "webp",
|
|
927
|
+
"image/gif": "gif"
|
|
928
|
+
};
|
|
929
|
+
/** Deterministic staged path: same session+turn+index reuses the file. */
|
|
930
|
+
function stagedPath(dir, key, index, mediaType) {
|
|
931
|
+
const ext = EXT[mediaType] ?? "png";
|
|
932
|
+
return join(dir, key + "-" + String(index) + "." + ext);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Stage up to `maxImages` images into `dir`, returning prompt lines that
|
|
936
|
+
* reference them by absolute path. Deterministic names mean a retried or
|
|
937
|
+
* replayed turn overwrites the same file instead of accumulating copies.
|
|
938
|
+
*/
|
|
939
|
+
async function stageImages(opts) {
|
|
940
|
+
const staged = [];
|
|
941
|
+
const lines = [];
|
|
942
|
+
let skipped = 0;
|
|
943
|
+
const budget = Math.max(0, opts.maxImages);
|
|
944
|
+
if (budget > 0 && opts.images.length > 0) {
|
|
945
|
+
await mkdir(opts.dir, { recursive: true });
|
|
946
|
+
let i = 0;
|
|
947
|
+
for (const ref of opts.images) {
|
|
948
|
+
if (staged.length >= budget) {
|
|
949
|
+
skipped += opts.images.length - staged.length - skipped;
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
const label = ref.name ?? ref.attachmentId;
|
|
953
|
+
if (ref.bytes > opts.maxBytes) {
|
|
954
|
+
skipped++;
|
|
955
|
+
lines.push("[image skipped: \"" + label + "\" exceeds mediaMaxBytes]");
|
|
956
|
+
i++;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
let data = null;
|
|
960
|
+
try {
|
|
961
|
+
data = await opts.readImage(ref);
|
|
962
|
+
} catch {
|
|
963
|
+
data = null;
|
|
964
|
+
}
|
|
965
|
+
if (!data) {
|
|
966
|
+
skipped++;
|
|
967
|
+
lines.push("[image unavailable: \"" + label + "\" could not be read from attachment storage]");
|
|
968
|
+
i++;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const path = stagedPath(opts.dir, opts.key, i, ref.mediaType);
|
|
972
|
+
await writeFile(path, data);
|
|
973
|
+
staged.push({
|
|
974
|
+
path,
|
|
975
|
+
name: label,
|
|
976
|
+
width: ref.width,
|
|
977
|
+
height: ref.height,
|
|
978
|
+
bytes: ref.bytes
|
|
979
|
+
});
|
|
980
|
+
lines.push("[image attached: \"" + label + "\" — view it with your file/vision tools at " + path + " (" + ref.width + "x" + ref.height + ", " + ref.bytes + " bytes)]");
|
|
981
|
+
i++;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
promptSuffix: lines.join("\n"),
|
|
986
|
+
staged,
|
|
987
|
+
skipped
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Delete staged files older than `ttlMs`. Returns the removed count.
|
|
992
|
+
* Never throws: a failed sweep logs nothing and retries on the next pass.
|
|
993
|
+
*/
|
|
994
|
+
async function sweepDir(dir, ttlMs, now = Date.now()) {
|
|
995
|
+
if (ttlMs <= 0) return 0;
|
|
996
|
+
let removed = 0;
|
|
997
|
+
let entries;
|
|
998
|
+
try {
|
|
999
|
+
entries = await readdir(dir);
|
|
1000
|
+
} catch {
|
|
1001
|
+
return 0;
|
|
1002
|
+
}
|
|
1003
|
+
for (const name of entries) {
|
|
1004
|
+
const full = join(dir, name);
|
|
1005
|
+
try {
|
|
1006
|
+
const st = await stat(full);
|
|
1007
|
+
if (st.isFile() && now - st.mtimeMs > ttlMs) {
|
|
1008
|
+
await unlink(full);
|
|
1009
|
+
removed++;
|
|
1010
|
+
}
|
|
1011
|
+
} catch {}
|
|
1012
|
+
}
|
|
1013
|
+
return removed;
|
|
1014
|
+
}
|
|
1015
|
+
/** Default media directory: <dsh home>/agy-link/media. */
|
|
1016
|
+
function defaultMediaDir(stateDir) {
|
|
1017
|
+
return join(stateDir, "media");
|
|
1018
|
+
}
|
|
1019
|
+
//#endregion
|
|
1020
|
+
//#region src/host/runner.ts
|
|
1021
|
+
const IS_WIN = process.platform === "win32";
|
|
1022
|
+
/** Executable candidates for one PATH entry, per-platform. Exported for tests. */
|
|
1023
|
+
function binCandidates(dir, platform = process.platform) {
|
|
1024
|
+
return (platform === "win32" ? [
|
|
1025
|
+
".exe",
|
|
1026
|
+
".cmd",
|
|
1027
|
+
".bat"
|
|
1028
|
+
] : [""]).map((e) => join(dir, "agy" + e));
|
|
1029
|
+
}
|
|
1030
|
+
/** True when the resolved bin is a Windows cmd shim (needs shell wrapping). */
|
|
1031
|
+
function isCmdShim(bin) {
|
|
1032
|
+
return /\.(cmd|bat)$/i.test(bin);
|
|
1033
|
+
}
|
|
1034
|
+
/** cmd.exe argument quoting (cross-spawn rules). Exported for tests. */
|
|
1035
|
+
function windowsQuote(arg) {
|
|
1036
|
+
if (/[ \t\n\v"]/.test(arg) === false) return arg;
|
|
1037
|
+
let escaped = arg.replace(/(\\+)\"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1");
|
|
1038
|
+
escaped = "\"" + escaped.replace(/"/g, "\\\"") + "\"";
|
|
1039
|
+
return escaped;
|
|
1040
|
+
}
|
|
895
1041
|
function resolveAgyBin(cfg) {
|
|
896
1042
|
const candidates = [];
|
|
897
1043
|
if (cfg.agyBin !== "") candidates.push(cfg.agyBin);
|
|
898
1044
|
const pathEnv = process.env.PATH ?? "";
|
|
899
|
-
for (const dir of pathEnv.split(delimiter)) if (dir !== "") candidates.push(
|
|
900
|
-
|
|
901
|
-
|
|
1045
|
+
for (const dir of pathEnv.split(delimiter)) if (dir !== "") candidates.push(...binCandidates(dir));
|
|
1046
|
+
if (IS_WIN) {
|
|
1047
|
+
const local = process.env.LOCALAPPDATA ?? "";
|
|
1048
|
+
if (local !== "") candidates.push(join(local, "Programs", "agy", "agy.exe"));
|
|
1049
|
+
candidates.push(join(homedir(), ".local", "bin", "agy.exe"));
|
|
1050
|
+
candidates.push(join(homedir(), "AppData", "Roaming", "npm", "agy.cmd"));
|
|
1051
|
+
} else {
|
|
1052
|
+
candidates.push(join(homedir(), ".local", "bin", "agy"));
|
|
1053
|
+
candidates.push("/usr/local/bin/agy");
|
|
1054
|
+
candidates.push("/opt/homebrew/bin/agy");
|
|
1055
|
+
}
|
|
1056
|
+
const hits = [];
|
|
902
1057
|
for (const c of candidates) try {
|
|
903
|
-
accessSync(c, constants.
|
|
904
|
-
|
|
1058
|
+
accessSync(c, constants.F_OK);
|
|
1059
|
+
hits.push(c);
|
|
905
1060
|
} catch {
|
|
906
1061
|
continue;
|
|
907
1062
|
}
|
|
908
|
-
return null;
|
|
1063
|
+
return hits.find((h) => !isCmdShim(h)) ?? hits[0] ?? null;
|
|
909
1064
|
}
|
|
910
1065
|
function compareVersions(a, b) {
|
|
911
1066
|
const pa = a.split(/\./).map(Number);
|
|
@@ -922,6 +1077,24 @@ function parseVersion(out) {
|
|
|
922
1077
|
}
|
|
923
1078
|
function killTree(child) {
|
|
924
1079
|
if (child.pid === void 0) return;
|
|
1080
|
+
if (IS_WIN) {
|
|
1081
|
+
try {
|
|
1082
|
+
spawn("taskkill", [
|
|
1083
|
+
"/pid",
|
|
1084
|
+
String(child.pid),
|
|
1085
|
+
"/T",
|
|
1086
|
+
"/F"
|
|
1087
|
+
], {
|
|
1088
|
+
stdio: "ignore",
|
|
1089
|
+
windowsHide: true
|
|
1090
|
+
});
|
|
1091
|
+
} catch {
|
|
1092
|
+
try {
|
|
1093
|
+
child.kill();
|
|
1094
|
+
} catch {}
|
|
1095
|
+
}
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
925
1098
|
try {
|
|
926
1099
|
process.kill(-child.pid, "SIGTERM");
|
|
927
1100
|
} catch {
|
|
@@ -932,15 +1105,31 @@ function killTree(child) {
|
|
|
932
1105
|
}
|
|
933
1106
|
function startAgyProcess(opts) {
|
|
934
1107
|
const started = Date.now();
|
|
935
|
-
const child =
|
|
1108
|
+
const child = IS_WIN && isCmdShim(opts.bin) ? spawn(process.env.ComSpec ?? "cmd.exe", [
|
|
1109
|
+
"/d",
|
|
1110
|
+
"/s",
|
|
1111
|
+
"/c",
|
|
1112
|
+
[opts.bin, ...opts.args].map(windowsQuote).join(" ")
|
|
1113
|
+
], {
|
|
936
1114
|
cwd: opts.cwd,
|
|
937
1115
|
env: process.env,
|
|
938
|
-
detached: true,
|
|
939
1116
|
stdio: [
|
|
940
1117
|
"pipe",
|
|
941
1118
|
"pipe",
|
|
942
1119
|
"pipe"
|
|
943
|
-
]
|
|
1120
|
+
],
|
|
1121
|
+
windowsVerbatimArguments: true,
|
|
1122
|
+
windowsHide: true
|
|
1123
|
+
}) : spawn(opts.bin, opts.args, {
|
|
1124
|
+
cwd: opts.cwd,
|
|
1125
|
+
env: process.env,
|
|
1126
|
+
detached: !IS_WIN,
|
|
1127
|
+
stdio: [
|
|
1128
|
+
"pipe",
|
|
1129
|
+
"pipe",
|
|
1130
|
+
"pipe"
|
|
1131
|
+
],
|
|
1132
|
+
windowsHide: true
|
|
944
1133
|
});
|
|
945
1134
|
let stdout = "";
|
|
946
1135
|
let stderr = "";
|
|
@@ -965,7 +1154,7 @@ function startAgyProcess(opts) {
|
|
|
965
1154
|
pending += chunk;
|
|
966
1155
|
let nl;
|
|
967
1156
|
while ((nl = pending.indexOf("\n")) >= 0) {
|
|
968
|
-
const line = pending.slice(0, nl);
|
|
1157
|
+
const line = pending.slice(0, nl).replace(/\r$/, "");
|
|
969
1158
|
pending = pending.slice(nl + 1);
|
|
970
1159
|
opts.onLine?.(line);
|
|
971
1160
|
}
|
|
@@ -1163,6 +1352,7 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
1163
1352
|
if (opts.model !== "") args.push("--model", opts.model);
|
|
1164
1353
|
if (opts.effort && opts.effort !== "") args.push("--effort", opts.effort);
|
|
1165
1354
|
if (opts.conversationId) args.push("--conversation", opts.conversationId);
|
|
1355
|
+
for (const d of opts.addDirs ?? []) args.push("--add-dir", d);
|
|
1166
1356
|
args.push(...opts.extraArgs);
|
|
1167
1357
|
args.push("-p", opts.prompt);
|
|
1168
1358
|
return args;
|
|
@@ -1219,8 +1409,29 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
1219
1409
|
if (span.some((m) => m.role === "assistant")) prompt = buildDigest(span, 0, cfg.digestMaxChars) + prompt;
|
|
1220
1410
|
}
|
|
1221
1411
|
}
|
|
1222
|
-
if (prompt.trim() === "") throw new LlmError("request carries no user text to forward to agy", Err.AGY_ERROR);
|
|
1223
1412
|
if (cfg.forwardSystemPrompt && options.system) prompt = "System instructions:\n" + options.system + "\n\n" + prompt;
|
|
1413
|
+
let stagedDirs = [];
|
|
1414
|
+
if (!isAux) {
|
|
1415
|
+
const imageRefs = [];
|
|
1416
|
+
for (const m of trailingUser) for (const b of m.content ?? []) {
|
|
1417
|
+
const blk = b;
|
|
1418
|
+
if (blk && blk.type === "image" && blk.attachment) imageRefs.push(blk.attachment);
|
|
1419
|
+
}
|
|
1420
|
+
if (imageRefs.length > 0 && this.deps.readImage) {
|
|
1421
|
+
const dir = cfg.mediaDir !== "" ? cfg.mediaDir : defaultMediaDir(stateDir());
|
|
1422
|
+
const res = await stageImages({
|
|
1423
|
+
dir,
|
|
1424
|
+
key: (sessionKey !== "" ? sessionKey.replace(/[^a-zA-Z0-9_-]+/g, "_") : "anon") + "-" + messages.length,
|
|
1425
|
+
images: imageRefs,
|
|
1426
|
+
readImage: this.deps.readImage,
|
|
1427
|
+
maxImages: cfg.mediaMaxImages,
|
|
1428
|
+
maxBytes: cfg.mediaMaxBytes
|
|
1429
|
+
});
|
|
1430
|
+
if (res.promptSuffix !== "") prompt = prompt === "" ? res.promptSuffix : prompt + "\n\n" + res.promptSuffix;
|
|
1431
|
+
if (res.staged.length > 0) stagedDirs = [dir];
|
|
1432
|
+
}
|
|
1433
|
+
if (prompt.trim() === "") throw new LlmError("request carries no user text or images to forward to agy", Err.AGY_ERROR);
|
|
1434
|
+
} else if (prompt.trim() === "") throw new LlmError("request carries no user text to forward to agy", Err.AGY_ERROR);
|
|
1224
1435
|
const before = snapshotConversations();
|
|
1225
1436
|
const mapper = new EventMapper({ toolOutput: this.deps.toolOutput });
|
|
1226
1437
|
const parser = new StreamJsonParser();
|
|
@@ -1234,7 +1445,8 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
1234
1445
|
conversationId: !isAux && binding !== void 0 ? binding.conversationId : void 0,
|
|
1235
1446
|
permissionMode: isAux ? "plan" : cfg.permissionMode,
|
|
1236
1447
|
timeoutMs: cfg.timeoutMs,
|
|
1237
|
-
extraArgs: cfg.extraArgs
|
|
1448
|
+
extraArgs: cfg.extraArgs,
|
|
1449
|
+
addDirs: stagedDirs
|
|
1238
1450
|
});
|
|
1239
1451
|
const release = await this.deps.acquire();
|
|
1240
1452
|
let released = false;
|
|
@@ -1327,6 +1539,59 @@ var AgyAdapter = class extends LlmAdapter {
|
|
|
1327
1539
|
};
|
|
1328
1540
|
//#endregion
|
|
1329
1541
|
//#region src/host/oneshot.ts
|
|
1542
|
+
/** Per-file inline cap; larger files are truncated. */
|
|
1543
|
+
const INLINE_MAX_BYTES = 262144;
|
|
1544
|
+
/** Text-detection: anything that is very likely not binary. */
|
|
1545
|
+
function looksTextual(head) {
|
|
1546
|
+
if (head === "") return true;
|
|
1547
|
+
let suspicious = 0;
|
|
1548
|
+
const n = Math.min(head.length, 2e3);
|
|
1549
|
+
for (let i = 0; i < n; i++) {
|
|
1550
|
+
const c = head.charCodeAt(i);
|
|
1551
|
+
if (c === 0 || c < 9 && c !== 0 || c > 13 && c < 32) suspicious++;
|
|
1552
|
+
}
|
|
1553
|
+
return suspicious / n < .05;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Inline text files into the prompt (v0.2): each readable textual file
|
|
1557
|
+
* becomes a fenced section. Paths resolve against the oneshot cwd;
|
|
1558
|
+
* absolute paths pass through.
|
|
1559
|
+
*/
|
|
1560
|
+
async function inlineFiles(prompt, paths, cwd) {
|
|
1561
|
+
if (paths.length === 0) return prompt;
|
|
1562
|
+
const sections = [];
|
|
1563
|
+
for (const raw of paths) {
|
|
1564
|
+
const p = isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
1565
|
+
let note = "";
|
|
1566
|
+
let body = "";
|
|
1567
|
+
try {
|
|
1568
|
+
const buf = await readFile(p);
|
|
1569
|
+
if (!looksTextual(buf.subarray(0, Math.min(buf.length, 4096)).toString("utf8"))) note = "(skipped: binary file - reference it by path and let agy read it with its own tools)";
|
|
1570
|
+
else if (buf.byteLength > INLINE_MAX_BYTES) {
|
|
1571
|
+
note = "(truncated to the first 262144 bytes)";
|
|
1572
|
+
body = buf.subarray(0, INLINE_MAX_BYTES).toString("utf8");
|
|
1573
|
+
} else body = buf.toString("utf8");
|
|
1574
|
+
} catch {
|
|
1575
|
+
note = "(skipped: unreadable - " + raw + ")";
|
|
1576
|
+
}
|
|
1577
|
+
sections.push("--- file: " + p + " " + note + " ---\n" + body);
|
|
1578
|
+
}
|
|
1579
|
+
return prompt + "\n\n" + sections.join("\n\n");
|
|
1580
|
+
}
|
|
1581
|
+
/** Write a JSON schema to a temp file; returns the --json-schema argv tail. */
|
|
1582
|
+
async function schemaArgs(schema) {
|
|
1583
|
+
const text = JSON.stringify(schema);
|
|
1584
|
+
const dir = await mkdtemp(join(tmpdir(), "agy-schema-"));
|
|
1585
|
+
const file = join(dir, "schema.json");
|
|
1586
|
+
await writeFile(file, text, "utf8");
|
|
1587
|
+
return {
|
|
1588
|
+
args: ["--json-schema", file],
|
|
1589
|
+
cleanup: () => rm(dir, {
|
|
1590
|
+
recursive: true,
|
|
1591
|
+
force: true
|
|
1592
|
+
})
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1330
1595
|
async function runAgyOnce(deps, req) {
|
|
1331
1596
|
const cfg = deps.cfg();
|
|
1332
1597
|
const bin = deps.bin();
|
|
@@ -1349,7 +1614,15 @@ async function runAgyOnce(deps, req) {
|
|
|
1349
1614
|
else args.push("--mode", mode);
|
|
1350
1615
|
if (req.model) args.push("--model", req.model);
|
|
1351
1616
|
if (req.effort) args.push("--effort", req.effort);
|
|
1352
|
-
|
|
1617
|
+
let prompt = req.prompt;
|
|
1618
|
+
if (req.readPaths && req.readPaths.length > 0) prompt = await inlineFiles(prompt, req.readPaths, cfg.workspaceRoot !== "" ? cfg.workspaceRoot : process.cwd());
|
|
1619
|
+
let cleanup = null;
|
|
1620
|
+
if (req.schema !== void 0 && req.schema !== null) {
|
|
1621
|
+
const sa = await schemaArgs(req.schema);
|
|
1622
|
+
args.push(...sa.args);
|
|
1623
|
+
cleanup = sa.cleanup;
|
|
1624
|
+
}
|
|
1625
|
+
args.push("-p", prompt);
|
|
1353
1626
|
const parser = new StreamJsonParser();
|
|
1354
1627
|
const textParts = [];
|
|
1355
1628
|
let resultText = "";
|
|
@@ -1371,6 +1644,7 @@ async function runAgyOnce(deps, req) {
|
|
|
1371
1644
|
}
|
|
1372
1645
|
}
|
|
1373
1646
|
}).outcome;
|
|
1647
|
+
if (cleanup) cleanup().catch(() => {});
|
|
1374
1648
|
for (const ev of parser.flush()) if (ev.kind === "result") {
|
|
1375
1649
|
if (ev.conversationId !== "") conversationId = ev.conversationId;
|
|
1376
1650
|
resultText = ev.response;
|
|
@@ -1484,6 +1758,14 @@ function defineAgyAskTool(deps) {
|
|
|
1484
1758
|
timeoutMinutes: {
|
|
1485
1759
|
type: "number",
|
|
1486
1760
|
description: "Optional timeout budget in minutes (default 10)."
|
|
1761
|
+
},
|
|
1762
|
+
readPaths: {
|
|
1763
|
+
type: "string",
|
|
1764
|
+
description: "Optional space/comma-separated text files to inline into the prompt (workspace-relative or absolute; binaries are skipped with a note)."
|
|
1765
|
+
},
|
|
1766
|
+
schema: {
|
|
1767
|
+
type: "string",
|
|
1768
|
+
description: "Optional JSON Schema (as a JSON string) enforcing the final answer via agy --json-schema."
|
|
1487
1769
|
}
|
|
1488
1770
|
},
|
|
1489
1771
|
output: {
|
|
@@ -1498,13 +1780,22 @@ function defineAgyAskTool(deps) {
|
|
|
1498
1780
|
exec.signal;
|
|
1499
1781
|
const cfg = deps.cfg();
|
|
1500
1782
|
const model = resolveAskModel(args.model ?? "", deps.catalog(), cfg.defaultModel);
|
|
1783
|
+
let parsedSchema;
|
|
1784
|
+
if (typeof args.schema === "string" && args.schema.trim() !== "") try {
|
|
1785
|
+
parsedSchema = JSON.parse(args.schema);
|
|
1786
|
+
} catch (e) {
|
|
1787
|
+
throw new Error("agy_ask: schema is not valid JSON: " + String(e));
|
|
1788
|
+
}
|
|
1789
|
+
const readPaths = typeof args.readPaths === "string" && args.readPaths.trim() !== "" ? args.readPaths.split(/[ ,]+/).filter(Boolean) : [];
|
|
1501
1790
|
const res = await runAgyOnce(deps, {
|
|
1502
1791
|
prompt: args.prompt,
|
|
1503
1792
|
model: model === "" ? void 0 : model,
|
|
1504
1793
|
effort: args.effort,
|
|
1505
1794
|
mode: args.mode,
|
|
1506
1795
|
timeoutMs: args.timeoutMinutes ? args.timeoutMinutes * 6e4 : void 0,
|
|
1507
|
-
signal: exec.signal
|
|
1796
|
+
signal: exec.signal,
|
|
1797
|
+
readPaths,
|
|
1798
|
+
schema: parsedSchema
|
|
1508
1799
|
});
|
|
1509
1800
|
if (!res.ok) throw new Error("agy_ask failed: " + (res.error ?? "unknown error"));
|
|
1510
1801
|
const footer = res.conversationId ? "\n\n(agy conversation: " + res.conversationId + " — continue it with: agy --conversation " + res.conversationId + ")" : "";
|
|
@@ -1958,6 +2249,193 @@ function require$$basename(p) {
|
|
|
1958
2249
|
return i >= 0 ? p.slice(i + 1) : p;
|
|
1959
2250
|
}
|
|
1960
2251
|
//#endregion
|
|
2252
|
+
//#region src/host/mcp-bridge.ts
|
|
2253
|
+
const MCP_SERVER_KEY = "dsh-tools";
|
|
2254
|
+
/** MCP tool names are [a-zA-Z0-9_-]; DSH names may contain dots. */
|
|
2255
|
+
function toMcpName(name) {
|
|
2256
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2257
|
+
}
|
|
2258
|
+
function readBody(req) {
|
|
2259
|
+
return new Promise((resolve) => {
|
|
2260
|
+
const chunks = [];
|
|
2261
|
+
req.on("data", (c) => chunks.push(c));
|
|
2262
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
2263
|
+
req.on("error", () => resolve(""));
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
function sendJson(res, status, body) {
|
|
2267
|
+
const text = JSON.stringify(body);
|
|
2268
|
+
res.writeHead(status, {
|
|
2269
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
2270
|
+
"Content-Length": Buffer.byteLength(text)
|
|
2271
|
+
});
|
|
2272
|
+
res.end(text);
|
|
2273
|
+
}
|
|
2274
|
+
/** Best-effort extraction of readable text from an execution result. */
|
|
2275
|
+
function resultText(result) {
|
|
2276
|
+
if (result === null || result === void 0) return "";
|
|
2277
|
+
const r = result;
|
|
2278
|
+
if (Array.isArray(r.content)) {
|
|
2279
|
+
const parts = [];
|
|
2280
|
+
for (const b of r.content) {
|
|
2281
|
+
const blk = b;
|
|
2282
|
+
if (blk && blk.type === "text" && typeof blk.text === "string") parts.push(blk.text);
|
|
2283
|
+
}
|
|
2284
|
+
if (parts.length > 0) return parts.join("\n");
|
|
2285
|
+
}
|
|
2286
|
+
if (typeof r.text === "string") return r.text;
|
|
2287
|
+
if (typeof r.output === "string") return r.output;
|
|
2288
|
+
try {
|
|
2289
|
+
return JSON.stringify(result, null, 2);
|
|
2290
|
+
} catch {
|
|
2291
|
+
return String(result);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Start the loopback endpoint. Resolves once listening. The tools service
|
|
2296
|
+
* may arrive later (optional service): pass a thunk.
|
|
2297
|
+
*/
|
|
2298
|
+
function startMcpBridge(opts) {
|
|
2299
|
+
const token = randomBytes(24).toString("hex");
|
|
2300
|
+
let callSeq = 0;
|
|
2301
|
+
const server = createServer((req, res) => {
|
|
2302
|
+
(async () => {
|
|
2303
|
+
const url = (req.url ?? "").split("?")[0];
|
|
2304
|
+
if (String(req.headers["authorization"] ?? "") !== "Bearer " + token) {
|
|
2305
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
if ((req.method === "GET" || req.method === "POST") && (url === "/tools" || url === "/mcp/tools")) {
|
|
2309
|
+
const svc = opts.tools();
|
|
2310
|
+
if (!svc) {
|
|
2311
|
+
sendJson(res, 503, { error: "tools service unavailable" });
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
const allow = opts.allowlist().split(",").map((s) => s.trim()).filter(Boolean);
|
|
2315
|
+
const allowSet = new Set(allow);
|
|
2316
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2317
|
+
sendJson(res, 200, { tools: svc.schemas().filter((t) => allow.length === 0 || allowSet.has(t.name)).filter((t) => {
|
|
2318
|
+
if (t.name === "run_code" || t.name === "agy_ask") return false;
|
|
2319
|
+
const mapped = toMcpName(t.name);
|
|
2320
|
+
if (seen.has(mapped)) return false;
|
|
2321
|
+
seen.add(mapped);
|
|
2322
|
+
return true;
|
|
2323
|
+
}).map((t) => ({
|
|
2324
|
+
name: toMcpName(t.name),
|
|
2325
|
+
dshName: t.name,
|
|
2326
|
+
description: t.description,
|
|
2327
|
+
inputSchema: {
|
|
2328
|
+
type: "object",
|
|
2329
|
+
...t.parameters
|
|
2330
|
+
}
|
|
2331
|
+
})) });
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
if (req.method === "POST" && (url === "/call" || url === "/mcp/call")) {
|
|
2335
|
+
const body = await readBody(req);
|
|
2336
|
+
let parsed = {};
|
|
2337
|
+
try {
|
|
2338
|
+
parsed = JSON.parse(body);
|
|
2339
|
+
} catch {
|
|
2340
|
+
sendJson(res, 400, { error: "invalid JSON" });
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
const svc = opts.tools();
|
|
2344
|
+
if (!svc) {
|
|
2345
|
+
sendJson(res, 503, { error: "tools service unavailable" });
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
const dshName = typeof parsed.dshName === "string" ? parsed.dshName : "";
|
|
2349
|
+
if (dshName === "" || dshName === "run_code" || dshName === "agy_ask") {
|
|
2350
|
+
sendJson(res, 400, { error: "bad tool name" });
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
callSeq++;
|
|
2354
|
+
try {
|
|
2355
|
+
sendJson(res, 200, {
|
|
2356
|
+
ok: true,
|
|
2357
|
+
text: resultText(await svc.execute({
|
|
2358
|
+
callId: "agy-mcp-" + callSeq,
|
|
2359
|
+
name: dshName,
|
|
2360
|
+
arguments: parsed.arguments ?? {}
|
|
2361
|
+
}))
|
|
2362
|
+
});
|
|
2363
|
+
} catch (e) {
|
|
2364
|
+
sendJson(res, 200, {
|
|
2365
|
+
ok: false,
|
|
2366
|
+
error: String(e)
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
sendJson(res, 404, { error: "not found" });
|
|
2372
|
+
})().catch(() => {
|
|
2373
|
+
try {
|
|
2374
|
+
sendJson(res, 500, { error: "internal" });
|
|
2375
|
+
} catch {}
|
|
2376
|
+
});
|
|
2377
|
+
});
|
|
2378
|
+
return new Promise((resolve, reject) => {
|
|
2379
|
+
server.once("error", reject);
|
|
2380
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2381
|
+
const addr = server.address();
|
|
2382
|
+
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
|
|
2383
|
+
opts.log?.("mcp bridge listening on 127.0.0.1:" + port);
|
|
2384
|
+
resolve({
|
|
2385
|
+
bridgeScript: opts.bridgeScript,
|
|
2386
|
+
token,
|
|
2387
|
+
url: "http://127.0.0.1:" + port,
|
|
2388
|
+
port,
|
|
2389
|
+
close: () => new Promise((done) => server.close(() => done()))
|
|
2390
|
+
});
|
|
2391
|
+
});
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Merge our server entry into the workspace .mcp.json. Returns a restore
|
|
2396
|
+
* function that puts the previous content back (or deletes the file we
|
|
2397
|
+
* created). Never throws.
|
|
2398
|
+
*/
|
|
2399
|
+
function writeMcpConfig(workspaceRoot, bridge) {
|
|
2400
|
+
const file = join(workspaceRoot, ".mcp.json");
|
|
2401
|
+
let previous = null;
|
|
2402
|
+
try {
|
|
2403
|
+
if (existsSync(file)) previous = readFileSync(file, "utf8");
|
|
2404
|
+
} catch {
|
|
2405
|
+
previous = null;
|
|
2406
|
+
}
|
|
2407
|
+
let root = {};
|
|
2408
|
+
if (previous !== null) try {
|
|
2409
|
+
const v = JSON.parse(previous);
|
|
2410
|
+
if (v && typeof v === "object") root = v;
|
|
2411
|
+
} catch {
|
|
2412
|
+
root = {};
|
|
2413
|
+
}
|
|
2414
|
+
const servers = root.mcpServers && typeof root.mcpServers === "object" ? root.mcpServers : {};
|
|
2415
|
+
servers[MCP_SERVER_KEY] = {
|
|
2416
|
+
type: "stdio",
|
|
2417
|
+
command: process.execPath,
|
|
2418
|
+
args: [bridge.bridgeScript],
|
|
2419
|
+
env: {
|
|
2420
|
+
DSH_MCP_URL: bridge.url,
|
|
2421
|
+
DSH_MCP_TOKEN: bridge.token
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
root.mcpServers = servers;
|
|
2425
|
+
try {
|
|
2426
|
+
writeFileSync(file, JSON.stringify(root, null, 2) + "\n", "utf8");
|
|
2427
|
+
} catch {
|
|
2428
|
+
return () => {};
|
|
2429
|
+
}
|
|
2430
|
+
return () => {
|
|
2431
|
+
try {
|
|
2432
|
+
if (previous === null) {
|
|
2433
|
+
if (existsSync(file)) unlinkSync(file);
|
|
2434
|
+
} else writeFileSync(file, previous, "utf8");
|
|
2435
|
+
} catch {}
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
//#endregion
|
|
1961
2439
|
//#region src/index.ts
|
|
1962
2440
|
const name = "dsh-agy-link";
|
|
1963
2441
|
const inject = ["llm", "commands"];
|
|
@@ -2036,6 +2514,15 @@ function apply(ctx, entryConfig = {}) {
|
|
|
2036
2514
|
},
|
|
2037
2515
|
onParser: (p) => {
|
|
2038
2516
|
lastParser = p;
|
|
2517
|
+
},
|
|
2518
|
+
readImage: async (ref) => {
|
|
2519
|
+
const svc = ctx.attachments;
|
|
2520
|
+
if (!svc || typeof svc.readImage !== "function") return null;
|
|
2521
|
+
try {
|
|
2522
|
+
return (await svc.readImage(ref))?.data ?? null;
|
|
2523
|
+
} catch {
|
|
2524
|
+
return null;
|
|
2525
|
+
}
|
|
2039
2526
|
}
|
|
2040
2527
|
});
|
|
2041
2528
|
const setOverride = (key, value) => {
|
|
@@ -2275,9 +2762,54 @@ function apply(ctx, entryConfig = {}) {
|
|
|
2275
2762
|
}
|
|
2276
2763
|
});
|
|
2277
2764
|
}
|
|
2765
|
+
const mediaDir = () => {
|
|
2766
|
+
const cfg = getConfig();
|
|
2767
|
+
return cfg.mediaDir !== "" ? cfg.mediaDir : defaultMediaDir(stateDir());
|
|
2768
|
+
};
|
|
2769
|
+
ctx.effect(() => {
|
|
2770
|
+
const timer = setInterval(() => {
|
|
2771
|
+
sweepDir(mediaDir(), getConfig().mediaTtlMs).catch(() => void 0);
|
|
2772
|
+
}, Math.max(6e4, Math.min(getConfig().mediaTtlMs, 36e5)));
|
|
2773
|
+
return () => clearInterval(timer);
|
|
2774
|
+
});
|
|
2775
|
+
const bridgeState = {
|
|
2776
|
+
bridge: null,
|
|
2777
|
+
restore: null
|
|
2778
|
+
};
|
|
2779
|
+
const syncMcpBridge = () => {
|
|
2780
|
+
const cfg = getConfig();
|
|
2781
|
+
const want = cfg.mcpBridge && cfg.enabled;
|
|
2782
|
+
if (want && bridgeState.bridge === null) (async () => {
|
|
2783
|
+
try {
|
|
2784
|
+
const script = fileURLToPath(new URL("./bridge.mjs", import.meta.url));
|
|
2785
|
+
const toolsSvc = ctx.get("tools");
|
|
2786
|
+
const bridge = await startMcpBridge({
|
|
2787
|
+
bridgeScript: script,
|
|
2788
|
+
tools: () => ctx.get("tools"),
|
|
2789
|
+
allowlist: () => getConfig().mcpToolAllowlist,
|
|
2790
|
+
log
|
|
2791
|
+
});
|
|
2792
|
+
bridgeState.bridge = bridge;
|
|
2793
|
+
const root = cfg.workspaceRoot !== "" ? cfg.workspaceRoot : process.cwd();
|
|
2794
|
+
bridgeState.restore = writeMcpConfig(root, bridge);
|
|
2795
|
+
log("mcp bridge ready at " + bridge.url + (toolsSvc ? "" : " (tools service not yet available)"));
|
|
2796
|
+
} catch (e) {
|
|
2797
|
+
log("mcp bridge failed to start: " + String(e));
|
|
2798
|
+
}
|
|
2799
|
+
})();
|
|
2800
|
+
else if (!want && bridgeState.bridge !== null) {
|
|
2801
|
+
bridgeState.restore?.();
|
|
2802
|
+
bridgeState.bridge.close();
|
|
2803
|
+
bridgeState.bridge = null;
|
|
2804
|
+
bridgeState.restore = null;
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
syncMcpBridge();
|
|
2278
2808
|
ctx.effect(() => {
|
|
2279
2809
|
auth.dispose();
|
|
2280
2810
|
if (askToolDispose.current !== null) askToolDispose.current();
|
|
2811
|
+
bridgeState.restore?.();
|
|
2812
|
+
bridgeState.bridge?.close();
|
|
2281
2813
|
return () => void 0;
|
|
2282
2814
|
});
|
|
2283
2815
|
}
|
package/docs/KNOWN-GAPS.md
CHANGED
|
@@ -4,13 +4,21 @@ Deliberate v1 boundaries and upstream-behavior notes.
|
|
|
4
4
|
|
|
5
5
|
## Not forwarded to agy
|
|
6
6
|
|
|
7
|
-
- **Images
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **
|
|
13
|
-
|
|
7
|
+
- **Images as true multimodal blocks** - agy print mode (`-p`) has no image
|
|
8
|
+
input flag. Since v0.2 the bridge stages DSH image attachments to a local
|
|
9
|
+
media directory (`mediaDir`, TTL-swept) and references them by absolute
|
|
10
|
+
path in the prompt, granting `--add-dir` so agy can view them with its own
|
|
11
|
+
file/vision tools. Same approach as the pi extension.
|
|
12
|
+
- **DSH tools to agy (reverse MCP bridge)** - available since v0.2 behind the
|
|
13
|
+
`mcpBridge` config flag (experimental): the plugin runs a loopback-only,
|
|
14
|
+
token-guarded HTTP endpoint and registers a zero-dependency stdio MCP
|
|
15
|
+
server (`dsh-tools`) in the workspace `.mcp.json` (merged in, restored on
|
|
16
|
+
disable). `run_code` and `agy_ask` are never bridged; `mcpToolAllowlist`
|
|
17
|
+
restricts the set further.
|
|
18
|
+
- **Structured outputs** - `agy_ask` accepts a `schema` parameter (JSON
|
|
19
|
+
Schema as a JSON string) enforced via `--json-schema` since v0.2. Wiring
|
|
20
|
+
schema enforcement into DSH-native tool-call generation remains future
|
|
21
|
+
work: DSH `GenerateOptions` has no schema field to map onto.
|
|
14
22
|
|
|
15
23
|
## Mapping choices
|
|
16
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agy-link",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Google Antigravity (agy CLI) models for DeepSeek Harness — stream Gemini/Claude/GPT-OSS subscriptions into DSH with thinking, tool activity, token usage and in-GUI Google OAuth login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|