opencode-image 0.1.0 → 0.1.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/README.md CHANGED
@@ -34,7 +34,9 @@ opencode 启动时会自动从 npm 安装插件到 `~/.cache/opencode/packages/o
34
34
  | `outDir` | `<项目目录>/.opencode-images` | 图片输出目录,相对路径基于当前项目 |
35
35
  | `maxSize` | `1024` | 落盘图片最长边上限,超出等比缩小 |
36
36
  | `displayMaxSize` | `448` | 仅在对话里展示的尺寸,不影响落盘原图 |
37
+ | `serverPort` | `0` | 本地图片服务端口。`0` = 由项目目录派生(同一项目恒定,保证历史链接跨重启有效) |
37
38
  | `serverIdleMs` | `0` | 本地图片服务空闲关闭时间(毫秒),`0` = 不主动关闭 |
39
+ | `updateCheck` | `true` | 首次出图时查一次 npm 最新版,落后则在工具输出末尾附升级提示 |
38
40
  | `timeoutMs` | `600000` | 单次请求超时 |
39
41
 
40
42
  ## 图片是怎么显示出来的
@@ -43,17 +45,54 @@ openCode(桌面端 / Web)**只在助手回复的 Markdown 正文里渲染图
43
45
  而正文里的 `file://` 会被 Chromium 拦掉,所以插件做了这件事:
44
46
 
45
47
  1. 图片落盘到 `outDir`
46
- 2. 插件进程内起一个只绑 `127.0.0.1` 的 HTTP 服务,为图片分配随机 id
48
+ 2. 插件进程内起一个只绑 `127.0.0.1` 的 HTTP 服务
47
49
  3. 工具输出第一行给出 `![alt](http://127.0.0.1:<port>/img/<id>?w=448)`
48
50
  4. 工具描述里强制模型把这行**原样抄进回复正文**
49
51
 
50
- 服务的安全边界:只服务显式注册过的文件,URL 里没有真实路径,无法路径穿越读取任意文件。
52
+ **链接为什么跨重启还有效**:`id` 是「文件相对 `outDir` 的路径」的 base64url 编码,可逆且稳定;
53
+ 端口默认由项目目录派生(同一项目恒定)。所以服务**不需要任何内存登记表**,
54
+ 重启后历史消息里的旧链接仍能解析到同一个文件。
55
+
56
+ 服务的安全边界:请求里的 `id` 只允许解析成 `outDir` 内部的普通文件,
57
+ 解析后做 containment + `realpath` 双重校验,防路径穿越与符号链接逃逸。
58
+ `outDir` 之外的文件只能用进程内临时 id(不跨重启)。
51
59
 
52
60
  服务的生命周期:**首次生成图片时懒启动**,端口随机;`unref()` 后不阻塞 opencode 退出,
53
61
  opencode 进程结束即自动释放。默认不主动关闭——历史消息里的图片会被重新请求,提前关闭会 404。
54
62
 
55
63
  > 同时在工具结果里返回了 `attachments`,这样模型自己也能看到生成的图,便于继续迭代。
56
64
 
65
+ ## 升级
66
+
67
+ opencode 对 npm 形式的插件**不会自动升级**。
68
+
69
+ 它的安装逻辑是 `Npm.add()`:只要缓存目录里已经有这个包就直接复用,不会去比对版本
70
+ (opencode 源码 `packages/core/src/npm.ts` 里的短路分支)。而裸包名会被规范化成
71
+ `opencode-image@latest`,缓存目录名固定为 `~/.cache/opencode/packages/opencode-image@latest`,
72
+ 所以你会一直停留在**第一次安装到的版本**。
73
+
74
+ 升级只需要两条:
75
+
76
+ ```sh
77
+ npx opencode-image --update # 清理 opencode 的插件缓存
78
+ # 然后重启 opencode,会自动重新下载最新版
79
+ ```
80
+
81
+ 插件首次出图时也会查一次 npm,发现落后就在工具输出末尾提示你执行上面这条命令(可用 `updateCheck: false` 关掉)。
82
+
83
+ 不想用命令的话,等价于手动删缓存后重启:
84
+
85
+ ```sh
86
+ rm -rf ~/.cache/opencode/packages/opencode-image*
87
+ ```
88
+
89
+ 维护者发布新版:
90
+
91
+ ```sh
92
+ git commit -am "..." # npm version 要求工作区干净
93
+ npm run release # = version patch + publish + 清掉本机缓存
94
+ ```
95
+
57
96
  ## 环境变量
58
97
 
59
98
  ```bash
@@ -78,6 +117,8 @@ bun run build # 产出 dist/index.js + dist/index.d.ts
78
117
 
79
118
  - 只有能渲染 Markdown 图片的客户端才看得到图;纯终端 TUI 里会退化成一串 URL。
80
119
  - 预览图缓存在系统临时目录,`serverIdleMs = 0`(默认)时随进程退出由系统回收。
120
+ - 两个不同项目极少数情况下会派生出同一个端口,此时后启动的实例会报错并提示用 `serverPort` 指定端口。
121
+ - 工具结果里的 `attachments` 只是给模型看的,opencode 客户端**不会**把工具附件渲染成图片,所以内联显示必须靠上面的 Markdown 链接。
81
122
 
82
123
  ## License
83
124
 
package/bin/cli.js ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs"
3
+ import * as os from "node:os"
4
+ import * as path from "node:path"
5
+
6
+ const PACKAGE = "opencode-image"
7
+
8
+ const version = (() => {
9
+ try {
10
+ const file = new URL("../package.json", import.meta.url)
11
+ return JSON.parse(fs.readFileSync(file, "utf8")).version ?? "unknown"
12
+ } catch {
13
+ return "unknown"
14
+ }
15
+ })()
16
+
17
+ const USAGE = `${PACKAGE} v${version}
18
+
19
+ 用法:
20
+ npx ${PACKAGE} --update 清理 opencode 的插件缓存,然后重启 opencode 即可装上最新版
21
+ npx ${PACKAGE} --version 显示版本
22
+ npx ${PACKAGE} --help 显示帮助
23
+
24
+ 为什么需要 --update:
25
+ opencode 对 npm 形式的插件不做版本检查——只要缓存目录里已经装过就直接复用。
26
+ 所以发布新版后必须清掉缓存,opencode 才会重新下载。详见 README「升级」一节。
27
+ `
28
+
29
+ /** opencode 的插件缓存在 <cache>/packages/<spec>,spec 可能是 opencode-image 或 opencode-image@latest */
30
+ function cacheRoot() {
31
+ return process.env.OPENCODE_IMAGE_CACHE_ROOT ?? path.join(os.homedir(), ".cache", "opencode", "packages")
32
+ }
33
+
34
+ function update() {
35
+ const root = cacheRoot()
36
+ if (!fs.existsSync(root)) {
37
+ console.log(`缓存目录不存在,无需清理:${root}`)
38
+ console.log("直接重启 opencode 即可。")
39
+ return
40
+ }
41
+
42
+ const targets = fs
43
+ .readdirSync(root)
44
+ .filter((entry) => entry === PACKAGE || entry.startsWith(`${PACKAGE}@`))
45
+ .map((entry) => path.join(root, entry))
46
+
47
+ if (targets.length === 0) {
48
+ console.log(`没有找到 ${PACKAGE} 的缓存,无需清理。`)
49
+ console.log("直接重启 opencode 即可。")
50
+ return
51
+ }
52
+
53
+ for (const target of targets) {
54
+ fs.rmSync(target, { recursive: true, force: true })
55
+ console.log(`已删除 ${target}`)
56
+ }
57
+ console.log("")
58
+ console.log(`✅ 已清理 ${targets.length} 个缓存目录,重启 opencode 后会重新下载最新版。`)
59
+ }
60
+
61
+ const args = process.argv.slice(2)
62
+
63
+ if (args.includes("--help") || args.includes("-h")) {
64
+ console.log(USAGE)
65
+ process.exit(0)
66
+ }
67
+
68
+ if (args.includes("--version") || args.includes("-v")) {
69
+ console.log(version)
70
+ process.exit(0)
71
+ }
72
+
73
+ if (args.includes("--update")) {
74
+ update()
75
+ process.exit(0)
76
+ }
77
+
78
+ console.log(USAGE)
79
+ process.exit(1)
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import { tool } from "@opencode-ai/plugin";
3
- import * as fs3 from "node:fs";
3
+ import * as fs4 from "node:fs";
4
4
  import * as path3 from "node:path";
5
5
 
6
6
  // src/api.ts
@@ -137,6 +137,7 @@ var DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
137
137
  var DEFAULT_MODEL = "gpt-image-2";
138
138
  var DEFAULT_MAX_SIZE = 1024;
139
139
  var DEFAULT_DISPLAY_MAX_SIZE = 448;
140
+ var DEFAULT_SERVER_PORT = 0;
140
141
  var DEFAULT_SERVER_IDLE_MS = 0;
141
142
  var DEFAULT_TIMEOUT_MS = 600000;
142
143
  function resolveOptions(options) {
@@ -152,17 +153,76 @@ function resolveOptions(options) {
152
153
  outDir: opts.outDir,
153
154
  maxSize: opts.maxSize ?? DEFAULT_MAX_SIZE,
154
155
  displayMaxSize: opts.displayMaxSize ?? DEFAULT_DISPLAY_MAX_SIZE,
156
+ serverPort: opts.serverPort ?? DEFAULT_SERVER_PORT,
155
157
  serverIdleMs: opts.serverIdleMs ?? DEFAULT_SERVER_IDLE_MS,
158
+ updateCheck: opts.updateCheck ?? true,
156
159
  timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
157
160
  };
158
161
  }
159
162
 
160
- // src/server.ts
163
+ // src/update.ts
161
164
  import * as fs2 from "node:fs";
165
+ var REGISTRY = "https://registry.npmjs.org";
166
+ var PACKAGE = "opencode-image";
167
+ function parse(version) {
168
+ const [core, pre = ""] = version.split("-", 2);
169
+ const nums = core.split(".").map((part) => Number.parseInt(part, 10));
170
+ return { nums: [nums[0] || 0, nums[1] || 0, nums[2] || 0], pre };
171
+ }
172
+ function isNewer(candidate, current) {
173
+ const a = parse(candidate);
174
+ const b = parse(current);
175
+ for (let i = 0;i < 3; i++) {
176
+ if (a.nums[i] !== b.nums[i])
177
+ return a.nums[i] > b.nums[i];
178
+ }
179
+ if (a.pre === b.pre)
180
+ return false;
181
+ if (!a.pre)
182
+ return true;
183
+ if (!b.pre)
184
+ return false;
185
+ return a.pre > b.pre;
186
+ }
187
+ function readOwnVersion() {
188
+ try {
189
+ const file = new URL("../package.json", import.meta.url);
190
+ const pkg = JSON.parse(fs2.readFileSync(file, "utf8"));
191
+ return pkg.version;
192
+ } catch {
193
+ return;
194
+ }
195
+ }
196
+ function upgradeHint(latest, current) {
197
+ return `提示:${PACKAGE} 有新版本 ${latest}(当前 ${current})。升级:npx ${PACKAGE} --update 然后重启 opencode。`;
198
+ }
199
+ async function checkForUpdate(current, timeoutMs = 3000) {
200
+ const controller = new AbortController;
201
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
202
+ try {
203
+ const res = await fetch(`${REGISTRY}/${PACKAGE}/latest`, {
204
+ signal: controller.signal,
205
+ headers: { accept: "application/json" }
206
+ });
207
+ if (!res.ok)
208
+ return;
209
+ const body = await res.json();
210
+ if (!body.version || !isNewer(body.version, current))
211
+ return;
212
+ return upgradeHint(body.version, current);
213
+ } catch {
214
+ return;
215
+ } finally {
216
+ clearTimeout(timer);
217
+ }
218
+ }
219
+
220
+ // src/server.ts
221
+ import * as fs3 from "node:fs";
162
222
  import * as http from "node:http";
163
223
  import * as os from "node:os";
164
224
  import * as path2 from "node:path";
165
- import { randomUUID } from "node:crypto";
225
+ import { createHash, randomUUID } from "node:crypto";
166
226
  var MIME_BY_EXT = {
167
227
  ".png": "image/png",
168
228
  ".jpg": "image/jpeg",
@@ -170,71 +230,114 @@ var MIME_BY_EXT = {
170
230
  ".webp": "image/webp",
171
231
  ".gif": "image/gif"
172
232
  };
233
+ var ID_PATTERN = /^\/img\/([A-Za-z0-9_-]{1,2048})$/;
234
+ var APP_NAME = "opencode-image";
235
+ function derivePort(key) {
236
+ const digest = createHash("sha1").update(key).digest();
237
+ return 41000 + digest.readUInt16BE(0) % 4000;
238
+ }
173
239
 
174
240
  class ImageServer {
175
241
  idleMs;
242
+ configuredPort;
176
243
  server;
177
244
  port = 0;
178
- registry = new Map;
245
+ root;
246
+ ready = false;
247
+ owned = false;
248
+ extra = new Map;
179
249
  previews = new Map;
180
250
  timer;
181
251
  starting;
182
- constructor(idleMs) {
252
+ constructor(idleMs, configuredPort) {
183
253
  this.idleMs = idleMs;
254
+ this.configuredPort = configuredPort;
184
255
  }
185
256
  get baseURL() {
186
257
  return this.port ? `http://127.0.0.1:${this.port}` : "";
187
258
  }
188
- register(file) {
259
+ idFor(file) {
260
+ const root = this.root;
261
+ if (root) {
262
+ const relative2 = path2.relative(root, file);
263
+ if (relative2 && !relative2.startsWith("..") && !path2.isAbsolute(relative2)) {
264
+ return Buffer.from(relative2.split(path2.sep).join("/"), "utf8").toString("base64url");
265
+ }
266
+ }
267
+ for (const [id2, registered] of this.extra) {
268
+ if (registered === file)
269
+ return id2;
270
+ }
189
271
  const id = randomUUID().replace(/-/g, "").slice(0, 16);
190
- const mime = MIME_BY_EXT[path2.extname(file).toLowerCase()] ?? "application/octet-stream";
191
- this.registry.set(id, { file, mime });
272
+ this.extra.set(id, file);
192
273
  return id;
193
274
  }
194
- url(id, width) {
195
- return `${this.baseURL}/img/${id}?w=${width}`;
275
+ url(file, width) {
276
+ return `${this.baseURL}/img/${this.idFor(file)}?w=${width}`;
196
277
  }
197
- async start() {
198
- if (this.server) {
278
+ async start(opts) {
279
+ this.root = path2.resolve(opts.outDir);
280
+ if (this.ready) {
199
281
  this.arm();
200
282
  return;
201
283
  }
202
284
  if (!this.starting) {
203
- this.starting = this.listen().finally(() => {
285
+ this.starting = this.listen(opts).finally(() => {
204
286
  this.starting = undefined;
205
287
  });
206
288
  }
207
289
  await this.starting;
290
+ this.ready = true;
208
291
  this.arm();
209
292
  }
210
293
  stop() {
211
294
  if (this.timer)
212
295
  clearTimeout(this.timer);
213
296
  this.timer = undefined;
214
- const server = this.server;
297
+ if (this.owned) {
298
+ this.server?.closeAllConnections?.();
299
+ this.server?.close();
300
+ }
215
301
  this.server = undefined;
302
+ this.owned = false;
303
+ this.ready = false;
216
304
  this.port = 0;
217
- server?.closeAllConnections?.();
218
- server?.close();
219
305
  for (const file of this.previews.values())
220
- fs2.rmSync(file, { force: true });
306
+ fs3.rmSync(file, { force: true });
221
307
  this.previews.clear();
222
308
  }
223
- async listen() {
309
+ async listen(opts) {
310
+ const port = this.configuredPort || derivePort(opts.directory);
224
311
  const server = http.createServer((req, res) => void this.handle(req, res));
225
312
  server.unref();
226
- await new Promise((resolve, reject) => {
227
- server.once("error", reject);
228
- server.listen(0, "127.0.0.1", resolve);
313
+ const failure = await new Promise((resolve2) => {
314
+ server.once("error", resolve2);
315
+ server.listen(port, "127.0.0.1", () => resolve2(undefined));
229
316
  });
230
- const address = server.address();
231
- const port = typeof address === "object" && address ? address.port : 0;
232
- if (!port) {
233
- server.close();
234
- throw new Error("opencode-image: image server did not bind a port");
317
+ if (!failure) {
318
+ this.server = server;
319
+ this.port = port;
320
+ this.owned = true;
321
+ return;
322
+ }
323
+ server.close();
324
+ if (failure.code === "EADDRINUSE" && await this.servedByUs(port, opts.outDir)) {
325
+ this.port = port;
326
+ this.owned = false;
327
+ return;
328
+ }
329
+ throw new Error(`opencode-image: cannot listen on 127.0.0.1:${port} (${String(failure)}). ` + `Set the serverPort option to a free port, or stop whatever is using it.`);
330
+ }
331
+ async servedByUs(port, outDir) {
332
+ try {
333
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
334
+ if (!res.ok)
335
+ return false;
336
+ const body = await res.json();
337
+ return body.app === APP_NAME && body.root === path2.resolve(outDir);
338
+ } catch {
339
+ return false;
235
340
  }
236
- this.server = server;
237
- this.port = port;
238
341
  }
239
342
  arm() {
240
343
  if (this.timer)
@@ -247,7 +350,7 @@ class ImageServer {
247
350
  preview(source, max) {
248
351
  const key = `${source}@${max}`;
249
352
  const cached = this.previews.get(key);
250
- if (cached && fs2.existsSync(cached))
353
+ if (cached && fs3.existsSync(cached))
251
354
  return Promise.resolve(cached);
252
355
  const destination = path2.join(os.tmpdir(), `opencode-image-${process.pid}-${randomUUID()}.png`);
253
356
  return renderPreview(source, destination, max).then((file) => {
@@ -256,24 +359,60 @@ class ImageServer {
256
359
  return file;
257
360
  });
258
361
  }
362
+ resolve(id) {
363
+ const registered = this.extra.get(id);
364
+ if (registered)
365
+ return fs3.existsSync(registered) ? registered : undefined;
366
+ const root = this.root;
367
+ if (!root)
368
+ return;
369
+ let relative2;
370
+ try {
371
+ relative2 = Buffer.from(id, "base64url").toString("utf8");
372
+ } catch {
373
+ return;
374
+ }
375
+ if (!relative2 || relative2.includes("\x00") || path2.isAbsolute(relative2))
376
+ return;
377
+ const target = path2.resolve(root, relative2);
378
+ if (target === root || !target.startsWith(root + path2.sep))
379
+ return;
380
+ try {
381
+ if (!fs3.statSync(target).isFile())
382
+ return;
383
+ const real = fs3.realpathSync(target);
384
+ const realRoot = fs3.realpathSync(root);
385
+ if (real !== realRoot && !real.startsWith(realRoot + path2.sep))
386
+ return;
387
+ } catch {
388
+ return;
389
+ }
390
+ return target;
391
+ }
259
392
  handle(req, res) {
260
393
  this.arm();
261
394
  const parsed = new URL(req.url ?? "/", "http://127.0.0.1");
262
- const id = /^\/img\/([0-9a-f]{8,64})$/.exec(parsed.pathname)?.[1];
263
- const entry = id ? this.registry.get(id) : undefined;
264
- if (!entry || !fs2.existsSync(entry.file)) {
395
+ if (parsed.pathname === "/health") {
396
+ const body = JSON.stringify({ app: APP_NAME, root: this.root ?? "" });
397
+ res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
398
+ res.end(body);
399
+ return;
400
+ }
401
+ const id = ID_PATTERN.exec(parsed.pathname)?.[1];
402
+ const file = id ? this.resolve(id) : undefined;
403
+ if (!file) {
265
404
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
266
405
  res.end("not found");
267
406
  return;
268
407
  }
269
- this.send(entry, Number(parsed.searchParams.get("w")), res);
408
+ this.send(file, Number(parsed.searchParams.get("w")), res);
270
409
  }
271
- async send(entry, width, res) {
410
+ async send(file, width, res) {
272
411
  try {
273
- const target = width > 0 ? await this.preview(entry.file, Math.round(width)) : entry.file;
274
- const body = fs2.readFileSync(target);
412
+ const target = width > 0 ? await this.preview(file, Math.round(width)) : file;
413
+ const body = fs3.readFileSync(target);
275
414
  res.writeHead(200, {
276
- "content-type": entry.mime,
415
+ "content-type": MIME_BY_EXT[path2.extname(target).toLowerCase()] ?? "application/octet-stream",
277
416
  "content-length": body.length,
278
417
  "cache-control": "private, max-age=86400"
279
418
  });
@@ -287,8 +426,13 @@ class ImageServer {
287
426
 
288
427
  // src/index.ts
289
428
  var z = tool.schema;
429
+ function resolveOutDir(directory, outDir) {
430
+ if (!outDir)
431
+ return path3.join(directory, ".opencode-images");
432
+ return path3.isAbsolute(outDir) ? outDir : path3.join(directory, outDir);
433
+ }
290
434
  function resolveDestination(filename, outDir, ctx) {
291
- const dir = outDir ? path3.isAbsolute(outDir) ? outDir : path3.join(ctx.directory, outDir) : path3.join(ctx.directory, ".opencode-images");
435
+ const dir = resolveOutDir(ctx.directory, outDir);
292
436
  if (filename) {
293
437
  const target = path3.isAbsolute(filename) ? filename : path3.join(dir, filename);
294
438
  return `${target.replace(/\.[A-Za-z0-9]+$/, "")}.png`;
@@ -303,31 +447,44 @@ function asAttachment(file) {
303
447
  type: "file",
304
448
  mime: "image/png",
305
449
  filename: path3.basename(file),
306
- url: `data:image/png;base64,${fs3.readFileSync(file).toString("base64")}`
450
+ url: `data:image/png;base64,${fs4.readFileSync(file).toString("base64")}`
307
451
  }
308
452
  ];
309
453
  }
310
454
  var OpenCodeImagePlugin = async (_input, rawOptions) => {
311
455
  const options = resolveOptions(rawOptions);
312
- const server = new ImageServer(options.serverIdleMs);
313
- const result = async (label, prompt, file, usage) => {
456
+ const server = new ImageServer(options.serverIdleMs, options.serverPort);
457
+ const selfVersion = readOwnVersion();
458
+ let updateHint;
459
+ let updateChecked = false;
460
+ const upgradeNotice = async () => {
461
+ if (!options.updateCheck || updateChecked || !selfVersion)
462
+ return updateHint;
463
+ updateChecked = true;
464
+ updateHint = await checkForUpdate(selfVersion);
465
+ return updateHint;
466
+ };
467
+ const result = async (label, prompt, file, ctx, usage) => {
314
468
  const { width, height } = await imageDimensions(file);
315
- const id = server.register(file);
316
469
  let display;
317
470
  try {
318
- await server.start();
319
- display = `![${prompt.slice(0, 60)}](${server.url(id, options.displayMaxSize)})`;
471
+ await server.start({ directory: ctx.directory, outDir: resolveOutDir(ctx.directory, options.outDir) });
472
+ display = `![${prompt.slice(0, 60)}](${server.url(file, options.displayMaxSize)})`;
320
473
  } catch (error) {
321
474
  display = `[opencode-image: local image server unavailable, cannot render inline: ${String(error)}]`;
322
475
  }
476
+ const notice = await upgradeNotice();
477
+ const output = [
478
+ display,
479
+ `${label} saved to ${file} (${width}x${height})`,
480
+ `Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
481
+ `IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
482
+ ];
483
+ if (notice)
484
+ output.push(notice);
323
485
  return {
324
486
  title: `${label}: ${prompt.slice(0, 60)}`,
325
- output: [
326
- display,
327
- `${label} saved to ${file} (${width}x${height})`,
328
- `Refine it by calling image_edit with image="${file}" and a prompt describing the change.`,
329
- `IMPORTANT: copy the Markdown image line above verbatim into your reply text, otherwise the user cannot see the picture.`
330
- ].join(`
487
+ output: output.join(`
331
488
  `),
332
489
  metadata: { file, prompt, usage },
333
490
  attachments: asAttachment(file)
@@ -341,14 +498,14 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
341
498
  const raw = await fetchImageBytes(remote, signal);
342
499
  const destination = resolveDestination(args.filename, options.outDir, ctx);
343
500
  await persistImage(raw, destination, options.maxSize);
344
- return result("Image generated", args.prompt, destination, remote.usage);
501
+ return result("Image generated", args.prompt, destination, ctx, remote.usage);
345
502
  } finally {
346
503
  clear();
347
504
  }
348
505
  };
349
506
  const edit = async (args, ctx) => {
350
507
  const source = path3.isAbsolute(args.image) ? args.image : path3.resolve(ctx.directory, args.image);
351
- if (!fs3.existsSync(source))
508
+ if (!fs4.existsSync(source))
352
509
  throw new Error(`image_edit: source image not found: ${source}`);
353
510
  ctx.metadata({ title: `Editing image: ${args.prompt.slice(0, 60)}` });
354
511
  const { signal, clear } = withTimeout(ctx.abort, options.timeoutMs);
@@ -358,7 +515,7 @@ var OpenCodeImagePlugin = async (_input, rawOptions) => {
358
515
  const raw = await fetchImageBytes(remote, signal);
359
516
  const destination = resolveDestination(args.filename, options.outDir, ctx);
360
517
  await persistImage(raw, destination, options.maxSize);
361
- return result("Image edited", args.prompt, destination, remote.usage);
518
+ return result("Image edited", args.prompt, destination, ctx, remote.usage);
362
519
  } finally {
363
520
  clear();
364
521
  }
package/dist/options.d.ts CHANGED
@@ -12,8 +12,12 @@ export type PluginOptions = {
12
12
  maxSize?: number;
13
13
  /** 在对话里展示的图片最长边(像素);只影响展示,不影响落盘原图 */
14
14
  displayMaxSize?: number;
15
+ /** 本地图片服务端口;默认 0 = 由项目目录派生(同一项目恒定),需要避开占用时手动指定 */
16
+ serverPort?: number;
15
17
  /** 本地图片服务的空闲关闭时间(毫秒),0 表示不主动关闭 */
16
18
  serverIdleMs?: number;
19
+ /** 首次出图时查一次 npm 最新版,落后则在工具输出末尾附升级提示。默认开启 */
20
+ updateCheck?: boolean;
17
21
  /** 单次图像请求超时(毫秒) */
18
22
  timeoutMs?: number;
19
23
  };
@@ -21,6 +25,8 @@ export declare const DEFAULT_BASE_URL = "https://pi-ai.roiscloud.com/v1";
21
25
  export declare const DEFAULT_MODEL = "gpt-image-2";
22
26
  export declare const DEFAULT_MAX_SIZE = 1024;
23
27
  export declare const DEFAULT_DISPLAY_MAX_SIZE = 448;
28
+ /** 0 表示由项目目录派生端口,保证同一项目跨重启链接不变 */
29
+ export declare const DEFAULT_SERVER_PORT = 0;
24
30
  export declare const DEFAULT_SERVER_IDLE_MS = 0;
25
31
  export declare const DEFAULT_TIMEOUT_MS = 600000;
26
32
  export type ResolvedOptions = {
@@ -30,7 +36,9 @@ export type ResolvedOptions = {
30
36
  outDir?: string;
31
37
  maxSize: number;
32
38
  displayMaxSize: number;
39
+ serverPort: number;
33
40
  serverIdleMs: number;
41
+ updateCheck: boolean;
34
42
  timeoutMs: number;
35
43
  };
36
44
  /** 合并默认值、环境变量与用户配置;缺少 apiKey 时直接抛错 */
package/dist/server.d.ts CHANGED
@@ -1,35 +1,55 @@
1
+ /**
2
+ * 端口由项目目录派生:同一个项目永远是同一个端口,所以历史消息里的链接跨重启仍然有效;
3
+ * 不同项目基本不会撞到同一个端口。需要覆盖时用 serverPort 配置项。
4
+ */
5
+ export declare function derivePort(key: string): number;
1
6
  /**
2
7
  * 本地图片服务:把生成结果通过 http://127.0.0.1:<port>/img/<id>?w=<width> 暴露给客户端渲染。
3
8
  *
4
9
  * 客户端限制说明:图片只有出现在助手回复的 Markdown 正文里才会渲染,
5
10
  * 而正文里的 file:// 会被 Chromium 拦截,所以必须走本地 HTTP。
6
11
  *
7
- * 安全边界:只服务显式 register() 过的文件,id 为随机值,URL 里没有真实路径,
8
- * 因此无法通过路径穿越读取任意文件。
12
+ * 稳定性:id 是「文件相对 outDir 的路径」做 base64url 编码,可逆且稳定。
13
+ * 因此服务不需要任何内存登记表——同一个文件在任何一次启动里都能解析出同一个 id,
14
+ * 重启后历史消息里的旧链接依然可访问。
15
+ *
16
+ * 安全边界:请求里的 id 只允许解析成 outDir 内部的普通文件,
17
+ * 解析后会做 containment + realpath 双重校验,防路径穿越与符号链接逃逸。
9
18
  */
10
19
  export declare class ImageServer {
11
20
  private readonly idleMs;
21
+ private readonly configuredPort;
12
22
  private server?;
13
23
  private port;
14
- private registry;
24
+ private root?;
25
+ private ready;
26
+ private owned;
27
+ /** outDir 之外的文件退回进程内随机 id(仅本次运行有效) */
28
+ private extra;
15
29
  private previews;
16
30
  private timer?;
17
31
  private starting?;
18
- constructor(idleMs: number);
32
+ constructor(idleMs: number, configuredPort: number);
19
33
  get baseURL(): string;
20
- /** 注册文件并返回可访问的 id */
21
- register(file: string): string;
22
- url(id: string, width: number): string;
23
- /** 懒启动:首次需要时监听随机端口;unref 后不阻塞宿主进程退出 */
24
- start(): Promise<void>;
34
+ /** 生成稳定 id;outDir 内的文件跨重启一致 */
35
+ idFor(file: string): string;
36
+ url(file: string, width: number): string;
37
+ /** 懒启动:首次需要时按派生端口监听;unref 后不阻塞宿主进程退出 */
38
+ start(opts: {
39
+ directory: string;
40
+ outDir: string;
41
+ }): Promise<void>;
25
42
  /**
26
43
  * 关闭服务并清理预览缓存。
27
44
  * 默认不调用:历史消息里的图片会重新请求,提前关闭会 404。
28
45
  */
29
46
  stop(): void;
30
47
  private listen;
48
+ private servedByUs;
31
49
  private arm;
32
50
  private preview;
51
+ /** id -> 文件;只接受 outDir 内、真实存在、且 realpath 不逃逸的普通文件 */
52
+ private resolve;
33
53
  private handle;
34
54
  private send;
35
55
  }
@@ -0,0 +1,9 @@
1
+ export declare const REGISTRY = "https://registry.npmjs.org";
2
+ export declare const PACKAGE = "opencode-image";
3
+ /** 只做 x.y.z + 预发布标识的比较,够用且不引依赖 */
4
+ export declare function isNewer(candidate: string, current: string): boolean;
5
+ /** 从包自身的 package.json 读版本;dist/index.js 与 src/index.ts 都能解析到 */
6
+ export declare function readOwnVersion(): string | undefined;
7
+ export declare function upgradeHint(latest: string, current: string): string;
8
+ /** 查 npm 最新版;落后则返回提示行,否则返回 undefined。失败一律当作"没查到"。 */
9
+ export declare function checkForUpdate(current: string, timeoutMs?: number): Promise<string | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-image",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Image generation and editing tools for opencode, with inline rendering in the conversation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "files": [
35
35
  "dist",
36
+ "bin",
36
37
  "README.md",
37
38
  "LICENSE"
38
39
  ],
@@ -42,7 +43,8 @@
42
43
  "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
43
44
  "typecheck": "tsc --noEmit",
44
45
  "test": "bun test",
45
- "prepublishOnly": "bun run build && bun test"
46
+ "prepublishOnly": "bun run build && bun test",
47
+ "release": "npm version patch && npm publish && rm -rf ~/.cache/opencode/packages/opencode-image*"
46
48
  },
47
49
  "engines": {
48
50
  "node": ">=18"
@@ -54,5 +56,8 @@
54
56
  "devDependencies": {
55
57
  "@types/node": "^24.12.2",
56
58
  "typescript": "^5.8.2"
59
+ },
60
+ "bin": {
61
+ "opencode-image": "./bin/cli.js"
57
62
  }
58
63
  }