@harness-mix/cli 0.1.7 → 0.1.9

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.
Files changed (132) hide show
  1. package/NOTICE +12 -0
  2. package/README.md +51 -43
  3. package/docs/harness-management.md +1 -1
  4. package/docs/native-acp.md +7 -2
  5. package/docs/task-handoff-design.md +65 -0
  6. package/licenses/codex-styler-Apache-2.0.txt +193 -0
  7. package/licenses/heige-codex-skin-studio-MIT.txt +22 -0
  8. package/output/native-build/desktop-controller.mjs +2 -1
  9. package/output/native-build/renderer-extension.js +651 -9
  10. package/package.json +188 -188
  11. package/scripts/acp-capabilities-probe.cjs +1 -0
  12. package/scripts/build-native.cjs +1 -1
  13. package/scripts/codex-adapter-test.cjs +44 -0
  14. package/scripts/collaboration-recovery-test.cjs +3 -0
  15. package/scripts/e2e-codex.cjs +3 -1
  16. package/scripts/e2e-native-acp.cjs +1 -1
  17. package/scripts/error-kind-test.cjs +107 -0
  18. package/scripts/handoff-checkpoint-test.cjs +38 -0
  19. package/scripts/integrations-test.cjs +5 -2
  20. package/scripts/kiro-cursor-adapters-test.cjs +18 -0
  21. package/scripts/native-acp-depth-test.cjs +1 -1
  22. package/scripts/native-protocol-test.cjs +60 -4
  23. package/scripts/native-ui-smoke.cjs +92 -2
  24. package/scripts/switch-harness-test.cjs +79 -12
  25. package/scripts/tool-concurrency-regression-test.cjs +8 -6
  26. package/src/assets/icons/cline-color.svg +1 -0
  27. package/src/assets/skins/codex-styler/ASSET_LICENSES.md +36 -0
  28. package/src/assets/skins/codex-styler/NOTICE +11 -0
  29. package/src/assets/skins/codex-styler/themes/gilded-grandeur/hero.webp +0 -0
  30. package/src/assets/skins/codex-styler/themes/merry-big-top/hero.webp +0 -0
  31. package/src/assets/skins/codex-styler/themes/nocturne-studio/hero.webp +0 -0
  32. package/src/assets/skins/codex-styler/themes/quiet-garden/hero.webp +0 -0
  33. package/src/assets/skins/heige/ASSET_PROVENANCE.md +56 -0
  34. package/src/assets/skins/heige/NOTICE.md +13 -0
  35. package/src/assets/skins/heige/themes/dalao-dianyan/hero.webp +0 -0
  36. package/src/assets/skins/heige/themes/dalao-dianyan/theme.json +14 -0
  37. package/src/assets/skins/heige/themes/deepspace-dawn/hero.webp +0 -0
  38. package/src/assets/skins/heige/themes/deepspace-dawn/theme.json +14 -0
  39. package/src/assets/skins/heige/themes/deepspace-star/hero.webp +0 -0
  40. package/src/assets/skins/heige/themes/deepspace-star/theme.json +14 -0
  41. package/src/assets/skins/heige/themes/dragonball-nimbus/hero.webp +0 -0
  42. package/src/assets/skins/heige/themes/dragonball-nimbus/theme.json +16 -0
  43. package/src/assets/skins/heige/themes/dragonball-super-saiyan/hero.webp +0 -0
  44. package/src/assets/skins/heige/themes/dragonball-super-saiyan/theme.json +16 -0
  45. package/src/assets/skins/heige/themes/genshin-dawn/hero.webp +0 -0
  46. package/src/assets/skins/heige/themes/genshin-dawn/theme.json +14 -0
  47. package/src/assets/skins/heige/themes/genshin-night/hero.webp +0 -0
  48. package/src/assets/skins/heige/themes/genshin-night/theme.json +14 -0
  49. package/src/assets/skins/heige/themes/miku-488137/hero.webp +0 -0
  50. package/src/assets/skins/heige/themes/miku-488137/logo.webp +0 -0
  51. package/src/assets/skins/heige/themes/miku-488137/polaroid.webp +0 -0
  52. package/src/assets/skins/heige/themes/miku-488137/theme.json +16 -0
  53. package/src/assets/skins/heige/themes/naruto-hokage/hero.webp +0 -0
  54. package/src/assets/skins/heige/themes/naruto-hokage/theme.json +14 -0
  55. package/src/assets/skins/heige/themes/naruto-sasuke/hero.webp +0 -0
  56. package/src/assets/skins/heige/themes/naruto-sasuke/theme.json +14 -0
  57. package/src/assets/skins/heige/themes/wuthering-echo/hero.webp +0 -0
  58. package/src/assets/skins/heige/themes/wuthering-echo/theme.json +14 -0
  59. package/src/assets/skins/heige/themes/wuthering-tide/hero.webp +0 -0
  60. package/src/assets/skins/heige/themes/wuthering-tide/theme.json +14 -0
  61. package/src/main/adapters/cline.js +22 -0
  62. package/src/main/adapters/codex.js +78 -8
  63. package/src/main/adapters/index.js +2 -1
  64. package/src/main/adapters/native-acp-command.js +1 -0
  65. package/src/main/harness-adapter/error-kind.js +82 -0
  66. package/src/main/harness-adapter/event-normalizer.js +7 -1
  67. package/src/main/host/core-session.js +1 -1
  68. package/src/main/host/handoff-access.js +23 -4
  69. package/src/main/host/handoff-checkpoints.js +9 -2
  70. package/src/main/host/handoff-tools.js +3 -0
  71. package/src/main/host/handoff.js +11 -2
  72. package/src/main/host/runtime.js +272 -163
  73. package/src/main/host/session-history.js +54 -15
  74. package/src/main/native/icons.js +3 -2
  75. package/src/main/native/pets.js +234 -0
  76. package/src/main/native/protocol.js +197 -56
  77. package/src/main/protocol-core/projector.js +44 -41
  78. package/src/native-ui/desktop-control/dist/production-controller.d.ts.map +1 -1
  79. package/src/native-ui/desktop-control/dist/production-controller.js +1 -0
  80. package/src/native-ui/desktop-control/dist/production-controller.js.map +1 -1
  81. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  82. package/src/native-ui/desktop-control/src/production-controller.ts +1 -0
  83. package/src/native-ui/desktop-control/test/production-controller.test.ts +1 -0
  84. package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts +4 -2
  85. package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts.map +1 -1
  86. package/src/native-ui/renderer-extension/dist/types/renderer-agent-icon.d.ts.map +1 -1
  87. package/src/native-ui/renderer-extension/dist/types/renderer-agent-picker.d.ts.map +1 -1
  88. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  89. package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
  90. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  91. package/src/native-ui/renderer-extension/dist/types/renderer-sidebar-agent-icons.d.ts.map +1 -1
  92. package/src/native-ui/renderer-extension/dist/types/settings/connections-page.d.ts.map +1 -1
  93. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  94. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  95. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  96. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +1 -1
  97. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  98. package/src/native-ui/renderer-extension/dist/types/settings/shell.d.ts.map +1 -1
  99. package/src/native-ui/renderer-extension/dist/types/settings/skin-market.d.ts +4 -0
  100. package/src/native-ui/renderer-extension/dist/types/settings/skin-market.d.ts.map +1 -0
  101. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts +31 -0
  102. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -0
  103. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  104. package/src/native-ui/renderer-extension/dist/types/versioned-renderer-adapter.d.ts.map +1 -1
  105. package/src/native-ui/renderer-extension/package.json +1 -1
  106. package/src/native-ui/renderer-extension/src/agent-selection-state.ts +16 -0
  107. package/src/native-ui/renderer-extension/src/assets.d.ts +5 -0
  108. package/src/native-ui/renderer-extension/src/renderer-agent-icon.ts +5 -4
  109. package/src/native-ui/renderer-extension/src/renderer-agent-picker.ts +1 -0
  110. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +9 -6
  111. package/src/native-ui/renderer-extension/src/renderer-harness-handoff.ts +2 -2
  112. package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +2 -0
  113. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
  114. package/src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts +1 -0
  115. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -0
  116. package/src/native-ui/renderer-extension/src/settings/icons.ts +6 -0
  117. package/src/native-ui/renderer-extension/src/settings/localization.ts +4 -2
  118. package/src/native-ui/renderer-extension/src/settings/pages.ts +5 -2
  119. package/src/native-ui/renderer-extension/src/settings/shell.ts +3 -2
  120. package/src/native-ui/renderer-extension/src/settings/skin-market.css +213 -0
  121. package/src/native-ui/renderer-extension/src/settings/skin-market.ts +174 -0
  122. package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +399 -0
  123. package/src/native-ui/renderer-extension/src/versioned-renderer-adapter.ts +2 -1
  124. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +11 -1
  125. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
  126. package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +136 -0
  127. package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts +7 -0
  128. package/src/native-ui/shared-contracts/dist/thread-harness-switch.d.ts.map +1 -1
  129. package/src/native-ui/shared-contracts/dist/thread-harness-switch.js +3 -0
  130. package/src/native-ui/shared-contracts/dist/thread-harness-switch.js.map +1 -1
  131. package/src/native-ui/shared-contracts/dist/tsconfig.tsbuildinfo +1 -1
  132. package/src/native-ui/shared-contracts/src/thread-harness-switch.ts +3 -0
@@ -2,6 +2,10 @@ const { listNative, readNative } = require('./native-history');
2
2
  const external = id => ({ codex: 'codex-harness', claude: 'claude-code', dsh: 'deepseek-harness' }[id] || id);
3
3
  const local = id => ({ workbuddy: 'codebuddy', 'codex-harness': 'codex', 'claude-code': 'claude', 'deepseek-harness': 'dsh' }[id] || id);
4
4
 
5
+ // 引用会话读取:发送时的预取信封只带最近一页;MCP 只读工具按 offset 逐页向更早翻
6
+ const MAX_PAGE = 50;
7
+ const TRANSCRIPT_BUDGET = 24000;
8
+
5
9
  class SessionHistory {
6
10
  constructor(runtime, providers = { listNative, readNative }) { this.runtime = runtime; this.providers = providers; this.imports = new Map(); }
7
11
  sources() { return { harnesses: [{ harnessId: 'all-harnesses', name: '全部历史' }, ...[...this.runtime.adapters.values()].map(a => ({ harnessId: external(a.manifest.id), name: a.manifest.name + (['pi', 'claude', 'codex', 'codebuddy'].includes(a.manifest.id) ? '' : '(Host 历史)') }))] }; }
@@ -20,18 +24,11 @@ class SessionHistory {
20
24
  const rows = (await this.rows(harnessId)).filter(r => `${r.title} ${r.cwd} ${r.nativeSessionId}`.toLowerCase().includes(needle)).sort((a, b) => b.updatedAt - a.updatedAt || a.nativeSessionId.localeCompare(b.nativeSessionId));
21
25
  return { total: rows.length, candidates: rows.slice(offset, offset + limit).map(({ nativeSessionId, title, cwd, updatedAt, running }) => ({ nativeSessionId, title: title?.slice(0, 4096) || null, cwd, updatedAt, running })) };
22
26
  }
23
- async import({ harnessId, nativeSessionId }) {
24
- if (harnessId === 'all-harnesses') {
25
- const decoded = JSON.parse(Buffer.from(nativeSessionId, 'base64url').toString());
26
- if (!Array.isArray(decoded) || decoded.length !== 2 || !decoded.every(v => typeof v === 'string')) throw new Error('Invalid history identity');
27
- [harnessId, nativeSessionId] = decoded;
28
- }
29
- harnessId = local(harnessId);
30
- const key = JSON.stringify([harnessId, nativeSessionId]);
27
+ async import(identity) {
28
+ const key = JSON.stringify([identity.harnessId, identity.nativeSessionId]);
31
29
  if (this.imports.has(key)) return this.imports.get(key);
32
30
  const operation = (async () => {
33
- const row = (await this.rows(harnessId)).find(r => r.nativeSessionId === nativeSessionId);
34
- if (!row) throw new Error('Native session no longer exists');
31
+ const { harnessId, nativeSessionId, row } = await this.#locate(identity);
35
32
  if (row.threadId) return { threadId: row.threadId };
36
33
  if (row.running) throw new Error('Native session is active');
37
34
  const messages = await this.providers.readNative(harnessId, row);
@@ -42,7 +39,9 @@ class SessionHistory {
42
39
  return operation;
43
40
  }
44
41
 
45
- async context({ harnessId = 'all-harnesses', nativeSessionId }) {
42
+ /** 解码 harness-mix://session/<id> 引用(all-harnesses 时为 base64url 的 [harnessId, nativeSessionId])并定位行 */
43
+ async #locate({ harnessId = 'all-harnesses', nativeSessionId }) {
44
+ const sessionRef = nativeSessionId;
46
45
  if (harnessId === 'all-harnesses') {
47
46
  const decoded = JSON.parse(Buffer.from(nativeSessionId, 'base64url').toString());
48
47
  if (!Array.isArray(decoded) || decoded.length !== 2 || !decoded.every(v => typeof v === 'string')) throw new Error('Invalid history identity');
@@ -51,12 +50,52 @@ class SessionHistory {
51
50
  harnessId = local(harnessId);
52
51
  const row = (await this.rows(harnessId)).find(candidate => candidate.nativeSessionId === nativeSessionId);
53
52
  if (!row) throw new Error('Referenced native session no longer exists');
54
- const managed = row.threadId && this.runtime.threads.find(thread => thread.id === row.threadId);
53
+ return { harnessId, nativeSessionId, sessionRef, row };
54
+ }
55
+
56
+ #managed(row) { return row.threadId ? this.runtime.threads.find(thread => thread.id === row.threadId) : undefined; }
57
+
58
+ #metadata(harnessId, row, messageCount) {
59
+ const managed = this.#managed(row);
60
+ return {
61
+ harnessId: external(harnessId),
62
+ title: row.title || '未命名会话',
63
+ cwd: row.cwd,
64
+ branch: managed?.gitInfo?.branch ?? null,
65
+ model: managed?.model ?? null,
66
+ messageCount: messageCount ?? null,
67
+ usage: managed?.usage ?? null,
68
+ updatedAt: row.updatedAt ?? null,
69
+ running: row.running ?? (managed ? this.runtime.execution.isRunning(managed.id) : null),
70
+ };
71
+ }
72
+
73
+ /** 引用会话元数据:保持廉价,不为统计拉取原生全文(无内嵌消息的行 messageCount 为 null) */
74
+ async info(identity) {
75
+ const { harnessId, sessionRef, row } = await this.#locate(identity);
76
+ const messages = this.#managed(row)?.messages ?? row.messages ?? null;
77
+ const count = messages ? messages.filter(m => ['user', 'assistant'].includes(m.role) && m.text).length : null;
78
+ return { sessionRef, ...this.#metadata(harnessId, row, count) };
79
+ }
80
+
81
+ /** 引用会话正文:offset 0 为最近一页,按 nextOffset 逐页向更早翻;transcript 受字符预算约束 */
82
+ async context({ offset = 0, limit = 12, ...identity }) {
83
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE) throw new Error('Invalid history paging');
84
+ const { harnessId, sessionRef, row } = await this.#locate(identity);
85
+ const managed = this.#managed(row);
55
86
  const messages = managed ? managed.messages : await this.providers.readNative(harnessId, row);
56
- const selected = messages.filter(message => ['user', 'assistant'].includes(message.role) && message.text).slice(-12);
87
+ const all = messages.filter(message => ['user', 'assistant'].includes(message.role) && message.text);
88
+ const end = Math.max(0, all.length - offset);
89
+ const start = Math.max(0, end - limit);
90
+ const page = all.slice(start, end);
57
91
  return {
58
- harnessId: external(harnessId), title: row.title || '未命名会话', cwd: row.cwd,
59
- transcript: selected.map(message => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.text}`).join('\n').slice(-24000),
92
+ sessionRef,
93
+ ...this.#metadata(harnessId, row, all.length),
94
+ offset,
95
+ returned: page.length,
96
+ hasMore: start > 0,
97
+ nextOffset: start > 0 ? offset + page.length : null,
98
+ transcript: page.map(message => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.text}`).join('\n').slice(-TRANSCRIPT_BUDGET),
60
99
  };
61
100
  }
62
101
  }
@@ -20,8 +20,8 @@ const MODEL_FAMILIES = [
20
20
 
21
21
  const HARNESS_ICONS = [
22
22
  { id: 'kiro-cli', file: 'kiro-cli-color.svg', label: 'Kiro' },
23
- { id: 'cursor-cli', file: 'cursor.svg', label: 'Cursor' },
24
- { id: 'cursor', file: 'cursor.svg', label: 'Cursor' },
23
+ { id: 'cursor-cli', file: 'cursor.svg', label: 'Cursor' },
24
+ { id: 'cursor', file: 'cursor.svg', label: 'Cursor' },
25
25
  { id: 'antigravity', file: 'antigravity-color.svg', label: 'Antigravity' },
26
26
  { id: 'claude', file: 'claude-color.svg', label: 'Claude Code' },
27
27
  { id: 'codex', file: 'codex-harness.svg', label: 'Codex' },
@@ -36,6 +36,7 @@ const HARNESS_ICONS = [
36
36
  { id: 'codebuddy', file: 'codebuddy-color.svg', label: 'CodeBuddy' },
37
37
  { id: 'zcode', file: 'zcode-color.svg', label: 'ZCode' },
38
38
  { id: 'trae', file: 'trae-color.svg', label: 'Trae' },
39
+ { id: 'cline', file: 'cline-color.svg', label: 'Cline' },
39
40
  ];
40
41
 
41
42
  const svgCache = new Map();
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ // 桌宠市场数据源(只读官方资产,不随 Harness Mix 分发):
8
+ // - 官方预载:Codex Desktop 安装包 app.asar 内嵌的 *-spritesheet-vN-<hash>.webp,运行时按需提取;
9
+ // - 已安装:~/.codex/pets/<id>/{pet.json, spritesheet.webp}(官方 Pets 设置页读取同一目录)。
10
+ // 选择动作始终留给官方 Pets 设置页 / /pet 指令,Harness Mix 不代理账号级 accessory_id。
11
+
12
+ const PET_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
13
+ const SPRITESHEET_PATTERN = /^([a-z0-9]+(?:-[a-z0-9]+)*)-spritesheet-v\d+-[0-9a-f]+\.webp$/;
14
+ const MAX_SPRITESHEET_BYTES = 16 * 1024 * 1024;
15
+
16
+ function titleCase(id) {
17
+ if (id === 'bsod') return 'BSOD';
18
+ return id.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join(' ');
19
+ }
20
+
21
+ function petsDirectory(env = process.env) {
22
+ return env.HARNESS_MIX_PETS_DIR || path.join(os.homedir(), '.codex', 'pets');
23
+ }
24
+
25
+ // CODEXHOST_STOCK_CODEX_PATH 指向安装包内的 codex CLI(win: <root>/app/resources/codex.exe,
26
+ // mac: <root>/Contents/Resources/codex),app.asar 与其同目录。Host 进程由 Shim 启动时该变量已注入;
27
+ // 缺失时按平台探测(Windows 走 AppX 查询,macOS 检查标准 .app 路径),结果进程内缓存。
28
+ let resolvedAsarPath;
29
+ function resolveAsarPath(env = process.env) {
30
+ if (resolvedAsarPath !== undefined) return resolvedAsarPath;
31
+ const candidates = [];
32
+ if (typeof env.CODEXHOST_STOCK_CODEX_PATH === 'string' && env.CODEXHOST_STOCK_CODEX_PATH) {
33
+ candidates.push(path.join(path.dirname(env.CODEXHOST_STOCK_CODEX_PATH), 'app.asar'));
34
+ }
35
+ if (typeof env.HARNESS_MIX_DESKTOP_APP === 'string' && env.HARNESS_MIX_DESKTOP_APP) {
36
+ candidates.push(path.join(env.HARNESS_MIX_DESKTOP_APP, 'Contents', 'Resources', 'app.asar'));
37
+ }
38
+ resolvedAsarPath = candidates.find(file => fs.existsSync(file)) ?? null;
39
+ if (resolvedAsarPath) return resolvedAsarPath;
40
+ try {
41
+ if (process.platform === 'win32') {
42
+ const { execFileSync } = require('child_process');
43
+ const root = execFileSync('powershell', ['-NoProfile', '-Command',
44
+ '(Get-AppxPackage -Name OpenAI.Codex | Select-Object -First 1).InstallLocation'], { encoding: 'utf8', windowsHide: true }).trim();
45
+ if (root) resolvedAsarPath = [path.join(root, 'app', 'resources', 'app.asar')].find(file => fs.existsSync(file)) ?? null;
46
+ } else if (process.platform === 'darwin') {
47
+ resolvedAsarPath = ['/Applications/Codex.app', path.join(os.homedir(), 'Applications', 'Codex.app')]
48
+ .map(app => path.join(app, 'Contents', 'Resources', 'app.asar'))
49
+ .find(file => fs.existsSync(file)) ?? null;
50
+ }
51
+ } catch { /* 未安装或无权查询时按无官方源处理 */ }
52
+ return resolvedAsarPath;
53
+ }
54
+
55
+ // 最小 asar 读取器:| u32:4 | u32:pickleSize | u32:jsonSizeField | u32:jsonSize | json | pad | blobs |
56
+ // blob 起始 = 8 + pickleSize(已用真实 Codex app.asar 的 RIFF/WEBP 魔数验证)
57
+ function readAsarHeader(asarPath) {
58
+ const fd = fs.openSync(asarPath, 'r');
59
+ try {
60
+ const head = Buffer.alloc(16);
61
+ if (fs.readSync(fd, head, 0, 16, 0) !== 16) throw new Error('asar header too short');
62
+ const pickleSize = head.readUInt32LE(4);
63
+ const jsonSize = head.readUInt32LE(12);
64
+ if (jsonSize <= 0 || jsonSize > 64 * 1024 * 1024) throw new Error('asar header JSON size out of range');
65
+ const buffer = Buffer.alloc(jsonSize);
66
+ if (fs.readSync(fd, buffer, 0, jsonSize, 16) !== jsonSize) throw new Error('asar header JSON truncated');
67
+ return { tree: JSON.parse(buffer.toString('utf8')), blobBase: 8 + pickleSize };
68
+ } finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ }
72
+
73
+ function assetEntries(header) {
74
+ const files = header.tree?.files?.webview?.files?.assets?.files;
75
+ return files && typeof files === 'object' ? files : {};
76
+ }
77
+
78
+ function createPetMarket({ env = process.env } = {}) {
79
+ const headerCache = new Map(); // asarPath -> { mtimeMs, header }
80
+ const previewCache = new Map(); // cacheKey -> { mime, dataBase64 }
81
+
82
+ function asarHeader() {
83
+ const asarPath = resolveAsarPath(env);
84
+ if (!asarPath) return null;
85
+ const mtimeMs = fs.statSync(asarPath).mtimeMs;
86
+ const cached = headerCache.get(asarPath);
87
+ if (cached && cached.mtimeMs === mtimeMs) return { asarPath, header: cached.header };
88
+ const header = readAsarHeader(asarPath);
89
+ headerCache.set(asarPath, { mtimeMs, header });
90
+ return { asarPath, header };
91
+ }
92
+
93
+ function officialPets() {
94
+ const resolved = asarHeader();
95
+ if (!resolved) return { asarPath: null, header: null, pets: [] };
96
+ const pets = [];
97
+ for (const [name, entry] of Object.entries(assetEntries(resolved.header))) {
98
+ const match = name.match(SPRITESHEET_PATTERN);
99
+ if (!match || entry?.unpacked === true) continue;
100
+ const size = Number(entry.size);
101
+ if (!Number.isFinite(size) || size <= 0 || size > MAX_SPRITESHEET_BYTES) continue;
102
+ const id = match[1];
103
+ pets.push({
104
+ id,
105
+ displayName: titleCase(id),
106
+ description: 'Codex 官方内置桌宠',
107
+ source: 'official',
108
+ spritesheet: { name, size, offset: String(entry.offset ?? '0') },
109
+ });
110
+ }
111
+ pets.sort((a, b) => a.id.localeCompare(b.id));
112
+ return { asarPath: resolved.asarPath, header: resolved.header, pets };
113
+ }
114
+
115
+ function installedPets() {
116
+ const dir = petsDirectory(env);
117
+ let names = [];
118
+ try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
119
+ const pets = [];
120
+ for (const entry of names) {
121
+ if (!entry.isDirectory() || !PET_ID_PATTERN.test(entry.name)) continue;
122
+ const petJsonPath = path.join(dir, entry.name, 'pet.json');
123
+ let meta = {};
124
+ try { meta = JSON.parse(fs.readFileSync(petJsonPath, 'utf8')); } catch { continue; }
125
+ const spritesheet = typeof meta.spritesheetPath === 'string' && meta.spritesheetPath ? meta.spritesheetPath : 'spritesheet.webp';
126
+ if (spritesheet.includes('..') || path.isAbsolute(spritesheet) || spritesheet.includes('/') || spritesheet.includes('\\')) continue;
127
+ if (!fs.existsSync(path.join(dir, entry.name, spritesheet))) continue;
128
+ pets.push({
129
+ id: entry.name,
130
+ displayName: typeof meta.displayName === 'string' && meta.displayName ? meta.displayName : titleCase(entry.name),
131
+ description: typeof meta.description === 'string' ? meta.description : '',
132
+ source: 'installed',
133
+ spritesheet: { name: spritesheet },
134
+ });
135
+ }
136
+ pets.sort((a, b) => a.id.localeCompare(b.id));
137
+ return pets;
138
+ }
139
+
140
+ return {
141
+ catalog() {
142
+ const official = officialPets();
143
+ const installed = installedPets();
144
+ const installedIds = new Set(installed.map(pet => pet.id));
145
+ const data = [
146
+ ...installed,
147
+ ...official.pets.filter(pet => !installedIds.has(pet.id)).map(pet => ({ ...pet, installed: false })),
148
+ ];
149
+ for (const pet of data) pet.installed = pet.installed ?? installedIds.has(pet.id);
150
+ return {
151
+ data,
152
+ officialAvailable: official.asarPath !== null,
153
+ petsDir: petsDirectory(env),
154
+ };
155
+ },
156
+
157
+ preview({ id } = {}) {
158
+ if (!PET_ID_PATTERN.test(String(id || ''))) throw new Error('Invalid pet id');
159
+ const cached = previewCache.get(id);
160
+ if (cached) return cached;
161
+ const local = installedPets().find(pet => pet.id === id);
162
+ let buffer;
163
+ if (local) {
164
+ buffer = fs.readFileSync(path.join(petsDirectory(env), id, local.spritesheet.name));
165
+ } else {
166
+ const official = officialPets();
167
+ const pet = official.pets.find(entry => entry.id === id);
168
+ if (!pet) throw new Error(`Unknown pet: ${id}`);
169
+ const fd = fs.openSync(official.asarPath, 'r');
170
+ try {
171
+ buffer = Buffer.alloc(pet.spritesheet.size);
172
+ const position = official.header.blobBase + Number(pet.spritesheet.offset);
173
+ if (fs.readSync(fd, buffer, 0, pet.spritesheet.size, position) !== pet.spritesheet.size) {
174
+ throw new Error('Spritesheet truncated');
175
+ }
176
+ } finally {
177
+ fs.closeSync(fd);
178
+ }
179
+ }
180
+ if (buffer.length > MAX_SPRITESHEET_BYTES) throw new Error('Spritesheet too large');
181
+ const result = { id, mime: 'image/webp', dataBase64: buffer.toString('base64') };
182
+ previewCache.set(id, result);
183
+ if (previewCache.size > 24) previewCache.delete(previewCache.keys().next().value);
184
+ return result;
185
+ },
186
+
187
+ install({ id } = {}) {
188
+ if (!PET_ID_PATTERN.test(String(id || ''))) throw new Error('Invalid pet id');
189
+ const official = officialPets();
190
+ const pet = official.pets.find(entry => entry.id === id);
191
+ if (!pet) throw new Error(official.asarPath ? `桌宠 ${id} 不在官方预载目录中` : '未找到 Codex Desktop 安装,无法获取官方桌宠');
192
+ const targetDir = path.join(petsDirectory(env), id);
193
+ const petJsonPath = path.join(targetDir, 'pet.json');
194
+ const spritesheetPath = path.join(targetDir, 'spritesheet.webp');
195
+ if (fs.existsSync(petJsonPath) && fs.existsSync(spritesheetPath)) {
196
+ return { id, path: targetDir, installed: true, alreadyInstalled: true };
197
+ }
198
+ const fd = fs.openSync(official.asarPath, 'r');
199
+ let buffer;
200
+ try {
201
+ buffer = Buffer.alloc(pet.spritesheet.size);
202
+ const position = official.header.blobBase + Number(pet.spritesheet.offset);
203
+ if (fs.readSync(fd, buffer, 0, pet.spritesheet.size, position) !== pet.spritesheet.size) {
204
+ throw new Error('Spritesheet truncated');
205
+ }
206
+ } finally {
207
+ fs.closeSync(fd);
208
+ }
209
+ fs.mkdirSync(targetDir, { recursive: true });
210
+ fs.writeFileSync(spritesheetPath, buffer);
211
+ fs.writeFileSync(petJsonPath, `${JSON.stringify({
212
+ id,
213
+ displayName: pet.displayName,
214
+ description: pet.description,
215
+ spritesheetPath: 'spritesheet.webp',
216
+ }, null, 2)}\n`);
217
+ previewCache.delete(id);
218
+ return { id, path: targetDir, installed: true, alreadyInstalled: false };
219
+ },
220
+
221
+ uninstall({ id } = {}) {
222
+ if (!PET_ID_PATTERN.test(String(id || ''))) throw new Error('Invalid pet id');
223
+ const root = petsDirectory(env);
224
+ const targetDir = path.join(root, id);
225
+ if (path.relative(root, targetDir).startsWith('..')) throw new Error('Invalid pet directory');
226
+ if (!fs.existsSync(targetDir)) return { id, removed: false };
227
+ fs.rmSync(targetDir, { recursive: true, force: true });
228
+ previewCache.delete(id);
229
+ return { id, removed: true };
230
+ },
231
+ };
232
+ }
233
+
234
+ module.exports = { createPetMarket, petsDirectory, resolveAsarPath, readAsarHeader };