dsh-packycode-api 1.2.2 → 1.2.4

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.4
4
+
5
+ - 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.
6
+ - Removed the redundant "Separate image key" badge from the image settings header.
7
+ - 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.
8
+ - 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.
9
+
10
+ ## 1.2.3
11
+
12
+ - 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.
13
+ - Fixed native image results still missing from main chat in 1.2.2. DSH persists `tool/result.data.message` as a flat `ToolMessage`: the call identity is in `source.callId`, image/text parts are directly in `content`, and failure status is on the message. The gallery incorrectly looked for nested SDK `tool-result` blocks and discarded every successful native result. It now projects the actual message into the existing gallery view and retains the plugin metadata and durable image references.
14
+ - Replaced the incorrect native fixtures with the real message structure and added a sanitized stored-session regression that fails against the published 1.2.2 parser. Added a built-client integration through real Cordis 4.0.4, DSH 0.2.0-rc.2 event/view/group and Slot registries, bound session feed and image cache, including plugin unload/reinstall and history reload. Native, nested PTC and Claude CLI image presentation each exercise the built bundle.
15
+ - Browser comparison of the published 1.2.2 client and the corrected client with the same native ToolMessage: the old client renders zero main-chat images while the tool detail PNG loads; the corrected client renders one image with details collapsed and after history reload, with no console errors. `scripts/image-preview-check.mjs` supports `?protocol=native` or `?protocol=ptc` for focused replay. This uses a public PNG fixture without making paid API requests or changing existing sessions; it does not replace a physical Intel Mac retest.
16
+ - Validation, 2026-10-15: 390 offline tests, both rebuilt bundles and the 1.2.3 tarball. The built host tool also passed a real local HTTP multipart replay for null and repeated non-PNG mask arguments: both requests used `/v1/images/edits`, retained the original JPEG and saved Sunburst `max` setting, and omitted the unused mask. No paid generation, existing-session rewrite or DSH restart was performed for these fixes.
17
+
3
18
  ## 1.2.2
4
19
 
5
20
  - Fixed the remaining missing-gallery bug in 1.2.1. Real native `tool/result` and nested `tool/ptc-dispatch` records do not have `data.turn`; keying image contexts by that missing field separated image evidence from the actual `turn/end` context. Image contexts now use the tool call ID (or CLI message sequence) and obtain the owning turn and completion boundary from DSH's engine-provided Match Location. Results appear as soon as available and stay visible when history later supplies missing boundaries.
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.2`.
54
+ Choose the **official** install source, then click **Install**. To pin a version, enter `dsh-packycode-api@1.2.4`.
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.2`. 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.4`. 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
 
package/README.md CHANGED
@@ -51,7 +51,7 @@
51
51
  dsh-packycode-api
52
52
  ```
53
53
 
54
- 在**安装源**中选择**官方源**,然后点击**安装**。需要固定版本时填入 `dsh-packycode-api@1.2.2`。
54
+ 在**安装源**中选择**官方源**,然后点击**安装**。需要固定版本时填入 `dsh-packycode-api@1.2.4`。
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.2`。不要对 `desktop` 使用它 —— CLI 会直接拒绝(`profile "desktop" is managed exclusively by the Electron application`)。
73
+ **仅用于非 desktop profile:** `dsh plugin --profile <name> add dsh-packycode-api@1.2.4`。不要对 `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
 
@@ -32,6 +32,8 @@ Multipart 使用 `image`(单图)或重复的 `image[]`(多图),最多
32
32
 
33
33
  官方资料存在一个明确差异:`CreateImageEditRequest.mask` 要求 **小于 4 MB**;指南的 Mask requirements 则写图与 mask 格式、尺寸一致且小于 50 MB。实现建议采用更严格的 PNG mask <4 MB,并在需要 mask 时使用相同尺寸的 PNG 输入,直到实际网关验证更宽限制;不要把指南的 50 MB 直接当作 mask 已确认上限。[OpenAPI mask schema](https://github.com/openai/openai-openapi/blob/main/openapi.json)、[指南 Mask requirements](https://developers.openai.com/api/docs/guides/image-generation#mask-requirements)
34
34
 
35
+ 2026-10-15 再核对:[官方编辑 API 参考](https://developers.openai.com/api/reference/python/resources/images/methods/edit)仍将 `mask` 列为可选 PNG、与输入同尺寸且 <4 MB。用户随后报告“完善草图”触发同样的 mask 校验,并确认仅上传原图,没有单独遮罩。当前本机没有该报错会话的调用参数;离线回放可用“JPEG/WebP 参考图又被模型重复填到 mask 字段”复现完全相同的报错,不能据此声称已取得同事设备的原始参数。工具现支持 `maskImage: null` / CLI `maskId: null` 明确表示无遮罩,并只兼容宿主已验证的同一个非 PNG 参考附件被重复填入 mask 的情况。真实 PNG mask 和不同附件的 mask 保留原有检查;没有放宽 4 MB 限制,没有把编辑换成生图,也没有新增付费重试。
36
+
35
37
  JSON 编辑的字段为 `images: [{ image_url: "…" }]` 或 `images: [{ file_id: "…" }]`,每项二选一;`image_url` 可为完整 URL 或 data URL。JSON 的可选 mask 同样为图片引用对象。这是官方额外能力,不能据此假定 PackyCode 也支持它。[官方 OpenAPI:EditImageBodyJsonParam / ImageRefParam](https://github.com/openai/openai-openapi/blob/main/openapi.json)
36
38
 
37
39
  `gpt-image-2` 必须省略 `input_fidelity`,其图片输入始终按高保真处理。当前通用 schema 只明确为 GPT Image 1 / 1.5 声明可调 high/low,没有明确说明 2.5 两款可调;本次集成建议三款均省略此字段,不推断 2.5 的未记载行为。[官方高保真说明](https://developers.openai.com/api/docs/guides/image-generation#image-input-fidelity)、[官方 OpenAPI:InputFidelity](https://github.com/openai/openai-openapi/blob/main/openapi.json)
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: "图片生成与编辑",
@@ -215,9 +217,11 @@ window.__ModuleLoader__.load({
215
217
  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
218
  accountsTitle: "Accounts and keys",
217
219
  imagesTitle: "Image generation and editing",
218
- imagesSeparateKey: "Separate image key",
219
220
  imagesBalanceLoading: "Reading image key balances…",
220
221
  imagesBalanceFailed: "Could not read image balances",
222
+ imagesComposerLabel: "Image balance",
223
+ imagesBalanceKey: "Current image key",
224
+ imagesBalanceReadAt: "Updated at {time}",
221
225
  imagesBalanceHint: "Only image key quotas are counted here. Updates every minute, or use Refresh. Conversation key balances are separate.",
222
226
  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
227
  imagesEnabled: "Generate and edit images",
@@ -409,6 +413,11 @@ window.__ModuleLoader__.load({
409
413
  .pkc-input:disabled{opacity:.5}
410
414
  .pkc-image-choice{flex:none;width:100%;max-width:300px;cursor:pointer}
411
415
  .pkc-image-shortcut{display:inline-flex;align-items:center;gap:8px}
416
+ .pkc-image-balance{position:relative;display:inline-flex;align-items:center}
417
+ .pkc-image-balance-value{cursor:help;border-radius:var(--dsw-radius-xs,4px)}
418
+ .pkc-image-balance-value:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
419
+ .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}
420
+ .pkc-image-balance:hover>.pkc-image-balance-tooltip,.pkc-image-balance:focus-within>.pkc-image-balance-tooltip{visibility:visible;opacity:1}
412
421
  .pkc-image-result,.pkc-image-chat{display:flex;flex-direction:column;gap:10px;padding:8px 0}
413
422
  .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
423
  .pkc-generated-image img{display:block;max-width:100%;max-height:480px;object-fit:contain}
@@ -496,10 +505,18 @@ window.__ModuleLoader__.load({
496
505
  const IMAGE_TOOLS = /* @__PURE__ */ new Set(["packycode_image_generate", "claude_code"]);
497
506
  const hasImage = (content) => Array.isArray(content) && content.some((part) => part.type === "image" && part.attachment);
498
507
  function successfulImages(event) {
499
- if (event?.type === "tool/result" && event.data?.meta?.kind === "packycode-image") return (event.data.message?.content ?? []).filter((block) => block.type === "tool-result" && !block.isError && hasImage(block.content)).map((block) => ({
500
- ...block,
501
- meta: event.data.meta
502
- }));
508
+ if (event?.type === "tool/result" && event.data?.meta?.kind === "packycode-image") {
509
+ const message = event.data.message;
510
+ const toolCallId = message?.source?.callId;
511
+ if (message?.source?.kind !== "tool" || typeof toolCallId !== "string" || toolCallId === "" || message.isError === true || !hasImage(message.content)) return [];
512
+ return [{
513
+ type: "tool-result",
514
+ toolCallId,
515
+ content: message.content,
516
+ isError: false,
517
+ meta: event.data.meta
518
+ }];
519
+ }
503
520
  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 [{
504
521
  type: "tool-result",
505
522
  toolCallId: event.data.subCallId,
@@ -568,11 +585,19 @@ window.__ModuleLoader__.load({
568
585
  * @module dsh-packycode-api/totals
569
586
  */
570
587
  const amount = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
588
+ /** Displayed key quota is remaining plus used, independent of a period cap. */
589
+ function quotaTotalUsd(usage) {
590
+ if (usage?.unlimitedQuota === true) return void 0;
591
+ const remaining = amount(usage?.availableUsd);
592
+ const used = amount(usage?.usedUsd);
593
+ if (remaining === void 0 || used === void 0) return void 0;
594
+ return amount(Number((remaining + used).toFixed(6)));
595
+ }
571
596
  /**
572
597
  * Add up the balance rows the host returned.
573
598
  * @param rows - the `keys` array of a balance projection.
574
599
  * @returns summed amounts plus the conditions that make a sum incomplete:
575
- * keys whose read failed, and keys with no quota limit at all.
600
+ * keys whose read failed, and keys with unlimited quota.
576
601
  */
577
602
  function sumBalances(rows) {
578
603
  const total = {
@@ -598,11 +623,10 @@ window.__ModuleLoader__.load({
598
623
  }
599
624
  const remaining = amount(usage.availableUsd);
600
625
  const used = amount(usage.usedUsd);
601
- const limit = amount(usage.limitUsd);
602
626
  if (remaining !== void 0) total.remainingUsd += remaining;
603
627
  if (used !== void 0) total.usedUsd += used;
604
- if (limit !== void 0) total.limitUsd += limit;
605
628
  }
629
+ total.limitUsd = Number((total.remainingUsd + total.usedUsd).toFixed(6));
606
630
  return total;
607
631
  }
608
632
  //#endregion
@@ -639,13 +663,14 @@ window.__ModuleLoader__.load({
639
663
  /** The same quota presentation for conversation and image credential records. */
640
664
  function KeyBalance({ row, t }) {
641
665
  const usage = row?.usage;
666
+ const totalUsd = quotaTotalUsd(usage);
642
667
  const figures = [
643
668
  usage?.unlimitedQuota === true ? [t("keyRemaining"), t("keyUnlimited")] : usage?.availableUsd === void 0 ? void 0 : [t("keyRemaining"), formatUsd(usage.availableUsd)],
644
669
  usage?.usedUsd === void 0 ? void 0 : [t("keyUsed"), formatUsd(usage.usedUsd)],
645
- usage?.limitUsd === void 0 ? void 0 : [t("accountLimit"), formatUsd(usage.limitUsd)]
670
+ totalUsd === void 0 ? void 0 : [t("accountLimit"), formatUsd(totalUsd)]
646
671
  ].filter(Boolean);
647
672
  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(" · ");
648
- 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;
673
+ const share = usage?.unlimitedQuota !== true && totalUsd > 0 && typeof usage.availableUsd === "number" ? Math.max(0, Math.min(1, usage.availableUsd / totalUsd)) : void 0;
649
674
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
650
675
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
651
676
  className: "pkc-meta",
@@ -708,7 +733,7 @@ window.__ModuleLoader__.load({
708
733
  children: t("totalLimit")
709
734
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
710
735
  className: "pkc-stat-value",
711
- children: totals.unlimited > 0 ? t("keyUnlimited") : known("limitUsd") ? formatUsd(totals.limitUsd) : "—"
736
+ children: totals.unlimited > 0 ? t("keyUnlimited") : known("availableUsd") && known("usedUsd") ? formatUsd(totals.limitUsd) : "—"
712
737
  })]
713
738
  })
714
739
  ]
@@ -718,6 +743,103 @@ window.__ModuleLoader__.load({
718
743
  })] });
719
744
  }
720
745
  //#endregion
746
+ //#region src/client-image-balance.jsx
747
+ /** Mirror the host's next-usable image key using only its public balance rows. */
748
+ function currentImageKey(balance) {
749
+ const keys = balance?.keys ?? [];
750
+ const start = Math.max(0, keys.findIndex((row) => row.id === balance.activeId));
751
+ for (let offset = 0; offset < keys.length; offset += 1) {
752
+ const row = keys[(start + offset) % keys.length];
753
+ if (row.disabled !== true) return row;
754
+ }
755
+ }
756
+ /** Mounted inside the visible Create image shortcut, so its timer shares that lifetime. */
757
+ function ImageShortcutBalance({ call, t }) {
758
+ const [view, setView] = react.useState({ loading: true });
759
+ const refresh = react.useRef(() => {});
760
+ const tooltipId = react.useId();
761
+ react.useEffect(() => {
762
+ let live = true, generation = 0, pending = false;
763
+ const read = async (force = false, reset = false) => {
764
+ if (!live || pending && !force) return;
765
+ const current = ++generation;
766
+ pending = true;
767
+ setView((previous) => reset ? { loading: true } : {
768
+ ...previous,
769
+ loading: true
770
+ });
771
+ try {
772
+ const balance = await call("images/balance", { force });
773
+ if (live && current === generation) setView({
774
+ balance,
775
+ loading: false
776
+ });
777
+ } catch {
778
+ if (live && current === generation) setView((previous) => ({
779
+ ...previous,
780
+ loading: false,
781
+ failed: true
782
+ }));
783
+ } finally {
784
+ if (current === generation) pending = false;
785
+ }
786
+ };
787
+ refresh.current = read;
788
+ read();
789
+ const changed = () => {
790
+ read(true, true);
791
+ };
792
+ const timer = window.setInterval(() => {
793
+ read();
794
+ }, 6e4);
795
+ window.addEventListener("packycode-images-updated", changed);
796
+ return () => {
797
+ live = false;
798
+ generation += 1;
799
+ refresh.current = () => {};
800
+ window.clearInterval(timer);
801
+ window.removeEventListener("packycode-images-updated", changed);
802
+ };
803
+ }, [call]);
804
+ const row = currentImageKey(view.balance);
805
+ const usage = row?.usage;
806
+ const unlimited = usage?.unlimitedQuota === true;
807
+ const remaining = unlimited ? t("keyUnlimited") : formatUsd(usage?.availableUsd);
808
+ const stale = usage !== void 0 && (view.failed || row.stale !== void 0);
809
+ const display = remaining ?? (view.loading ? "…" : "—");
810
+ const details = [
811
+ t("imagesComposerLabel"),
812
+ row?.label ? `${t("imagesBalanceKey")}: ${row.label}` : void 0,
813
+ usage?.name ? `${t("keyName")}: ${usage.name}` : void 0,
814
+ `${t("keyRemaining")}: ${remaining ?? "—"}`,
815
+ `${t("keyUsed")}: ${formatUsd(usage?.usedUsd) ?? "—"}`,
816
+ `${t("accountLimit")}: ${unlimited ? t("keyUnlimited") : formatUsd(quotaTotalUsd(usage)) ?? "—"}`,
817
+ 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) })
818
+ ].filter(Boolean).join("\n");
819
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
820
+ className: "pkc-image-balance",
821
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
822
+ className: "pkc-composer pkc-image-balance-value",
823
+ role: "status",
824
+ tabIndex: 0,
825
+ "aria-label": `${t("imagesComposerLabel")}: ${stale ? "≈" : ""}${display}`,
826
+ "aria-describedby": tooltipId,
827
+ onMouseEnter: () => {
828
+ refresh.current();
829
+ },
830
+ onFocus: () => {
831
+ refresh.current();
832
+ },
833
+ children: stale ? `≈${display}` : display
834
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
835
+ className: "pkc-image-balance-tooltip",
836
+ id: tooltipId,
837
+ role: "tooltip",
838
+ children: details
839
+ })]
840
+ });
841
+ }
842
+ //#endregion
721
843
  //#region src/client-images.jsx
722
844
  const imageModelLabel = (id) => id.replace("gpt-image-", "GPT Image ").replace("-flare", " Flare").replace("-sunburst", " Sunburst");
723
845
  /** Only projections come back from the host; the password draft is cleared on success. */
@@ -796,13 +918,9 @@ window.__ModuleLoader__.load({
796
918
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
797
919
  className: "pkc-title",
798
920
  children: t("imagesTitle")
799
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
921
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
800
922
  className: "pkc-actions",
801
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
802
- className: "pkc-chip",
803
- "data-tone": "neutral",
804
- children: t("imagesSeparateKey")
805
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
923
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
806
924
  className: "pkc-btn",
807
925
  type: "button",
808
926
  disabled: busy || balanceLoading,
@@ -810,7 +928,7 @@ window.__ModuleLoader__.load({
810
928
  readBalance(true);
811
929
  },
812
930
  children: balanceLoading ? t("refreshing") : t("refresh")
813
- })]
931
+ })
814
932
  })]
815
933
  }),
816
934
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -1020,11 +1138,14 @@ window.__ModuleLoader__.load({
1020
1138
  const [error, setError] = react.useState();
1021
1139
  react.useEffect(() => {
1022
1140
  let live = true;
1141
+ let generation = 0;
1023
1142
  const refresh = () => {
1024
- if (active) call("images/status").then((value) => {
1025
- if (live) setReady(value.imageEnabled && value.available && value.keys.some((key) => !key.disabled));
1143
+ if (!active) return;
1144
+ const current = ++generation;
1145
+ call("images/status").then((value) => {
1146
+ if (live && current === generation) setReady(value.imageEnabled && value.available && value.keys.some((key) => !key.disabled));
1026
1147
  }).catch(() => {
1027
- if (live) setReady(false);
1148
+ if (live && current === generation) setReady(false);
1028
1149
  });
1029
1150
  };
1030
1151
  setReady(false);
@@ -1038,24 +1159,31 @@ window.__ModuleLoader__.load({
1038
1159
  if (!active || !ready) return null;
1039
1160
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1040
1161
  className: "pkc-image-shortcut",
1041
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1042
- className: "pkc-btn",
1043
- type: "button",
1044
- title: t("imagesShortcutHint"),
1045
- onClick: () => {
1046
- try {
1047
- prepare(t("imagesDraft"));
1048
- setError(void 0);
1049
- } catch {
1050
- setError(t("imagesDraftBusy"));
1051
- }
1052
- },
1053
- children: t("imagesCreate")
1054
- }), error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1055
- className: "pkc-error",
1056
- role: "alert",
1057
- children: error
1058
- })]
1162
+ children: [
1163
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1164
+ className: "pkc-btn",
1165
+ type: "button",
1166
+ title: t("imagesShortcutHint"),
1167
+ onClick: () => {
1168
+ try {
1169
+ prepare(t("imagesDraft"));
1170
+ setError(void 0);
1171
+ } catch {
1172
+ setError(t("imagesDraftBusy"));
1173
+ }
1174
+ },
1175
+ children: t("imagesCreate")
1176
+ }),
1177
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageShortcutBalance, {
1178
+ call,
1179
+ t
1180
+ }),
1181
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1182
+ className: "pkc-error",
1183
+ role: "alert",
1184
+ children: error
1185
+ })
1186
+ ]
1059
1187
  });
1060
1188
  }
1061
1189
  function GeneratedImage({ attachment, loadImage, openImage, t }) {
package/lib/index.js CHANGED
@@ -4547,9 +4547,10 @@ const SIGNATURE = "89504e470d0a1a0a";
4547
4547
  const ATTACHMENT_ID$1 = /^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
- /** Some model calls fill unused object fields with a zero-valued reference. */
4550
+ /** Explicit absence, including zero-valued placeholders filled by some models. */
4551
4551
  function emptyMaskReference(value) {
4552
- return value !== null && typeof value === "object" && (value.attachmentId === "" || value.attachmentId === `sha256:${"0".repeat(64)}`) && value.bytes === 0 && value.width === 0 && value.height === 0 && (value.name === void 0 || value.name === "") && (value.originalDimensions === void 0 || value.originalDimensions.width === 0 && value.originalDimensions.height === 0);
4552
+ if (value === null) return true;
4553
+ return value !== void 0 && typeof value === "object" && (value.attachmentId === "" || value.attachmentId === `sha256:${"0".repeat(64)}`) && value.bytes === 0 && value.width === 0 && value.height === 0 && (value.name === void 0 || value.name === "") && (value.originalDimensions === void 0 || value.originalDimensions.width === 0 && value.originalDimensions.height === 0);
4553
4554
  }
4554
4555
  /** A bounded reader applies to both successful payloads and error envelopes. */
4555
4556
  async function readJson(response, maximum, signal) {
@@ -4657,7 +4658,8 @@ function createPackyCodeImageClient({ resolveKey, getHost, fetchImpl = ambientFe
4657
4658
  body.append("image[]", new Blob([image.data], { type: image.mediaType }), `reference-${index + 1}.${extension}`);
4658
4659
  }
4659
4660
  if (mask !== void 0) {
4660
- if (mask.mediaType !== "image/png" || !(mask.data instanceof Uint8Array) || mask.data.byteLength >= 4 * 1024 * 1024) throw new ImageRequestError("PackyCode image mask must be a PNG smaller than 4 MB");
4661
+ if (mask.mediaType !== "image/png") throw new ImageRequestError("PackyCode maskImage must be a separate PNG mask, not an ordinary reference image; omit maskImage or set it to null for edits without a mask");
4662
+ if (!(mask.data instanceof Uint8Array) || mask.data.byteLength === 0 || mask.data.byteLength >= 4 * 1024 * 1024) throw new ImageRequestError("PackyCode image mask must be a PNG smaller than 4 MB");
4661
4663
  const dimensions = pngDimensions(mask.data);
4662
4664
  if (dimensions.width !== images[0].width || dimensions.height !== images[0].height) throw new ImageRequestError("PackyCode image mask must match the first reference image dimensions");
4663
4665
  body.append("mask", new Blob([mask.data], { type: "image/png" }), "mask.png");
@@ -4872,8 +4874,8 @@ function createPackyCodeImageTool({ client, attachments, getPreference, getSessi
4872
4874
  description: "1-16 complete attachment references for editing. Omit or use [] for a new image. Paths and unrelated images are not accepted; never drop real references after an edit fails."
4873
4875
  },
4874
4876
  maskImage: {
4875
- ...referenceSchema,
4876
- description: "Omit for new images and edits without a mask. Never invent a reference. Optional PNG mask from this session, matching the first reference and under 4 MB; alpha=0 marks the area to edit."
4877
+ oneOf: [{ type: "null" }, referenceSchema],
4878
+ description: "Omit or use null for new images and ordinary reference-image edits, including refining a sketch. Only supply an actual PNG mask provided for masking, matching the first reference and under 4 MB; alpha=0 marks the area to edit. Do not copy an ordinary reference into this field or invent a mask."
4877
4879
  }
4878
4880
  },
4879
4881
  output: {
@@ -4906,7 +4908,9 @@ function createPackyCodeImageTool({ client, attachments, getPreference, getSessi
4906
4908
  throw new ImageRequestError("PackyCode could not read the current session image references");
4907
4909
  }
4908
4910
  const images = editing ? await readReferences(args.referenceImages, attachments, messages, signal) : void 0;
4909
- const masks = maskImage === void 0 ? void 0 : await readReferences([maskImage], attachments, messages, signal);
4911
+ const repeatedIndex = maskImage === void 0 ? -1 : args.referenceImages.findIndex((ref) => ref.attachmentId === maskImage.attachmentId);
4912
+ const repeatedNonPng = repeatedIndex >= 0 && images[repeatedIndex].mediaType !== "image/png";
4913
+ const masks = maskImage === void 0 || repeatedNonPng ? void 0 : await readReferences([maskImage], attachments, messages, signal);
4910
4914
  if (!getPreference().imageEnabled) throw new ImageRequestError("PackyCode images are disabled in settings");
4911
4915
  const result = await client.request({
4912
4916
  ...args,
@@ -5026,9 +5030,11 @@ const MCP_IMAGE_SCHEMA = Object.freeze({
5026
5030
  description: "For edits use image handles from the session image catalog or a previous successful result in this run. Omit or [] for generation. Never invent handles or substitute files/URLs."
5027
5031
  },
5028
5032
  maskId: {
5029
- type: "string",
5030
- pattern: "^image_[1-9][0-9]*$",
5031
- description: "Optional session image handle for a PNG mask, matching the first reference. Omit when there is no mask."
5033
+ oneOf: [{ type: "null" }, {
5034
+ type: "string",
5035
+ pattern: "^image_[1-9][0-9]*$"
5036
+ }],
5037
+ description: "Omit or use null for ordinary reference-image editing, including sketch refinement. Only use an actual PNG mask provided for masking, matching the first reference; never reuse an ordinary reference as a mask."
5032
5038
  }
5033
5039
  },
5034
5040
  required: ["prompt"]
@@ -5086,7 +5092,8 @@ function createClaudeImageService({ sessionId, messages, client, attachments, ge
5086
5092
  if (!Array.isArray(ids) || ids.length > 16 || new Set(ids).size !== ids.length || ids.some((id) => !references.has(id))) invalid("Use 1-16 distinct image handles from this run’s session catalog for an edit.");
5087
5093
  if (value.operation === "edit" && ids.length === 0) invalid("Image edits require referenceIds; an edit will not be replaced with generation.");
5088
5094
  if (value.operation === "generate" && ids.length > 0) invalid("Image generation cannot include referenceIds; use edit.");
5089
- if (value.maskId !== void 0 && (!references.has(value.maskId) || ids.length === 0)) invalid("A mask requires references and a valid session image handle.");
5095
+ const maskId = value.maskId ?? void 0;
5096
+ if (maskId !== void 0 && (!references.has(maskId) || ids.length === 0)) invalid("A mask requires references and a valid session image handle.");
5090
5097
  const preference = getPreference();
5091
5098
  const model = value.model === void 0 || value.model === "settings" ? preference.imageModel : value.model;
5092
5099
  const quality = value.quality === void 0 || value.quality === "settings" ? preference.imageQuality : value.quality;
@@ -5099,7 +5106,7 @@ function createClaudeImageService({ sessionId, messages, client, attachments, ge
5099
5106
  } catch (error) {
5100
5107
  invalid(error.message);
5101
5108
  }
5102
- const { referenceIds: _ids, maskId, ...args } = value;
5109
+ const { referenceIds: _ids, maskId: _maskId, ...args } = value;
5103
5110
  return {
5104
5111
  ...args,
5105
5112
  model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-packycode-api",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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
  },