@harness-mix/cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/NOTICE +14 -0
  3. package/README.md +4 -24
  4. package/docs/multi-agent-collaboration.md +1 -1
  5. package/licenses/anthropic-codex-theme-MIT.txt +21 -0
  6. package/licenses/codex-dream-skin-MIT.txt +21 -0
  7. package/output/native-build/renderer-extension.js +703 -58
  8. package/package.json +192 -189
  9. package/scripts/collaboration-test.cjs +262 -200
  10. package/scripts/collaboration-ui-smoke.cjs +85 -77
  11. package/scripts/core-concurrency-test.cjs +23 -1
  12. package/scripts/e2e-pi-cancel.cjs +99 -0
  13. package/scripts/native-file-change-test.cjs +42 -2
  14. package/scripts/native-sidebar-test.cjs +8 -0
  15. package/scripts/native-vendor-adapters-test.cjs +67 -1
  16. package/scripts/pets-test.cjs +63 -0
  17. package/scripts/pi-cancel-race-test.cjs +102 -0
  18. package/scripts/stuck-turn-test.cjs +73 -0
  19. package/scripts/workspace-test.cjs +9 -0
  20. package/src/assets/skins/dream-skin/NOTICE.md +13 -0
  21. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/hero.webp +0 -0
  22. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/theme.json +34 -0
  23. package/src/main/adapters/claude.js +9 -6
  24. package/src/main/adapters/pi-family.js +41 -8
  25. package/src/main/harness-adapter/event-normalizer.js +270 -258
  26. package/src/main/host/collaboration-mcp.cjs +8 -1
  27. package/src/main/host/collaboration-tools.js +1 -1
  28. package/src/main/host/collaboration.js +81 -11
  29. package/src/main/host/runtime.js +1420 -1341
  30. package/src/main/native/pets.js +79 -2
  31. package/src/main/native/protocol.js +1160 -1145
  32. package/src/main/native/thread-list.js +6 -1
  33. package/src/main/workspace/core-review.js +75 -0
  34. package/src/main/workspace/diff.js +5 -1
  35. package/src/main/workspace/file-changes.js +9 -4
  36. package/src/main/workspace/review-controller.js +115 -102
  37. package/src/main/workspace/review.js +90 -88
  38. package/src/native-ui/renderer-extension/dist/contract-audit.js +16637 -0
  39. package/src/native-ui/renderer-extension/dist/index.js +34853 -0
  40. package/src/native-ui/renderer-extension/dist/production.js +34263 -0
  41. package/src/native-ui/renderer-extension/dist/renderer-binding-probe.js +34254 -0
  42. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  43. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +11 -0
  44. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  45. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts +73 -0
  46. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts.map +1 -0
  47. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +1 -0
  48. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  49. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  50. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts +12 -0
  51. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts.map +1 -0
  52. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  53. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  54. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  55. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
  56. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  57. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts +1 -1
  58. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts.map +1 -1
  59. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts +9 -0
  60. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts.map +1 -1
  61. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -1
  63. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  64. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3181 -3171
  65. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +14 -0
  66. package/src/native-ui/renderer-extension/src/renderer-pet-switcher.ts +308 -0
  67. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
  68. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +76 -21
  69. package/src/native-ui/renderer-extension/src/settings/collaboration-page.ts +139 -0
  70. package/src/native-ui/renderer-extension/src/settings/icons.ts +155 -153
  71. package/src/native-ui/renderer-extension/src/settings/localization.ts +785 -783
  72. package/src/native-ui/renderer-extension/src/settings/pages.ts +694 -681
  73. package/src/native-ui/renderer-extension/src/settings/pet-market.css +110 -1
  74. package/src/native-ui/renderer-extension/src/settings/pet-market.ts +276 -22
  75. package/src/native-ui/renderer-extension/src/settings/pets-client.ts +32 -0
  76. package/src/native-ui/renderer-extension/src/settings/shell.css +2294 -2255
  77. package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +51 -1
  78. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
  79. package/src/native-ui/renderer-extension/test/renderer-pet-switcher.test.ts +400 -0
  80. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +80 -79
  81. package/src/native-ui/renderer-extension/test/settings/pet-market.test.ts +378 -0
  82. package/src/native-ui/renderer-extension/test/settings/pets-client.test.ts +83 -0
  83. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
  84. package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +15 -1
@@ -0,0 +1,102 @@
1
+ // Pi 家族 adapter 的「取消竞态」单元测试(不起原生进程、不走模型):
2
+ // Pi RPC 的 prompt 在 preflight 完成后才 ack,期间 abort 没有活动 run 可停,会落空;
3
+ // 修复后:cancel 落在 prompt in-flight 窗口时,ack 之后必须补发一发 abort,
4
+ // 把刚启动的 run 真正停掉,避免用户看不见的僵尸运行。
5
+ const { piFamily } = require('../src/main/adapters/pi-family');
6
+
7
+ let passed = 0;
8
+ let failed = 0;
9
+ function test(name, fn) {
10
+ Promise.resolve()
11
+ .then(fn)
12
+ .then(() => { passed++; console.log('ok', name); })
13
+ .catch((error) => { failed++; console.error('FAIL', name, '-', error.message); process.exitCode = 1; });
14
+ }
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ function fakeSession({ preflightMs = 50, failFirst = null } = {}) {
19
+ const calls = [];
20
+ let promptAttempts = 0;
21
+ return {
22
+ calls,
23
+ get aborts() { return calls.filter((c) => c.type === 'abort').length; },
24
+ get prompts() { return calls.filter((c) => c.type === 'prompt'); },
25
+ process: {
26
+ harnessMixAnswer: undefined,
27
+ async command(payload) {
28
+ calls.push(payload);
29
+ if (payload.type === 'prompt') {
30
+ promptAttempts += 1;
31
+ if (failFirst && promptAttempts === 1) throw new Error(failFirst);
32
+ // 模拟 preflight:ack 之前的异步窗口(此时原生侧尚无 active run)
33
+ await sleep(preflightMs);
34
+ return {};
35
+ }
36
+ if (payload.type === 'abort') return {};
37
+ return {};
38
+ },
39
+ },
40
+ };
41
+ }
42
+
43
+ const adapter = piFamily({ id: 'pi', name: 'Pi', icon: '', bin: 'pi', packageHint: '' }).create(() => {});
44
+ const hooks = { emit() {} };
45
+
46
+ // 1) cancel 落在 preflight 窗口:ack 后必须补发第二发 abort
47
+ test('cancel during prompt preflight re-issues abort after ack', async () => {
48
+ const session = fakeSession({ preflightMs: 60 });
49
+ const pending = adapter.send(session, 'hello', hooks);
50
+ await sleep(10); // prompt 已发出、ack 未回(in-flight)
51
+ await adapter.cancel(session); // 第一发 abort(原生侧落空)
52
+ await pending;
53
+ await sleep(10); // 补发是 fire-and-forget,等它落盘
54
+ if (session.aborts !== 2) throw new Error(`expected 2 aborts (cancel + re-abort after ack), got ${session.aborts}`);
55
+ });
56
+
57
+ // 2) 正常路径:send 已完成后再 cancel,不应多补 abort
58
+ test('cancel after prompt ack does not re-abort', async () => {
59
+ const session = fakeSession({ preflightMs: 5 });
60
+ await adapter.send(session, 'hello', hooks);
61
+ await adapter.cancel(session);
62
+ await sleep(10);
63
+ if (session.aborts !== 1) throw new Error(`expected exactly 1 abort, got ${session.aborts}`);
64
+ });
65
+
66
+ // 3) 无 cancel:不应多任何 abort
67
+ test('plain send issues no abort', async () => {
68
+ const session = fakeSession({ preflightMs: 5 });
69
+ await adapter.send(session, 'hello', hooks);
70
+ await sleep(10);
71
+ if (session.aborts !== 0) throw new Error(`expected 0 aborts, got ${session.aborts}`);
72
+ });
73
+
74
+ // 4) already processing → followUp 排队路径上 cancel:ack 后同样补停
75
+ test('cancel during followUp-queue retry also re-aborts after ack', async () => {
76
+ const session = fakeSession({ preflightMs: 60, failFirst: 'Agent is already processing. Specify streamingBehavior...' });
77
+ const pending = adapter.send(session, 'hello', hooks);
78
+ await sleep(10);
79
+ await adapter.cancel(session);
80
+ await pending;
81
+ await sleep(10);
82
+ if (session.prompts.length !== 2) throw new Error(`expected prompt retry as followUp, got ${session.prompts.length} prompts`);
83
+ if (session.prompts[1].streamingBehavior !== 'followUp') throw new Error('retry must use streamingBehavior followUp');
84
+ if (session.aborts !== 2) throw new Error(`expected 2 aborts (cancel + re-abort after ack), got ${session.aborts}`);
85
+ });
86
+
87
+ // 5) preflight 失败(ack 拒绝)后 cancel 标志不残留、不误伤后续 send
88
+ test('failed preflight clears in-flight state; later send unaffected', async () => {
89
+ const session = fakeSession({ failFirst: 'Authentication failed for "kimi-coding"' });
90
+ await adapter.send(session, 'hello', hooks).catch(() => {});
91
+ await adapter.cancel(session); // 此时已无 in-flight
92
+ await sleep(10);
93
+ if (session.aborts !== 1) throw new Error(`expected exactly 1 abort, got ${session.aborts}`);
94
+ await adapter.send(session, 'again', hooks); // 第二次 send 走 preflight 成功
95
+ await sleep(10);
96
+ if (session.aborts !== 1) throw new Error(`stale cancel flag leaked into later send (aborts=${session.aborts})`);
97
+ });
98
+
99
+ process.on('beforeExit', () => {
100
+ console.log(`pi-cancel-race: ${passed} passed${failed ? `, ${failed} failed` : ''}`);
101
+ if (failed) process.exitCode = 1;
102
+ });
@@ -0,0 +1,73 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs/promises');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { HostRuntime } = require('../src/main/host/runtime');
6
+
7
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
8
+ async function until(fn) {
9
+ for (let i = 0; i < 300; i++) { if (fn()) return; await sleep(10); }
10
+ throw new Error('timed out');
11
+ }
12
+
13
+ // 卡死回合看门狗:原生 Harness wedge(零事件)时回合必须按超时自动结算;
14
+ // 活跃回合与等待审批的合法静默回合不得被误杀。
15
+ (async () => {
16
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'hm-stuck-turn-'));
17
+ const root = path.join(directory, 'workspace');
18
+ await fs.mkdir(root);
19
+
20
+ const rt = new HostRuntime({ dataDirectory: directory, stuckTurnMs: 300, stuckSweepMs: 50 });
21
+ await rt.store.load();
22
+
23
+ const emits = new Map();
24
+ const adapter = {
25
+ manifest: { id: 'test-harness', name: 'Test', capabilities: {} },
26
+ async open(input) { emits.set(input.thread.id, input.emit); return {}; },
27
+ async send(session) {},
28
+ async cancel(session) {},
29
+ async close() {},
30
+ };
31
+ rt.adapters.set(adapter.manifest.id, adapter);
32
+ rt.status[adapter.manifest.id] = { available: true };
33
+
34
+ try {
35
+ // 1. 持续有事件的活跃回合:看门狗不得误伤
36
+ const active = await rt.createThread({ harnessId: 'test-harness', cwd: root });
37
+ await rt.send(active.id, 'keep emitting');
38
+ const emitActive = emits.get(active.id);
39
+ for (let i = 0; i < 8; i++) { emitActive({ kind: 'text-delta', text: '.' }); await sleep(80); }
40
+ assert.equal(rt.execution.isRunning(active.id), true, 'active turn must survive the watchdog');
41
+ emitActive({ kind: 'completed', finalAnswer: true });
42
+ await until(() => active.status === 'ready' && !active.reviewPending);
43
+
44
+ // 2. 等待用户审批的回合是合法静默:超过阈值也不得结算
45
+ const approval = await rt.createThread({ harnessId: 'test-harness', cwd: root });
46
+ await rt.send(approval.id, 'needs approval');
47
+ const emitApproval = emits.get(approval.id);
48
+ emitApproval({ kind: 'approval', requestId: 'r1', method: 'confirm', title: 'Allow?' });
49
+ await sleep(700);
50
+ assert.equal(rt.execution.isRunning(approval.id), true, 'approval-waiting turn is legitimate silence and must survive');
51
+ emitApproval({ kind: 'interaction-responded', requestId: 'r1' });
52
+ emitApproval({ kind: 'completed', finalAnswer: true });
53
+ await until(() => approval.status === 'ready' && !approval.reviewPending);
54
+
55
+ // 3. 零事件 wedge 回合:超时后自动按错误结算,UI 不再永远转圈
56
+ const stuck = await rt.createThread({ harnessId: 'test-harness', cwd: root });
57
+ await rt.send(stuck.id, 'wedged session');
58
+ await until(() => stuck.status === 'error');
59
+ assert.match(stuck.error, /卡死/);
60
+ const turn = rt.execution.lastTurn(stuck.id);
61
+ assert.equal(turn.status, 'error');
62
+ await until(() => !stuck.reviewPending);
63
+ const open = rt.core.getItemsForTurn(turn.id).filter(item => !['completed', 'error', 'cancelled'].includes(item.status));
64
+ assert.equal(open.length, 0, 'watchdog settlement finalizes every open item');
65
+
66
+ console.log('stuck-turn-test: active/approval-waiting turns survive; wedged zero-event turn auto-settles and finalizes items');
67
+ } finally {
68
+ await rt.close();
69
+ }
70
+ })().catch(error => {
71
+ console.error(error);
72
+ process.exitCode = 1;
73
+ });
@@ -49,6 +49,15 @@ const { HostRuntime } = require('../src/main/host/runtime');
49
49
  await fs.symlink(path.join(directory, 'outside'), path.join(root, 'link'), 'junction');
50
50
  await assert.rejects(resolveFile(root, 'link/file.txt'), /不跟随/);
51
51
  const empty = diff('', ''); assert.equal(empty.rows.length, 0);
52
+ // 行尾符幻影:LF 基线 vs CRLF 当前内容(逐行相同)不产生变更,也不进入审查记录
53
+ const eol = diff('alpha\nbravo\n', 'alpha\r\nbravo\r\n');
54
+ assert.equal(eol.added, 0); assert.equal(eol.removed, 0);
55
+ assert.ok(diff('alpha\n', 'alpha\r\nbravo\r\n').added === 1, '真实新增行不受行尾归一化影响');
56
+ const eolStore = new ReviewStore(path.join(directory, 'eol')), eolId = await eolStore.begin(root);
57
+ await fs.writeFile(path.join(root, 'dirty.js'), 'user preexisting change\r\nold line\r\n');
58
+ const eolPreview = await eolStore.preview(eolId);
59
+ assert.ok(!eolPreview.changes.some(c => c.path === 'dirty.js'), 'CRLF-only flip is not a change');
60
+ await fs.writeFile(path.join(root, 'dirty.js'), 'user preexisting change\nold line\n');
52
61
  const rt = new HostRuntime({ observer: new ParityObserver(), dataDirectory: path.join(directory, 'runtime') });
53
62
  const updates = [];
54
63
  rt.subscribe(e => { if (e.type === 'turn/diff/updated') updates.push(e); });
@@ -0,0 +1,13 @@
1
+ # Codex Dream Skin — bundled preset notice
2
+
3
+ The `themes/gothic-void-crusade/` preset is redistributed from
4
+ [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin)
5
+ (MIT License, see `licenses/codex-dream-skin-MIT.txt`).
6
+
7
+ | Asset | Origin | License |
8
+ | --- | --- | --- |
9
+ | `themes/gothic-void-crusade/hero.webp` | `macos/presets/preset-gothic-void-crusade/background.jpg` (2560×1440), re-encoded to WebP. Original gothic sci-fi artwork designed and contributed to the source project by [@seansong-ideogram](https://github.com/seansong-ideogram). | MIT (via source repository) |
10
+ | `themes/gothic-void-crusade/theme.json` | Source preset metadata, kept verbatim for provenance. Not read at runtime; the renderer skin definition lives in `src/native-ui/renderer-extension/src/settings/skin-runtime.ts`. | MIT (via source repository) |
11
+
12
+ Palette and preview focus are taken from the source `theme.json`
13
+ (`colors.background/accent/muted/text`, `art.focusX/focusY`).
@@ -0,0 +1,34 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "preset-gothic-void-crusade",
4
+ "name": "Gothic Void Crusade",
5
+ "brandSubtitle": "CODEX DREAM SKIN",
6
+ "tagline": "A solemn cathedral-world horizon for focused work.",
7
+ "projectPrefix": "Select project · ",
8
+ "projectLabel": "◉ Select project",
9
+ "statusText": "VOID CRUSADE ONLINE",
10
+ "quote": "MAKE SOMETHING WONDERFUL",
11
+ "image": "background.jpg",
12
+ "appearance": "dark",
13
+ "art": {
14
+ "focusX": 0.76,
15
+ "focusY": 0.45,
16
+ "safeArea": "left",
17
+ "taskMode": "ambient"
18
+ },
19
+ "colors": {
20
+ "background": "#0d0d0e",
21
+ "panel": "#171513",
22
+ "panelAlt": "#211d18",
23
+ "accent": "#c8a55a",
24
+ "accentAlt": "#e3c27a",
25
+ "secondary": "#74352e",
26
+ "highlight": "#8a2f27",
27
+ "text": "#f3ead7",
28
+ "muted": "#b5a386",
29
+ "line": "rgba(200, 165, 90, .28)"
30
+ },
31
+ "promoTitle": "Codex Dream Skin",
32
+ "promoSub": "Gothic Void Crusade",
33
+ "promoUrl": "https://github.com/Fei-Away/Codex-Dream-Skin"
34
+ }
@@ -272,6 +272,9 @@ function spawnSession(sdk, { cwd, resumeId, newSessionId, permissionMode, modelI
272
272
  state: { turn: null, crashed: false, lastUsage: undefined, latestPlanLimit: undefined, checkpointId: undefined },
273
273
  };
274
274
 
275
+ // Host 侧投影异常绝不能杀死原生事件泵:crashed 只保留给真正的原生流错误。
276
+ const safeEmit = (mapped) => { try { emit(mapped); } catch { /* host projection must not kill the native pump */ } };
277
+
275
278
  session.query = sdk.query({
276
279
  prompt: input,
277
280
  options: {
@@ -293,7 +296,7 @@ function spawnSession(sdk, { cwd, resumeId, newSessionId, permissionMode, modelI
293
296
  }
294
297
  }
295
298
  const requestId = randomUUID();
296
- emit(projectApproval(requestId, toolName, toolInput, suggestions));
299
+ safeEmit(projectApproval(requestId, toolName, toolInput, suggestions));
297
300
  return new Promise((resolve) => {
298
301
  const onAbort = () => {
299
302
  session.pendingApprovals.delete(requestId);
@@ -316,7 +319,7 @@ function spawnSession(sdk, { cwd, resumeId, newSessionId, permissionMode, modelI
316
319
  session.nativeSessionId = event.session_id;
317
320
  session.model = { id: event.model, name: event.model };
318
321
  session.permissionMode = event.permissionMode ?? session.permissionMode;
319
- emit({ kind: "session", nativeSessionId: event.session_id, model: session.model });
322
+ safeEmit({ kind: "session", nativeSessionId: event.session_id, model: session.model });
320
323
  continue;
321
324
  }
322
325
  if (event.type === "rate_limit_event") {
@@ -326,7 +329,7 @@ function spawnSession(sdk, { cwd, resumeId, newSessionId, permissionMode, modelI
326
329
  if (typeof onPlanLimit === "function") onPlanLimit(limit);
327
330
  const current = session.state.lastUsage || {};
328
331
  session.state.lastUsage = applyClaudePlanLimitToUsage(current, limit);
329
- emit({ kind: "usage", usage: session.state.lastUsage });
332
+ safeEmit({ kind: "usage", usage: session.state.lastUsage });
330
333
  }
331
334
  continue;
332
335
  }
@@ -337,14 +340,14 @@ function spawnSession(sdk, { cwd, resumeId, newSessionId, permissionMode, modelI
337
340
  for (const mapped of projectEvent(event)) {
338
341
  // 结算事件携带原生检查点(assistant uuid),供 fork 边界定位
339
342
  if (mapped.kind === 'completed') mapped.nativeRef = { ...mapped.nativeRef, checkpointId: session.state.checkpointId };
340
- emit(mapped);
343
+ safeEmit(mapped);
341
344
  }
342
345
  session.state.turn?.resolve();
343
346
  session.state.turn = null;
344
347
  continue;
345
348
  }
346
349
  if (event.type === 'assistant' && event.uuid) session.state.checkpointId = event.uuid;
347
- for (const mapped of projectEvent(event)) emit(mapped);
350
+ for (const mapped of projectEvent(event)) safeEmit(mapped);
348
351
  }
349
352
  // 流正常结束(close):未结算的回合按取消处理
350
353
  session.state.turn?.resolve();
@@ -352,7 +355,7 @@ function spawnSession(sdk, { cwd, resumeId, newSessionId, permissionMode, modelI
352
355
  } catch (error) {
353
356
  session.state.crashed = true;
354
357
  if (session.state.turn) { session.state.turn.reject(error); session.state.turn = null; }
355
- else emit({ kind: "error", message: `Claude 会话中断:${error.message}` });
358
+ else safeEmit({ kind: "error", message: `Claude 会话中断:${error.message}` });
356
359
  }
357
360
  })();
358
361
  return session;
@@ -26,6 +26,15 @@ function sanitizeName(title) {
26
26
  return String(title || "").replace(/["&|<>^%]/g, "").trim().slice(0, 40) || "Harness Mix 任务";
27
27
  }
28
28
 
29
+ // 文件编辑类工具的目标路径(结构化元数据):Host 用它把全工作区快照收窄为
30
+ // 「本轮真实触碰的文件」,让并发同目录的会话各自只显示自己的改动。
31
+ const FILE_EDIT_TOOLS = new Set(["edit", "write", "multiedit", "multi_edit", "apply_patch"]);
32
+ function editTarget(event) {
33
+ const tool = String(event.toolName || "").toLowerCase();
34
+ const target = event.args && typeof event.args.path === "string" ? event.args.path : null;
35
+ return FILE_EDIT_TOOLS.has(tool) && target ? { path: target } : {};
36
+ }
37
+
29
38
  /** 工具结果里的图片块 → 统一 artifact 事件(各 Harness 返回产物在桌面层对齐呈现;超大图片不入会话存档) */
30
39
  function imageArtifacts(content, toolCallId) {
31
40
  if (!Array.isArray(content)) return [];
@@ -68,9 +77,11 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
68
77
  recordNative(manifest.id, event);
69
78
  if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta' && event.assistantMessageEvent.delta) process.harnessMixAnswer = (process.harnessMixAnswer || '') + event.assistantMessageEvent.delta;
70
79
  if (event.type === 'message_end' && event.message?.role === 'assistant') {
71
- if (event.message.stopReason === 'error' || event.message.errorMessage) {
72
- emit({ kind: 'error', message: event.message.errorMessage || `${name} native model turn failed` });
73
- } else if (!process.harnessMixAnswer) {
80
+ // 失败不在 message_end 定论:紧随其后的 agent_end 携带 willRetry,由它决定投影
81
+ // 「自动重试中」还是最终错误。若在此直接发 error,Host 会提前把 Turn 结算为失败,
82
+ // 自动重试成功的事件全被丢弃,用户重发消息还会撞上原生 "Agent is already processing"。
83
+ const failed = event.message.stopReason === 'error' || event.message.errorMessage;
84
+ if (!failed && !process.harnessMixAnswer) {
74
85
  const answer = Array.isArray(event.message.content) ? event.message.content.filter(block => block?.type === 'text').map(block => block.text || '').join('\n') : '';
75
86
  if (answer) emit({ kind: 'text-delta', text: answer });
76
87
  }
@@ -137,10 +148,31 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
137
148
  // 图片走 RPC 原生 images 字段(base64);文本附件由 Host 内联进 text
138
149
  const images = (attachments?.images ?? []).map((a) => ({ type: 'image', data: a.data, mimeType: a.mime }));
139
150
  session.process.harnessMixAnswer = '';
140
- await session.process.command({ type: "prompt", message: text, ...(images.length ? { images } : {}) });
151
+ // Pi prompt 命令在 preflight(扩展 input 钩子、压缩检查等)完成后才 ack;
152
+ // 此期间 abort 没有活动 run 可停,会落空,消息在 ack 后照常开跑(僵尸运行)。
153
+ // 用 in-flight 计数 + cancelRequested 标志:ack 之后补一发 abort 把刚启动的 run 真正停掉。
154
+ session.promptInFlight = (session.promptInFlight || 0) + 1;
155
+ try {
156
+ try {
157
+ await session.process.command({ type: "prompt", message: text, ...(images.length ? { images } : {}) });
158
+ } catch (error) {
159
+ // Host 与原生状态偶发分叉(取消竞态、原生运行未收尾)时,按原生协议提示把消息
160
+ // 排队为 followUp(当前运行结束后处理),而不是把内部错误直接抛给用户。
161
+ if (!/already processing/i.test(String(error?.message))) throw error;
162
+ await session.process.command({ type: "prompt", message: text, streamingBehavior: "followUp", ...(images.length ? { images } : {}) });
163
+ }
164
+ } finally {
165
+ session.promptInFlight -= 1;
166
+ if (session.cancelRequested && !session.promptInFlight) {
167
+ session.cancelRequested = false;
168
+ void session.process.command({ type: "abort" }).catch(() => {});
169
+ }
170
+ }
141
171
  },
142
172
 
143
173
  async cancel(session) {
174
+ // prompt 仍在 preflight(ack 未回)时,这发 abort 大概率落空:置标志,由 send() 在 ack 后补停
175
+ if (session.promptInFlight) session.cancelRequested = true;
144
176
  await session.process.command({ type: "abort" });
145
177
  },
146
178
 
@@ -286,12 +318,12 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
286
318
  return null;
287
319
  }
288
320
  case "tool_execution_start":
289
- return { kind: "tool", toolCallId: event.toolCallId, title: event.toolName || "工具", state: "running", detail: firstLine(event.args ? Object.values(event.args)[0] : ""), input: toolText(event.args?.command ?? JSON.stringify(event.args ?? {}, null, 2)) };
321
+ return { kind: "tool", toolCallId: event.toolCallId, title: event.toolName || "工具", state: "running", detail: firstLine(event.args ? Object.values(event.args)[0] : ""), input: toolText(event.args?.command ?? JSON.stringify(event.args ?? {}, null, 2)), ...editTarget(event) };
290
322
  case "tool_execution_update":
291
- return { kind: "tool", toolCallId: event.toolCallId, title: event.toolName || "工具", state: "running", detail: summarizeContent(event.partialResult?.content), output: toolText(event.partialResult?.content) };
323
+ return { kind: "tool", toolCallId: event.toolCallId, title: event.toolName || "工具", state: "running", detail: summarizeContent(event.partialResult?.content), output: toolText(event.partialResult?.content), ...editTarget(event) };
292
324
  case "tool_execution_end":
293
325
  return [
294
- { kind: "tool", toolCallId: event.toolCallId, title: event.toolName || "工具", state: event.isError ? "error" : "done", detail: summarizeContent(event.result?.content), output: toolText(event.result?.content) },
326
+ { kind: "tool", toolCallId: event.toolCallId, title: event.toolName || "工具", state: event.isError ? "error" : "done", detail: summarizeContent(event.result?.content), output: toolText(event.result?.content), ...editTarget(event) },
295
327
  ...imageArtifacts(event.result?.content, event.toolCallId),
296
328
  ];
297
329
  case "extension_ui_request": {
@@ -336,7 +368,8 @@ function piFamily({ id, name, icon, bin, packageHint, aliases }) {
336
368
  }
337
369
  }
338
370
 
339
- return { manifest, create, project };
371
+ // forwardEvent 暴露给回放/单测(runtime 经由 create().open() 闭包使用同一份实现)
372
+ return { manifest, create, project, forwardEvent };
340
373
  }
341
374
 
342
375
  module.exports = { piFamily, PI_PERMISSION_MODES };