@harness-mix/cli 0.1.8 → 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
@@ -3,6 +3,7 @@ const fs = require('node:fs/promises');
3
3
  const path = require('node:path');
4
4
  const { HandoffCheckpoints } = require('../src/main/host/handoff-checkpoints');
5
5
  const { HandoffAccess } = require('../src/main/host/handoff-access');
6
+ const { SessionHistory } = require('../src/main/host/session-history');
6
7
  const { dispatch } = require('../src/main/host/handoff-mcp.cjs');
7
8
 
8
9
  async function main() {
@@ -47,6 +48,17 @@ async function main() {
47
48
  thread.messages[0].text = 'mutated after handoff';
48
49
  assert.doesNotMatch(JSON.stringify(runtime.handoffs.owned(thread.id, checkpoint.checkpointId)), /mutated after handoff/);
49
50
 
51
+ // 引用会话只读工具:pi 原生历史存根(30 轮),用户消息里显式给出 harness-mix://session/ 链接才可读
52
+ const sessionRef = Buffer.from(JSON.stringify(['pi', 'past-1'])).toString('base64url');
53
+ thread.messages.push({ id: 'user-2', role: 'user', text: `看看 [旧会话](harness-mix://session/${sessionRef}) 的结论` });
54
+ runtime.adapters.set('pi', { manifest: { integrations: { mcp: false } } });
55
+ runtime.threads = [thread];
56
+ runtime.execution = { isRunning: () => false };
57
+ runtime.history = new SessionHistory(runtime, {
58
+ async listNative() { return [{ nativeSessionId: 'past-1', title: '旧会话', cwd: process.cwd(), updatedAt: 5, running: null }]; },
59
+ async readNative() { return Array.from({ length: 30 }, (_, i) => ({ id: `m${i}`, role: i % 2 ? 'assistant' : 'user', text: `turn-${i}`, at: i })); },
60
+ });
61
+
50
62
  const access = new HandoffAccess(runtime);
51
63
  const connection = await access.connection({ ...thread, harnessId: 'target', pendingHandoff: { checkpointId: checkpoint.checkpointId } });
52
64
  assert.ok(connection);
@@ -59,6 +71,7 @@ async function main() {
59
71
  assert.deepEqual(catalog.tools.map(tool => tool.name), [
60
72
  'get_handoff_checkpoint', 'list_handoff_conversation', 'list_handoff_evidence',
61
73
  'read_handoff_evidence', 'list_handoff_files', 'read_handoff_plan',
74
+ 'get_session_info', 'list_session_messages',
62
75
  ]);
63
76
  const result = await dispatch({ id: 2, method: 'tools/call', params: { name: 'get_handoff_checkpoint', arguments: { checkpoint_id: checkpoint.checkpointId } } });
64
77
  const summary = JSON.parse(result.content[0].text);
@@ -67,6 +80,31 @@ async function main() {
67
80
  const denied = await dispatch({ id: 3, method: 'tools/call', params: { name: 'get_handoff_checkpoint', arguments: { checkpoint_id: 'handoff_outside_scope' } } });
68
81
  assert.equal(denied.isError, true);
69
82
  assert.match(denied.content[0].text, /outside this native session scope/);
83
+
84
+ // 引用会话:元数据廉价读取(无内嵌消息时 messageCount 为 null)
85
+ const info = JSON.parse((await dispatch({ id: 4, method: 'tools/call', params: { name: 'get_session_info', arguments: { session_id: sessionRef } } })).content[0].text);
86
+ assert.equal(info.harnessId, 'pi');
87
+ assert.equal(info.title, '旧会话');
88
+ assert.equal(info.messageCount, null);
89
+ assert.equal(info.branch, null);
90
+ // 引用会话:正文分页(offset 0 = 最近一页,nextOffset 逐页向更早翻)
91
+ const recent = JSON.parse((await dispatch({ id: 5, method: 'tools/call', params: { name: 'list_session_messages', arguments: { session_id: sessionRef, limit: 12 } } })).content[0].text);
92
+ assert.equal(recent.messageCount, 30);
93
+ assert.equal(recent.returned, 12);
94
+ assert.equal(recent.hasMore, true);
95
+ assert.equal(recent.nextOffset, 12);
96
+ assert.match(recent.transcript, /User: turn-28/);
97
+ assert.doesNotMatch(recent.transcript, /turn-17/);
98
+ const oldest = JSON.parse((await dispatch({ id: 6, method: 'tools/call', params: { name: 'list_session_messages', arguments: { session_id: sessionRef, offset: 24, limit: 12 } } })).content[0].text);
99
+ assert.equal(oldest.returned, 6);
100
+ assert.equal(oldest.hasMore, false);
101
+ assert.equal(oldest.nextOffset, null);
102
+ assert.match(oldest.transcript, /User: turn-0/);
103
+ // 未被用户引用的会话拒绝读取
104
+ const otherRef = Buffer.from(JSON.stringify(['pi', 'past-2'])).toString('base64url');
105
+ const deniedSession = await dispatch({ id: 7, method: 'tools/call', params: { name: 'get_session_info', arguments: { session_id: otherRef } } });
106
+ assert.equal(deniedSession.isError, true);
107
+ assert.match(deniedSession.content[0].text, /not referenced/);
70
108
  } finally {
71
109
  if (oldUrl === undefined) delete process.env.HARNESS_MIX_HANDOFF_URL; else process.env.HARNESS_MIX_HANDOFF_URL = oldUrl;
72
110
  if (oldKey === undefined) delete process.env.HARNESS_MIX_HANDOFF_KEY; else process.env.HARNESS_MIX_HANDOFF_KEY = oldKey;
@@ -78,7 +78,7 @@ async function main() {
78
78
  const acp = acpServers(selected.servers, collab);
79
79
  assert.equal(acp.length, 2); assert.equal(acp[1].name, 'harness-mix'); assert.deepEqual(acp[0].env, []);
80
80
  assert.ok(namedServers(selected.servers, collab)['hm-user-probe']);
81
- const catalog = await manager.catalog(); assert.equal(catalog.harnesses.length, 16);
81
+ const catalog = await manager.catalog(); assert.equal(catalog.harnesses.length, 17);
82
82
  for (const a of adapters.values()) {
83
83
  const entry = catalog.harnesses.find(h => h.id === a.manifest.id);
84
84
  assert.equal(entry.mcp, a.manifest.integrations?.mcp === true);
@@ -93,6 +93,9 @@ async function main() {
93
93
  assert.deepEqual(manager.roots(adapters.get('zcode'), { scope: 'project', cwd: project }), [path.join(project, '.zcode/skills'), path.join(project, '.agents/skills')]);
94
94
  // Trae IDE (.trae) and TraeCode CLI (.traecli) share one managed root set.
95
95
  assert.deepEqual(manager.roots(adapters.get('trae'), { scope: 'project', cwd: project }), [path.join(project, '.trae/skills'), path.join(project, '.traecli/skills'), path.join(project, '.agents/skills')]);
96
+ // Cline 官方文档:全局 ~/.cline/skills 与项目 .cline/skills。
97
+ assert.deepEqual(manager.roots(adapters.get('cline'), { scope: 'global', cwd: null }), [path.join(home, '.cline/skills')]);
98
+ assert.deepEqual(manager.roots(adapters.get('cline'), { scope: 'project', cwd: project }), [path.join(project, '.cline/skills')]);
96
99
  // Hermes documents no project-scope skills directory, so only its global root is managed.
97
100
  assert.deepEqual(manager.roots(adapters.get('hermes'), { scope: 'project', cwd: project }), []);
98
101
  assert.deepEqual(manager.roots(adapters.get('hermes'), { scope: 'global', cwd: null }), [path.join(home, '.hermes/skills')]);
@@ -116,7 +119,7 @@ async function main() {
116
119
  const globalOnly = await blocked.ensureSkillRoots({ cwd: 'relative-not-absolute' }, adapters.get('codex'), {});
117
120
  assert.equal(globalOnly.length, 1);
118
121
  // Exercise the real protocol dispatcher without starting native processes.
119
- assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/catalog', {})).harnesses.length, 16);
122
+ assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/catalog', {})).harnesses.length, 17);
120
123
  assert.equal((await NativeProtocol.prototype.request.call({ runtime }, 'codexhost/integrations/list', local)).skills.length, 3);
121
124
  console.log('integrations: scope precedence, persistence, native status projection, no credential fields, unsupported capabilities, skill install/disable/restore, links, and protocol PASS');
122
125
  }
@@ -57,6 +57,24 @@ if (process.argv.includes('--fixture')) {
57
57
  assert.deepEqual(nativeCommand(module.manifest.id, ['acp']), { command: process.execPath, args: ['acp'] });
58
58
  } finally { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; }
59
59
  }
60
+ // Cline:官方 `cline --acp`;诚实能力声明(无 plan/原生 diff/thinking 档/独立提问),
61
+ // 保留 resume 与 plan/act 权限模式;命令只认 cline 与 HARNESS_MIX_CLINE_EXECUTABLE。
62
+ {
63
+ const module = require('../src/main/adapters/cline');
64
+ const caps = module.manifest.capabilities;
65
+ assert.deepEqual(module.manifest.integrations.skills, { global: ['.cline/skills'], project: ['.cline/skills'] });
66
+ for (const [capability, expected] of [['fork', false], ['questions', false], ['thinkingLevels', false], ['plan', false], ['nativeDiff', false], ['usage', false], ['resume', true], ['permissionModes', true], ['models', true], ['attachments', true], ['approvals', true]])
67
+ assert.equal(caps[capability], expected, `cline ${capability}`);
68
+ const previous = process.env.HARNESS_MIX_CLINE_EXECUTABLE;
69
+ try {
70
+ delete process.env.HARNESS_MIX_CLINE_EXECUTABLE;
71
+ process.env.HARNESS_MIX_CLINE_EXECUTABLE = __filename + '.missing';
72
+ assert.throws(() => nativeCommand('cline', ['--acp']), /未安装/);
73
+ assert.equal((await module.create().inspect()).available, false);
74
+ process.env.HARNESS_MIX_CLINE_EXECUTABLE = process.execPath;
75
+ assert.deepEqual(nativeCommand('cline', ['--acp']), { command: process.execPath, args: ['--acp'] });
76
+ } finally { if (previous === undefined) delete process.env.HARNESS_MIX_CLINE_EXECUTABLE; else process.env.HARNESS_MIX_CLINE_EXECUTABLE = previous; }
77
+ }
60
78
  const adapter = acpAdapter({ id: 'fixture', name: 'Fixture', bin: () => ({ command: process.execPath, args: [__filename, '--fixture'] }), args: [], requestTimeoutMs: 5000 }).create();
61
79
  const events = [];
62
80
  let session;
@@ -47,7 +47,7 @@ if (process.argv.includes('--fixture')) {
47
47
  });
48
48
  } else {
49
49
  (async () => {
50
- for (const vendor of ['codebuddy', 'kiro-cli', 'cursor-cli', 'qoder', 'zcode', 'trae']) {
50
+ for (const vendor of ['codebuddy', 'kiro-cli', 'cursor-cli', 'qoder', 'zcode', 'trae', 'cline']) {
51
51
  const module = nativeAcp({ id: vendor, name: vendor, args: [], timeoutMs: 2000, command: () => ({ command: process.execPath, args: [__filename, '--fixture'] }) });
52
52
  const adapter = module.create(), events = [];
53
53
  let s;
@@ -16,13 +16,20 @@ async function main() {
16
16
  const adapter = { manifest: { id: 'pi', name: 'Pi', capabilities: { streaming: true, models: true, approvals: true, questions: true, resume: true } },
17
17
  async open(input) { emits.push(input.emit); emit = input.emit; return {}; },
18
18
  async describe() { return { models: [{ id: 'demo', name: 'Demo', provider: 'test' }], thinkingLevels: [{ id: 'high', label: 'High', default: true }, { id: 'low', label: 'Low' }], permissionModes: [] }; },
19
- async send() {}, async cancel() {}, async close() {},
19
+ async send(session, text, hooks, extras) { sendExtras.push(extras); }, async cancel() {}, async close() {},
20
20
  async respond(session, id, answer) { answers.push({ id, answer }); } };
21
+ const sendExtras = [];
21
22
  runtime.adapters.set('pi', adapter); runtime.status.pi = { available: true };
22
23
  const events = [];
24
+ let observeQueueOrder = false;
25
+ let queueResponseResolved = false;
26
+ let queueNotificationBeforeResponse = false;
23
27
  const section = { id: 'test-pinned-section', name: 'Pinned', appearance: null };
24
28
  const officialRequests = [];
25
- const bridge = new NativeProtocol(runtime, event => events.push(event), async (method, params) => {
29
+ const bridge = new NativeProtocol(runtime, event => {
30
+ if (observeQueueOrder && event?.method === 'thread/queue/changed' && !queueResponseResolved) queueNotificationBeforeResponse = true;
31
+ events.push(event);
32
+ }, async (method, params) => {
26
33
  officialRequests.push({ method, params });
27
34
  if (method === 'threadSection/list') return { data: [section], nextCursor: null };
28
35
  if (method === 'account/read') return { account: { type: 'chatgpt', email: 'native@example.com', planType: 'plus' }, requiresOpenaiAuth: true };
@@ -149,13 +156,18 @@ async function main() {
149
156
  await wait(() => !runtime.threads.find(t => t.id === prewarmed.thread.id).reviewPending && !runtime.sending.has(prewarmed.thread.id));
150
157
  emit = emits[0]; // 恢复主线程的事件源(open 顺序:主线程序,预热线程后)
151
158
  schemas.threadInspectionSchema.parse(await bridge.request('codexhost/thread/inspect', { threadId }));
152
- const turn = await bridge.request('turn/start', { threadId, input: [{ type: 'text', text: 'test' }] });
159
+ const turn = await bridge.request('turn/start', { threadId, input: [{ type: 'text', text: 'test' }], approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } });
160
+ // turn/start 在 currentTurn 建立后即返回,adapter.send 的调用在其后;等待透传到达
161
+ await wait(() => sendExtras.at(-1)?.turnPermissions != null);
162
+ assert.deepEqual(sendExtras.at(-1).turnPermissions, { approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } }, 'turn/start 权限参数透传到适配器');
153
163
  assert.ok(runtime.core.getTurn(turn.turn.id), 'Native turn IDs come from the existing ProtocolCore');
154
164
  emit({ kind: 'thinking-delta', text: 'reason' });
155
165
  emit({ kind: 'text-delta', text: 'hello' });
156
166
  emit({ kind: 'text-delta', text: ' world' });
157
167
  emit({ kind: 'tool', toolCallId: 't', title: 'Read', state: 'running', input: '{}' });
158
168
  emit({ kind: 'tool', toolCallId: 't', title: 'Read', state: 'completed', output: 'done' });
169
+ emit({ kind: 'tool', toolCallId: 'sh', title: 'Bash', state: 'running', input: '{"command":"cat package.json"}' });
170
+ emit({ kind: 'tool', toolCallId: 'sh', title: 'Bash', state: 'completed', output: '{}' });
159
171
  emit({ kind: 'approval', requestId: 'permission', method: 'confirm', title: 'Allow?' });
160
172
  const approval = events.find(e => e.id?.startsWith('harness-mix:approval:'));
161
173
  assert.equal(answers.length, 0, 'Approval is not fabricated');
@@ -169,7 +181,13 @@ async function main() {
169
181
  emit({ kind: 'completed', finalAnswer: true });
170
182
  await wait(() => !runtime.threads.find(t => t.id === threadId).reviewPending && !runtime.sending.has(threadId));
171
183
  assert.equal(events.filter(e => e.method === 'item/agentMessage/delta').map(e => e.params.delta).join(''), 'hello world');
172
- assert.ok(events.some(e => e.method === 'item/completed' && e.params.item.type === 'mcpToolCall'));
184
+ // 非终端工具投影为 dynamicToolCall(摘要显示真实工具名),终端命令投影为原生 commandExecution
185
+ assert.ok(events.some(e => e.method === 'item/completed' && e.params.item.type === 'dynamicToolCall' && e.params.item.tool === 'Read'));
186
+ const execItem = events.filter(e => e.method === 'item/completed').map(e => e.params.item).find(i => i.type === 'commandExecution');
187
+ assert.equal(execItem?.command, 'cat package.json', 'Shell tool projects the real command text');
188
+ assert.equal(execItem?.commandActions?.[0]?.type, 'read', 'Simple cat command classifies as a read action');
189
+ assert.equal(execItem?.commandActions?.[0]?.path, 'package.json');
190
+ assert.equal(typeof execItem?.durationMs, 'number', 'Command execution carries a real duration');
173
191
  // Desktop 以 `diff --git a/x b/x` 切分文件并提取路径,且只在 @@ hunk 头之后计数增删行
174
192
  const turnDiff = events.filter(e => e.method === 'turn/diff/updated').at(-1).params.diff;
175
193
  assert.ok(turnDiff.includes('diff --git a/a.txt b/a.txt'), 'turn diff carries git-style file headers');
@@ -177,11 +195,26 @@ async function main() {
177
195
  assert.ok(turnDiff.includes('@@ -0,0 +1,1 @@') && turnDiff.includes('+hello'), 'turn diff carries a countable hunk');
178
196
  assert.ok(events.some(e => e.method === 'turn/diff/updated' && e.params.diff.includes('a.txt')));
179
197
  assert.equal(events.filter(e => e.method === 'turn/completed' && e.params.threadId === threadId).length, 1);
198
+ // Desktop「已处理/用时」计时契约:item/started 带 startedAtMs、item/completed 带
199
+ // completedAtMs,终态 Turn 带 startedAt/completedAt(epoch 秒)与 durationMs(毫秒);
200
+ // 缺这些字段时 Desktop 无法合成 worked-for 计时项,完成回合只显示集成摘要。
201
+ const completedTurn = events.filter(e => e.method === 'turn/completed' && e.params.threadId === threadId && e.params.turn.status === 'completed').at(-1).params.turn;
202
+ assert.equal(typeof completedTurn.durationMs, 'number', 'Completed turn carries durationMs for the Desktop worked-for timer');
203
+ assert.ok(Number.isInteger(completedTurn.startedAt) && Number.isInteger(completedTurn.completedAt), 'Completed turn carries epoch-second startedAt/completedAt');
204
+ assert.ok(completedTurn.durationMs >= 0 && completedTurn.completedAt >= completedTurn.startedAt, 'Turn timing fields are coherent');
205
+ assert.ok(events.filter(e => e.method === 'item/started').every(e => typeof e.params.startedAtMs === 'number'), 'item/started carries startedAtMs');
206
+ assert.ok(events.filter(e => e.method === 'item/completed').every(e => typeof e.params.completedAtMs === 'number'), 'item/completed carries completedAtMs');
180
207
  const history = await bridge.request('thread/read', { threadId });
181
208
  assert.equal(history.thread.turns[0].status, 'completed');
209
+ assert.equal(typeof history.thread.turns[0].durationMs, 'number', 'Restored turns keep durationMs');
182
210
  const projectedChange = history.thread.turns[0].items.flatMap(i => i.type === 'fileChange' ? i.changes : []).find(change => change.path === 'a.txt');
183
211
  assert.equal(projectedChange?.kind.type, 'add', 'fileChange item projects the added kind');
184
212
  assert.ok(projectedChange?.diff.includes('diff --git a/a.txt b/a.txt') && projectedChange.diff.includes('@@ -0,0 +1,1 @@'), 'fileChange item carries a full unified diff');
213
+ await bridge.request('thread/settings/update', { threadId, approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } });
214
+ assert.deepEqual(runtime.threads.find(t => t.id === threadId).options.turnPermissions, { approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } }, 'Desktop 权限菜单设置存储到线程');
215
+ const resumed = await bridge.request('thread/resume', { threadId });
216
+ assert.equal(resumed.approvalPolicy, 'never', 'thread/resume 回显实际生效的权限');
217
+ assert.deepEqual(resumed.sandbox, { type: 'dangerFullAccess' }, 'thread/resume 回显实际生效的沙箱');
185
218
  await bridge.request('thread/name/set', { threadId, name: 'Local Core' });
186
219
  await bridge.request('thread/archive', { threadId });
187
220
  assert.equal(runtime.threads.find(t => t.id === threadId).archived, true);
@@ -341,13 +374,18 @@ async function main() {
341
374
  const qThread = await bridge.request('thread/start', { cwd: root, model: routeModel('pi') });
342
375
  const qThreadId = qThread.thread.id;
343
376
  assert.deepEqual(await bridge.request('thread/queue/list', { threadId: qThreadId }), { data: [], nextCursor: null });
377
+ observeQueueOrder = true;
344
378
  const q1 = await bridge.request('thread/queue/add', {
345
379
  threadId: qThreadId,
346
380
  input: [{ type: 'text', text: '排队补充需求 1' }],
347
381
  clientUserMessageId: 'client-msg-1',
348
382
  });
383
+ queueResponseResolved = true;
349
384
  assert.equal(typeof q1.queuedSubmission.id, 'string');
350
385
  assert.equal(q1.queuedSubmission.clientUserMessageId, 'client-msg-1');
386
+ await wait(() => events.at(-1)?.method === 'thread/queue/changed' && events.at(-1)?.params?.threadId === qThreadId);
387
+ assert.equal(queueNotificationBeforeResponse, false, 'Queue mutation response precedes changed notification so Desktop edits keep the current id');
388
+ observeQueueOrder = false;
351
389
  assert.equal(events.at(-1)?.method, 'thread/queue/changed');
352
390
  assert.equal(events.at(-1)?.params?.threadId, qThreadId);
353
391
 
@@ -388,6 +426,24 @@ async function main() {
388
426
  assert.equal(qList.data.length, 1);
389
427
  assert.equal(qList.data[0].id, q1.queuedSubmission.id);
390
428
 
429
+ // 输入准备或原生启动失败时不得丢失排队项,仍可编辑后重试。
430
+ await bridge.request('thread/queue/update', {
431
+ threadId: qThreadId,
432
+ queuedSubmissionId: q1.queuedSubmission.id,
433
+ input: [{ type: 'unsupported-test-input' }],
434
+ });
435
+ await assert.rejects(bridge.request('thread/queue/start', {
436
+ threadId: qThreadId,
437
+ queuedSubmissionId: q1.queuedSubmission.id,
438
+ }), /Unsupported native input type/);
439
+ qList = await bridge.request('thread/queue/list', { threadId: qThreadId });
440
+ assert.equal(qList.data[0]?.id, q1.queuedSubmission.id, 'Failed queue start keeps the item for editing or retry');
441
+ await bridge.request('thread/queue/update', {
442
+ threadId: qThreadId,
443
+ queuedSubmissionId: q1.queuedSubmission.id,
444
+ input: [{ type: 'text', text: '修正后的排队消息' }],
445
+ });
446
+
391
447
  // 空闲状态启动排队消息
392
448
  const startedQ = await bridge.request('thread/queue/start', {
393
449
  threadId: qThreadId,
@@ -12,7 +12,7 @@ app.whenReady().then(async () => {
12
12
  const icons = getAllIconsDictionary();
13
13
  icons.harnesses['claude-code'] = icons.harnesses.claude;
14
14
  icons.harnesses['deepseek-harness'] = icons.harnesses.dsh;
15
- const bundle = await esbuild.build({ stdin: { contents: `export { createRendererAgentIcon } from './src/native-ui/renderer-extension/src/renderer-agent-icon.ts'; export { mountRendererAgentPicker, renderRendererAgentPicker } from './src/native-ui/renderer-extension/src/renderer-agent-picker.ts'; export { mountRendererModelPicker, renderRendererModelPicker } from './src/native-ui/renderer-extension/src/renderer-model-picker.ts'; export { mountRendererHarnessHandoff } from './src/native-ui/renderer-extension/src/renderer-harness-handoff.ts'; export { restoredThreadOwnership } from './src/native-ui/renderer-extension/src/renderer-binding-probe.ts'; export { installRendererSidebarAgentIcons } from './src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts'; export { installRendererSettingsLifecycle } from './src/native-ui/renderer-extension/src/harness-mix-settings.ts'; export { createAccountsSettingsPage } from './src/native-ui/renderer-extension/src/settings/accounts-page.ts'; export { createStorageSettingsPage } from './src/native-ui/renderer-extension/src/settings/storage-page.ts'; export { aboutPage } from './src/native-ui/renderer-extension/src/settings/pages.ts'; export { rendererSettingsMessages } from './src/native-ui/renderer-extension/src/settings/localization.ts'; export { installRendererSettingsShell } from './src/native-ui/renderer-extension/src/settings/shell.ts';`, resolveDir: process.cwd() }, bundle: true, alias: { '@codexhost/shared-contracts': path.resolve('src/native-ui/shared-contracts/src/index.ts') }, platform: 'browser', format: 'iife', globalName: 'NativeUI', write: false, loader: { '.svg': 'dataurl', '.png': 'dataurl', '.css': 'text' }, logLevel: 'silent' });
15
+ const bundle = await esbuild.build({ stdin: { contents: `export { createRendererAgentIcon } from './src/native-ui/renderer-extension/src/renderer-agent-icon.ts'; export { mountRendererAgentPicker, renderRendererAgentPicker } from './src/native-ui/renderer-extension/src/renderer-agent-picker.ts'; export { mountRendererModelPicker, renderRendererModelPicker } from './src/native-ui/renderer-extension/src/renderer-model-picker.ts'; export { mountRendererHarnessHandoff } from './src/native-ui/renderer-extension/src/renderer-harness-handoff.ts'; export { restoredThreadOwnership } from './src/native-ui/renderer-extension/src/renderer-binding-probe.ts'; export { installRendererSidebarAgentIcons } from './src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts'; export { installRendererSettingsLifecycle } from './src/native-ui/renderer-extension/src/harness-mix-settings.ts'; export { createAccountsSettingsPage } from './src/native-ui/renderer-extension/src/settings/accounts-page.ts'; export { createStorageSettingsPage } from './src/native-ui/renderer-extension/src/settings/storage-page.ts'; export { createSkinSettingsPage } from './src/native-ui/renderer-extension/src/settings/skin-market.ts'; export { applyRendererSkin } from './src/native-ui/renderer-extension/src/settings/skin-runtime.ts'; export { aboutPage } from './src/native-ui/renderer-extension/src/settings/pages.ts'; export { rendererSettingsMessages } from './src/native-ui/renderer-extension/src/settings/localization.ts'; export { installRendererSettingsShell } from './src/native-ui/renderer-extension/src/settings/shell.ts';`, resolveDir: process.cwd() }, bundle: true, alias: { '@codexhost/shared-contracts': path.resolve('src/native-ui/shared-contracts/src/index.ts') }, platform: 'browser', format: 'iife', globalName: 'NativeUI', write: false, loader: { '.svg': 'dataurl', '.png': 'dataurl', '.webp': 'dataurl', '.css': 'text' }, logLevel: 'silent' });
16
16
  const win = new BrowserWindow({ show: false, width: 900, height: 650, webPreferences: { contextIsolation: true, sandbox: true, offscreen: true, backgroundThrottling: false } });
17
17
  await win.loadURL('data:text/html,<html><head><style>body{background:%2317191d;color:white;font:16px Arial;padding:40px}button{background:%23272a30;color:white;border:0;padding:12px}img{vertical-align:middle}main{display:flex;gap:25px;margin-bottom:40px}</style></head><body><h2>Harness Mix native components</h2><main></main></body></html>');
18
18
  await win.webContents.executeJavaScript(`globalThis.__HARNESS_MIX_ICONS__=${JSON.stringify(icons)};globalThis.__HARNESS_MIX_MODEL_FAMILIES__=${JSON.stringify(MODEL_FAMILIES.map(f => ({ id: f.id, pattern: f.regex.source })))};${bundle.outputFiles[0].text}`);
@@ -22,7 +22,7 @@ app.whenReady().then(async () => {
22
22
  lifecycle.dispose();
23
23
  return pageIds;
24
24
  })()`);
25
- assert.deepEqual(liveSettingsPages, ['connections', 'accounts', 'mcp', 'skills', 'session-import', 'storage', 'updates', 'about']);
25
+ assert.deepEqual(liveSettingsPages, ['connections', 'accounts', 'mcp', 'skills', 'session-import', 'storage', 'skins', 'updates', 'about']);
26
26
  const { NativeProtocol } = require('../src/main/native/protocol');
27
27
  const nativeThreads = ['pi', 'claude', 'dsh', 'antigravity'].map((harnessId, index) => ({ id: 'sidebar-' + index, harnessId, model: { id: 'model-' + index, provider: 'native' }, options: {} }));
28
28
  const protocol = new NativeProtocol({ threads: nativeThreads, getThread: id => nativeThreads.find(thread => thread.id === id), subscribe: () => () => {}, core: { subscribe: () => () => {} }, describe: async () => ({}), getCapabilities: () => ({}) }, () => {});
@@ -137,6 +137,7 @@ app.whenReady().then(async () => {
137
137
  inspectStorage:async () => ({storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:5242880,recordCount:12,legacyBytes:0}),
138
138
  optimizeStorage:async () => ({before:{storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:5242880,recordCount:12,legacyBytes:0},after:{storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:4194304,recordCount:12,legacyBytes:0}}),
139
139
  })),
140
+ NativeUI.createSkinSettingsPage(messages),
140
141
  NativeUI.aboutPage(messages, () => ({ readCurrentVersion:async () => ({version:'0.1.4'}), checkUpdate:async () => { throw new Error('not expected'); } })),
141
142
  ], messages, document);
142
143
  shell.openSettings(undefined, 'accounts');
@@ -156,6 +157,35 @@ app.whenReady().then(async () => {
156
157
  assert.match(storageText, /Schema v3/);
157
158
  assert.match(storageText, /12 个任务/);
158
159
  fs.writeFileSync(path.join(out, 'settings-storage.png'), (await win.webContents.capturePage()).toPNG());
160
+ const skinResult = await win.webContents.executeJavaScript(`(async () => {
161
+ globalThis.__codexhostSettingsShellV1.openSettings(undefined, 'skins');
162
+ const shadow = globalThis.__codexhostSettingsShellV1.root.shadowRoot;
163
+ const firstButton = shadow.querySelector('button[data-skin-action="styler-nocturne-studio"]');
164
+ const mikuButton = shadow.querySelector('button[data-skin-action="miku-488137"]');
165
+ firstButton.click();
166
+ mikuButton.click();
167
+ await new Promise(resolve => setTimeout(resolve, 300));
168
+ return {
169
+ text: shadow.textContent,
170
+ active: document.documentElement.getAttribute('data-harness-mix-skin'),
171
+ style: Boolean(document.getElementById('harness-mix-renderer-skin')),
172
+ cardCount: shadow.querySelectorAll('.skin-card').length,
173
+ bundledPreviewCount: [...shadow.querySelectorAll('.skin-preview')].filter(preview => preview.style.backgroundImage.includes('data:image/webp')).length,
174
+ };
175
+ })()`);
176
+ assert.match(skinResult.text, /Native Codex.*Miku 488137.*原神 · 晨曦.*鸣潮 · 共鸣.*龙珠 · 筋斗云.*金辉盛境.*夜曲工作室.*静谧花园/s);
177
+ assert.equal(skinResult.active, 'miku-488137');
178
+ assert.equal(skinResult.style, true);
179
+ assert.equal(skinResult.cardCount, 17);
180
+ assert.equal(skinResult.bundledPreviewCount, 16);
181
+ fs.writeFileSync(path.join(out, 'settings-skins.png'), (await win.webContents.capturePage()).toPNG());
182
+ await win.webContents.executeJavaScript(`(() => {
183
+ const shadow = globalThis.__codexhostSettingsShellV1.root.shadowRoot;
184
+ const page = shadow.querySelector('.settings-page');
185
+ page.scrollTop = page.scrollHeight;
186
+ })()`);
187
+ await new Promise(resolve => setTimeout(resolve, 100));
188
+ fs.writeFileSync(path.join(out, 'settings-skins-discovery.png'), (await win.webContents.capturePage()).toPNG());
159
189
  const aboutText = await win.webContents.executeJavaScript(`(async () => {
160
190
  globalThis.__codexhostSettingsShellV1.openSettings(undefined, 'about');
161
191
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -163,6 +193,66 @@ app.whenReady().then(async () => {
163
193
  })()`);
164
194
  assert.match(aboutText, /v0\.1\.4/);
165
195
  fs.writeFileSync(path.join(out, 'settings-about.png'), (await win.webContents.capturePage()).toPNG());
196
+ await win.setSize(1055, 616);
197
+ const skinShowcase = await win.webContents.executeJavaScript(`(async () => {
198
+ document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新建任务</strong><span>拉取请求</span><span>云端</span><span>已安排</span><span>插件</span><small>项目</small><span class="active" data-app-action-sidebar-thread-active="true">Miku Codex</span><span>开源项目</span><span>Harness Mix</span></nav><footer>Harness Mix</footer></aside><main class="main-surface" data-app-shell-main-surface="default"><section class="welcome"><h1>你想让我们在 Miku Codex 中构建什么?</h1><div class="suggestions"><button class="bg-card">探索并理解代码</button><button class="bg-card">构建新功能、应用或工具</button><button class="bg-card">审查代码并提出修改建议</button><button class="bg-card">修复问题和失败</button></div></section><section class="composer-surface-chrome"><small>Miku Codex  本地  main</small><div>随心输入</div><footer>+ 完全访问 <span>5.6 Luna ↑</span></footer></section></main></div>';
199
+ const fixtureStyle = document.createElement('style');
200
+ fixtureStyle.textContent = '*{box-sizing:border-box}html,body,#root{width:100%;height:100%;margin:0}body{padding:0!important;overflow:hidden;font-family:Arial,"Microsoft YaHei",sans-serif}#root{display:flex}.app-shell-left-panel{display:flex;flex:0 0 204px;flex-direction:column;padding:6px 10px 12px}.app-shell-left-panel button{border:0;color:inherit;font:700 16px inherit;text-align:left}.app-shell-left-panel nav{display:flex;flex-direction:column;gap:12px;padding:5px 2px;font-size:13px}.app-shell-left-panel nav small{margin-top:6px;opacity:.55}.app-shell-left-panel nav .active{padding:7px;border-radius:7px}.app-shell-left-panel>footer{margin-top:auto;font-size:12px}.main-surface{position:relative;display:flex;flex:1;align-items:center;justify-content:center;overflow:hidden}.welcome{width:min(660px,70vw);text-align:center;transform:translateY(-35px)}.welcome h1{margin:0 0 26px;font-size:23px;font-weight:500}.suggestions{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.suggestions button{min-height:76px;padding:12px;border:1px solid #fff8;border-radius:9px;color:inherit;text-align:left;font-weight:600}.composer-surface-chrome{position:absolute;right:13%;bottom:12px;left:13%;min-height:94px;padding:12px 18px;border:1px solid transparent;border-radius:16px}.composer-surface-chrome>div{padding:13px 0;color:#52617a99}.composer-surface-chrome footer{font-size:11px}.composer-surface-chrome footer span{float:right}';
201
+ document.head.append(fixtureStyle);
202
+ NativeUI.applyRendererSkin('miku-488137', document);
203
+ await new Promise(resolve => setTimeout(resolve, 150));
204
+ const root = getComputedStyle(document.getElementById('root'));
205
+ const main = getComputedStyle(document.querySelector('.main-surface'));
206
+ const logo = getComputedStyle(document.querySelector('.app-shell-left-panel button'));
207
+ const polaroid = getComputedStyle(document.body, '::after');
208
+ return { rootBackground:root.backgroundImage, mainBackground:main.backgroundImage, logoBackground:logo.backgroundImage, polaroidBackground:polaroid.backgroundImage };
209
+ })()`);
210
+ assert.match(skinShowcase.rootBackground, /data:image\/webp/);
211
+ assert.match(skinShowcase.mainBackground, /linear-gradient/);
212
+ assert.match(skinShowcase.logoBackground, /data:image\/webp/);
213
+ assert.match(skinShowcase.polaroidBackground, /data:image\/webp/);
214
+ fs.writeFileSync(path.join(out, 'skin-miku-full.png'), (await win.webContents.capturePage()).toPNG());
215
+ await win.setSize(1020, 716);
216
+ const readableConversation = await win.webContents.executeJavaScript(`(async () => {
217
+ document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新建任务</strong><span>Pull Request</span><span>定时任务</span><span>插件</span><small>项目</small><span>snipaste-pro</span><span>harness-mix</span><span class="active" data-app-action-sidebar-thread-active="true">查找换肤界面差异原因</span></nav><footer>Harness Mix</footer></aside><main class="main-surface chat" data-app-shell-main-surface="default"><header>查找换肤界面差异原因</header><div class="chat-scroll"><article data-local-conversation-final-assistant><div data-response-annotation-conversation><small>用时 19秒</small><p>是的,准确说是修改了 Codex UI 的运行时表现层。</p><p>它会:</p><ul><li>注入 CSS、替换颜色变量和背景图。</li><li>把部分原生面板变成半透明。</li><li>增加 Logo、拍立得等纯视觉装饰。</li><li>保留原有按钮、输入框、会话和交互逻辑。</li></ul><p>它不会修改 Codex 的安装包、模型调用、账号、权限或会话归属。</p></div></article></div><section class="composer-surface-chrome"><div>随心输入</div><footer>+ 完全访问 <span>GPT-5.6 Sol ↑</span></footer></section></main></div>';
218
+ const chatStyle = document.createElement('style');
219
+ chatStyle.textContent = '.app-shell-left-panel button{background:transparent}.chat.main-surface{display:block;padding:0 22px}.chat>header{height:56px;padding:18px 4px;border-bottom:1px solid #6b789633;font-size:14px}.chat-scroll{height:calc(100% - 180px);overflow:auto;padding:42px 8px 80px}.chat article{max-width:760px;margin:0 auto;font-size:15px;line-height:1.7}.chat article small{opacity:.55}.chat article p{margin:13px 0}.chat article li{margin:4px 0}.chat .composer-surface-chrome{right:22px;bottom:14px;left:22px;background:#fff}';
220
+ document.head.append(chatStyle);
221
+ NativeUI.applyRendererSkin('dragonball-nimbus', document);
222
+ await new Promise(resolve => setTimeout(resolve, 150));
223
+ const response = getComputedStyle(document.querySelector('[data-response-annotation-conversation]'));
224
+ return { background:response.backgroundColor, color:response.color, padding:response.padding, radius:response.borderRadius };
225
+ })()`);
226
+ assert.notEqual(readableConversation.background, 'rgba(0, 0, 0, 0)');
227
+ assert.notEqual(readableConversation.background, 'transparent');
228
+ assert.equal(readableConversation.padding, '14px 16px 12px');
229
+ assert.equal(readableConversation.radius, '18px');
230
+ fs.writeFileSync(path.join(out, 'skin-conversation-readable.png'), (await win.webContents.capturePage()).toPNG());
231
+ const darkSurfaceResult = await win.webContents.executeJavaScript(`(async () => {
232
+ document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新对话</strong><span>Pull Request</span><span>定时任务</span><span>插件</span><small>项目</small><span class="active" data-app-action-sidebar-thread-active="true">暗色皮肤全界面适配</span></nav></aside><main class="main-surface dark-fixture" data-app-shell-main-surface="default"><header>暗色皮肤全界面适配</header><article data-response-annotation-conversation><p>正文、工具状态、评审卡和输入区都需要保持清晰。</p><section class="review-card"><div>已编辑 3 个文件</div><button class="review-action">审核</button><button class="review-row">src/settings/skin-runtime.ts</button><button class="review-row">scripts/native-ui-smoke.cjs</button></section></article><section class="composer-real" data-composer-layout="multiline"><input placeholder="随心输入"><button>完全访问</button><button>GPT-5.6 Sol</button></section></main></div>';
233
+ const darkFixtureStyle = document.createElement('style');
234
+ darkFixtureStyle.textContent = 'html,body,#root{width:100%;height:100%;margin:0}.dark-fixture{position:relative;display:block;padding:24px}.dark-fixture>header{padding:12px 0}.dark-fixture article{max-width:700px;margin:80px auto}.review-card{margin-top:24px;padding:14px;border:1px solid var(--color-token-border-default);border-radius:12px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 50%,transparent)}.review-card button{color:var(--color-text-primary)}.review-action{float:right;background:var(--color-background-primary-soft-alpha)}.review-row{display:block;width:100%;margin-top:10px;padding:10px;text-align:left;border:0;background:color-mix(in srgb,var(--color-surface) 70%,transparent)}.composer-real{position:absolute;right:24px;bottom:16px;left:24px;padding:18px;border-radius:20px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 86%,transparent)}.composer-real input{width:70%;padding:8px;color:var(--color-text-primary);background:transparent;border:0}.composer-real button{margin-left:8px;color:var(--color-text-secondary);background:transparent;border:0}';
235
+ document.head.append(darkFixtureStyle);
236
+ NativeUI.applyRendererSkin('dragonball-nimbus', document);
237
+ await new Promise(resolve => setTimeout(resolve, 50));
238
+ const before = { buttons:document.querySelectorAll('button').length, inputs:document.querySelectorAll('input').length };
239
+ const lightReviewBackground = getComputedStyle(document.querySelector('.review-card')).backgroundColor;
240
+ NativeUI.applyRendererSkin('genshin-night', document);
241
+ await new Promise(resolve => setTimeout(resolve, 150));
242
+ const review = getComputedStyle(document.querySelector('.review-card'));
243
+ const row = getComputedStyle(document.querySelector('.review-row'));
244
+ const composer = getComputedStyle(document.querySelector('.composer-real'));
245
+ const after = { buttons:document.querySelectorAll('button').length, inputs:document.querySelectorAll('input').length };
246
+ return { before, after, active:document.documentElement.getAttribute('data-harness-mix-skin'), lightReviewBackground, reviewBackground:review.backgroundColor, rowBackground:row.backgroundColor, composerBackground:composer.backgroundColor, color:review.color };
247
+ })()`);
248
+ assert.deepEqual(darkSurfaceResult.after, darkSurfaceResult.before);
249
+ assert.equal(darkSurfaceResult.active, 'genshin-night');
250
+ assert.notEqual(darkSurfaceResult.reviewBackground, darkSurfaceResult.lightReviewBackground);
251
+ assert.doesNotMatch(darkSurfaceResult.reviewBackground, /255, 255, 255/);
252
+ assert.doesNotMatch(darkSurfaceResult.rowBackground, /255, 255, 255/);
253
+ assert.doesNotMatch(darkSurfaceResult.composerBackground, /255, 255, 255/);
254
+ assert.equal(darkSurfaceResult.color, 'rgb(240, 230, 200)');
255
+ fs.writeFileSync(path.join(out, 'skin-dark-all-surfaces.png'), (await win.webContents.capturePage()).toPNG());
166
256
  win.destroy();
167
257
  console.log('PASS: real Electron native picker, graphical Harness handoff, official Account/Usage, sharded Storage metrics, live About version and all project Harness/model SVGs load');
168
258
  app.quit();
@@ -2,7 +2,7 @@ const assert = require('node:assert/strict');
2
2
  const fs = require('node:fs/promises');
3
3
  const path = require('node:path');
4
4
  const { HostRuntime } = require('../src/main/host/runtime');
5
- const { CAP, buildHandoffContext } = require('../src/main/host/handoff');
5
+ const { CAP, buildHandoffContext, composeHandoffEnvelope } = require('../src/main/host/handoff');
6
6
 
7
7
  /**
8
8
  * 跨 Harness 原地切换(switchHarness)脚本级测试:
@@ -13,15 +13,18 @@ const { CAP, buildHandoffContext } = require('../src/main/host/handoff');
13
13
  const wait = async fn => { for (let i = 0; i < 300; i++) { if (fn()) return; await new Promise(r => setTimeout(r, 10)); } throw new Error('Timed out'); };
14
14
 
15
15
  function fakeHarness(id, name) {
16
- const state = { sent: [], opens: [], closed: 0, hold: null, withFileChange: false };
16
+ const state = { sent: [], opens: [], closed: 0, hold: null, withFileChange: false, openError: null, openWait: null, sendError: null };
17
17
  const adapter = {
18
18
  manifest: { id, name, aliases: [id], capabilities: {} },
19
19
  async open(input) {
20
20
  state.opens.push({ restore: input.thread.restore === true, nativeSessionId: input.thread.nativeSessionId, nativeSessionFile: input.thread.nativeSessionFile });
21
+ if (state.openWait) await state.openWait;
22
+ if (state.openError) throw new Error(state.openError);
21
23
  return { nativeSessionId: input.thread.nativeSessionId };
22
24
  },
23
25
  async send(session, text, hooks) {
24
26
  state.sent.push(text);
27
+ if (state.sendError) throw new Error(state.sendError);
25
28
  if (state.hold) { state.hold.hooks = hooks; return; } // 挂起:Turn 保持 running,等待测试手动 settle
26
29
  if (state.withFileChange) hooks.emit({ kind: 'file-change', changes: [{ path: 'src/login.ts', before: '', after: 'x', complete: true, changeType: 'added' }] });
27
30
  hooks.emit({ kind: 'text-delta', text: `${name} 回复` });
@@ -46,6 +49,8 @@ async function main() {
46
49
  assert.deepEqual(optimized.conversationTail.map(message => message.role), ['user', 'assistant', 'user', 'assistant']);
47
50
  assert.equal(optimized.conversationTail.at(-1).text, longSolution, '最新方案应获得更大的交接预算');
48
51
  assert.ok(optimized.conversationTail.reduce((sum, message) => sum + message.text.length, 0) <= CAP.totalChars);
52
+ assert.match(composeHandoffEnvelope({ fromHarnessId: 'a', context: {}, intent: 'review' }), /Do not modify files/);
53
+ assert.match(composeHandoffEnvelope({ fromHarnessId: 'a', context: {}, intent: 'reanalyze' }), /Reanalyze the task independently/);
49
54
 
50
55
  await fs.mkdir('output', { recursive: true });
51
56
  const root = await fs.mkdtemp(path.resolve('output/switch-harness-'));
@@ -90,17 +95,21 @@ async function main() {
90
95
  const checkpointBeforeSend = rt.handoffs.get(thread.id, firstCheckpointId);
91
96
  assert.equal(checkpointBeforeSend.intent, 'continue');
92
97
  assert.equal(checkpointBeforeSend.fileState.files.some(file => file.path === 'src/login.ts' && file.changeType === 'added'), true);
93
- assert.equal(rt.handoffs.owned(thread.id, firstCheckpointId).status, 'checkpoint-created');
94
- assert.equal(thread.restore, false, '新 Harness 上是全新原生会话');
95
- assert.equal(rt.sessions.has(thread.id), false, '切换后旧原生会话已关闭');
98
+ assert.equal(rt.handoffs.owned(thread.id, firstCheckpointId).status, 'ready');
99
+ assert.equal(b.state.opens[0].restore, false, '新 Harness 上是全新原生会话');
100
+ assert.equal(thread.restore, undefined, '目标 Session 打开后清理瞬态 restore 标记');
101
+ assert.equal(thread.pendingHandoff.phase, 'ready');
102
+ assert.equal(rt.sessions.has(thread.id), true, '目标 Harness 连接成功后才完成切换');
96
103
  assert.equal(a.state.closed, 1);
97
- assert.equal(b.state.opens.length, 0, '目标 Harness 惰性拉起,切换瞬间不开进程');
104
+ assert.equal(b.state.opens.length, 1, '切换阶段预先连接目标 Harness');
98
105
  assert.equal(thread.messages.filter(m => m.role === 'user').length, 1, '/switch 指令本身不落在会话里');
106
+ await assert.rejects(rt.switchHarness(thread.id, 'a'), /上一次 Harness 接力尚未完成/);
107
+ assert.ok((await rt.listCommands({ threadId: thread.id })).some(command => command.id === 'switch-cancel'));
99
108
 
100
109
  // 4) 切换后首轮:信封注入 prompt,可见消息保持干净,注入后清除标记
101
110
  await rt.send(thread.id, '继续');
102
111
  await settle(thread.id);
103
- assert.equal(b.state.opens.length, 1);
112
+ assert.equal(b.state.opens.length, 1, '首轮发送复用切换阶段已连接的目标 Session');
104
113
  assert.equal(b.state.opens[0].restore, false);
105
114
  assert.notEqual(thread.nativeSessionId, aNativeId);
106
115
  assert.match(b.state.sent[0], /\[Harness Mix handoff\]/);
@@ -119,18 +128,18 @@ async function main() {
119
128
  await settle(thread.id);
120
129
  assert.ok(!b.state.sent[1].includes('[Harness Mix handoff]'), '信封只注入一次');
121
130
 
122
- // 6) 切回 A:原生会话引用恢复(惰性 open 走 restore 路径),chain 压栈两条
131
+ // 6) 切回 A:原生会话引用恢复并在提交切换前验证连接,chain 压栈两条
123
132
  await rt.send(thread.id, '/switch a');
124
133
  assert.equal(thread.harnessId, 'a');
125
134
  assert.equal(thread.harnessChain.length, 2);
126
135
  assert.equal(thread.harnessChain[1].harnessId, 'b');
127
136
  assert.equal(thread.nativeSessionId, aNativeId, '切回恢复原原生会话标识');
128
- assert.equal(thread.restore, true);
129
- await rt.send(thread.id, '我回来了');
130
- await settle(thread.id);
137
+ assert.equal(thread.restore, undefined);
131
138
  assert.equal(a.state.opens.length, 2);
132
139
  assert.equal(a.state.opens[1].restore, true, '切回走原生恢复路径');
133
140
  assert.equal(a.state.opens[1].nativeSessionId, aNativeId);
141
+ await rt.send(thread.id, '我回来了');
142
+ await settle(thread.id);
134
143
  assert.match(a.state.sent[1], /\[Harness Mix handoff\]/, '切回首轮同样注入信封');
135
144
 
136
145
  // 7) 守卫:执行中 / 同 Harness / 未知 Harness / 草稿 / 协作子任务
@@ -152,7 +161,65 @@ async function main() {
152
161
  const child = await rt.createThread({ harnessId: 'a', cwd: root, parentThreadId: thread.id });
153
162
  await assert.rejects(rt.switchHarness(child.id, 'b'), /协作子任务/);
154
163
 
155
- // 8) 持久化:threads.json 里切换链完整(含原生会话引用,供重启后惰性恢复)
164
+ // 8) 待投递接力可显式取消:关闭目标 Session,恢复源 Harness,不污染既有链
165
+ const chainBeforeCancel = thread.harnessChain.length;
166
+ const cancelCandidate = await rt.switchHarness(thread.id, 'b', { intent: 'review' });
167
+ assert.equal(thread.pendingHandoff.phase, 'ready');
168
+ await rt.send(thread.id, '/switch cancel');
169
+ assert.equal(thread.harnessId, 'a');
170
+ assert.equal(thread.pendingHandoff, undefined);
171
+ assert.equal(thread.harnessChain.length, chainBeforeCancel);
172
+ assert.equal(rt.handoffs.owned(thread.id, cancelCandidate.checkpointId).status, 'cancelled');
173
+ assert.equal(rt.sessions.get(thread.id)?.adapter, a.adapter);
174
+
175
+ // 9) 目标连接失败时事务回滚:线程归属、原生源 Session 与 chain 都恢复
176
+ const c = fakeHarness('c', 'Gamma');
177
+ c.state.openError = 'Gamma auth expired';
178
+ rt.adapters.set('c', c.adapter); rt.status.c = { available: true };
179
+ await assert.rejects(rt.switchHarness(thread.id, 'c'), /已恢复原 Harness.*Gamma auth expired/);
180
+ assert.equal(thread.harnessId, 'a');
181
+ assert.equal(thread.pendingHandoff, undefined);
182
+ assert.equal(thread.harnessChain.length, chainBeforeCancel);
183
+ assert.equal(rt.sessions.get(thread.id)?.adapter, a.adapter);
184
+ const rolledBack = [...rt.handoffs.checkpoints.values()].find(row => row.targetHarnessId === 'c');
185
+ assert.equal(rolledBack.status, 'rolled-back');
186
+
187
+ // 10) 首轮投递失败保留检查点;下一次发送复用同一目标 Session 和同一检查点重试
188
+ a.state.withFileChange = false;
189
+ const retryThread = await rt.createThread({ harnessId: 'a', cwd: root });
190
+ await rt.send(retryThread.id, '准备接力重试');
191
+ await settle(retryThread.id);
192
+ const retrySwitch = await rt.switchHarness(retryThread.id, 'b', { intent: 'review' });
193
+ b.state.sendError = 'temporary transport failure';
194
+ await rt.send(retryThread.id, '先审查');
195
+ await settle(retryThread.id);
196
+ assert.equal(retryThread.pendingHandoff.phase, 'failed');
197
+ assert.equal(rt.handoffs.owned(retryThread.id, retrySwitch.checkpointId).status, 'failed');
198
+ b.state.sendError = null;
199
+ await rt.send(retryThread.id, '重试审查');
200
+ await settle(retryThread.id);
201
+ assert.equal(retryThread.pendingHandoff, undefined);
202
+ assert.equal(rt.handoffs.owned(retryThread.id, retrySwitch.checkpointId).status, 'active');
203
+ assert.match(b.state.sent.at(-1), /Handoff mode: review.*Do not modify files/s);
204
+
205
+ // 11) 并发切换按 Thread 串行化:第二个 RPC 不得在第一个连接期间再创建检查点
206
+ const concurrentThread = await rt.createThread({ harnessId: 'a', cwd: root });
207
+ await rt.send(concurrentThread.id, '准备并发切换');
208
+ await settle(concurrentThread.id);
209
+ const d = fakeHarness('d', 'Delta');
210
+ let releaseDelta;
211
+ d.state.openWait = new Promise(resolve => { releaseDelta = resolve; });
212
+ rt.adapters.set('d', d.adapter); rt.status.d = { available: true };
213
+ const firstSwitch = rt.switchHarness(concurrentThread.id, 'd');
214
+ await wait(() => rt.switching.has(concurrentThread.id));
215
+ await assert.rejects(rt.switchHarness(concurrentThread.id, 'b'), /正在切换 Harness/);
216
+ releaseDelta();
217
+ await firstSwitch;
218
+ assert.equal(concurrentThread.harnessId, 'd');
219
+ await rt.cancelHarnessSwitch(concurrentThread.id);
220
+ assert.equal(concurrentThread.harnessId, 'a');
221
+
222
+ // 12) 持久化:threads.json 里切换链完整(含原生会话引用,供重启后惰性恢复)
156
223
  await rt.store.save(rt.threads);
157
224
  const persisted = (await rt.store.load()).find(t => t.id === thread.id);
158
225
  assert.equal(persisted.harnessId, 'a');
@@ -89,20 +89,22 @@ async function main() {
89
89
  const call = { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'same-id', name: 'Read', input: { file_path: 'a.txt' } }] } };
90
90
  for (const event of projectEvent(call)) emitA(event);
91
91
  emitB({ kind: 'tool', toolCallId: 'same-id', title: 'Read', state: 'running', input: 'b.txt' });
92
- assert.equal(notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'mcpToolCall').length, 0);
92
+ assert.equal(notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'dynamicToolCall').length, 0);
93
93
  for (const event of projectEvent({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'same-id', content: [{ type: 'text', text: 'only A' }] }] } })) emitA(event);
94
94
  emitB({ kind: 'tool', toolCallId: 'same-id', state: 'error', output: 'only B failed' });
95
- const completed = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'mcpToolCall');
95
+ const completed = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'dynamicToolCall');
96
96
  assert.equal(completed.length, 2);
97
- assert.equal(completed.find(n => n.params.threadId === a.id).params.item.result.content[0].text, 'only A');
98
- assert.equal(completed.find(n => n.params.threadId === b.id).params.item.error.message, 'only B failed');
97
+ assert.equal(completed.find(n => n.params.threadId === a.id).params.item.contentItems[0].text, 'only A');
98
+ const failedB = completed.find(n => n.params.threadId === b.id).params.item;
99
+ assert.equal(failedB.status, 'failed');
100
+ assert.equal(failedB.contentItems[0].text, 'only B failed');
99
101
  assert.equal(a.tools[0].input, '{"file_path":"a.txt"}');
100
102
  assert.equal(a.tools[0].output, 'only A');
101
103
  assert.equal(b.tools[0].output, 'only B failed');
102
104
  emitB({ kind: 'tool', toolCallId: 'same-id', state: 'error', output: { error: 'late detail' } });
103
- const late = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'mcpToolCall').at(-1);
105
+ const late = notifications.filter(n => n.method === 'item/completed' && n.params?.item?.type === 'dynamicToolCall').at(-1);
104
106
  assert.equal(late.params.threadId, b.id);
105
- assert.equal(late.params.item.result.content[0].text, '{"error":"late detail"}');
107
+ assert.equal(late.params.item.contentItems[0].text, '{"error":"late detail"}');
106
108
  emitA({ kind: 'completed', finalAnswer: true });
107
109
  assert.equal(rt.execution.isRunning(b.id), true);
108
110
  emitB({ kind: 'completed', finalAnswer: true });
@@ -0,0 +1 @@
1
+ <svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cline</title><path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path><path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path></svg>