harness-mix 0.1.2 → 0.1.3

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 (96) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +60 -15
  3. package/docs/images/codex-desktop-home.png +0 -0
  4. package/docs/images/codex-desktop-session.png +0 -0
  5. package/docs/multi-agent-collaboration.md +68 -68
  6. package/output/native-build/renderer-extension.js +2225 -1761
  7. package/package.json +1 -2
  8. package/scripts/collaboration-recovery-test.cjs +22 -2
  9. package/scripts/collaboration-ui-smoke.cjs +10 -5
  10. package/scripts/native-protocol-test.cjs +20 -0
  11. package/scripts/workspace-test.cjs +2 -14
  12. package/src/main/adapters/antigravity.js +49 -18
  13. package/src/main/adapters/claude.js +3 -2
  14. package/src/main/adapters/codex.js +10 -8
  15. package/src/main/adapters/dsh.js +3 -2
  16. package/src/main/adapters/opencode.js +21 -13
  17. package/src/main/adapters/pi-family.js +3 -3
  18. package/src/main/host/collaboration-worktree.js +63 -4
  19. package/src/main/host/collaboration.js +28 -2
  20. package/src/main/host/runtime.js +22 -1
  21. package/src/main/native/protocol.js +250 -24
  22. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  23. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
  24. package/src/native-ui/renderer-extension/dist/types/index.d.ts +2 -0
  25. package/src/native-ui/renderer-extension/dist/types/index.d.ts.map +1 -1
  26. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  27. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +40 -0
  28. package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -0
  29. package/src/native-ui/renderer-extension/dist/types/renderer-credits-control.d.ts.map +1 -1
  30. package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
  31. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +8 -0
  32. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  33. package/src/native-ui/renderer-extension/dist/types/renderer-usage-control.d.ts.map +1 -1
  34. package/src/native-ui/renderer-extension/dist/types/settings/connections-page.d.ts +3 -1
  35. package/src/native-ui/renderer-extension/dist/types/settings/connections-page.d.ts.map +1 -1
  36. package/src/native-ui/renderer-extension/dist/types/settings/harness-accounts.d.ts +8 -1
  37. package/src/native-ui/renderer-extension/dist/types/settings/harness-accounts.d.ts.map +1 -1
  38. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts +16 -0
  39. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  40. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +1 -0
  41. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  42. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  43. package/src/native-ui/renderer-extension/src/assets/README.md +8 -38
  44. package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +9 -1
  45. package/src/native-ui/renderer-extension/src/index.ts +9 -0
  46. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +67 -6
  47. package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +198 -0
  48. package/src/native-ui/renderer-extension/src/renderer-credits-control.ts +32 -12
  49. package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +139 -46
  50. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +7 -2
  51. package/src/native-ui/renderer-extension/src/renderer-usage-control.ts +34 -3
  52. package/src/native-ui/renderer-extension/src/settings/accounts.css +80 -4
  53. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +68 -29
  54. package/src/native-ui/renderer-extension/src/settings/harness-accounts.ts +89 -28
  55. package/src/native-ui/renderer-extension/src/settings/localization.ts +48 -0
  56. package/src/native-ui/renderer-extension/src/settings/pages.ts +19 -2
  57. package/src/native-ui/renderer-extension/src/settings/shell.css +62 -0
  58. package/src/native-ui/renderer-extension/src/settings/shell.ts +1 -1
  59. package/src/native-ui/renderer-extension/test/renderer-collab-cards.test.ts +45 -0
  60. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +1 -0
  61. package/src/native-ui/shared-contracts/dist/harness-accounts.d.ts +20 -6
  62. package/src/native-ui/shared-contracts/dist/harness-accounts.d.ts.map +1 -1
  63. package/src/native-ui/shared-contracts/dist/harness-accounts.js +4 -1
  64. package/src/native-ui/shared-contracts/dist/harness-accounts.js.map +1 -1
  65. package/src/native-ui/shared-contracts/dist/harness-models.d.ts +22 -0
  66. package/src/native-ui/shared-contracts/dist/harness-models.d.ts.map +1 -1
  67. package/src/native-ui/shared-contracts/dist/harness-models.js +2 -1
  68. package/src/native-ui/shared-contracts/dist/harness-models.js.map +1 -1
  69. package/src/native-ui/shared-contracts/dist/tsconfig.tsbuildinfo +1 -1
  70. package/src/native-ui/shared-contracts/src/harness-accounts.ts +4 -1
  71. package/src/native-ui/shared-contracts/src/harness-models.ts +6 -1
  72. package/docs/images/harness-mix-home.png +0 -0
  73. package/docs/images/harness-mix-session.png +0 -0
  74. package/scripts/git-test.cjs +0 -33
  75. package/src/assets/icons/activity-compact.svg +0 -1
  76. package/src/assets/icons/activity-edit.svg +0 -1
  77. package/src/assets/icons/activity-terminal.svg +0 -1
  78. package/src/assets/icons/commands.svg +0 -1
  79. package/src/assets/icons/home-chat.svg +0 -1
  80. package/src/assets/icons/home-folder.svg +0 -1
  81. package/src/assets/icons/thread-fork.svg +0 -1
  82. package/src/main/workspace/git.js +0 -73
  83. package/src/main/workspace/ipc.js +0 -50
  84. package/src/main/workspace/terminal.js +0 -34
  85. package/src/native-ui/renderer-extension/src/assets/claude-agent.svg +0 -3
  86. package/src/native-ui/renderer-extension/src/assets/codex-logo-bright.png +0 -0
  87. package/src/native-ui/renderer-extension/src/assets/codex-logo-transparent.png +0 -0
  88. package/src/native-ui/renderer-extension/src/assets/codex-logo.png +0 -0
  89. package/src/native-ui/renderer-extension/src/assets/codexhost-icon.png +0 -0
  90. package/src/native-ui/renderer-extension/src/assets/codexhost-logo-transparent.png +0 -0
  91. package/src/native-ui/renderer-extension/src/assets/codexhost-logo.png +0 -0
  92. package/src/native-ui/renderer-extension/src/assets/codexhost-readme.svg +0 -8
  93. package/src/native-ui/renderer-extension/src/assets/pi-agent.svg +0 -4
  94. package/src/native-ui/renderer-extension/src/settings/accounts-quota-interactive.prototype.html +0 -1418
  95. package/src/native-ui/renderer-extension/src/settings/update-notes.preview.html +0 -491
  96. package/src/native-ui/renderer-extension/src/settings/windows-update.preview.html +0 -2135
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-mix",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Harness Mix native Codex UI bridge for Codex, Pi, Claude Code, DeepSeek Harness, Antigravity, CodeBuddy, Kiro CLI, Cursor CLI and other native coding harnesses.",
6
6
  "keywords": [
@@ -109,7 +109,6 @@
109
109
  "test:core-all": "node scripts/contracts-test.cjs && node scripts/projector-test.cjs && node scripts/turn-manager-test.cjs && node scripts/sequence-test.cjs && node scripts/shadow-test.cjs && node scripts/architecture-test.cjs && node scripts/adapters-test.cjs && node scripts/dsh-adapter-test.cjs && node scripts/codex-adapter-test.cjs && node scripts/antigravity-adapter-test.cjs && node scripts/openclaw-adapter-test.cjs && node scripts/core-replay-test.cjs && node scripts/determinism-test.cjs && node scripts/core-runtime-test.cjs && node scripts/core-services-test.cjs && node scripts/native-file-change-test.cjs && node scripts/native-updater-test.cjs && node scripts/native-update-apply-test.cjs && node scripts/native-vendor-adapters-test.cjs && node scripts/core-concurrency-test.cjs && node scripts/concurrency-layers-test.cjs && node scripts/tool-concurrency-regression-test.cjs && node scripts/switch-harness-test.cjs && node scripts/native-acp-depth-test.cjs && node scripts/thread-title-test.cjs && node scripts/native-job-object-test.cjs",
110
110
  "test:transcript": "node scripts/transcript-test.cjs",
111
111
  "test:workspace": "node scripts/workspace-test.cjs",
112
- "test:git": "node scripts/git-test.cjs",
113
112
  "e2e:pi": "node scripts/e2e-pi.cjs",
114
113
  "e2e:shadow-pi": "node scripts/e2e-shadow.cjs pi",
115
114
  "e2e:shadow-claude": "node scripts/e2e-shadow.cjs claude",
@@ -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 { randomUUID } = require('node:crypto');
5
- const { createWorkspace, reviewWorkspace, applyWorkspace, git } = require('../src/main/host/collaboration-worktree');
5
+ const { createWorkspace, reviewWorkspace, applyWorkspace, discardWorkspace, pushWorkspace, git } = require('../src/main/host/collaboration-worktree');
6
6
  const { Collaboration } = require('../src/main/host/collaboration');
7
7
  const { HostRuntime } = require('../src/main/host/runtime');
8
8
  const { SessionHistory } = require('../src/main/host/session-history');
@@ -29,7 +29,10 @@ async function main() {
29
29
  assert.doesNotMatch(review.patch, /untracked.txt/);
30
30
  await assert.rejects(applyWorkspace(workspace, 'stale'), /重新审查/);
31
31
  await fs.writeFile(path.join(repo, 'file.txt'), 'conflicting edit\n');
32
- await assert.rejects(applyWorkspace(workspace, review.digest));
32
+ const conflictRev = await reviewWorkspace(workspace);
33
+ assert.equal(conflictRev.hasConflict, true);
34
+ assert.deepEqual(conflictRev.conflictingFiles, ['file.txt']);
35
+ await assert.rejects(applyWorkspace(workspace, review.digest), /冲突文件/);
33
36
  assert.equal(await fs.readFile(path.join(repo, 'file.txt'), 'utf8'), 'conflicting edit\n');
34
37
  assert.equal(await fs.stat(path.join(repo, 'new.txt')).catch(() => null), null, 'Conflict applies no partial patch');
35
38
  await fs.writeFile(path.join(repo, 'file.txt'), 'unstaged\n');
@@ -37,6 +40,23 @@ async function main() {
37
40
  assert.equal(await fs.readFile(path.join(repo, 'file.txt'), 'utf8'), 'worker\n');
38
41
  assert.equal(await git(repo, ['diff', '--cached', '--binary']), staged);
39
42
 
43
+ // 隔离工作区冲突排查、远程推送与分支丢弃
44
+ const workspace2 = await createWorkspace(repo, randomUUID());
45
+ await fs.writeFile(path.join(workspace2.cwd, 'file2.txt'), 'w2 content\n');
46
+ const review2 = await reviewWorkspace(workspace2);
47
+ assert.equal(review2.hasConflict, false);
48
+ const remoteBare = path.join(root, 'remote-bare.git');
49
+ await git(repo, ['init', '--bare', remoteBare]);
50
+ await git(repo, ['remote', 'add', 'origin', remoteBare]);
51
+ const pushRes = await pushWorkspace(workspace2, 'origin');
52
+ assert.equal(pushRes.pushed, true);
53
+ assert.equal(pushRes.remote, 'origin');
54
+ assert.equal(pushRes.branch, workspace2.branch);
55
+ const discardRes = await discardWorkspace(workspace2);
56
+ assert.equal(discardRes.discarded, true);
57
+ assert.equal(discardRes.branch, workspace2.branch);
58
+ assert.equal(await fs.stat(workspace2.root).catch(() => null), null, 'Worktree directory cleaned');
59
+
40
60
  const rt = new HostRuntime({ dataDirectory: path.join(root, 'data') }); await rt.store.load();
41
61
  await rt.collaboration.initialize();
42
62
  rt.collaboration.jobs.set('saved', { id: 'saved', owner: 'parent', agent: 'pi', childId: 'child', status: 'running', task: 'continue', workspace });
@@ -36,12 +36,17 @@ app.whenReady().then(async () => {
36
36
  editor.value='@旧';editor.setSelectionRange(2,2);editor.dispatchEvent(new Event('input',{bubbles:true}));await new Promise(r=>setTimeout(r,30));
37
37
  document.querySelector('[data-harness-mix-mentions] [role="option"]').click();
38
38
  const sessionBadge=[...document.querySelectorAll('[data-harness-mix-selected-mentions] span')].some(b=>b.title.startsWith('历史会话'));
39
- return {filtered,text,richText,badge,icons,disabled,sessionText:editor.value,sessionBadge};
39
+ const root = editor.closest('[data-codex-composer-root]');
40
+ root.dispatchEvent(new Event('submit', { bubbles: true }));
41
+ const submittedText = editor.value;
42
+ return {filtered,text,submittedText,richText,badge,icons,disabled,sessionText:editor.value,sessionBadge};
40
43
  })()`);
41
- assert.equal(result.filtered, 1); assert.equal(result.text, '你负责后端, @claude ');
42
- assert.match(result.richText, /^你负责后端, @pi\s$/); assert.equal(result.disabled, 1);
43
- assert.equal(result.badge, 'harnesses/claude'); assert.equal(result.icons, 3); assert.equal(result.disabled, 1);
44
- assert.match(result.sessionText, /harness-mix:\/\/session\/c2Vzc2lvbg/); assert.equal(result.sessionBadge, true);
44
+ assert.equal(result.filtered, 1);
45
+ assert.equal(result.text, '你负责后端, ');
46
+ assert.match(result.submittedText, /@claude/);
47
+ assert.equal(result.disabled, 1);
48
+ assert.equal(result.badge, 'harnesses/claude'); assert.equal(result.icons, 3);
49
+ assert.equal(result.sessionBadge, true);
45
50
  const out = path.resolve('output/collaboration-ui'); await fs.mkdir(out, { recursive: true });
46
51
  await new Promise(r => setTimeout(r,100));
47
52
  await fs.writeFile(path.join(out, 'mentions.png'), (await win.webContents.capturePage()).toPNG());
@@ -232,6 +232,26 @@ async function main() {
232
232
  await assert.rejects(bridge.request('thread/fork', { threadId, threadSource: 'thread_description' }), /Ephemeral fork is not supported/);
233
233
  await assert.rejects(bridge.request('thread/fork', { threadId, excludeTurns: true }), /Ephemeral fork is not supported/);
234
234
  await assert.rejects(bridge.request('thread/start', { cwd: root, model: routeModel('pi'), ephemeral: true, threadSource: 'thread_description' }), /Ephemeral background thread is not supported/);
235
+ // Worktree 隔离工作区丢弃与推送协议接口
236
+ await assert.rejects(bridge.request('codexhost/thread/workspace/discard', { threadId }), /该任务未使用 Worktree 隔离工作区/);
237
+ await assert.rejects(bridge.request('codexhost/thread/workspace/push', { threadId }), /该任务未使用 Worktree 隔离工作区/);
238
+ const dummyWorktreeThread = await bridge.request('thread/start', { cwd: root, model: routeModel('pi') });
239
+ const dummyThreadId = dummyWorktreeThread.thread.id;
240
+ const dummyWorkspaceDir = path.join(root, 'dummy-wt');
241
+ await fs.mkdir(dummyWorkspaceDir, { recursive: true });
242
+ const dummyTargetThread = runtime.threads.find(t => t.id === dummyThreadId);
243
+ dummyTargetThread.workspace = {
244
+ mode: 'worktree',
245
+ root: dummyWorkspaceDir,
246
+ cwd: dummyWorkspaceDir,
247
+ source: root,
248
+ branch: 'codexhost/test-wt-branch',
249
+ };
250
+ await assert.rejects(bridge.request('codexhost/thread/workspace/push', { threadId: dummyThreadId }), /git/i);
251
+ const discardResult = await bridge.request('codexhost/thread/workspace/discard', { threadId: dummyThreadId });
252
+ assert.equal(discardResult.discarded, true);
253
+ assert.equal(discardResult.branch, 'codexhost/test-wt-branch');
254
+ assert.equal(dummyTargetThread.workspace, undefined, 'Discard deletes thread.workspace');
235
255
  console.log('PASS: native protocol, external steering, command execution and Usage projection');
236
256
  } finally { bridge.close(); await runtime.close(); }
237
257
  }
@@ -6,7 +6,6 @@ const os = require('node:os');
6
6
  const { ReviewStore } = require('../src/main/workspace/review');
7
7
  const { readText, resolveFile } = require('../src/main/workspace/files');
8
8
  const { diff } = require('../src/main/workspace/diff');
9
- const { CommandTerminal } = require('../src/main/workspace/terminal');
10
9
  const { HostRuntime } = require('../src/main/host/runtime');
11
10
 
12
11
  (async () => {
@@ -50,17 +49,6 @@ const { HostRuntime } = require('../src/main/host/runtime');
50
49
  await fs.symlink(path.join(directory, 'outside'), path.join(root, 'link'), 'junction');
51
50
  await assert.rejects(resolveFile(root, 'link/file.txt'), /不跟随/);
52
51
  const empty = diff('', ''); assert.equal(empty.rows.length, 0);
53
- const terminal = new CommandTerminal(() => {});
54
- const s = terminal.run(root, "Write-Output 'terminal-ok'");
55
- for (let i = 0; i < 100 && terminal.sessions.get(s.id).running; i++) await new Promise(r => setTimeout(r, 50));
56
- assert.match(terminal.sessions.get(s.id).output, /terminal-ok/);
57
- assert.equal(terminal.sessions.get(s.id).exitCode, 0);
58
- const long = terminal.run(root, 'Start-Sleep -Seconds 30');
59
- await new Promise(r => setTimeout(r, 300));
60
- await terminal.stop(long.id);
61
- for (let i = 0; i < 50 && terminal.sessions.get(long.id).running; i++) await new Promise(r => setTimeout(r, 50));
62
- assert.equal(terminal.sessions.get(long.id).running, false);
63
- await terminal.close();
64
52
  const rt = new HostRuntime({ observer: new ParityObserver(), dataDirectory: path.join(directory, 'runtime') });
65
53
  const updates = [];
66
54
  rt.subscribe(e => { if (e.type === 'turn/diff/updated') updates.push(e); });
@@ -72,7 +60,7 @@ const { HostRuntime } = require('../src/main/host/runtime');
72
60
  emit({ kind: 'text-delta', text: 'progress' });
73
61
  emit({ kind: 'tool', toolCallId: 'one', title: 'edit', state: 'running' });
74
62
  await fs.writeFile(path.join(root, 'dirty.js'), 'native tool edit\n');
75
- await new Promise(r => setTimeout(r, 2300));
63
+ await new Promise(r => setTimeout(r, 3200));
76
64
  emit({ kind: 'tool', toolCallId: 'one', title: 'edit', state: 'done' });
77
65
  emit({ kind: 'text-delta', text: 'conclusion' });
78
66
  emit({ kind: 'completed', finalAnswer: true });
@@ -94,5 +82,5 @@ const { HostRuntime } = require('../src/main/host/runtime');
94
82
  await rt.undoFile(t.id, answer.id, 'dirty.js');
95
83
  assert.match(await fs.readFile(path.join(root, 'dirty.js'), 'utf8'), /user preexisting change/);
96
84
  await rt.close();
97
- console.log('baseline preserves dirty edits; add/delete/modify undo; conflict, traversal, junction guards; persisted review; real pwsh output/stop passed');
85
+ console.log('baseline preserves dirty edits; add/delete/modify undo; conflict, traversal, junction guards; persisted review passed');
98
86
  })().catch(e => { console.error(e); process.exit(1); });
@@ -14,6 +14,7 @@ const manifest = {
14
14
  icon: 'antigravity-color.svg',
15
15
  aliases: ['agy', 'antigravity'],
16
16
  capabilities: {
17
+ collaborationTools: true,
17
18
  streaming: true,
18
19
  thinking: true,
19
20
  tools: true,
@@ -249,18 +250,18 @@ function formatAntigravityResultError(result) {
249
250
  return status ? `Antigravity 回合失败(${status})` : 'Antigravity 回合失败(未知错误)';
250
251
  }
251
252
 
252
- function resultSucceeded(result) {
253
- return errorText(result?.status).toUpperCase() === 'SUCCESS';
254
- }
255
-
256
- function modelSupportsEffort(model) {
257
- const id = String(model?.id ?? '').trim();
258
- if (Array.isArray(model?.efforts)) return model.efforts.length > 0;
259
- // Claude models expose built-in thinking in agy and reject --effort.
260
- return !/^claude(?:[-_.]|$)/i.test(id);
261
- }
262
-
263
- function formatPrompt(text) {
253
+ function resultSucceeded(result) {
254
+ return errorText(result?.status).toUpperCase() === 'SUCCESS';
255
+ }
256
+
257
+ function modelSupportsEffort(model) {
258
+ const id = String(model?.id ?? '').trim();
259
+ if (Array.isArray(model?.efforts)) return model.efforts.length > 0;
260
+ // Claude models expose built-in thinking in agy and reject --effort.
261
+ return !/^claude(?:[-_.]|$)/i.test(id);
262
+ }
263
+
264
+ function formatPrompt(text) {
264
265
  if (!text) return '';
265
266
  if (text.startsWith('/') || text.includes('ArtifactMetadata')) return text;
266
267
  return `${ANTIGRAVITY_WORKSPACE_FILE_INSTRUCTION}${text}`;
@@ -638,6 +639,29 @@ class QuestionBridge {
638
639
  CODEXHOST_AGY_QUESTION_CLIENT: `"${clientPath.replaceAll('\\', '/')}"`,
639
640
  };
640
641
 
642
+ if (options.collaboration) {
643
+ const pluginDir = path.join(dir, '.agents', 'plugins', 'harness-mix');
644
+ const plainPluginDir = path.join(dir, 'plugins', 'harness-mix');
645
+ await fs.promises.mkdir(pluginDir, { recursive: true });
646
+ await fs.promises.mkdir(plainPluginDir, { recursive: true });
647
+ const pluginManifest = JSON.stringify({ name: 'harness-mix-collaboration', description: 'Harness Mix Multi-agent Collaboration' }, null, 2);
648
+ const mcpConfig = JSON.stringify({
649
+ mcpServers: {
650
+ 'harness-mix': {
651
+ command: options.collaboration.command,
652
+ args: options.collaboration.args,
653
+ env: options.collaboration.env,
654
+ },
655
+ },
656
+ }, null, 2);
657
+ await fs.promises.writeFile(path.join(pluginDir, 'plugin.json'), pluginManifest, 'utf8');
658
+ await fs.promises.writeFile(path.join(pluginDir, 'mcp_config.json'), mcpConfig, 'utf8');
659
+ await fs.promises.writeFile(path.join(plainPluginDir, 'plugin.json'), pluginManifest, 'utf8');
660
+ await fs.promises.writeFile(path.join(plainPluginDir, 'mcp_config.json'), mcpConfig, 'utf8');
661
+ await fs.promises.writeFile(path.join(dir, '.agents', 'mcp_config.json'), mcpConfig, 'utf8');
662
+ Object.assign(bridge.environment, options.collaboration.env || {});
663
+ }
664
+
641
665
  return bridge;
642
666
  }
643
667
 
@@ -792,13 +816,14 @@ function create(emit, options = {}) {
792
816
  if (error) {
793
817
  resolve({ available: false, detail: '未找到 Antigravity CLI(agy)' });
794
818
  } else {
819
+ if (!cachedQuota) fetchAntigravityQuota().then((q) => { if (q) cachedQuota = q; }).catch(() => {});
795
820
  resolve({ available: true, detail: `agy ${String(stdout).trim()}` });
796
821
  }
797
822
  });
798
823
  });
799
824
  },
800
825
 
801
- async open({ thread, emit: emitEvent, diagnostic }) {
826
+ async open({ thread, emit: emitEvent, diagnostic, collaboration }) {
802
827
  // Do not start a second agy process while a real turn may be starting.
803
828
  // Quota is loaded only by the explicit account/usage refresh path below.
804
829
  await restoreLegacyClipboardAttachments(thread);
@@ -811,6 +836,8 @@ function create(emit, options = {}) {
811
836
  model,
812
837
  thinkingLevel,
813
838
  permissionMode,
839
+ collaboration: collaboration || null,
840
+ collaborationEnabled: !!collaboration,
814
841
  activeTurn: null,
815
842
  bridge: null,
816
843
  usage: undefined,
@@ -831,10 +858,14 @@ function create(emit, options = {}) {
831
858
  const bridge = await QuestionBridge.create({
832
859
  approvals,
833
860
  emit: emitEvent,
861
+ collaboration: session.collaboration,
834
862
  });
835
863
  session.bridge = bridge;
836
864
 
837
865
  let effectivePrompt = prompt ?? '';
866
+ if (session.collaborationEnabled) {
867
+ effectivePrompt = `[多 Agent 协作系统已就绪。你作为主协调者,可调用已注入的 MCP 工具:delegate_to_agent(agent_type, task) 向其他已注册的 Harness(如 @pi、@claude-code、@codex、@deepseek-harness 等)委派子任务,使用 get_delegation_status(task_ids) 等待结果,使用 update_agent_plan(steps) 设定与更新分步计划。]\n\n${effectivePrompt}`;
868
+ }
838
869
  const { imageEntries, extraDirs } = await prepareImageAttachments(attachments?.images, session.cwd);
839
870
  if (imageEntries.length) {
840
871
  const imageNotice = [
@@ -859,9 +890,9 @@ function create(emit, options = {}) {
859
890
  if (session.model?.id) {
860
891
  args.push('--model', session.model.id);
861
892
  }
862
- if (session.thinkingLevel && modelSupportsEffort(session.model)) {
863
- args.push('--effort', session.thinkingLevel);
864
- }
893
+ if (session.thinkingLevel && modelSupportsEffort(session.model)) {
894
+ args.push('--effort', session.thinkingLevel);
895
+ }
865
896
  if (['skip', 'desktop', 'dangerously-skip-permissions', 'desktop-approvals'].includes(session.permissionMode)) {
866
897
  args.push('--dangerously-skip-permissions');
867
898
  }
@@ -1323,8 +1354,8 @@ module.exports = {
1323
1354
  create,
1324
1355
  parseModelsOutput,
1325
1356
  parseUsage,
1326
- formatAntigravityResultError,
1327
- modelSupportsEffort,
1357
+ formatAntigravityResultError,
1358
+ modelSupportsEffort,
1328
1359
  formatPrompt,
1329
1360
  prepareImageAttachments,
1330
1361
  mergePendingStep,
@@ -25,7 +25,8 @@ const CLAUDE_PERMISSION_MODES = [
25
25
 
26
26
  function summarizeInput(input) {
27
27
  if (!input || typeof input !== "object") return "";
28
- const value = input.command ?? input.file_path ?? input.pattern ?? input.description ?? Object.values(input)[0];
28
+ if (input.agent_type && input.task) return `@${input.agent_type}: ${input.task}`.split("\n")[0].slice(0, 120);
29
+ const value = input.command ?? input.file_path ?? input.pattern ?? input.task ?? input.description ?? Object.values(input)[0];
29
30
  return typeof value === "string" ? value.split("\n")[0].slice(0, 120) : "";
30
31
  }
31
32
 
@@ -152,7 +153,7 @@ function usageFromResult(event, planLimit) {
152
153
  const usage = event?.usage;
153
154
  if (!usage || typeof usage !== "object") return undefined;
154
155
  const models = Object.values(event.modelUsage ?? {});
155
- const window = models.find((m) => Number.isFinite(m?.contextWindow))?.contextWindow ?? null;
156
+ const window = models.find((m) => Number.isFinite(m?.contextWindow))?.contextWindow ?? 200_000;
156
157
  const input = usage.input_tokens;
157
158
  const output = usage.output_tokens;
158
159
  const cacheRead = usage.cache_read_input_tokens;
@@ -106,7 +106,7 @@ function usageView(tokenUsage) {
106
106
  const last = tokenUsage.last ?? {};
107
107
  const total = tokenUsage.total ?? {};
108
108
  const tokens = Number.isFinite(last.totalTokens) ? last.totalTokens : null;
109
- const contextWindow = Number.isFinite(tokenUsage.modelContextWindow) ? tokenUsage.modelContextWindow : null;
109
+ const contextWindow = Number.isFinite(tokenUsage.modelContextWindow) ? tokenUsage.modelContextWindow : 128_000;
110
110
  return {
111
111
  tokens,
112
112
  contextWindow,
@@ -290,13 +290,15 @@ function queueRequest(message, session, emit) {
290
290
  });
291
291
  }
292
292
 
293
- if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval'].includes(method)) {
293
+ if (method.endsWith('/requestApproval') || method.includes('requestApproval')) {
294
294
  const requestId = `codex-${id}`;
295
295
  return new Promise((resolve) => {
296
296
  session.pendingApprovals.set(requestId, { method, resolve, params });
297
297
  const title = method.includes('commandExecution') ? 'Codex 请求运行命令'
298
- : method.includes('fileChange') ? 'Codex 请求修改文件' : 'Codex 请求额外权限';
299
- const messageText = params.reason ?? params.command ?? (params.grantRoot ? `允许写入 ${params.grantRoot}` : text(params.permissions));
298
+ : method.includes('fileChange') ? 'Codex 请求修改文件'
299
+ : (method.includes('mcp') || method.includes('tool')) ? `Codex 请求调用工具 · ${params.tool ?? params.toolName ?? params.serverName ?? 'MCP'}`
300
+ : 'Codex 请求权限审批';
301
+ const messageText = params.reason ?? params.command ?? params.tool ?? (params.grantRoot ? `允许写入 ${params.grantRoot}` : text(params.permissions));
300
302
  emit({
301
303
  kind: 'approval', requestId, title, message: messageText,
302
304
  options: approvalOptions(method, params),
@@ -473,8 +475,8 @@ function create() {
473
475
  models,
474
476
  thinkingLevels: selected?.efforts ?? [],
475
477
  permissionModes: [
476
- { id: 'default', label: '原生默认', hint: '使用 Codex 当前配置的权限策略' },
477
- ...profiles.filter((profile) => profile.allowed).map((profile) => ({ id: profile.id, label: profile.id, hint: profile.description })),
478
+ { id: 'default', label: '原生默认', description: '使用 Codex 当前配置的权限策略' },
479
+ ...profiles.filter((profile) => profile.allowed).map((profile) => ({ id: profile.id, label: profile.id, description: profile.description })),
478
480
  ],
479
481
  };
480
482
  } finally { host.release(); }
@@ -489,8 +491,8 @@ function create() {
489
491
  models,
490
492
  thinkingLevels: selected?.efforts ?? [],
491
493
  permissionModes: [
492
- { id: 'default', label: '原生默认', hint: '使用 Codex 当前配置的权限策略' },
493
- ...profiles.filter((profile) => profile.allowed).map((profile) => ({ id: profile.id, label: profile.id, hint: profile.description })),
494
+ { id: 'default', label: '原生默认', description: '使用 Codex 当前配置的权限策略' },
495
+ ...profiles.filter((profile) => profile.allowed).map((profile) => ({ id: profile.id, label: profile.id, description: profile.description })),
494
496
  ],
495
497
  };
496
498
  },
@@ -161,8 +161,9 @@ function projectWireEvent(frame, session) {
161
161
  function usageFrom(usage, contextWindow) {
162
162
  if (!usage) return undefined;
163
163
  const tokens = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) || null;
164
- if (!tokens && !contextWindow) return undefined;
165
- return { tokens, contextWindow: contextWindow ?? null, contextPercent: tokens && contextWindow ? 100 * tokens / contextWindow : null };
164
+ const window = contextWindow ?? 64_000;
165
+ if (!tokens && !window) return undefined;
166
+ return { tokens, contextWindow: window, contextPercent: tokens && window ? 100 * tokens / window : null };
166
167
  }
167
168
 
168
169
  /** approval/request waterfall → Host 审批卡片 */
@@ -2,7 +2,7 @@ const { execFile } = require('node:child_process');
2
2
  const { OpenCodeServer, executable } = require('./opencode-server');
3
3
  const manifest = { id: 'opencode', name: 'OpenCode', icon: 'opencode-color.svg', aliases: ['opencode'], capabilities: {
4
4
  collaborationTools: true, streaming: true, thinking: true, tools: true, approvals: true, questions: true, models: true, thinkingLevels: true,
5
- permissionModes: true, resume: true, fork: true, forkFromMessage: true, compaction: true, usage: true, contextUsage: false, attachments: true,
5
+ permissionModes: true, resume: true, fork: true, forkFromMessage: true, compaction: true, usage: true, contextUsage: true, attachments: true,
6
6
  } };
7
7
  const enc = encodeURIComponent;
8
8
  const route = (s, suffix = '') => `/session/${enc(s.nativeSessionId)}${suffix}`;
@@ -142,18 +142,26 @@ function create() {
142
142
  await session.host.request('POST', route(session, '/summarize'), { providerID: model.provider, modelID: model.id }, 30 * 60 * 1000);
143
143
  hooks.emit({ kind: 'compaction', state: 'completed', outcome: 'succeeded' });
144
144
  hooks.emit({ kind: 'completed', finalAnswer: false });
145
- }
146
- finally { session.state.active = false; }
147
- },
148
- async getContextUsage(session) {
149
- const info = await session.host.request('GET', route(session)), t = info.tokens || {};
150
- return { inputTokens: t.input, outputTokens: t.output, reasoningOutputTokens: t.reasoning, cacheRead: t.cache?.read, cacheWrite: t.cache?.write, cost: info.cost };
151
- },
152
- async fork(source, context) {
153
- const host = await new OpenCodeServer(source.cwd).start();
154
- try {
155
- const history = await host.request('GET', `/session/${enc(source.nativeSessionId)}/message`), boundary = context.message?.coreTurn?.nativeTurnRef?.checkpointId;
156
- if (context.message && !boundary) throw new Error('OpenCode reply has no native message boundary');
145
+ }
146
+ finally { session.state.active = false; }
147
+ },
148
+ async getContextUsage(session) {
149
+ const info = await session.host.request('GET', route(session)), t = info.tokens || {};
150
+ const model = session.state.models?.find(m => m.id === session.model?.id);
151
+ const contextWindow = model?.contextWindow || 128_000;
152
+ const tokens = (t.input || 0) + (t.output || 0) + (t.cache?.read || 0);
153
+ return {
154
+ inputTokens: t.input, outputTokens: t.output, reasoningOutputTokens: t.reasoning,
155
+ cacheRead: t.cache?.read, cacheWrite: t.cache?.write, cost: info.cost,
156
+ tokens, contextWindow,
157
+ contextPercent: tokens && contextWindow ? (100 * tokens / contextWindow) : null,
158
+ };
159
+ },
160
+ async fork(source, context) {
161
+ const host = await new OpenCodeServer(source.cwd).start();
162
+ try {
163
+ const history = await host.request('GET', `/session/${enc(source.nativeSessionId)}/message`), boundary = context.message?.coreTurn?.nativeTurnRef?.checkpointId;
164
+ if (context.message && !boundary) throw new Error('OpenCode reply has no native message boundary');
157
165
  const index = boundary ? history.findIndex(m => m.info.id === boundary) : -1;
158
166
  if (boundary && index < 0) throw new Error('OpenCode fork boundary no longer exists');
159
167
  const next = boundary && history[index + 1]?.info.id;
@@ -45,9 +45,9 @@ function emitAll(emitEvent, mapped) {
45
45
 
46
46
  /** Pi 家族(Pi / Oh My Pi)的权限模型 = 项目信任(project trust):启动时用 --approve / --no-approve 覆盖一次 */
47
47
  const PI_PERMISSION_MODES = [
48
- { id: "default", label: "默认", hint: "按全局设置处理项目级设置与扩展(defaultProjectTrust)" },
49
- { id: "approve", label: "信任项目", hint: "本次信任该项目的设置与扩展(--approve)" },
50
- { id: "no-approve", label: "忽略项目资源", hint: "本次不加载项目级设置与扩展(--no-approve)" },
48
+ { id: "default", label: "默认", description: "按全局设置处理项目级设置与扩展(defaultProjectTrust)" },
49
+ { id: "approve", label: "信任项目", description: "本次信任该项目的设置与扩展(--approve)" },
50
+ { id: "no-approve", label: "忽略项目资源", description: "本次不加载项目级设置与扩展(--no-approve)" },
51
51
  ];
52
52
 
53
53
  /**
@@ -62,19 +62,48 @@ async function reviewWorkspace(workspace) {
62
62
  if (workspace?.mode !== 'worktree') throw new Error('此任务使用共享目录,请在任务文件变更中审查');
63
63
  const currentTree = await tree(workspace.root);
64
64
  const patch = await git(workspace.root, ['diff', '--binary', '--no-ext-diff', '--no-textconv', workspace.baseTree, currentTree, '--']);
65
- return { patch, digest: createHash('sha256').update(patch).digest('hex'), branch: workspace.branch, cwd: workspace.cwd };
65
+ const digest = createHash('sha256').update(patch).digest('hex');
66
+ const result = { patch, digest, branch: workspace.branch, cwd: workspace.cwd, hasConflict: false, conflictingFiles: [] };
67
+ if (patch) {
68
+ const file = path.join(os.tmpdir(), `harness-mix-check-${randomUUID()}`);
69
+ try {
70
+ await fs.writeFile(file, patch);
71
+ await git(workspace.source, ['apply', '--check', '--binary', file]);
72
+ } catch (checkErr) {
73
+ result.hasConflict = true;
74
+ const stderr = String(checkErr.stderr || checkErr.message || '');
75
+ const files = [...stderr.matchAll(/error:\s*patch failed:\s*([^:\r\n]+)/g)].map(m => m[1].trim());
76
+ result.conflictingFiles = files.length ? [...new Set(files)] : [];
77
+ result.conflictReason = stderr.slice(0, 500);
78
+ } finally {
79
+ await fs.rm(file, { force: true }).catch(() => {});
80
+ }
81
+ }
82
+ return result;
66
83
  }
67
84
 
68
85
  async function applyWorkspace(workspace, expectedDigest) {
69
86
  const review = await reviewWorkspace(workspace);
70
87
  if (!expectedDigest || review.digest !== expectedDigest) throw new Error('子任务改动已变化,请重新审查');
71
88
  if (!review.patch) return review;
89
+ if (review.hasConflict) {
90
+ const fileList = review.conflictingFiles.length
91
+ ? review.conflictingFiles.map(f => ` - ${f}`).join('\n')
92
+ : ' (主工作区当前状态与补丁不兼容)';
93
+ throw new Error(
94
+ `无法应用工作区改动:检测到文件合并冲突。\n冲突文件:\n${fileList}\n` +
95
+ `建议操作:\n1. 可在子任务独立工作区 (${workspace.cwd}) 中手动合并或解决冲突;\n` +
96
+ `2. 使用 pushWorkspace 将隔离分支 (${workspace.branch}) 推送至 Git 远程仓库以发起 PR;\n` +
97
+ `3. 或使用 discardWorkspace 放弃并删除该分支。`
98
+ );
99
+ }
72
100
  const file = path.join(os.tmpdir(), `harness-mix-patch-${randomUUID()}`);
73
101
  try {
74
102
  await fs.writeFile(file, review.patch);
75
- await git(workspace.source, ['apply', '--check', '--binary', file]);
76
103
  await git(workspace.source, ['apply', '--binary', file]);
77
- } finally { await fs.rm(file, { force: true }); }
104
+ } catch (error) {
105
+ throw new Error(`应用改动失败:${error.stderr || error.message}`);
106
+ } finally { await fs.rm(file, { force: true }).catch(() => {}); }
78
107
  return review;
79
108
  }
80
109
 
@@ -89,4 +118,34 @@ async function removeWorkspace(workspace) {
89
118
  await fs.rm(workspace.root, { recursive: true, force: true }).catch(() => {});
90
119
  }
91
120
 
92
- module.exports = { createWorkspace, reviewWorkspace, applyWorkspace, removeWorkspace, git, tree };
121
+ async function discardWorkspace(workspace) {
122
+ if (!workspace || workspace.mode !== 'worktree') return { discarded: false };
123
+ await removeWorkspace(workspace);
124
+ return { discarded: true, branch: workspace.branch };
125
+ }
126
+
127
+ async function pushWorkspace(workspace, remote = 'origin', remoteBranch = workspace.branch) {
128
+ if (!workspace || workspace.mode !== 'worktree') throw new Error('仅隔离工作区支持推送远程分支');
129
+ const currentTree = await tree(workspace.root);
130
+ let parentCommit = workspace.baseCommit;
131
+ try {
132
+ parentCommit = (await git(workspace.source, ['rev-parse', workspace.branch])).trim();
133
+ } catch {}
134
+ const commitMsg = `Harness Mix collaboration: ${workspace.branch}`;
135
+ const newCommit = (await git(workspace.source, [
136
+ '-c', 'user.name=Harness Mix',
137
+ '-c', 'user.email=harness-mix@localhost',
138
+ 'commit-tree', currentTree,
139
+ '-p', parentCommit,
140
+ '-m', commitMsg,
141
+ ])).trim();
142
+ await git(workspace.source, ['update-ref', `refs/heads/${workspace.branch}`, newCommit]);
143
+ const remotes = (await git(workspace.source, ['remote'])).split(/\r?\n/).map(r => r.trim()).filter(Boolean);
144
+ if (!remotes.includes(remote)) {
145
+ throw new Error(`Git 远程 '${remote}' 不存在。可用远程:${remotes.join(', ') || '无'}`);
146
+ }
147
+ await git(workspace.source, ['push', remote, `${workspace.branch}:${remoteBranch}`]);
148
+ return { remote, branch: remoteBranch, commit: newCommit, pushed: true };
149
+ }
150
+
151
+ module.exports = { createWorkspace, reviewWorkspace, applyWorkspace, removeWorkspace, discardWorkspace, pushWorkspace, git, tree };
@@ -4,7 +4,7 @@ const path = require('node:path');
4
4
  const { tools } = require('./collaboration-tools');
5
5
  const { z } = require('zod');
6
6
  const { Store } = require('./store');
7
- const { createWorkspace, reviewWorkspace, applyWorkspace } = require('./collaboration-worktree');
7
+ const { createWorkspace, reviewWorkspace, applyWorkspace, discardWorkspace, pushWorkspace } = require('./collaboration-worktree');
8
8
  const validators = new Map(tools.map(tool => [tool.name, z.fromJSONSchema(tool.inputSchema)]));
9
9
  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
10
10
 
@@ -58,6 +58,24 @@ class Collaboration {
58
58
  } finally { delete job.applying; }
59
59
  }
60
60
 
61
+ async discard(id) {
62
+ const job = this.jobs.get(id);
63
+ if (!job) throw new Error('子任务不存在');
64
+ if (job.status === 'running') throw new Error('子任务正在运行,请先取消');
65
+ const result = await discardWorkspace(job.workspace);
66
+ job.status = 'cancelled';
67
+ delete job.workspace;
68
+ await this.save();
69
+ return result;
70
+ }
71
+
72
+ async push(id, { remote = 'origin', branch } = {}) {
73
+ const job = this.jobs.get(id);
74
+ if (!job) throw new Error('子任务不存在');
75
+ if (job.status === 'running') throw new Error('请等待子任务完成后再推送分支');
76
+ return pushWorkspace(job.workspace, remote, branch);
77
+ }
78
+
61
79
  async connection(thread) {
62
80
  await this.initialize();
63
81
  if (!this.starting) this.starting = new Promise((resolve, reject) => {
@@ -94,7 +112,8 @@ class Collaboration {
94
112
  const pending = job.childId ? this.runtime.core.interactions?.pending(job.childId)?.[0] : null;
95
113
  return { task_id: job.id, parent_thread_id: job.owner, child_thread_id: job.childId, agent_type: job.agent, status: job.status,
96
114
  display_status: pending ? 'waiting_approval' : job.status, attention: pending ? { type: pending.type, title: pending.title, message: pending.message } : undefined,
97
- task: job.task, workspace: job.workspace, applied: !!job.appliedDigest, result: job.result, error: job.error };
115
+ task: job.task, workspace: job.workspace, applied: !!job.appliedDigest, result: job.result, error: job.error,
116
+ diff: job.diff, digest: job.digest, branch: job.workspace?.branch };
98
117
  }
99
118
 
100
119
  async call(owner, name, args) {
@@ -193,6 +212,13 @@ class Collaboration {
193
212
  const messages = rt.core.getItemsForTurn(turn.id).filter(i => i.type === 'agent_message');
194
213
  const finals = messages.filter(i => i.phase === 'final');
195
214
  job.result = (finals.length ? finals : messages).map(i => i.content || '').join('\n').slice(0, 48000);
215
+ if (job.workspace?.mode === 'worktree' && job.status === 'completed') {
216
+ try {
217
+ const rev = await reviewWorkspace(job.workspace);
218
+ job.diff = rev.patch;
219
+ job.digest = rev.digest;
220
+ } catch {}
221
+ }
196
222
  } catch (error) { if (job.status === 'running') { job.status = 'failed'; job.error = error.message; } }
197
223
  finally { await this.save(); emit({ kind: 'tool', toolCallId, state: job.status === 'completed' ? 'done' : 'error', output: JSON.stringify(this.view(job)) }); }
198
224
  }
@@ -11,7 +11,7 @@ const { CoreSession } = require('./core-session');
11
11
  const { Collaboration, mentionedAgents } = require('./collaboration');
12
12
  const { SessionHistory } = require('./session-history');
13
13
  const { buildHandoffContext, composeHandoffEnvelope } = require('./handoff');
14
- const { createWorkspace, reviewWorkspace, applyWorkspace, removeWorkspace } = require('./collaboration-worktree');
14
+ const { createWorkspace, reviewWorkspace, applyWorkspace, removeWorkspace, discardWorkspace, pushWorkspace } = require('./collaboration-worktree');
15
15
 
16
16
  /**
17
17
  * Host Runtime:harness-mix 的核心职责 —— 自研 Desktop 背后的
@@ -227,6 +227,27 @@ class HostRuntime {
227
227
  return result;
228
228
  }
229
229
 
230
+ async discardThreadWorkspace(threadId) {
231
+ const thread = this.#requireThread(threadId);
232
+ if (thread.workspace?.mode !== 'worktree') throw new Error('该任务未使用 Worktree 隔离工作区');
233
+ if (this.execution.isRunning(thread.id) || thread.reviewPending) throw new Error('请等待任务完成后再丢弃隔离分支');
234
+ const result = await discardWorkspace(thread.workspace);
235
+ delete thread.workspace;
236
+ this.#notify('status', `已丢弃并删除隔离分支 ${result.branch || ''}`, thread.id);
237
+ await this.#save();
238
+ this.#broadcast();
239
+ return result;
240
+ }
241
+
242
+ async pushThreadWorkspace(threadId, { remote = 'origin', branch } = {}) {
243
+ const thread = this.#requireThread(threadId);
244
+ if (thread.workspace?.mode !== 'worktree') throw new Error('该任务未使用 Worktree 隔离工作区');
245
+ if (this.execution.isRunning(thread.id) || thread.reviewPending) throw new Error('请等待任务完成后再推送分支');
246
+ const result = await pushWorkspace(thread.workspace, remote, branch);
247
+ this.#notify('status', `已成功将分支 ${result.branch} 推送到远程 ${result.remote}`, thread.id);
248
+ return result;
249
+ }
250
+
230
251
  async send(threadId, text, { commandId, attachments, delegateOf, collaborationOf, isolated = false } = {}) {
231
252
  // Reserve before opening a native session: two submissions can otherwise both
232
253
  // pass isRunning() while awaiting the same opening promise.