dsh-packycode-api 1.2.3 → 1.2.5

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.5
4
+
5
+ - Fixed generated-image clicks doing nothing in DSH desktop. The optional `nativeImageViewer` service is absent in the current desktop build, and its popup policy denies the previous `window.open(blob:...)` fallback. PackyCode now owns an in-app large-image overlay shared by main-chat and tool-detail results, with Escape/close, focus restoration and viewport fitting.
6
+ - Added **Download original** immediately to the left of the overlay's Close button. It opens a save-location picker and transfers the preserved PNG rather than DSH's normalized chat preview. Desktop builds without File System Access use Electron's normal download/save-dialog routine without a preset destination. Cancellation does not become an error or start another download.
7
+ - Original reads resolve native/PTC/Claude image evidence from a read-only session observation, accept no client-supplied paths and stay inside the plugin's original-image directory. The bounded chunk transfer checks the complete SHA-256 before writing; closing the viewer cancels pending reads, and missing/corrupt originals remain retryable without substituting the preview.
8
+ - Validation, 2026-10-15: 411 offline tests, both bundles and the tarball; real-browser verification of the built client confirmed large-image opening, uncropped fitting, Download original left of Close, Escape and focus restoration. Save-picker invocation, cancellation, exact original-byte writes and interrupted/corrupt transfers are covered by automated regressions. Native save-dialog interaction remains a manual DSH check because the current computer-use interface cannot control the Codex app's native chooser. No paid image request or DSH restart was performed.
9
+
10
+ ## 1.2.4
11
+
12
+ - Added the current image key's remaining quota immediately after the Create image shortcut. Hovering or focusing it shows the key label, account name, remaining/used/total quota and update time. It shares the shortcut's visibility, refreshes every minute while mounted and after image-setting/key changes, and marks stale or failed reads without confusing them with zero credit. Real React lifecycle regressions cover provider switches, image availability, disabled/missing keys and late responses.
13
+ - Removed the redundant "Separate image key" badge from the image settings header.
14
+ - Image/account quota totals now use total remaining plus total used, as requested, rather than depending on the optional periodic `quota_limit` field. Non-resetting image keys therefore show their total even when no period cap is set. Per-key quota figures and progress bars use the same calculation. Missing balance components remain unknown, and unlimited keys retain their unlimited display.
15
+ - Validation, 2026-10-15: 397 tests, both rebuilt bundles and the versioned tarball. Browser verification with real React and DSH 0.2.0-rc.2 composer CSS confirmed the balance sits to the right of Create image, the hover/focus details show the current key's quota, and provider switches, the image setting and the host's hidden toolbar hide both controls together. A read-only live image-key quota projection also confirmed the no-period-cap shape; no image generation was needed for these UI changes.
16
+
3
17
  ## 1.2.3
4
18
 
5
19
  - Fixed ordinary reference-only image edits failing with `PackyCode image mask must be a PNG smaller than 4 MB` when a model also fills the optional mask field with the same JPEG/WebP reference. Native `maskImage` and CLI `maskId` now explicitly accept `null`; the field descriptions distinguish sketch refinement from using an actual mask. Only an identical reference already resolved by the host as non-PNG is treated as a redundant non-mask. Reference bytes, edit endpoint, model and quality stay intact; real PNG masks and all distinct mask references still undergo format, size and dimension checks. Invalid mask-format errors now explain how to request editing without a mask. Added native and authenticated MCP regressions without paid calls.
package/README.en.md CHANGED
@@ -51,7 +51,7 @@ Open the desktop application's **Plugins** page, click **Add plugin**, and enter
51
51
  dsh-packycode-api
52
52
  ```
53
53
 
54
- Choose the **official** install source, then click **Install**. To pin a version, enter `dsh-packycode-api@1.2.3`.
54
+ Choose the **official** install source, then click **Install**. To pin a version, enter `dsh-packycode-api@1.2.5`.
55
55
 
56
56
  A **first install requires no DSH restart**: open **Settings → PackyCode** and add a key immediately. When updating an already loaded older version, follow the restart guidance in the **Plugin version** card.
57
57
 
@@ -70,7 +70,7 @@ Choose a model from the `PackyCode` group. `deepseek-flash`, `gpt-6.1-sol`, `gpt
70
70
 
71
71
  The **Plugins** page shows the installed state and version, and is also where you enable, disable, or uninstall.
72
72
 
73
- **For non-desktop profiles only:** `dsh plugin --profile <name> add dsh-packycode-api@1.2.3`. Do not use it for `desktop` — the CLI refuses it (`profile "desktop" is managed exclusively by the Electron application`).
73
+ **For non-desktop profiles only:** `dsh plugin --profile <name> add dsh-packycode-api@1.2.5`. Do not use it for `desktop` — the CLI refuses it (`profile "desktop" is managed exclusively by the Electron application`).
74
74
 
75
75
  For a local checkout, keep the `link:` prefix: `dsh plugin --profile <name> add link:/path/to/dsh-packycode-api`. **Do not pass a bare path or `file:`** — that install copies the whole checkout, `node_modules` included, into the profile, where it then shadows the packages the DSH installation itself provides and the plugin looks uninstalled; see the FAQ. If you already installed that way, uninstall first and reinstall as above.
76
76
 
@@ -156,6 +156,8 @@ Tool callers can set `model` and `quality` to `settings` to follow the saved pre
156
156
 
157
157
  Image 2 accepts `auto / low / medium / high`; both 2.5 models also accept `xhigh / max`. These are Images API `quality` values, separate from text reasoning effort. There is no automatic model substitution, quality downgrade or paid retry. Each call returns one PNG; original bytes are saved under `~/.dsh/packycode-api/images/` (following `DSH_HOME` when set), alongside the chat preview. References and optional PNG masks must belong to the current session; file paths cannot replace attachment references.
158
158
 
159
+ Click a generated image to open the in-app large preview. **Download original**, immediately left of Close, opens a save-location picker for the complete preserved PNG. Cancelling is harmless; a moved or deleted original reports a retryable error instead of downloading the chat thumbnail. Saved conversations also support this as long as their original file is still on this host.
160
+
159
161
  After adding the image key, `node scripts/image-check.mjs --all --edit` checks all three engines and one edit. Use `--model <id> --edit` for generation and editing on a specific engine, adding `--quality xhigh` or `--quality max` to check a quality. This consumes image quota, saves outputs under `.artifacts/`, and prints only a masked key tail plus the gateway-reported model/quality.
160
162
 
161
163
  Live checks on 2026-10-15 passed generation and editing at `low` on all three engines. Sunburst also passed both operations at `xhigh` and `max`, with matching quality values in the responses. All 10 requests succeeded without retries and returned 1024×1024 PNGs; visual inspection confirmed the requested color edits with composition preserved. Generate/edit times were about 43/48 seconds at xhigh and 84/91 seconds at max. These are built-client gateway checks; no desktop conversation was created to verify the full chat flow in this run. See the [protocol research and gateway results](docs/research/image-api.md) for scope and evidence.
package/README.md CHANGED
@@ -51,7 +51,7 @@
51
51
  dsh-packycode-api
52
52
  ```
53
53
 
54
- 在**安装源**中选择**官方源**,然后点击**安装**。需要固定版本时填入 `dsh-packycode-api@1.2.3`。
54
+ 在**安装源**中选择**官方源**,然后点击**安装**。需要固定版本时填入 `dsh-packycode-api@1.2.5`。
55
55
 
56
56
  首次安装后**无需重启 DSH**,可直接打开**设置 → PackyCode** 添加 Key。更新已加载的旧版时,按「插件版本」卡片的重启提示操作。
57
57
 
@@ -70,7 +70,7 @@ dsh-packycode-api
70
70
 
71
71
  桌面应用的**插件**页可以看到已安装状态与版本,也可以在那里启用、停用或卸载。
72
72
 
73
- **仅用于非 desktop profile:** `dsh plugin --profile <name> add dsh-packycode-api@1.2.3`。不要对 `desktop` 使用它 —— CLI 会直接拒绝(`profile "desktop" is managed exclusively by the Electron application`)。
73
+ **仅用于非 desktop profile:** `dsh plugin --profile <name> add dsh-packycode-api@1.2.5`。不要对 `desktop` 使用它 —— CLI 会直接拒绝(`profile "desktop" is managed exclusively by the Electron application`)。
74
74
 
75
75
  本地开发要加 `link:` 前缀:`dsh plugin --profile <name> add link:/path/to/dsh-packycode-api`。**不要直接写路径(或 `file:`)** —— 那样的安装会把整个仓库目录(连同 `node_modules`)复制进 profile,之后它会在 profile 内部挡住 DSH 自带的包,插件看起来像没装,见「常见问题」。已经那样装过的话,先卸载再按上面的方式重装。
76
76
 
@@ -156,6 +156,8 @@ Claude Code CLI → 本机临时 MCP web_search → 插件选定的 DeepSeek/GPT
156
156
 
157
157
  `gpt-image-2` 的质量为 `auto / low / medium / high`;两款 2.5 另支持 `xhigh / max`。质量按 Images API 的 `quality` 原样发送,不作为文字推理强度;不会自动换型号、降档或重试付费请求。输出先采用 PNG,每次一张,聊天预览之外的原图保存在 `~/.dsh/packycode-api/images/`(设置了 `DSH_HOME` 时跟随该目录)。参考图与可选 PNG mask 必须来自当前会话;不能用路径替代附件引用。
158
158
 
159
+ 点击生成图可在应用内查看大图,右上角关闭按钮左侧的「下载原图」可选择保存位置,另存本机保留的完整 PNG。取消保存不会报错;若原图已被移走或删除,会提示失败,不会改为下载聊天缩略图。已有会话也可使用此功能,前提是记录对应的本机原图仍存在。
160
+
159
161
  用户填入图片 Key 后,可运行 `node scripts/image-check.mjs --all --edit` 验证三个型号与一次编辑;用 `--model <型号> --edit` 单独验证某个型号的生图和编辑,搭配 `--quality xhigh` 或 `--quality max` 验证质量。测试会消耗图片额度,输出写入 `.artifacts/`,日志只显示 Key 的掩码尾号,并记录网关回报的型号/质量。
160
162
 
161
163
  2026-10-15 实测:三个型号均通过 `low` 生图与编辑;Sunburst 额外通过 `xhigh`、`max` 生图与编辑,两档响应的 quality 均与请求一致。合计 10 次成功请求,无重试,全部返回 1024×1024 PNG;编辑正确改色并保留构图。xhigh 生成/编辑约 43/48 秒,max 约 84/91 秒。这是已构建图片 client 的网关验证;本轮未新建 DSH 对话验证完整聊天流程。完整范围和记录见[协议核查与网关实测](docs/research/image-api.md)。
package/lib/client.js CHANGED
@@ -40,9 +40,11 @@ window.__ModuleLoader__.load({
40
40
  intro: "填入 PackyCode(PackyAPI)的 API Key 即可使用内置模型路由。多个 Key 按顺序使用:额度用尽或失效时自动切到下一个;某个 Key 的分组用不了所选模型时,自动改用分组合适的 Key。",
41
41
  accountsTitle: "账户与 Key",
42
42
  imagesTitle: "图片生成与编辑",
43
- imagesSeparateKey: "独立图片 Key",
44
43
  imagesBalanceLoading: "正在读取图片 Key 余额…",
45
44
  imagesBalanceFailed: "图片余额读取失败",
45
+ imagesComposerLabel: "图片剩余额度",
46
+ imagesBalanceKey: "当前图片 Key",
47
+ imagesBalanceReadAt: "更新于 {time}",
46
48
  imagesBalanceHint: "仅汇总图片 Key 的额度,每分钟自动更新;也可手动刷新。与对话 Key 的余额分开统计。",
47
49
  imagesIntro: "使用独立图片 Key 调用 PackyCode 图片接口。生成新图或上传图片后描述修改要求;图片模型独立于对话模型。原始 PNG 保存在本机,聊天中显示图片预览。",
48
50
  imagesEnabled: "图片生成与编辑",
@@ -72,6 +74,11 @@ window.__ModuleLoader__.load({
72
74
  imagesOpen: "打开图片",
73
75
  imagesRetryPreview: "预览加载失败,点击重试",
74
76
  imagesLoadingPreview: "正在加载图片…",
77
+ imagesViewerTitle: "图片预览",
78
+ imagesClosePreview: "关闭图片预览",
79
+ imagesDownloadOriginal: "下载原图",
80
+ imagesDownloadPreparing: "正在准备原图…",
81
+ imagesDownloadFailed: "原图保存失败。请确认本机原图仍存在,然后点击“下载原图”重试。",
75
82
  refresh: "刷新",
76
83
  refreshing: "刷新中…",
77
84
  totalRemaining: "合计剩余",
@@ -215,9 +222,11 @@ window.__ModuleLoader__.load({
215
222
  intro: "Paste a PackyCode (PackyAPI) API key to use the built-in model route. Keys are used in order: an exhausted or rejected key hands over to the next one, and a key whose groups cannot reach the selected model is passed over for one whose groups can.",
216
223
  accountsTitle: "Accounts and keys",
217
224
  imagesTitle: "Image generation and editing",
218
- imagesSeparateKey: "Separate image key",
219
225
  imagesBalanceLoading: "Reading image key balances…",
220
226
  imagesBalanceFailed: "Could not read image balances",
227
+ imagesComposerLabel: "Image balance",
228
+ imagesBalanceKey: "Current image key",
229
+ imagesBalanceReadAt: "Updated at {time}",
221
230
  imagesBalanceHint: "Only image key quotas are counted here. Updates every minute, or use Refresh. Conversation key balances are separate.",
222
231
  imagesIntro: "Use a dedicated image key with PackyCode’s Images API. Describe a new image or attach images with editing instructions. The image engine is independent of the conversation model. Original PNGs are saved locally and previews appear in chat.",
223
232
  imagesEnabled: "Generate and edit images",
@@ -247,6 +256,11 @@ window.__ModuleLoader__.load({
247
256
  imagesOpen: "Open image",
248
257
  imagesRetryPreview: "Preview failed; click to retry",
249
258
  imagesLoadingPreview: "Loading image…",
259
+ imagesViewerTitle: "Image preview",
260
+ imagesClosePreview: "Close image preview",
261
+ imagesDownloadOriginal: "Download original",
262
+ imagesDownloadPreparing: "Preparing original…",
263
+ imagesDownloadFailed: "Could not save the original. Check that the original file still exists on this device, then try Download original again.",
250
264
  refresh: "Refresh",
251
265
  refreshing: "Refreshing…",
252
266
  totalRemaining: "Total remaining",
@@ -409,9 +423,26 @@ window.__ModuleLoader__.load({
409
423
  .pkc-input:disabled{opacity:.5}
410
424
  .pkc-image-choice{flex:none;width:100%;max-width:300px;cursor:pointer}
411
425
  .pkc-image-shortcut{display:inline-flex;align-items:center;gap:8px}
426
+ .pkc-image-balance{position:relative;display:inline-flex;align-items:center}
427
+ .pkc-image-balance-value{cursor:help;border-radius:var(--dsw-radius-xs,4px)}
428
+ .pkc-image-balance-value:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
429
+ .pkc-image-balance-tooltip{position:absolute;bottom:calc(100% + 8px);left:0;z-index:100;box-sizing:border-box;width:max-content;max-width:min(320px,calc(100vw - 32px));padding:10px 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-shadow-lv1);font-size:12px;line-height:20px;white-space:pre-line;overflow-wrap:anywhere;visibility:hidden;opacity:0;pointer-events:none}
430
+ .pkc-image-balance:hover>.pkc-image-balance-tooltip,.pkc-image-balance:focus-within>.pkc-image-balance-tooltip{visibility:visible;opacity:1}
412
431
  .pkc-image-result,.pkc-image-chat{display:flex;flex-direction:column;gap:10px;padding:8px 0}
413
432
  .pkc-generated-image{display:grid;place-items:center;align-self:flex-start;max-width:100%;width:320px;min-height:80px;padding:0;overflow:hidden;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:zoom-in}
414
433
  .pkc-generated-image img{display:block;max-width:100%;max-height:480px;object-fit:contain}
434
+ .pkc-image-backdrop{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:20px;background:rgba(0,0,0,.84);box-sizing:border-box}
435
+ .pkc-image-viewer{display:flex;flex-direction:column;box-sizing:border-box;width:100%;height:100%;min-height:0;color:#f5f5f5;font:13px/1.5 var(--ds-font-family,system-ui,sans-serif)}
436
+ .pkc-image-viewer-toolbar{display:flex;align-items:center;gap:8px;flex:0 0 auto;min-height:40px;margin-bottom:12px}
437
+ .pkc-image-viewer-name{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:rgba(255,255,255,.75)}
438
+ .pkc-image-viewer-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;box-sizing:border-box;flex:none;height:36px;padding:0 12px;border:1px solid rgba(255,255,255,.18);border-radius:10px;background:rgba(255,255,255,.08);color:#f5f5f5;font:inherit;cursor:pointer;white-space:nowrap}
439
+ .pkc-image-viewer-button:hover:not(:disabled){background:rgba(255,255,255,.18)}
440
+ .pkc-image-viewer-button:focus-visible{outline:2px solid #a7c3ff;outline-offset:2px}
441
+ .pkc-image-viewer-button:disabled{opacity:.55;cursor:wait}
442
+ .pkc-image-viewer-close{width:36px;padding:0;border-color:transparent;background:transparent}
443
+ .pkc-image-viewer-canvas{display:flex;align-items:center;justify-content:center;flex:1;min-height:0;overflow:hidden}
444
+ .pkc-image-viewer-canvas img{display:block;width:100%;height:100%;min-width:0;min-height:0;object-fit:contain}
445
+ .pkc-image-viewer-error{align-self:center;flex:none;margin:12px 0 0;padding:8px 12px;border-radius:8px;background:#4d2222;color:#ffd3d3;text-align:center}
415
446
  .pkc-form{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
416
447
 
417
448
  .pkc-chip{flex:none;padding:0 8px;border-radius:999px;font-size:11px;font-weight:500;line-height:18px;white-space:nowrap}
@@ -576,11 +607,19 @@ window.__ModuleLoader__.load({
576
607
  * @module dsh-packycode-api/totals
577
608
  */
578
609
  const amount = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
610
+ /** Displayed key quota is remaining plus used, independent of a period cap. */
611
+ function quotaTotalUsd(usage) {
612
+ if (usage?.unlimitedQuota === true) return void 0;
613
+ const remaining = amount(usage?.availableUsd);
614
+ const used = amount(usage?.usedUsd);
615
+ if (remaining === void 0 || used === void 0) return void 0;
616
+ return amount(Number((remaining + used).toFixed(6)));
617
+ }
579
618
  /**
580
619
  * Add up the balance rows the host returned.
581
620
  * @param rows - the `keys` array of a balance projection.
582
621
  * @returns summed amounts plus the conditions that make a sum incomplete:
583
- * keys whose read failed, and keys with no quota limit at all.
622
+ * keys whose read failed, and keys with unlimited quota.
584
623
  */
585
624
  function sumBalances(rows) {
586
625
  const total = {
@@ -606,11 +645,10 @@ window.__ModuleLoader__.load({
606
645
  }
607
646
  const remaining = amount(usage.availableUsd);
608
647
  const used = amount(usage.usedUsd);
609
- const limit = amount(usage.limitUsd);
610
648
  if (remaining !== void 0) total.remainingUsd += remaining;
611
649
  if (used !== void 0) total.usedUsd += used;
612
- if (limit !== void 0) total.limitUsd += limit;
613
650
  }
651
+ total.limitUsd = Number((total.remainingUsd + total.usedUsd).toFixed(6));
614
652
  return total;
615
653
  }
616
654
  //#endregion
@@ -647,13 +685,14 @@ window.__ModuleLoader__.load({
647
685
  /** The same quota presentation for conversation and image credential records. */
648
686
  function KeyBalance({ row, t }) {
649
687
  const usage = row?.usage;
688
+ const totalUsd = quotaTotalUsd(usage);
650
689
  const figures = [
651
690
  usage?.unlimitedQuota === true ? [t("keyRemaining"), t("keyUnlimited")] : usage?.availableUsd === void 0 ? void 0 : [t("keyRemaining"), formatUsd(usage.availableUsd)],
652
691
  usage?.usedUsd === void 0 ? void 0 : [t("keyUsed"), formatUsd(usage.usedUsd)],
653
- usage?.limitUsd === void 0 ? void 0 : [t("accountLimit"), formatUsd(usage.limitUsd)]
692
+ totalUsd === void 0 ? void 0 : [t("accountLimit"), formatUsd(totalUsd)]
654
693
  ].filter(Boolean);
655
694
  const meta = [usage?.name === void 0 ? t("keyNameUnknown") : `${t("keyName")} ${usage.name}`, RESET_KEYS[usage?.resetPeriod] === void 0 ? void 0 : t(RESET_KEYS[usage.resetPeriod])].filter(Boolean).join(" · ");
656
- const share = usage?.unlimitedQuota !== true && typeof usage?.limitUsd === "number" && usage.limitUsd > 0 && typeof usage.availableUsd === "number" ? Math.max(0, Math.min(1, usage.availableUsd / usage.limitUsd)) : void 0;
695
+ const share = usage?.unlimitedQuota !== true && totalUsd > 0 && typeof usage.availableUsd === "number" ? Math.max(0, Math.min(1, usage.availableUsd / totalUsd)) : void 0;
657
696
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
658
697
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
659
698
  className: "pkc-meta",
@@ -716,7 +755,7 @@ window.__ModuleLoader__.load({
716
755
  children: t("totalLimit")
717
756
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
718
757
  className: "pkc-stat-value",
719
- children: totals.unlimited > 0 ? t("keyUnlimited") : known("limitUsd") ? formatUsd(totals.limitUsd) : "—"
758
+ children: totals.unlimited > 0 ? t("keyUnlimited") : known("availableUsd") && known("usedUsd") ? formatUsd(totals.limitUsd) : "—"
720
759
  })]
721
760
  })
722
761
  ]
@@ -726,6 +765,357 @@ window.__ModuleLoader__.load({
726
765
  })] });
727
766
  }
728
767
  //#endregion
768
+ //#region src/client-image-balance.jsx
769
+ /** Mirror the host's next-usable image key using only its public balance rows. */
770
+ function currentImageKey(balance) {
771
+ const keys = balance?.keys ?? [];
772
+ const start = Math.max(0, keys.findIndex((row) => row.id === balance.activeId));
773
+ for (let offset = 0; offset < keys.length; offset += 1) {
774
+ const row = keys[(start + offset) % keys.length];
775
+ if (row.disabled !== true) return row;
776
+ }
777
+ }
778
+ /** Mounted inside the visible Create image shortcut, so its timer shares that lifetime. */
779
+ function ImageShortcutBalance({ call, t }) {
780
+ const [view, setView] = react.useState({ loading: true });
781
+ const refresh = react.useRef(() => {});
782
+ const tooltipId = react.useId();
783
+ react.useEffect(() => {
784
+ let live = true, generation = 0, pending = false;
785
+ const read = async (force = false, reset = false) => {
786
+ if (!live || pending && !force) return;
787
+ const current = ++generation;
788
+ pending = true;
789
+ setView((previous) => reset ? { loading: true } : {
790
+ ...previous,
791
+ loading: true
792
+ });
793
+ try {
794
+ const balance = await call("images/balance", { force });
795
+ if (live && current === generation) setView({
796
+ balance,
797
+ loading: false
798
+ });
799
+ } catch {
800
+ if (live && current === generation) setView((previous) => ({
801
+ ...previous,
802
+ loading: false,
803
+ failed: true
804
+ }));
805
+ } finally {
806
+ if (current === generation) pending = false;
807
+ }
808
+ };
809
+ refresh.current = read;
810
+ read();
811
+ const changed = () => {
812
+ read(true, true);
813
+ };
814
+ const timer = window.setInterval(() => {
815
+ read();
816
+ }, 6e4);
817
+ window.addEventListener("packycode-images-updated", changed);
818
+ return () => {
819
+ live = false;
820
+ generation += 1;
821
+ refresh.current = () => {};
822
+ window.clearInterval(timer);
823
+ window.removeEventListener("packycode-images-updated", changed);
824
+ };
825
+ }, [call]);
826
+ const row = currentImageKey(view.balance);
827
+ const usage = row?.usage;
828
+ const unlimited = usage?.unlimitedQuota === true;
829
+ const remaining = unlimited ? t("keyUnlimited") : formatUsd(usage?.availableUsd);
830
+ const stale = usage !== void 0 && (view.failed || row.stale !== void 0);
831
+ const display = remaining ?? (view.loading ? "…" : "—");
832
+ const details = [
833
+ t("imagesComposerLabel"),
834
+ row?.label ? `${t("imagesBalanceKey")}: ${row.label}` : void 0,
835
+ usage?.name ? `${t("keyName")}: ${usage.name}` : void 0,
836
+ `${t("keyRemaining")}: ${remaining ?? "—"}`,
837
+ `${t("keyUsed")}: ${formatUsd(usage?.usedUsd) ?? "—"}`,
838
+ `${t("accountLimit")}: ${unlimited ? t("keyUnlimited") : formatUsd(quotaTotalUsd(usage)) ?? "—"}`,
839
+ stale ? fill(t("composerStale"), { time: readTime(row.stale?.fetchedAt ?? view.balance?.fetchedAt) }) : view.failed || row?.error ? t("imagesBalanceFailed") : view.loading ? t("imagesBalanceLoading") : view.balance?.fetchedAt === void 0 ? void 0 : fill(t("imagesBalanceReadAt"), { time: readTime(view.balance.fetchedAt) })
840
+ ].filter(Boolean).join("\n");
841
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
842
+ className: "pkc-image-balance",
843
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
844
+ className: "pkc-composer pkc-image-balance-value",
845
+ role: "status",
846
+ tabIndex: 0,
847
+ "aria-label": `${t("imagesComposerLabel")}: ${stale ? "≈" : ""}${display}`,
848
+ "aria-describedby": tooltipId,
849
+ onMouseEnter: () => {
850
+ refresh.current();
851
+ },
852
+ onFocus: () => {
853
+ refresh.current();
854
+ },
855
+ children: stale ? `≈${display}` : display
856
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
857
+ className: "pkc-image-balance-tooltip",
858
+ id: tooltipId,
859
+ role: "tooltip",
860
+ children: details
861
+ })]
862
+ });
863
+ }
864
+ //#endregion
865
+ //#region src/client-image-download.js
866
+ /** Original PNG transfer. The chat preview is never used as the download source. */
867
+ const MAX_BYTES = 48 * 1024 * 1024;
868
+ const CHUNK_BYTES = 256 * 1024;
869
+ function imageDownloadName(attachment) {
870
+ const name = (typeof attachment?.name === "string" ? attachment.name : "packycode-generated.png").replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/gu, "-").replace(/[. ]+$/u, "").trim().slice(0, 160);
871
+ return name === "" ? "packycode-generated.png" : name.replace(/\.(png|jpe?g|webp)$/iu, "") + ".png";
872
+ }
873
+ async function readOriginalImage(call, sessionId, attachmentId, { signal, onProgress } = {}) {
874
+ signal?.throwIfAborted();
875
+ const ref = await call("images/original", {
876
+ sessionId,
877
+ attachmentId
878
+ }, signal);
879
+ if (ref?.attachmentId !== attachmentId || ref?.mediaType !== "image/png" || !Number.isSafeInteger(ref.bytes) || ref.bytes < 24 || ref.bytes > MAX_BYTES || !/^[0-9a-f]{64}$/u.test(ref.sha256) || typeof ref.assetId !== "string") throw new Error("Original image metadata is invalid");
880
+ signal?.throwIfAborted();
881
+ const data = new Uint8Array(ref.bytes);
882
+ let offset = 0;
883
+ while (offset < data.length) {
884
+ signal?.throwIfAborted();
885
+ const chunk = await call("images/original/chunk", {
886
+ sessionId,
887
+ attachmentId,
888
+ assetId: ref.assetId,
889
+ offset
890
+ }, signal);
891
+ signal?.throwIfAborted();
892
+ if (chunk?.attachmentId !== attachmentId || chunk.assetId !== ref.assetId || chunk.offset !== offset || chunk.totalBytes !== ref.bytes || typeof chunk.encoded !== "string" || chunk.encoded.length === 0 || chunk.encoded.length > Math.ceil(CHUNK_BYTES / 3) * 4 || typeof chunk.done !== "boolean") throw new Error("Original image chunk is invalid");
893
+ const decoded = atob(chunk.encoded);
894
+ if (decoded.length === 0 || decoded.length > CHUNK_BYTES || offset + decoded.length > data.length) throw new Error("Original image chunk is invalid");
895
+ for (let index = 0; index < decoded.length; index += 1) data[offset + index] = decoded.charCodeAt(index);
896
+ offset += decoded.length;
897
+ if (chunk.done !== (offset === data.length)) throw new Error("Original image transfer is incomplete");
898
+ onProgress?.(Math.floor(offset / data.length * 100));
899
+ }
900
+ const digest = await crypto.subtle.digest("SHA-256", data);
901
+ signal?.throwIfAborted();
902
+ if ([...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("") !== ref.sha256) throw new Error("Original image integrity check failed");
903
+ return data;
904
+ }
905
+ async function saveOriginalImage(read, attachment, { signal, onProgress } = {}) {
906
+ const name = imageDownloadName(attachment);
907
+ let destination;
908
+ if (typeof window.showSaveFilePicker === "function") try {
909
+ destination = await window.showSaveFilePicker({
910
+ suggestedName: name,
911
+ excludeAcceptAllOption: true,
912
+ types: [{
913
+ description: "PNG",
914
+ accept: { "image/png": [".png"] }
915
+ }]
916
+ });
917
+ } catch (error) {
918
+ if (error?.name !== "NotSupportedError") throw error;
919
+ }
920
+ signal?.throwIfAborted();
921
+ const data = await read({
922
+ signal,
923
+ onProgress
924
+ });
925
+ signal?.throwIfAborted();
926
+ if (destination !== void 0) {
927
+ const stream = await destination.createWritable();
928
+ try {
929
+ signal?.throwIfAborted();
930
+ await stream.write(data);
931
+ signal?.throwIfAborted();
932
+ await stream.close();
933
+ } catch (error) {
934
+ await stream.abort().catch(() => void 0);
935
+ throw error;
936
+ }
937
+ return;
938
+ }
939
+ const url = URL.createObjectURL(new Blob([data], { type: "image/png" }));
940
+ const anchor = document.createElement("a");
941
+ anchor.href = url;
942
+ anchor.download = name;
943
+ document.body.append(anchor);
944
+ try {
945
+ anchor.click();
946
+ } finally {
947
+ anchor.remove();
948
+ window.setTimeout(() => URL.revokeObjectURL(url), 6e4);
949
+ }
950
+ }
951
+ //#endregion
952
+ //#region src/client-image-viewer.jsx
953
+ /** One plugin-owned overlay; independent of optional host image viewers. */
954
+ function createImageViewer() {
955
+ let image;
956
+ let revision = 0;
957
+ const listeners = /* @__PURE__ */ new Set();
958
+ const publish = (value) => {
959
+ image = value;
960
+ for (const listener of [...listeners]) listener();
961
+ };
962
+ return {
963
+ getSnapshot: () => image,
964
+ subscribe(listener) {
965
+ listeners.add(listener);
966
+ return () => listeners.delete(listener);
967
+ },
968
+ open: (request) => publish({
969
+ ...request,
970
+ revision: ++revision
971
+ }),
972
+ close: () => publish(void 0),
973
+ dispose() {
974
+ publish(void 0);
975
+ listeners.clear();
976
+ }
977
+ };
978
+ }
979
+ function ImageDialog({ image, viewer, t }) {
980
+ const dialog = react.useRef(null);
981
+ const close = react.useRef(null);
982
+ const download = react.useRef();
983
+ const [busy, setBusy] = react.useState(false);
984
+ const [progress, setProgress] = react.useState(0);
985
+ const [error, setError] = react.useState(false);
986
+ react.useEffect(() => {
987
+ const overflow = document.body.style.overflow;
988
+ document.body.style.overflow = "hidden";
989
+ close.current?.focus();
990
+ const onKey = (event) => {
991
+ if (event.key === "Escape") {
992
+ event.preventDefault();
993
+ event.stopPropagation();
994
+ viewer.close();
995
+ }
996
+ if (event.key === "Tab") {
997
+ const buttons = [...dialog.current?.querySelectorAll("button:not(:disabled),[tabindex=\"0\"]") ?? []];
998
+ const first = buttons[0], last = buttons.at(-1);
999
+ if (event.shiftKey && document.activeElement === first) {
1000
+ event.preventDefault();
1001
+ last?.focus();
1002
+ } else if (!event.shiftKey && document.activeElement === last) {
1003
+ event.preventDefault();
1004
+ first?.focus();
1005
+ }
1006
+ }
1007
+ };
1008
+ document.addEventListener("keydown", onKey, true);
1009
+ return () => {
1010
+ download.current?.abort();
1011
+ document.body.style.overflow = overflow;
1012
+ document.removeEventListener("keydown", onKey, true);
1013
+ if (image.opener?.isConnected !== false) image.opener?.focus?.();
1014
+ };
1015
+ }, [image, viewer]);
1016
+ const save = async () => {
1017
+ if (download.current !== void 0) return;
1018
+ const controller = new AbortController();
1019
+ download.current = controller;
1020
+ setBusy(true);
1021
+ setError(false);
1022
+ setProgress(0);
1023
+ try {
1024
+ await saveOriginalImage(image.readOriginal, image.attachment, {
1025
+ signal: controller.signal,
1026
+ onProgress: (value) => {
1027
+ if (!controller.signal.aborted) setProgress(value);
1028
+ }
1029
+ });
1030
+ } catch (failure) {
1031
+ if (!controller.signal.aborted && failure?.name !== "AbortError") setError(true);
1032
+ } finally {
1033
+ if (!controller.signal.aborted) {
1034
+ download.current = void 0;
1035
+ setBusy(false);
1036
+ }
1037
+ }
1038
+ };
1039
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1040
+ className: "pkc-image-backdrop",
1041
+ onClick: (event) => {
1042
+ if (event.target === event.currentTarget) viewer.close();
1043
+ },
1044
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1045
+ className: "pkc-image-viewer",
1046
+ role: "dialog",
1047
+ "aria-modal": "true",
1048
+ "aria-label": t("imagesViewerTitle"),
1049
+ ref: dialog,
1050
+ children: [
1051
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1052
+ className: "pkc-image-viewer-toolbar",
1053
+ children: [
1054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1055
+ className: "pkc-image-viewer-name",
1056
+ children: image.attachment.name ?? t("imagesTitle")
1057
+ }),
1058
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1059
+ type: "button",
1060
+ className: "pkc-image-viewer-button",
1061
+ disabled: busy,
1062
+ onClick: save,
1063
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1064
+ width: "16",
1065
+ height: "16",
1066
+ viewBox: "0 0 24 24",
1067
+ fill: "none",
1068
+ stroke: "currentColor",
1069
+ strokeWidth: "1.8",
1070
+ "aria-hidden": "true",
1071
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5" })
1072
+ }), busy ? `${t("imagesDownloadPreparing")} ${progress}%` : t("imagesDownloadOriginal")]
1073
+ }),
1074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1075
+ ref: close,
1076
+ type: "button",
1077
+ className: "pkc-image-viewer-button pkc-image-viewer-close",
1078
+ title: t("imagesClosePreview"),
1079
+ "aria-label": t("imagesClosePreview"),
1080
+ onClick: () => viewer.close(),
1081
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1082
+ width: "20",
1083
+ height: "20",
1084
+ viewBox: "0 0 24 24",
1085
+ fill: "none",
1086
+ stroke: "currentColor",
1087
+ strokeWidth: "1.8",
1088
+ "aria-hidden": "true",
1089
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12M18 6 6 18" })
1090
+ })
1091
+ })
1092
+ ]
1093
+ }),
1094
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1095
+ className: "pkc-image-viewer-canvas",
1096
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
1097
+ src: image.src,
1098
+ alt: image.attachment.name ?? t("imagesTitle")
1099
+ })
1100
+ }),
1101
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1102
+ className: "pkc-image-viewer-error",
1103
+ role: "alert",
1104
+ children: t("imagesDownloadFailed")
1105
+ })
1106
+ ]
1107
+ })
1108
+ });
1109
+ }
1110
+ function PackyCodeImageViewer({ viewer, t }) {
1111
+ const image = react.useSyncExternalStore(viewer.subscribe, viewer.getSnapshot);
1112
+ return image === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageDialog, {
1113
+ image,
1114
+ viewer,
1115
+ t
1116
+ }, image.revision);
1117
+ }
1118
+ //#endregion
729
1119
  //#region src/client-images.jsx
730
1120
  const imageModelLabel = (id) => id.replace("gpt-image-", "GPT Image ").replace("-flare", " Flare").replace("-sunburst", " Sunburst");
731
1121
  /** Only projections come back from the host; the password draft is cleared on success. */
@@ -804,13 +1194,9 @@ window.__ModuleLoader__.load({
804
1194
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
805
1195
  className: "pkc-title",
806
1196
  children: t("imagesTitle")
807
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1197
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
808
1198
  className: "pkc-actions",
809
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
810
- className: "pkc-chip",
811
- "data-tone": "neutral",
812
- children: t("imagesSeparateKey")
813
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1199
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
814
1200
  className: "pkc-btn",
815
1201
  type: "button",
816
1202
  disabled: busy || balanceLoading,
@@ -818,7 +1204,7 @@ window.__ModuleLoader__.load({
818
1204
  readBalance(true);
819
1205
  },
820
1206
  children: balanceLoading ? t("refreshing") : t("refresh")
821
- })]
1207
+ })
822
1208
  })]
823
1209
  }),
824
1210
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -1028,11 +1414,14 @@ window.__ModuleLoader__.load({
1028
1414
  const [error, setError] = react.useState();
1029
1415
  react.useEffect(() => {
1030
1416
  let live = true;
1417
+ let generation = 0;
1031
1418
  const refresh = () => {
1032
- if (active) call("images/status").then((value) => {
1033
- if (live) setReady(value.imageEnabled && value.available && value.keys.some((key) => !key.disabled));
1419
+ if (!active) return;
1420
+ const current = ++generation;
1421
+ call("images/status").then((value) => {
1422
+ if (live && current === generation) setReady(value.imageEnabled && value.available && value.keys.some((key) => !key.disabled));
1034
1423
  }).catch(() => {
1035
- if (live) setReady(false);
1424
+ if (live && current === generation) setReady(false);
1036
1425
  });
1037
1426
  };
1038
1427
  setReady(false);
@@ -1046,24 +1435,31 @@ window.__ModuleLoader__.load({
1046
1435
  if (!active || !ready) return null;
1047
1436
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1048
1437
  className: "pkc-image-shortcut",
1049
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1050
- className: "pkc-btn",
1051
- type: "button",
1052
- title: t("imagesShortcutHint"),
1053
- onClick: () => {
1054
- try {
1055
- prepare(t("imagesDraft"));
1056
- setError(void 0);
1057
- } catch {
1058
- setError(t("imagesDraftBusy"));
1059
- }
1060
- },
1061
- children: t("imagesCreate")
1062
- }), error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1063
- className: "pkc-error",
1064
- role: "alert",
1065
- children: error
1066
- })]
1438
+ children: [
1439
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1440
+ className: "pkc-btn",
1441
+ type: "button",
1442
+ title: t("imagesShortcutHint"),
1443
+ onClick: () => {
1444
+ try {
1445
+ prepare(t("imagesDraft"));
1446
+ setError(void 0);
1447
+ } catch {
1448
+ setError(t("imagesDraftBusy"));
1449
+ }
1450
+ },
1451
+ children: t("imagesCreate")
1452
+ }),
1453
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageShortcutBalance, {
1454
+ call,
1455
+ t
1456
+ }),
1457
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1458
+ className: "pkc-error",
1459
+ role: "alert",
1460
+ children: error
1461
+ })
1462
+ ]
1067
1463
  });
1068
1464
  }
1069
1465
  function GeneratedImage({ attachment, loadImage, openImage, t }) {
@@ -1156,26 +1552,31 @@ window.__ModuleLoader__.load({
1156
1552
  }, block.toolCallId))
1157
1553
  });
1158
1554
  }
1159
- function installImagePresentation(ctx, t) {
1555
+ function installImagePresentation(ctx, t, call) {
1160
1556
  ctx.inject(["uiConversation"], (scope) => {
1161
1557
  const conversation = scope.get("uiConversation");
1162
1558
  if (typeof conversation?.events?.register !== "function" || typeof conversation?.imageUrl !== "function") return;
1559
+ const viewer = createImageViewer();
1560
+ scope.effect(() => () => viewer.dispose(), "packycode-api: image viewer");
1561
+ scope.slots.inject("shell.overlay", () => scope.slots.register({
1562
+ name: "shell.overlay",
1563
+ id: "packycode-image-viewer",
1564
+ order: 21,
1565
+ inject: () => ({
1566
+ viewer,
1567
+ t
1568
+ })
1569
+ }, PackyCodeImageViewer));
1163
1570
  const imageProps = (sessionId) => ({
1164
1571
  t,
1165
1572
  loadImage: (attachment) => conversation.imageUrl(sessionId, attachment),
1166
1573
  openImage: (src, attachment, opener) => {
1167
- if (scope.get("nativeImageViewer")?.open?.({
1168
- items: [{
1169
- id: attachment.attachmentId,
1170
- src,
1171
- name: attachment.name ?? "packycode-generated.png",
1172
- width: attachment.width,
1173
- height: attachment.height
1174
- }],
1574
+ viewer.open({
1575
+ src,
1576
+ attachment,
1175
1577
  opener,
1176
- source: "packycode-generated"
1177
- }) === true) return;
1178
- window.open(src, "_blank", "noopener,noreferrer");
1578
+ readOriginal: (options) => readOriginalImage(call, sessionId, attachment.attachmentId, options)
1579
+ });
1179
1580
  }
1180
1581
  });
1181
1582
  scope.effect(() => conversation.events.register(imageConversationNode), "packycode-api: image results in chat");
@@ -1235,7 +1636,7 @@ window.__ModuleLoader__.load({
1235
1636
  * call carries an object.
1236
1637
  */
1237
1638
  function channelCaller(rpc) {
1238
- return (endpoint, payload = {}) => rpc.call(CHANNEL, endpoint, payload).then((response) => {
1639
+ return (endpoint, payload = {}, signal) => rpc.call(CHANNEL, endpoint, payload, signal).then((response) => {
1239
1640
  if (!response?.ok) throw new Error(response?.error?.message ?? "PackyCode RPC failed");
1240
1641
  return response.value;
1241
1642
  });
@@ -2287,8 +2688,8 @@ window.__ModuleLoader__.load({
2287
2688
  }, "packycode-api: style");
2288
2689
  const connection = ctx.get("connection");
2289
2690
  const t = ctx.locale.bind(NS);
2290
- installImagePresentation(ctx, t);
2291
2691
  const call = channelCaller(connection.rpc);
2692
+ installImagePresentation(ctx, t, call);
2292
2693
  ctx.slots.inject("settings.section", () => ctx.slots.register({
2293
2694
  name: "settings.section",
2294
2695
  id: "packycode-api",
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
- import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { homedir, tmpdir } from "node:os";
3
- import { dirname, isAbsolute, join, posix, resolve, sep, win32 } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, posix, resolve, sep, win32 } from "node:path";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { HarnessError, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, createUserMessage, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
6
  import { spawn } from "node:child_process";
7
7
  import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
8
- import { mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
8
+ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
9
9
  import { createServer } from "node:http";
10
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
11
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
@@ -4544,7 +4544,7 @@ const EDIT_MEDIA_TYPES = [
4544
4544
  "image/webp"
4545
4545
  ];
4546
4546
  const SIGNATURE = "89504e470d0a1a0a";
4547
- const ATTACHMENT_ID$1 = /^sha256:[0-9a-f]{64}$/u;
4547
+ const ATTACHMENT_ID$2 = /^sha256:[0-9a-f]{64}$/u;
4548
4548
  var ImageRequestError = class extends Error {};
4549
4549
  const nonempty = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
4550
4550
  /** Explicit absence, including zero-valued placeholders filled by some models. */
@@ -4746,13 +4746,13 @@ async function readReferences(values, attachments, messages, signal) {
4746
4746
  const available = /* @__PURE__ */ new Map();
4747
4747
  const visit = (content) => {
4748
4748
  for (const block of content ?? []) {
4749
- if (block?.type === "image" && ATTACHMENT_ID$1.test(block.attachment?.attachmentId)) available.set(block.attachment.attachmentId, block.attachment);
4749
+ if (block?.type === "image" && ATTACHMENT_ID$2.test(block.attachment?.attachmentId)) available.set(block.attachment.attachmentId, block.attachment);
4750
4750
  if (block?.type === "tool-result" && Array.isArray(block.content)) visit(block.content);
4751
4751
  }
4752
4752
  };
4753
4753
  for (const message of messages ?? []) if (Array.isArray(message?.content)) visit(message.content);
4754
4754
  const ids = values.map((value) => value?.attachmentId);
4755
- if (ids.some((id) => typeof id !== "string" || !ATTACHMENT_ID$1.test(id) || !available.has(id))) throw new ImageRequestError("PackyCode image reference is missing from this session; use read_image and copy its complete attachment reference");
4755
+ if (ids.some((id) => typeof id !== "string" || !ATTACHMENT_ID$2.test(id) || !available.has(id))) throw new ImageRequestError("PackyCode image reference is missing from this session; use read_image and copy its complete attachment reference");
4756
4756
  if (new Set(ids).size !== ids.length) throw new ImageRequestError("PackyCode image references must not contain duplicates");
4757
4757
  const images = [];
4758
4758
  let total = 0;
@@ -4970,7 +4970,7 @@ const MAX_CLI_IMAGE_CALLS = 4;
4970
4970
  const MAX_SESSION_REFERENCES = 20;
4971
4971
  const PREVIEW_BYTES = 1024 * 1024;
4972
4972
  const PREVIEW_PIXELS = 1024 * 1024;
4973
- const ATTACHMENT_ID = /^sha256:[0-9a-f]{64}$/u;
4973
+ const ATTACHMENT_ID$1 = /^sha256:[0-9a-f]{64}$/u;
4974
4974
  const FIELDS = [
4975
4975
  "prompt",
4976
4976
  "operation",
@@ -5050,7 +5050,7 @@ function createClaudeImageService({ sessionId, messages, client, attachments, ge
5050
5050
  const visit = (content) => {
5051
5051
  for (const block of content ?? []) {
5052
5052
  const ref = block?.type === "image" ? block.attachment : void 0;
5053
- if (ATTACHMENT_ID.test(ref?.attachmentId)) {
5053
+ if (ATTACHMENT_ID$1.test(ref?.attachmentId)) {
5054
5054
  collected.delete(ref.attachmentId);
5055
5055
  collected.set(ref.attachmentId, Object.fromEntries([
5056
5056
  "attachmentId",
@@ -5644,6 +5644,185 @@ function createImageController({ ring, balance, readPreference, writePreference,
5644
5644
  };
5645
5645
  }
5646
5646
  //#endregion
5647
+ //#region src/image-conversation.js
5648
+ /** Project this plugin's durable image results into chat without duplicating context. */
5649
+ const IMAGE_CHAT_KIND = "packycode-image-output";
5650
+ const IMAGE_TOOLS = /* @__PURE__ */ new Set(["packycode_image_generate", "claude_code"]);
5651
+ const hasImage = (content) => Array.isArray(content) && content.some((part) => part.type === "image" && part.attachment);
5652
+ function successfulImages(event) {
5653
+ if (event?.type === "tool/result" && event.data?.meta?.kind === "packycode-image") {
5654
+ const message = event.data.message;
5655
+ const toolCallId = message?.source?.callId;
5656
+ if (message?.source?.kind !== "tool" || typeof toolCallId !== "string" || toolCallId === "" || message.isError === true || !hasImage(message.content)) return [];
5657
+ return [{
5658
+ type: "tool-result",
5659
+ toolCallId,
5660
+ content: message.content,
5661
+ isError: false,
5662
+ meta: event.data.meta
5663
+ }];
5664
+ }
5665
+ if (event?.type === "tool/ptc-dispatch" && IMAGE_TOOLS.has(event.data?.name) && event.data.isError === false && typeof event.data.subCallId === "string" && hasImage(event.data.content)) return [{
5666
+ type: "tool-result",
5667
+ toolCallId: event.data.subCallId,
5668
+ content: event.data.content,
5669
+ meta: { kind: "packycode-image" }
5670
+ }];
5671
+ const source = event?.data?.message?.source;
5672
+ if (event?.type !== "assistant/message" || source?.kind !== "model" || source.provider !== "packycode" || !/^claude-/u.test(source.model ?? "")) return [];
5673
+ return (event.data.message.content ?? []).flatMap((block, index) => block.type === "image" && block.attachment ? [{
5674
+ type: "tool-result",
5675
+ toolCallId: `packycode-cli-image-${event.seq}-${index}`,
5676
+ content: [block]
5677
+ }] : []);
5678
+ }
5679
+ const imageConversationNode = {
5680
+ kind: IMAGE_CHAT_KIND,
5681
+ target: "chat",
5682
+ match(event) {
5683
+ const images = successfulImages(event);
5684
+ if (images.length === 0) return null;
5685
+ const id = event.type === "assistant/message" ? `cli:${event.seq}` : event.data?.subCallId ?? event.data?.message?.source?.callId ?? images[0].toolCallId ?? `event:${event.seq}`;
5686
+ return {
5687
+ id: String(id),
5688
+ role: "update"
5689
+ };
5690
+ },
5691
+ start: () => ({}),
5692
+ update: (context) => context.state ?? {},
5693
+ buildViewNode(context) {
5694
+ const results = context.matches.filter((match) => successfulImages(match.event).length > 0);
5695
+ if (results.length === 0) return null;
5696
+ const turn = (results.find((match) => match.location?.kind === "turn" || match.location?.kind === "step")?.location)?.turn;
5697
+ const finished = turn?.end;
5698
+ const blocks = [...new Map(results.flatMap(({ event }) => successfulImages(event).map((block) => [block.toolCallId, {
5699
+ ...block,
5700
+ kind: "tool-result"
5701
+ }]))).values()];
5702
+ const answerSeq = turn?.steps?.at(-1)?.data.get("assistant-step")?.finalNode?.seq;
5703
+ const lastImageSeq = Math.max(...results.map((match) => match.event.seq));
5704
+ return {
5705
+ key: context.key,
5706
+ id: context.id,
5707
+ kind: IMAGE_CHAT_KIND,
5708
+ target: "chat",
5709
+ location: { kind: "session" },
5710
+ visibility: "visible",
5711
+ anchorSeq: answerSeq > lastImageSeq ? answerSeq + .025 : finished === void 0 ? lastImageSeq + .025 : finished.seq - .025,
5712
+ data: {
5713
+ ...turn === void 0 ? {} : { turn: turn.turn },
5714
+ blocks
5715
+ }
5716
+ };
5717
+ }
5718
+ };
5719
+ //#endregion
5720
+ //#region src/image-originals.js
5721
+ /** Session-authorized access to PNGs preserved before DSH normalizes previews. */
5722
+ const ORIGINAL_CHUNK_BYTES = 256 * 1024;
5723
+ const ATTACHMENT_ID = /^sha256:[0-9a-f]{64}$/u;
5724
+ const FILE_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/u;
5725
+ const unavailable = () => /* @__PURE__ */ new Error("PackyCode original image is unavailable in this session");
5726
+ /** Read paths only from successful, host-recorded image events, never RPC input. */
5727
+ function originalFiles(events, attachmentId, directory) {
5728
+ const files = [];
5729
+ for (const event of events) {
5730
+ if (imageConversationNode.match(event) === null) continue;
5731
+ const content = event.type === "tool/ptc-dispatch" ? event.data.content : event.data.message.content;
5732
+ let candidate;
5733
+ for (const part of content) {
5734
+ if (part.type === "text" || part.type === "thinking") for (const match of String(part.text ?? "").matchAll(/(?:^|\n)(?:Original PNG on the DSH host: |原图:)([^\r\n]+)/gu)) {
5735
+ const path = match[1];
5736
+ candidate = dirname(path) === directory && FILE_NAME.test(basename(path)) ? basename(path) : void 0;
5737
+ }
5738
+ if (part.type === "image") {
5739
+ if (part.attachment?.attachmentId === attachmentId && candidate !== void 0) files.push(candidate);
5740
+ candidate = void 0;
5741
+ }
5742
+ }
5743
+ }
5744
+ return files;
5745
+ }
5746
+ function createImageOriginalReader({ imageDirectory, getSessionEvents }) {
5747
+ const directory = resolve(imageDirectory);
5748
+ async function withFile(payload, signal, read, chunk = false) {
5749
+ let file;
5750
+ try {
5751
+ signal?.throwIfAborted();
5752
+ const allowed = chunk ? [
5753
+ "sessionId",
5754
+ "attachmentId",
5755
+ "assetId",
5756
+ "offset"
5757
+ ] : ["sessionId", "attachmentId"];
5758
+ if (!payload || typeof payload !== "object" || Object.keys(payload).some((key) => !allowed.includes(key)) || typeof payload.sessionId !== "string" || payload.sessionId.length === 0 || payload.sessionId.length > 200 || !ATTACHMENT_ID.test(payload.attachmentId) || chunk && (!FILE_NAME.test(payload.assetId) || !Number.isSafeInteger(payload.offset) || payload.offset < 0)) throw unavailable();
5759
+ const events = await getSessionEvents(payload.sessionId, signal);
5760
+ if (!Array.isArray(events)) throw unavailable();
5761
+ const candidates = originalFiles(events, payload.attachmentId, directory);
5762
+ const name = chunk ? candidates.find((value) => value === payload.assetId) : candidates.at(-1);
5763
+ if (name === void 0) throw unavailable();
5764
+ const path = join(directory, name);
5765
+ if ((await lstat(path)).isSymbolicLink()) throw unavailable();
5766
+ file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
5767
+ const stat = await file.stat();
5768
+ if (!stat.isFile() || stat.size < 24 || stat.size > 50331648) throw unavailable();
5769
+ const header = Buffer.alloc(24);
5770
+ await file.read(header, 0, header.length, 0);
5771
+ const dimensions = pngDimensions(header);
5772
+ const result = await read(file, name, stat, dimensions);
5773
+ signal?.throwIfAborted();
5774
+ const after = await file.stat();
5775
+ if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) throw unavailable();
5776
+ return result;
5777
+ } catch {
5778
+ throw unavailable();
5779
+ } finally {
5780
+ await file?.close().catch(() => void 0);
5781
+ }
5782
+ }
5783
+ return {
5784
+ info(payload, signal) {
5785
+ return withFile(payload, signal, async (file, assetId, stat, dimensions) => {
5786
+ const hash = createHash("sha256");
5787
+ const buffer = Buffer.alloc(Math.min(ORIGINAL_CHUNK_BYTES, stat.size));
5788
+ let offset = 0;
5789
+ while (offset < stat.size) {
5790
+ signal?.throwIfAborted();
5791
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, stat.size - offset), offset);
5792
+ if (bytesRead === 0) throw unavailable();
5793
+ hash.update(buffer.subarray(0, bytesRead));
5794
+ offset += bytesRead;
5795
+ }
5796
+ return {
5797
+ assetId,
5798
+ attachmentId: payload.attachmentId,
5799
+ name: `packycode-${assetId}`,
5800
+ mediaType: "image/png",
5801
+ bytes: stat.size,
5802
+ ...dimensions,
5803
+ sha256: hash.digest("hex")
5804
+ };
5805
+ });
5806
+ },
5807
+ chunk(payload, signal) {
5808
+ return withFile(payload, signal, async (file, assetId, stat) => {
5809
+ if (payload.offset >= stat.size) throw unavailable();
5810
+ const buffer = Buffer.alloc(Math.min(ORIGINAL_CHUNK_BYTES, stat.size - payload.offset));
5811
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, payload.offset);
5812
+ if (bytesRead === 0) throw unavailable();
5813
+ return {
5814
+ assetId,
5815
+ attachmentId: payload.attachmentId,
5816
+ offset: payload.offset,
5817
+ totalBytes: stat.size,
5818
+ encoded: buffer.subarray(0, bytesRead).toString("base64"),
5819
+ done: payload.offset + bytesRead === stat.size
5820
+ };
5821
+ }, true);
5822
+ }
5823
+ };
5824
+ }
5825
+ //#endregion
5647
5826
  //#region src/key-chooser.js
5648
5827
  /**
5649
5828
  * Which stored key a request runs with, and what happens when the gateway
@@ -6782,7 +6961,8 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
6782
6961
  * Every reply carries only what this plugin owns: key labels and state, the
6783
6962
  * balance and group projections the host built, host overrides, whether this
6784
6963
  * device has the Claude Code CLI, the plugin version, and the web search
6785
- * preference and model visibility. Key material never crosses this boundary.
6964
+ * preference and model visibility, or bounded session-authorized original PNG
6965
+ * chunks. Key material never crosses this boundary.
6786
6966
  *
6787
6967
  * @module dsh-packycode-api/rpc
6788
6968
  */
@@ -6808,7 +6988,7 @@ const keyId = (payload) => String(payload?.id ?? "");
6808
6988
  * web search controller (without one, its endpoints refuse).
6809
6989
  * @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
6810
6990
  */
6811
- function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCli, pluginManager, search, modelSelection, images }) {
6991
+ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCli, pluginManager, search, modelSelection, images, imageOriginals }) {
6812
6992
  const need = (service, message) => {
6813
6993
  if (service === void 0) throw new Error(message);
6814
6994
  return service;
@@ -6819,6 +6999,7 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCl
6819
6999
  const groupReader = () => need(groups, "PackyCode key groups are unavailable in this runtime");
6820
7000
  const models = () => need(modelSelection, "PackyCode model selection is unavailable in this runtime");
6821
7001
  const imageSupport = () => need(images, "PackyCode images are unavailable in this runtime");
7002
+ const originals = () => need(imageOriginals, "PackyCode original images are unavailable in this runtime");
6822
7003
  const status = async () => ({
6823
7004
  ...await ring.list(),
6824
7005
  hosts: hosts(),
@@ -6892,6 +7073,8 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCl
6892
7073
  force: payload?.force === true,
6893
7074
  signal
6894
7075
  }),
7076
+ "images/original": (payload, signal) => originals().info(payload, signal),
7077
+ "images/original/chunk": (payload, signal) => originals().chunk(payload, signal),
6895
7078
  "images/update": (payload) => imageSupport().update(payload),
6896
7079
  "images/keys/add": (payload) => imageSupport().mutate("add", {
6897
7080
  key: payload?.key,
@@ -8105,7 +8288,26 @@ function apply(ctx, config) {
8105
8288
  pluginManager: createPackyCodePluginManager(),
8106
8289
  search,
8107
8290
  modelSelection,
8108
- images
8291
+ images,
8292
+ imageOriginals: createImageOriginalReader({
8293
+ imageDirectory: join(dshHome, "packycode-api", "images"),
8294
+ getSessionEvents: async (sessionId, signal) => {
8295
+ const query = ctx.get?.("sessionQuery");
8296
+ if (typeof query?.observeSession === "function") {
8297
+ const observation = await query.observeSession(sessionId, {
8298
+ signal,
8299
+ projectionMode: "none"
8300
+ });
8301
+ try {
8302
+ return [...observation.events];
8303
+ } finally {
8304
+ observation[Symbol.dispose]();
8305
+ }
8306
+ }
8307
+ if (typeof query?.readSession === "function") return (await query.readSession(sessionId)).events;
8308
+ return [];
8309
+ }
8310
+ })
8109
8311
  });
8110
8312
  /**
8111
8313
  * The shipped Connection getter retains its own service context, whose row
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-packycode-api",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Use PackyCode API keys in DeepSeek Harness with multi-protocol models, image generation and editing, separate key rings, and balance display",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -100,6 +100,7 @@
100
100
  "packycode-host-v020-fixture": "file:tests/fixtures/host-v020",
101
101
  "react": "18.3.1",
102
102
  "react-dom": "18.3.1",
103
+ "react-test-renderer": "18.3.1",
103
104
  "tsdown": "0.22.2",
104
105
  "yaml": "2.9.1"
105
106
  },