@tt-a1i/hive 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.en.md +5 -4
  3. package/README.md +1 -1
  4. package/dist/src/cli/hive-update.d.ts +32 -0
  5. package/dist/src/cli/hive-update.js +113 -22
  6. package/dist/src/cli/hive.d.ts +32 -0
  7. package/dist/src/cli/hive.js +72 -17
  8. package/dist/src/cli/team.js +17 -5
  9. package/dist/src/server/agent-command-resolver.d.ts +10 -1
  10. package/dist/src/server/agent-command-resolver.js +48 -4
  11. package/dist/src/server/agent-launch-resolver.js +9 -3
  12. package/dist/src/server/agent-manager-support.d.ts +28 -0
  13. package/dist/src/server/agent-manager-support.js +58 -4
  14. package/dist/src/server/agent-run-bootstrap.d.ts +17 -1
  15. package/dist/src/server/agent-run-bootstrap.js +30 -2
  16. package/dist/src/server/agent-startup-instructions.js +1 -1
  17. package/dist/src/server/app.d.ts +1 -0
  18. package/dist/src/server/app.js +12 -2
  19. package/dist/src/server/fs-browse.d.ts +14 -1
  20. package/dist/src/server/fs-browse.js +48 -5
  21. package/dist/src/server/fs-pick-folder.js +54 -11
  22. package/dist/src/server/hive-team-guidance.js +5 -4
  23. package/dist/src/server/open-target-commands.js +30 -4
  24. package/dist/src/server/post-start-input-writer.js +6 -3
  25. package/dist/src/server/routes-team.js +10 -1
  26. package/dist/src/server/runtime-store.d.ts +3 -1
  27. package/dist/src/server/session-capture-claude.d.ts +23 -0
  28. package/dist/src/server/session-capture-claude.js +24 -1
  29. package/dist/src/server/session-capture-opencode.d.ts +18 -0
  30. package/dist/src/server/session-capture-opencode.js +27 -2
  31. package/dist/src/server/startup-command-parser.d.ts +15 -0
  32. package/dist/src/server/startup-command-parser.js +33 -2
  33. package/dist/src/server/tasks-file-watcher.d.ts +26 -0
  34. package/dist/src/server/tasks-file-watcher.js +29 -3
  35. package/dist/src/server/team-operations.d.ts +5 -1
  36. package/dist/src/server/team-operations.js +44 -3
  37. package/dist/src/server/terminal-input-profile.js +2 -8
  38. package/dist/src/server/terminal-ws-server.js +26 -8
  39. package/package.json +2 -2
  40. package/web/dist/assets/{AddWorkerDialog-D-XO6MoI.js → AddWorkerDialog-DeZhTQLi.js} +2 -2
  41. package/web/dist/assets/AddWorkspaceDialog-DDpXNEKf.js +1 -0
  42. package/web/dist/assets/{FirstRunWizard-xHiver2Q.js → FirstRunWizard-B5wLcat5.js} +1 -1
  43. package/web/dist/assets/{MarketplaceDrawer-CqE8_4ZP.js → MarketplaceDrawer-BC0eBOEW.js} +1 -1
  44. package/web/dist/assets/{WorkerModal-DgOuzZMW.js → WorkerModal-BwMHq-Bi.js} +1 -1
  45. package/web/dist/assets/WorkspaceTaskDrawer-CxvT4nqs.js +1 -0
  46. package/web/dist/assets/{WorkspaceTerminalPanels-BqEcvvSH.js → WorkspaceTerminalPanels-CvibsPSd.js} +1 -1
  47. package/web/dist/assets/index-Ddb7bDN5.js +75 -0
  48. package/web/dist/assets/path-join-S7qkXQtP.js +1 -0
  49. package/web/dist/index.html +1 -1
  50. package/web/dist/sw.js +1 -1
  51. package/web/dist/assets/AddWorkspaceDialog-D4InpBpd.js +0 -1
  52. package/web/dist/assets/WorkspaceTaskDrawer-D0Y-Gyvw.js +0 -1
  53. package/web/dist/assets/chevron-right-Bmg7DcUj.js +0 -1
  54. package/web/dist/assets/index-CWW5vUjQ.js +0 -76
@@ -1,6 +1,45 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  export const MAX_RUN_OUTPUT_LENGTH = 1_000_000;
3
3
  const FORCE_KILL_DELAY_MS = 750;
4
+ const defaultExecRunner = (cmd, args) => {
5
+ execFileSync(cmd, [...args], { stdio: 'ignore', windowsHide: true });
6
+ };
7
+ /**
8
+ * Windows analogue of POSIX `process.kill(-pgid, SIGKILL)`. node-pty on
9
+ * Windows hands `pty.kill()` to TerminateProcess against the PTY's main
10
+ * process only — children that the worker spawned (npm install, build
11
+ * scripts, custom tooling) become orphans and keep writing to the
12
+ * filesystem after the worker card flips to stopped.
13
+ *
14
+ * `taskkill /pid <pid> /t /f` walks the process tree (`/t`) and forces
15
+ * termination (`/f`), matching what task manager would do.
16
+ *
17
+ * IMPORTANT — call this BEFORE any other termination of the parent
18
+ * process. taskkill /T builds the tree by querying the parent for its
19
+ * descendants; if the parent is already gone (e.g. pty.kill() ran
20
+ * first) the enumeration returns empty and the children become
21
+ * orphans. /F also terminates the parent itself, so a parent-kill
22
+ * after this call is only useful as a fallback when taskkill itself
23
+ * failed (taskkill missing from PATH, restricted PowerShell, etc.).
24
+ *
25
+ * Best-effort: non-zero exits (process already gone, taskkill missing
26
+ * from PATH, access denied) are swallowed and surface as a `false`
27
+ * return. The caller is expected to fall back to pty.kill().
28
+ *
29
+ * Exported for unit testing — the `runner` parameter lets tests assert
30
+ * the exact argv without mocking node:child_process.
31
+ */
32
+ export const taskkillProcessTree = (pid, platform = process.platform, runner = defaultExecRunner) => {
33
+ if (platform !== 'win32' || pid <= 0)
34
+ return false;
35
+ try {
36
+ runner('taskkill', ['/pid', String(pid), '/t', '/f']);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ };
4
43
  export const toAgentRunSnapshot = (run) => ({
5
44
  runId: run.runId,
6
45
  agentId: run.agentId,
@@ -62,8 +101,18 @@ export const attachAgentPty = (run, pty, ptyOutputBus) => {
62
101
  };
63
102
  const killPty = (signal) => {
64
103
  try {
65
- if (process.platform === 'win32')
66
- pty.kill();
104
+ if (process.platform === 'win32') {
105
+ // taskkill /pid <pid> /t /f walks the parent's process tree
106
+ // BEFORE terminating it — so we have to run it while the parent
107
+ // is still alive. Calling pty.kill() first (the previous
108
+ // ordering) detaches the children: taskkill /T then fails with
109
+ // "process not found" and the npm-installs / build scripts
110
+ // become orphans. taskkill /f also terminates the parent, so
111
+ // pty.kill() is the fallback for the rare case where taskkill
112
+ // is missing from PATH or refused (e.g. restricted PowerShell).
113
+ if (!taskkillProcessTree(pty.pid))
114
+ pty.kill();
115
+ }
67
116
  else
68
117
  pty.kill(signal);
69
118
  }
@@ -88,8 +137,13 @@ export const attachAgentPty = (run, pty, ptyOutputBus) => {
88
137
  forceKillTimer = setTimeout(() => {
89
138
  forceKillTimer = undefined;
90
139
  try {
91
- if (process.platform === 'win32')
92
- pty.kill();
140
+ if (process.platform === 'win32') {
141
+ // Same ordering as killPty(): tree-kill before terminating the
142
+ // parent, so taskkill /T can still enumerate the process tree.
143
+ // pty.kill() is the fallback for taskkill-missing hosts.
144
+ if (!taskkillProcessTree(pty.pid))
145
+ pty.kill();
146
+ }
93
147
  else
94
148
  pty.kill('SIGKILL');
95
149
  }
@@ -3,6 +3,23 @@ import type { AgentLaunchConfigInput } from './agent-run-store.js';
3
3
  import type { AgentSessionStorePort } from './agent-runtime-ports.js';
4
4
  import type { CommandPresetRecord } from './command-preset-store.js';
5
5
  import { type SessionCaptureSnapshot } from './session-capture.js';
6
+ /**
7
+ * Builds a `{ <PATH-key>: <new-value> }` object for the spawn env override.
8
+ * Critical on Windows: the OS env block reports PATH under its native casing
9
+ * (typically `Path`). Writing to a literal `PATH` key would, after spread
10
+ * with `process.env`, leave two entries — `Path` carrying the original value
11
+ * and `PATH` carrying our prepend. CreateProcess then sees both and the
12
+ * effective lookup order is undefined; in practice the child PTY often falls
13
+ * back to the original `Path` and never sees `HIVE_BIN_DIR`, breaking every
14
+ * `team` shim resolution.
15
+ *
16
+ * We detect the existing key (case-insensitive on Windows) and overwrite IT,
17
+ * so the merge produces exactly one PATH entry.
18
+ *
19
+ * Exported for unit testing — `buildAgentRunBootstrap` is the only in-tree
20
+ * caller.
21
+ */
22
+ export declare const buildSpawnPathEnvEntry: (parentEnv: NodeJS.ProcessEnv, hiveBinDir: string, platform: NodeJS.Platform) => NodeJS.ProcessEnv;
6
23
  export declare const buildAgentRunBootstrap: (workspace: WorkspaceSummary, agentId: string, config: AgentLaunchConfigInput, sessionStore: AgentSessionStorePort, getCommandPreset: (id: string) => CommandPresetRecord | undefined, agent?: AgentSummary) => {
7
24
  sessionCaptureSnapshot: {
8
25
  discriminator?: {
@@ -50,7 +67,6 @@ export declare const buildAgentRunBootstrap: (workspace: WorkspaceSummary, agent
50
67
  HIVE_PROJECT_ID: string;
51
68
  HIVE_AGENT_ID: string;
52
69
  HIVE_AGENT_TOKEN: string;
53
- PATH: string;
54
70
  };
55
71
  };
56
72
  export declare const startAgentRunCapture: ({ agentId, sessionCaptureSnapshot, sessionStore, startConfig, workspace, }: {
@@ -1,4 +1,4 @@
1
- import { delimiter, dirname, resolve, sep } from 'node:path';
1
+ import { dirname, posix, resolve, sep, win32 } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { buildAgentLegacyIdentityMarker, buildAgentSessionBindingMarker, } from './agent-startup-instructions.js';
4
4
  import { withPresetResumeArgs } from './preset-launch-support.js';
@@ -12,6 +12,34 @@ const resolveHiveBinDir = () => {
12
12
  };
13
13
  const HIVE_BIN_DIR = resolveHiveBinDir();
14
14
  const SESSION_CAPTURE_TIMEOUT_MS = 30_000;
15
+ /**
16
+ * Builds a `{ <PATH-key>: <new-value> }` object for the spawn env override.
17
+ * Critical on Windows: the OS env block reports PATH under its native casing
18
+ * (typically `Path`). Writing to a literal `PATH` key would, after spread
19
+ * with `process.env`, leave two entries — `Path` carrying the original value
20
+ * and `PATH` carrying our prepend. CreateProcess then sees both and the
21
+ * effective lookup order is undefined; in practice the child PTY often falls
22
+ * back to the original `Path` and never sees `HIVE_BIN_DIR`, breaking every
23
+ * `team` shim resolution.
24
+ *
25
+ * We detect the existing key (case-insensitive on Windows) and overwrite IT,
26
+ * so the merge produces exactly one PATH entry.
27
+ *
28
+ * Exported for unit testing — `buildAgentRunBootstrap` is the only in-tree
29
+ * caller.
30
+ */
31
+ export const buildSpawnPathEnvEntry = (parentEnv, hiveBinDir, platform) => {
32
+ const existingKey = platform === 'win32'
33
+ ? Object.keys(parentEnv).find((key) => key.toLowerCase() === 'path')
34
+ : undefined;
35
+ const key = existingKey ?? 'PATH';
36
+ const existingValue = existingKey ? parentEnv[existingKey] : parentEnv.PATH;
37
+ // Target platform's delimiter — Windows uses `;`, POSIX `:` — independent
38
+ // of where this function is running (tests on macOS verify the win32 path).
39
+ const platformDelimiter = platform === 'win32' ? win32.delimiter : posix.delimiter;
40
+ const value = existingValue ? `${hiveBinDir}${platformDelimiter}${existingValue}` : hiveBinDir;
41
+ return { [key]: value };
42
+ };
15
43
  const resolveLaunchPreset = (config, getCommandPreset) => {
16
44
  if (config.presetAugmentationDisabled)
17
45
  return undefined;
@@ -52,7 +80,7 @@ export const buildAgentRunBootstrap = (workspace, agentId, config, sessionStore,
52
80
  HIVE_PROJECT_ID: workspace.id,
53
81
  HIVE_AGENT_ID: agentId,
54
82
  HIVE_AGENT_TOKEN: '',
55
- PATH: `${HIVE_BIN_DIR}${delimiter}${process.env.PATH ?? ''}`,
83
+ ...buildSpawnPathEnvEntry(process.env, HIVE_BIN_DIR, process.platform),
56
84
  },
57
85
  };
58
86
  };
@@ -18,7 +18,7 @@ export const buildAgentStartupInstructions = ({ agent, workspace, }) => {
18
18
  lines.push('你的职责:', '- 直接响应 user,澄清需求并拆解任务', `- 维护 ${TASKS_RELATIVE_PATH}`, '- 按 worker 名称派单,并根据汇报推进下一步', '', '可用 team 命令:', '- team list', '- team send <worker-name> "<task>"', '- team cancel --dispatch <id> "<reason>"', '', '派单时必须使用 worker name,不要使用 worker id。', '取消未完成派单时必须使用 dispatch id。', '', 'Hive worker 派单规则:', ...getHiveTeamRules(agent));
19
19
  }
20
20
  else {
21
- lines.push('可用 team 命令:', '- team report "<完整汇报>" [--dispatch <id>] [--artifact <path>] 完成/失败/阻塞汇报', '- team report --stdin [--dispatch <id>] [--artifact <path>] 同上,从 stdin 读正文(适合多行/含引号/特殊字符)', '- team status "<当前状态>" [--artifact <path>] 中段进度/待命/接入状态', '- team status --stdin [--artifact <path>] 同上,从 stdin 读正文', '- team list 查看 workspace 内的 worker(含状态)', '- team --help 仅查命令用法;**不是**汇报手段', '', '语法要点:', '- 正文是第一个 positional argument,flag 顺序任意:`team report "结论" --dispatch X` 和 `team report --dispatch X "结论"` 都成立。', "- 长正文(多行 / 含引号 / shell 特殊字符 / heredoc)一律走 `--stdin`,并用 *quoted* heredoc(`<<'EOF'`)防止 shell 展开 $vars / 反引号 / 命令替换:", " 例:`team report --stdin --dispatch <id> <<'EOF'`", ' `... 长报告(含 $VAR、`backtick`、"引号" 都按字面量保留)...`', ' `EOF`', '- CLI 报错会同时打印 USAGE,可直接对照修正参数。', '', '完成任务后必须执行 `team report "<结论>"`。', '失败、阻塞或部分完成也用 `team report "<当前状态与原因>"` 汇报。', '没有进行中的任务时,用 `team status "<当前状态>"` 汇报接入、待命或阻塞状态。', '不要调用 team send;worker 之间不能直接派单。', '', 'Hive worker 边界:', ...getHiveTeamRules(agent));
21
+ lines.push('可用 team 命令:', '- team report "<完整汇报>" [--dispatch <id>] [--artifact <path>] 完成/失败/阻塞汇报', '- team report --stdin [--dispatch <id>] [--artifact <path>] 同上,从 stdin 读正文(适合多行/含引号/特殊字符)', '- team status "<当前状态>" [--artifact <path>] 中段进度/待命/接入状态', '- team status --stdin [--artifact <path>] 同上,从 stdin 读正文', '- team list 查看 workspace 内的 worker(含状态)', '- team --help 仅查命令用法;**不是**汇报手段', '', '语法要点:', '- 正文是第一个 positional argument,flag 顺序任意:`team report "结论" --dispatch X` 和 `team report --dispatch X "结论"` 都成立。', '- 长正文(多行 / 含引号 / shell 特殊字符)一律走 `--stdin`,通过你所在 shell 的管道喂给它(POSIX 用 quoted heredoc `<<\'EOF\' EOF` 防止 $var/反引号展开,Windows cmd `type body.txt |` 或临时文件 `< body.txt`)。`--stdin` 自己只负责" stdin ",不依赖任何 shell 语法。', '- CLI 报错会同时打印 USAGE,可直接对照修正参数。', '', '完成任务后必须执行 `team report "<结论>"`。', '失败、阻塞或部分完成也用 `team report "<当前状态与原因>"` 汇报。', '没有进行中的任务时,用 `team status "<当前状态>"` 汇报接入、待命或阻塞状态。', '不要调用 team send;worker 之间不能直接派单。', '', 'Hive worker 边界:', ...getHiveTeamRules(agent));
22
22
  }
23
23
  lines.push('');
24
24
  return lines.join('\n');
@@ -15,5 +15,6 @@ interface CreateAppOptions {
15
15
  export declare const createApp: ({ store, pickFolderService, openWorkspaceService, packageVersionReader, tasksFileService, versionService, }: CreateAppOptions) => {
16
16
  server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
17
17
  store: RuntimeStore;
18
+ closeWebSockets: () => void;
18
19
  };
19
20
  export type { CreateAppOptions };
@@ -196,6 +196,16 @@ export const createApp = ({ store, pickFolderService = pickFolder, openWorkspace
196
196
  sendJson(response, 500, { error: message });
197
197
  }
198
198
  });
199
- createTerminalWebSocketServer(server, store, tasksFileService);
200
- return { server, store };
199
+ const wsServer = createTerminalWebSocketServer(server, store, tasksFileService);
200
+ return {
201
+ server,
202
+ store,
203
+ // Tear-down for the WebSocket layer. Callers must invoke this
204
+ // BEFORE awaiting `server.close()` if they want a prompt return:
205
+ // `server.close()` waits on every existing socket, and Node's
206
+ // `closeAllConnections()` does NOT terminate already-upgraded
207
+ // WebSocket clients. Without this hook a Ctrl+C in the Hive
208
+ // runtime hangs as long as any browser tab is connected.
209
+ closeWebSockets: wsServer.close,
210
+ };
201
211
  };
@@ -22,4 +22,17 @@ export interface FsProbeResponse {
22
22
  suggested_name: string;
23
23
  }
24
24
  export declare const browseDirectory: (requestedPath: string) => Promise<FsBrowseResponse>;
25
- export declare const probeDirectory: (requestedPath: string) => Promise<FsProbeResponse>;
25
+ export interface ProbeDirectoryOptions {
26
+ /**
27
+ * When `true` (default), probe rejects paths outside `$HOME` so the
28
+ * in-browser FS tree can't be tricked into revealing arbitrary disk
29
+ * contents via a hand-crafted path string.
30
+ *
31
+ * When `false`, the sandbox check is skipped — callers who already
32
+ * have a user-authorized path (e.g. paths returned by the OS-native
33
+ * folder picker) must use this so a Windows user picking `D:\projects`
34
+ * isn't rejected just because their `$HOME` lives on `C:`.
35
+ */
36
+ enforceSandbox?: boolean;
37
+ }
38
+ export declare const probeDirectory: (requestedPath: string, options?: ProbeDirectoryOptions) => Promise<FsProbeResponse>;
@@ -5,6 +5,29 @@ import { promisify } from 'node:util';
5
5
  import { getFsBrowseRoot, isPathWithinRoot } from './fs-sandbox.js';
6
6
  const execFileP = promisify(execFile);
7
7
  const GIT_BRANCH_TIMEOUT_MS = 800;
8
+ /**
9
+ * Map a filesystem rejection (from `readdir`, `stat`, etc.) to a string
10
+ * suitable for surfacing in the browse response. The common Windows
11
+ * failure paths — System Volume Information / $Recycle.Bin (EACCES),
12
+ * dangling junctions (EBUSY / EINVAL), paths past MAX_PATH on systems
13
+ * without long-path support (ENAMETOOLONG) — each get a recognizable
14
+ * prefix so the UI does not just show the raw errno.
15
+ */
16
+ const formatFilesystemError = (error) => {
17
+ if (!(error instanceof Error))
18
+ return 'Failed to read directory';
19
+ const code = error.code;
20
+ if (code === 'EACCES' || code === 'EPERM') {
21
+ return `Permission denied: ${error.message}`;
22
+ }
23
+ if (code === 'ENAMETOOLONG') {
24
+ return `Path is too long for this filesystem: ${error.message}`;
25
+ }
26
+ if (code === 'EBUSY' || code === 'EINVAL') {
27
+ return `Path is busy or unavailable: ${error.message}`;
28
+ }
29
+ return error.message;
30
+ };
8
31
  const detectGitRepository = async (entryPath) => {
9
32
  try {
10
33
  const info = await stat(resolve(entryPath, '.git'));
@@ -49,7 +72,7 @@ export const browseDirectory = async (requestedPath) => {
49
72
  return {
50
73
  current_path: candidate,
51
74
  entries: [],
52
- error: error instanceof Error ? error.message : 'Failed to stat directory',
75
+ error: formatFilesystemError(error),
53
76
  ok: false,
54
77
  parent_path: null,
55
78
  root_path: rootPath,
@@ -65,7 +88,24 @@ export const browseDirectory = async (requestedPath) => {
65
88
  root_path: rootPath,
66
89
  };
67
90
  }
68
- const rawEntries = await readdir(candidate, { withFileTypes: true });
91
+ let rawEntries;
92
+ try {
93
+ rawEntries = await readdir(candidate, { withFileTypes: true });
94
+ }
95
+ catch (error) {
96
+ // Windows hits this for System Volume Information, $Recycle.Bin,
97
+ // broken junctions, and long paths on hosts without long-path
98
+ // support. Returning ok:false lets the picker surface a readable
99
+ // message instead of crashing the HTTP handler.
100
+ return {
101
+ current_path: candidate,
102
+ entries: [],
103
+ error: formatFilesystemError(error),
104
+ ok: false,
105
+ parent_path: null,
106
+ root_path: rootPath,
107
+ };
108
+ }
69
109
  const directoryEntries = rawEntries
70
110
  .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
71
111
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -91,9 +131,12 @@ export const browseDirectory = async (requestedPath) => {
91
131
  root_path: rootPath,
92
132
  };
93
133
  };
94
- export const probeDirectory = async (requestedPath) => {
134
+ export const probeDirectory = async (requestedPath, options = {}) => {
135
+ const enforceSandbox = options.enforceSandbox ?? true;
95
136
  const rootPath = getFsBrowseRoot();
96
- const candidate = resolve(rootPath, requestedPath.trim());
137
+ const candidate = enforceSandbox
138
+ ? resolve(rootPath, requestedPath.trim())
139
+ : resolve(requestedPath.trim());
97
140
  const base = {
98
141
  current_branch: null,
99
142
  exists: false,
@@ -103,7 +146,7 @@ export const probeDirectory = async (requestedPath) => {
103
146
  path: candidate,
104
147
  suggested_name: candidate.split(/[\\/]/).filter(Boolean).pop() ?? '',
105
148
  };
106
- if (!isPathWithinRoot(rootPath, candidate)) {
149
+ if (enforceSandbox && !isPathWithinRoot(rootPath, candidate)) {
107
150
  return base;
108
151
  }
109
152
  try {
@@ -5,6 +5,13 @@ import { probeDirectory } from './fs-browse.js';
5
5
  const MACOS_CANCEL_PATTERNS = [/-128/, /-1743/, /user canceled/i, /execution error/i];
6
6
  // zenity documents exit code 1 on Cancel. kdialog uses exit code 1 as well.
7
7
  const LINUX_CANCEL_EXIT_CODES = new Set([1]);
8
+ // Cap how long we'll wait for a single picker invocation. A reasonable
9
+ // modal-dialog dwell time is well under this — the cap exists to catch
10
+ // genuinely wedged pickers (PowerShell startup hang under restricted
11
+ // execution policy, zenity hung on a missing DBus, osascript blocked on
12
+ // the macOS Accessibility prompt) so the HTTP request returns instead
13
+ // of pinning a connection forever.
14
+ const PICKER_TIMEOUT_MS = 5 * 60 * 1000;
8
15
  const defaultRunCommand = (command, args, options) => new Promise((resolve) => {
9
16
  const child = execFile(command, args, options, (error, stdout, stderr) => {
10
17
  const errno = error;
@@ -30,10 +37,15 @@ const emptyResponse = (overrides = {}) => ({
30
37
  ...overrides,
31
38
  });
32
39
  const finalizeWithProbe = async (path) => {
33
- const probe = await probeDirectory(path);
40
+ // The OS-native folder picker is itself a user-authorization surface
41
+ // — sandboxing again here would reject any drive other than the one
42
+ // hosting `$HOME` (a common Windows case: `D:\projects`, `E:\code`).
43
+ // The in-browser FS tree (fs-browse.ts:browseDirectory) keeps its
44
+ // own sandbox; only the native picker bypasses it.
45
+ const probe = await probeDirectory(path, { enforceSandbox: false });
34
46
  if (!probe.ok || !probe.is_dir) {
35
47
  return emptyResponse({
36
- error: 'Selected path is outside the Hive browse sandbox or is not a directory.',
48
+ error: 'Selected path is not a directory.',
37
49
  path,
38
50
  probe,
39
51
  });
@@ -42,7 +54,7 @@ const finalizeWithProbe = async (path) => {
42
54
  };
43
55
  const macOsPick = async (run) => {
44
56
  const script = 'POSIX path of (choose folder with prompt "Select Hive workspace")';
45
- const result = await run('osascript', ['-e', script], {});
57
+ const result = await run('osascript', ['-e', script], { timeout: PICKER_TIMEOUT_MS });
46
58
  if (result.spawnError?.code === 'ENOENT') {
47
59
  return emptyResponse({ error: 'osascript is unavailable on this host.', supported: false });
48
60
  }
@@ -65,7 +77,7 @@ const macOsPick = async (run) => {
65
77
  return finalizeWithProbe(picked);
66
78
  };
67
79
  const linuxPick = async (run) => {
68
- const result = await run('zenity', ['--file-selection', '--directory', '--title=Select Hive workspace'], {});
80
+ const result = await run('zenity', ['--file-selection', '--directory', '--title=Select Hive workspace'], { timeout: PICKER_TIMEOUT_MS });
69
81
  if (result.spawnError?.code === 'ENOENT') {
70
82
  return emptyResponse({
71
83
  error: 'zenity not installed. Install zenity or use Advanced: paste path.',
@@ -84,16 +96,47 @@ const linuxPick = async (run) => {
84
96
  return finalizeWithProbe(picked);
85
97
  };
86
98
  const windowsPick = async (run) => {
99
+ /* Hive's PowerShell child has no visible main window, so a bare
100
+ `$dialog.ShowDialog()` inherits the desktop as IWin32Window parent —
101
+ and ends up below the foreground browser in z-order. To the user
102
+ that looks like a hang ("Add Workspace pops up then nothing"); the
103
+ picker is open, just occluded.
104
+
105
+ Fix: build a TopMost invisible owner Form, `Show()` it so it has a
106
+ real HWND (an unshown Form has none, and ShowDialog silently falls
107
+ back to desktop-parent), then pass the owner to `ShowDialog($owner)`.
108
+ The owner inherits TopMost z-order onto the dialog. The owner itself
109
+ stays invisible — Opacity 0, parked at (-32000, -32000), 1x1 size,
110
+ no taskbar entry — so the user only sees the picker. Dispose in
111
+ `finally` to release the HWND each invocation.
112
+
113
+ `Add-Type -AssemblyName System.Drawing` is required because Point /
114
+ Size live in System.Drawing.dll, not System.Windows.Forms.dll. */
87
115
  const script = [
88
116
  'Add-Type -AssemblyName System.Windows.Forms',
89
- '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
90
- '$dialog.Description = "Select Hive workspace"',
91
- '$dialog.ShowNewFolderButton = $false',
92
- '$result = $dialog.ShowDialog()',
93
- 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.WriteLine($dialog.SelectedPath); exit 0 }',
94
- 'exit 1',
117
+ 'Add-Type -AssemblyName System.Drawing',
118
+ '$owner = New-Object System.Windows.Forms.Form',
119
+ "$owner.FormBorderStyle = 'None'",
120
+ '$owner.Opacity = 0',
121
+ '$owner.ShowInTaskbar = $false',
122
+ "$owner.StartPosition = 'Manual'",
123
+ '$owner.Location = New-Object System.Drawing.Point(-32000, -32000)',
124
+ '$owner.Size = New-Object System.Drawing.Size(1, 1)',
125
+ '$owner.TopMost = $true',
126
+ '$owner.Show()',
127
+ 'try {',
128
+ ' $dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
129
+ ' $dialog.Description = "Select Hive workspace"',
130
+ ' $dialog.ShowNewFolderButton = $false',
131
+ ' $result = $dialog.ShowDialog($owner)',
132
+ ' if ($result -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.WriteLine($dialog.SelectedPath); exit 0 }',
133
+ ' exit 1',
134
+ '} finally {',
135
+ ' $owner.Close()',
136
+ ' $owner.Dispose()',
137
+ '}',
95
138
  ].join('; ');
96
- const result = await run('powershell.exe', ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', script], {});
139
+ const result = await run('powershell.exe', ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', script], { timeout: PICKER_TIMEOUT_MS });
97
140
  if (result.spawnError?.code === 'ENOENT') {
98
141
  return emptyResponse({
99
142
  error: 'PowerShell is unavailable on this host. Use Advanced: paste path.',
@@ -14,7 +14,7 @@
14
14
  * abstract identity restatement.
15
15
  */
16
16
  export const ORCHESTRATOR_REMINDER_TAIL = '<hive-system-reminder>\n' +
17
- 'You are the Hive Orchestrator. Reply by either: (a) `team send "<worker-name>" "<task>"` to dispatch follow-up work to a Hive worker, (b) `team cancel --dispatch <id> "<reason>"` to cancel an obsolete dispatch, or (c) plain text to the user. Never call your CLI\'s built-in subagent tools (Task / Explore / etc.) — they bypass Hive and will not appear in the UI.\n' +
17
+ 'You are the Hive Orchestrator. Reply by either: (a) `team send "<worker-name>" "<task>"` to dispatch follow-up work to a Hive worker — run `team list` first when the roster may have changed since your last view (Hive does not push membership changes; stale names fail dispatch), (b) `team cancel --dispatch <id> "<reason>"` to cancel an obsolete dispatch, or (c) plain text to the user. Never call your CLI\'s built-in subagent tools (Task / Explore / etc.) — they bypass Hive and will not appear in the UI.\n' +
18
18
  '</hive-system-reminder>';
19
19
  /**
20
20
  * Tail reminder appended to dispatches sent TO a worker. Reinforces the
@@ -27,7 +27,7 @@ export const buildWorkerReminderTail = (dispatchId) => '<hive-system-reminder>\n
27
27
  '</hive-system-reminder>';
28
28
  const ORCHESTRATOR_RULES = [
29
29
  'Hive worker 是右侧卡片里的真实 CLI agent,不是你所在 CLI 的内置 subagent / 子代理工具。',
30
- ' user 要你“让 worker ... / worker 找活 / 让成员处理”时,先执行 `team list` 确认真实 Hive worker。',
30
+ '派单前必须以最新 roster 为准:每次 `team send` 之前,如果距上一次 `team list` 之后发生过 user 回复或你自己的派单/取消,重新跑一次 `team list` 拿当前成员名单。Hive 不主动推送成员变更——别按对会话早期 roster 的记忆派单,否则容易派到已改名或已删除的 worker。',
31
31
  '普通、低风险、几分钟内能直接完成的小任务可以自己做;不要为了形式感派 worker。需要并行、长时间执行、独立 review/test、专门角色,或 user 明确要求 worker/成员处理时,再用 `team send`。',
32
32
  '如果只有一个可用 worker,直接用 `team send <worker-name> "<task>"` 派给它;不要把选择题丢回给 user。',
33
33
  '当 user 要你“让 worker ...”时,必须用 `team send <worker-name> "<task>"` 派给 Hive worker。',
@@ -58,7 +58,8 @@ export const buildProtocolDoc = () => [
58
58
  '',
59
59
  'This file is auto-generated by Hive on every workspace open. If you',
60
60
  '(the agent) lost context after `/compact` or an internal summarization,',
61
- '`cat .hive/PROTOCOL.md` to re-anchor.',
61
+ 're-read `.hive/PROTOCOL.md` (POSIX: `cat`, Windows cmd: `type`, PowerShell:',
62
+ '`Get-Content`) to re-anchor.',
62
63
  '',
63
64
  '## You are running inside Hive',
64
65
  '',
@@ -81,7 +82,7 @@ export const buildProtocolDoc = () => [
81
82
  '## `team` CLI — worker',
82
83
  '',
83
84
  '- `team report "<result>" --dispatch <id>` — report task outcome',
84
- "- `team report --stdin --dispatch <id>` — same, body from stdin (use `<<'EOF'` heredoc for long bodies)",
85
+ '- `team report --stdin --dispatch <id>` — same, body from stdin (pipe content in via your shell — POSIX heredoc, `type file |`, or whatever your environment supports)',
85
86
  '- `team status "<state>"` — update orchestrator when no dispatch is active',
86
87
  '',
87
88
  '## Orchestrator rules',
@@ -44,18 +44,37 @@ const linuxAttempts = (targetId, path) => {
44
44
  return [{ command: 'xdg-open', args: [path] }];
45
45
  }
46
46
  };
47
+ /**
48
+ * Wrap a PATHEXT-dependent shim (`code.cmd`, `cursor.cmd`, etc.) in cmd.exe
49
+ * so Windows resolves the `.cmd` extension. `execFile` does not consult
50
+ * PATHEXT and cannot launch `.cmd` files directly, so a bare `code` argv
51
+ * returns ENOENT even when VSCode is installed.
52
+ *
53
+ * `cmd.exe /d /s /c` parses the trailing command line. We pass the binary
54
+ * name and path as separate argv elements; node's child_process quotes the
55
+ * path when it contains whitespace, and cmd's `/s` quote-stripping does NOT
56
+ * fire because the command line starts with the binary name (e.g. `code`),
57
+ * not a `"`.
58
+ */
59
+ const cmdExeShimAttempt = (bin, path) => ({
60
+ command: 'cmd.exe',
61
+ args: ['/d', '/s', '/c', bin, path],
62
+ });
47
63
  const windowsAttempts = (targetId, path) => {
48
64
  switch (targetId) {
49
65
  case 'finder':
66
+ // explorer.exe is a real PE binary; the existing exit-code-1 success
67
+ // heuristic in classifyFailure depends on the spawn going directly to
68
+ // explorer, so do NOT wrap this in cmd.exe.
50
69
  return [{ command: 'explorer', args: [path] }];
51
70
  case 'vscode':
52
- return [{ command: 'code', args: [path] }];
71
+ return [cmdExeShimAttempt('code', path)];
53
72
  case 'vscode-insiders':
54
- return [{ command: 'code-insiders', args: [path] }];
73
+ return [cmdExeShimAttempt('code-insiders', path)];
55
74
  case 'cursor':
56
- return [{ command: 'cursor', args: [path] }];
75
+ return [cmdExeShimAttempt('cursor', path)];
57
76
  case 'zed':
58
- return [{ command: 'zed', args: [path] }];
77
+ return [cmdExeShimAttempt('zed', path)];
59
78
  default:
60
79
  return [{ command: 'explorer', args: [path] }];
61
80
  }
@@ -94,6 +113,13 @@ const APP_NOT_INSTALLED_PATTERNS = [
94
113
  /can'?t find/i,
95
114
  /not authorized to send keystrokes/i,
96
115
  /application can'?t be found/i,
116
+ // Windows: when cmd.exe can't resolve the .cmd shim (e.g. VSCode not
117
+ // installed), it prints this message to stderr and exits non-zero. We
118
+ // wrap editor invocations in `cmd /d /s /c` so the spawn itself always
119
+ // succeeds (cmd.exe is always present); the missing-binary signal is in
120
+ // the stderr text, not in spawn ENOENT.
121
+ /is not recognized as an internal or external command/i,
122
+ /不是内部或外部命令/, // zh-CN Windows localization of the above
97
123
  ];
98
124
  const classifyFailure = (result) => {
99
125
  if (result.spawnError?.code === 'ENOENT')
@@ -1,4 +1,4 @@
1
- import { basename } from 'node:path';
1
+ import { normalizeExecutableToken } from './startup-command-parser.js';
2
2
  const INTERACTIVE_COMMANDS = new Set(['claude', 'codex', 'gemini', 'opencode']);
3
3
  const READY_CHECK_INTERVAL_MS = 50;
4
4
  const READY_TIMEOUT_MS = 3000;
@@ -11,8 +11,11 @@ const PASTE_ACK_TIMEOUT_MS = 3000;
11
11
  const COMMANDS_WITH_BRACKETED_PASTE = new Set(['claude', 'codex', 'opencode']);
12
12
  export const toBracketedPasteSubmission = (text) => `\u001b[200~${text}\u001b[201~`;
13
13
  const getSubmitAfterPasteDelayMs = (text) => Math.min(MAX_SUBMIT_AFTER_PASTE_DELAY_MS, Math.max(MIN_SUBMIT_AFTER_PASTE_DELAY_MS, Math.ceil(text.length / PASTE_CHARS_PER_DELAY_MS)));
14
- export const isInteractiveAgentCommand = (command) => INTERACTIVE_COMMANDS.has(basename(command).toLowerCase());
15
- const getCommandName = (command) => basename(command).toLowerCase();
14
+ export const isInteractiveAgentCommand = (command) => {
15
+ const brand = normalizeExecutableToken(command);
16
+ return brand !== null && INTERACTIVE_COMMANDS.has(brand);
17
+ };
18
+ const getCommandName = (command) => normalizeExecutableToken(command) ?? '';
16
19
  const hasGeminiPromptReady = (output) => /\bType your message\b/u.test(output);
17
20
  export const hasInteractivePromptReady = (output, command = '') => {
18
21
  const commandName = getCommandName(command);
@@ -27,7 +27,16 @@ export const teamRoutes = [
27
27
  fromAgentId,
28
28
  hivePort: String(request.socket.localPort ?? ''),
29
29
  });
30
- sendJson(response, 202, { dispatch_id: dispatch.id, ok: true });
30
+ /* `restarted_worker` makes the silent auto-wake transparent — when
31
+ the worker had no active run at dispatch time, ensureWorkerRun
32
+ spawned a fresh PTY for it. The orchestrator's CLI prints a
33
+ stderr notice on this flag so the agent can explain the wake-up
34
+ to the user instead of being out of sync with worker state. */
35
+ sendJson(response, 202, {
36
+ dispatch_id: dispatch.id,
37
+ ok: true,
38
+ restarted_worker: dispatch.restartedWorker,
39
+ });
31
40
  }),
32
41
  route('POST', '/api/team/cancel', async ({ request, response, store }) => {
33
42
  const body = await readJsonBody(request);
@@ -19,7 +19,9 @@ interface RuntimeStore {
19
19
  renameWorker: (workspaceId: string, workerId: string, name: string) => AgentSummary;
20
20
  recordUserInput: (workspaceId: string, orchestratorId: string, text: string) => void;
21
21
  dispatchTask: (workspaceId: string, workerId: string, text: string, input?: DispatchTaskInput) => Promise<DispatchRecord>;
22
- dispatchTaskByWorkerName: (workspaceId: string, workerName: string, text: string, input?: DispatchTaskInput) => Promise<DispatchRecord>;
22
+ dispatchTaskByWorkerName: (workspaceId: string, workerName: string, text: string, input?: DispatchTaskInput) => Promise<DispatchRecord & {
23
+ restartedWorker: boolean;
24
+ }>;
23
25
  reportTask: (workspaceId: string, workerId: string, input?: ReportTaskInput) => ReportTaskResult;
24
26
  statusTask: (workspaceId: string, workerId: string, input?: StatusTaskInput) => ReportTaskResult;
25
27
  cancelTask: (workspaceId: string, dispatchId: string, input: CancelTaskInput) => ReportTaskResult;
@@ -1,4 +1,27 @@
1
1
  export declare const getClaudeProjectsRoot: (pattern?: string) => string;
2
+ /**
3
+ * Match the directory-name encoding Claude Code itself uses for its project
4
+ * metadata under `~/.claude/projects/`. Empirically (probed via `claude
5
+ * --print "x"` in directories named with each character) Claude Code
6
+ * replaces *every* character outside `[A-Za-z0-9-]` with a single `-`,
7
+ * one-for-one, preserving literal hyphens.
8
+ *
9
+ * The previous regex `[\\/:\s]` only matched `\`, `/`, `:`, and whitespace —
10
+ * leaving `_`, `.`, parens, brackets, `@`, `#`, `&`, `+`, and any non-ASCII
11
+ * character (including CJK usernames) intact, while Claude Code's own
12
+ * encoder replaced them. The mismatch meant Hive looked for sessions under
13
+ * a different directory than the one Claude Code wrote to, so session
14
+ * resume silently failed for any workspace path containing those chars.
15
+ * Windows + CJK Windows usernames (`C:\Users\张三\project`) and
16
+ * underscored Windows project paths (`C:\my_project`) are the most common
17
+ * triggers, but the bug is cross-platform.
18
+ *
19
+ * Backward compatibility: workspaces whose path contains ONLY chars that
20
+ * the old regex already matched (`/\:` + whitespace) get the same encoded
21
+ * dirname as before — no behavior change. Workspaces with any other
22
+ * special chars previously had broken session resume; the fix moves them
23
+ * to the correct (working) directory.
24
+ */
2
25
  export declare const encodeClaudeProjectPath: (cwd: string) => string;
3
26
  interface ClaudeSessionCaptureDiscriminator {
4
27
  contentIncludes?: string | readonly string[];
@@ -18,7 +18,30 @@ export const getClaudeProjectsRoot = (pattern) => {
18
18
  }
19
19
  return root;
20
20
  };
21
- export const encodeClaudeProjectPath = (cwd) => cwd.replace(/[\\/:\s]/g, '-');
21
+ /**
22
+ * Match the directory-name encoding Claude Code itself uses for its project
23
+ * metadata under `~/.claude/projects/`. Empirically (probed via `claude
24
+ * --print "x"` in directories named with each character) Claude Code
25
+ * replaces *every* character outside `[A-Za-z0-9-]` with a single `-`,
26
+ * one-for-one, preserving literal hyphens.
27
+ *
28
+ * The previous regex `[\\/:\s]` only matched `\`, `/`, `:`, and whitespace —
29
+ * leaving `_`, `.`, parens, brackets, `@`, `#`, `&`, `+`, and any non-ASCII
30
+ * character (including CJK usernames) intact, while Claude Code's own
31
+ * encoder replaced them. The mismatch meant Hive looked for sessions under
32
+ * a different directory than the one Claude Code wrote to, so session
33
+ * resume silently failed for any workspace path containing those chars.
34
+ * Windows + CJK Windows usernames (`C:\Users\张三\project`) and
35
+ * underscored Windows project paths (`C:\my_project`) are the most common
36
+ * triggers, but the bug is cross-platform.
37
+ *
38
+ * Backward compatibility: workspaces whose path contains ONLY chars that
39
+ * the old regex already matched (`/\:` + whitespace) get the same encoded
40
+ * dirname as before — no behavior change. Workspaces with any other
41
+ * special chars previously had broken session resume; the fix moves them
42
+ * to the correct (working) directory.
43
+ */
44
+ export const encodeClaudeProjectPath = (cwd) => cwd.replace(/[^A-Za-z0-9-]/g, '-');
22
45
  const listSessionIds = (cwd, projectsRoot = getDefaultProjectsRoot()) => {
23
46
  const projectDir = join(projectsRoot, encodeClaudeProjectPath(cwd));
24
47
  try {