@tt-a1i/hive 1.1.5 → 1.2.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable user-facing changes will be documented in this file.
4
4
 
5
+ ## 1.2.0 - 2026-05-18
6
+
7
+ Opens the active workspace in your editor, terminal, or file manager from
8
+ Hive's topbar.
9
+
10
+ - Adds an "Open" split button to the topbar that launches the active workspace
11
+ in a chosen application. Ten targets on macOS (VS Code, VS Code Insiders,
12
+ Cursor, Windsurf, Finder, Terminal, iTerm2, Ghostty, IntelliJ IDEA, Zed) and
13
+ six on Windows / Linux (VS Code, VS Code Insiders, Cursor, Windsurf, File
14
+ Explorer / File Manager, Zed).
15
+ - Persists the preferred target per browser via `localStorage` so the next
16
+ click jumps to the same app. Stale preferences for apps that aren't valid on
17
+ the current platform fall back to the OS file manager instead of erroring.
18
+ - Surfaces failures as localized toast notifications. Distinguishes
19
+ "app not installed", "launcher not on PATH", and other failure modes so a
20
+ missing Cursor install reads differently from a misconfigured `code` CLI.
21
+ - Backend launches each command via `execFile` with an argv array — no shell
22
+ is involved, so workspace paths containing spaces, Unicode, or quotes pass
23
+ through verbatim. Paths containing newlines or NUL bytes are rejected before
24
+ dispatch.
25
+ - Special-cases Windows `explorer.exe`, which returns exit code 1 even on
26
+ success: spawn-errors are still surfaced, but a non-zero exit no longer
27
+ shows a spurious toast.
28
+
5
29
  ## 1.1.5 - 2026-05-18
6
30
 
7
31
  Custom startup command and close-guard fixes.
@@ -1,15 +1,17 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import { type PickFolderResponse } from './fs-pick-folder.js';
3
+ import type { OpenWorkspaceService } from './route-types.js';
3
4
  import type { RuntimeStore } from './runtime-store.js';
4
5
  import { type TasksFileService } from './tasks-file.js';
5
6
  import { type VersionService } from './version-service.js';
6
7
  interface CreateAppOptions {
7
8
  store: RuntimeStore;
8
9
  pickFolderService?: () => Promise<PickFolderResponse>;
10
+ openWorkspaceService?: OpenWorkspaceService;
9
11
  tasksFileService?: TasksFileService;
10
12
  versionService?: VersionService;
11
13
  }
12
- export declare const createApp: ({ store, pickFolderService, tasksFileService, versionService, }: CreateAppOptions) => {
14
+ export declare const createApp: ({ store, pickFolderService, openWorkspaceService, tasksFileService, versionService, }: CreateAppOptions) => {
13
15
  server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
14
16
  store: RuntimeStore;
15
17
  };
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { pickFolder } from './fs-pick-folder.js';
7
7
  import { HttpError } from './http-errors.js';
8
8
  import { assertLocalRequest } from './local-request-guard.js';
9
+ import { openWorkspace } from './open-target-commands.js';
9
10
  import { matchRoute } from './routes.js';
10
11
  import { createTasksFileService } from './tasks-file.js';
11
12
  import { createTerminalWebSocketServer } from './terminal-ws-server.js';
@@ -75,7 +76,7 @@ const sendJson = (response, statusCode, body) => {
75
76
  response.setHeader('content-type', 'application/json; charset=utf-8');
76
77
  response.end(JSON.stringify(body));
77
78
  };
78
- export const createApp = ({ store, pickFolderService = pickFolder, tasksFileService = createTasksFileService(), versionService = createVersionService(), }) => {
79
+ export const createApp = ({ store, pickFolderService = pickFolder, openWorkspaceService = (input) => openWorkspace(input), tasksFileService = createTasksFileService(), versionService = createVersionService(), }) => {
79
80
  const staticDir = process.env.HIVE_STATIC_DIR ?? getDefaultStaticDir();
80
81
  const staticAvailablePromise = canServeStatic(staticDir);
81
82
  const server = createServer(async (request, response) => {
@@ -91,6 +92,7 @@ export const createApp = ({ store, pickFolderService = pickFolder, tasksFileServ
91
92
  store,
92
93
  tasksFileService,
93
94
  pickFolderService,
95
+ openWorkspaceService,
94
96
  versionService,
95
97
  params: match.params,
96
98
  });
@@ -0,0 +1,53 @@
1
+ import { type ExecFileOptions } from 'node:child_process';
2
+ import { type OpenTargetId, type OpenTargetPlatform, type OpenWorkspaceErrorCode } from '../shared/open-targets.js';
3
+ export type { OpenTargetId, OpenTargetPlatform, OpenWorkspaceErrorCode, } from '../shared/open-targets.js';
4
+ export { getEffectiveOpenTargetId, isOpenTargetId, isOpenTargetSupported, OPEN_TARGET_IDS_BY_PLATFORM, } from '../shared/open-targets.js';
5
+ export declare const resolveOpenTargetPlatform: (platform: NodeJS.Platform) => OpenTargetPlatform;
6
+ export interface OpenAttempt {
7
+ command: string;
8
+ args: string[];
9
+ }
10
+ /**
11
+ * Returns the ordered list of commands to try. First success wins; remaining
12
+ * entries are fallbacks (e.g. IntelliJ IDEA → IntelliJ IDEA CE on older Macs).
13
+ * Empty list means the requested target is unsupported on this platform —
14
+ * callers should have already routed through `getEffectiveOpenTargetId` to
15
+ * fall back, so this should never happen in practice.
16
+ */
17
+ export declare const buildOpenAttempts: (targetId: OpenTargetId, path: string, platform: OpenTargetPlatform) => OpenAttempt[];
18
+ export interface OpenCommandSuccess {
19
+ ok: true;
20
+ effectiveTargetId: OpenTargetId;
21
+ }
22
+ export interface OpenCommandFailure {
23
+ ok: false;
24
+ effectiveTargetId: OpenTargetId;
25
+ errorCode: OpenWorkspaceErrorCode;
26
+ stderr: string;
27
+ }
28
+ export type OpenCommandResult = OpenCommandSuccess | OpenCommandFailure;
29
+ interface SpawnResult {
30
+ stderr: string;
31
+ stdout: string;
32
+ status: number | null;
33
+ signal: string | null;
34
+ spawnError: NodeJS.ErrnoException | null;
35
+ }
36
+ export type RunOpenCommand = (command: string, args: string[], options: ExecFileOptions) => Promise<SpawnResult>;
37
+ export interface OpenWorkspaceInput {
38
+ path: string;
39
+ targetId: OpenTargetId;
40
+ }
41
+ export interface OpenWorkspaceOptions {
42
+ platform?: NodeJS.Platform;
43
+ runCommand?: RunOpenCommand;
44
+ }
45
+ /**
46
+ * Workspace paths originate from the OS folder picker or from manual paste;
47
+ * the picker output is sandbox-validated at create time, but a path stored
48
+ * before path-validation existed (or one pasted into a hypothetical migration
49
+ * future) could contain `\n` / `\0`. Reject those here so we never hand an
50
+ * ambiguous path to `xdg-open`, where shell wrappers split on newline.
51
+ */
52
+ export declare const isOpenWorkspacePathSafe: (path: string) => boolean;
53
+ export declare const openWorkspace: (input: OpenWorkspaceInput, options?: OpenWorkspaceOptions) => Promise<OpenCommandResult>;
@@ -0,0 +1,193 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { getDefaultOpenTargetIdForPlatform, getEffectiveOpenTargetId, isOpenTargetId, } from '../shared/open-targets.js';
3
+ export { getEffectiveOpenTargetId, isOpenTargetId, isOpenTargetSupported, OPEN_TARGET_IDS_BY_PLATFORM, } from '../shared/open-targets.js';
4
+ export const resolveOpenTargetPlatform = (platform) => {
5
+ if (platform === 'darwin')
6
+ return 'mac';
7
+ if (platform === 'win32')
8
+ return 'windows';
9
+ if (platform === 'linux')
10
+ return 'linux';
11
+ return 'other';
12
+ };
13
+ const macAttempts = (targetId, path) => {
14
+ switch (targetId) {
15
+ case 'finder':
16
+ return [{ command: 'open', args: [path] }];
17
+ case 'vscode':
18
+ return [{ command: 'open', args: ['-a', 'Visual Studio Code', path] }];
19
+ case 'vscode-insiders':
20
+ return [{ command: 'open', args: ['-a', 'Visual Studio Code - Insiders', path] }];
21
+ case 'cursor':
22
+ return [{ command: 'open', args: ['-a', 'Cursor', path] }];
23
+ case 'windsurf':
24
+ return [{ command: 'open', args: ['-a', 'Windsurf', path] }];
25
+ case 'terminal':
26
+ return [{ command: 'open', args: ['-a', 'Terminal', path] }];
27
+ case 'iterm2':
28
+ // Bundle name has always been `iTerm.app` even after the iTerm2 rename;
29
+ // the `iTerm2` fallback in earlier ports is cargo-cult.
30
+ return [{ command: 'open', args: ['-a', 'iTerm', path] }];
31
+ case 'ghostty':
32
+ return [{ command: 'open', args: ['-a', 'Ghostty', path] }];
33
+ case 'intellijidea':
34
+ // 2025.3 unified the editions, but installs predating that still ship
35
+ // `IntelliJ IDEA CE.app` — retry once on the CE bundle.
36
+ return [
37
+ { command: 'open', args: ['-a', 'IntelliJ IDEA', path] },
38
+ { command: 'open', args: ['-a', 'IntelliJ IDEA CE', path] },
39
+ ];
40
+ case 'zed':
41
+ return [{ command: 'open', args: ['-a', 'Zed', path] }];
42
+ }
43
+ };
44
+ const linuxAttempts = (targetId, path) => {
45
+ switch (targetId) {
46
+ case 'finder':
47
+ return [{ command: 'xdg-open', args: [path] }];
48
+ case 'vscode':
49
+ return [{ command: 'code', args: [path] }];
50
+ case 'vscode-insiders':
51
+ return [{ command: 'code-insiders', args: [path] }];
52
+ case 'cursor':
53
+ return [{ command: 'cursor', args: [path] }];
54
+ case 'windsurf':
55
+ return [{ command: 'windsurf', args: [path] }];
56
+ case 'zed':
57
+ return [{ command: 'zed', args: [path] }];
58
+ default:
59
+ return [{ command: 'xdg-open', args: [path] }];
60
+ }
61
+ };
62
+ const windowsAttempts = (targetId, path) => {
63
+ switch (targetId) {
64
+ case 'finder':
65
+ return [{ command: 'explorer', args: [path] }];
66
+ case 'vscode':
67
+ return [{ command: 'code', args: [path] }];
68
+ case 'vscode-insiders':
69
+ return [{ command: 'code-insiders', args: [path] }];
70
+ case 'cursor':
71
+ return [{ command: 'cursor', args: [path] }];
72
+ case 'windsurf':
73
+ return [{ command: 'windsurf', args: [path] }];
74
+ case 'zed':
75
+ return [{ command: 'zed', args: [path] }];
76
+ default:
77
+ return [{ command: 'explorer', args: [path] }];
78
+ }
79
+ };
80
+ /**
81
+ * Returns the ordered list of commands to try. First success wins; remaining
82
+ * entries are fallbacks (e.g. IntelliJ IDEA → IntelliJ IDEA CE on older Macs).
83
+ * Empty list means the requested target is unsupported on this platform —
84
+ * callers should have already routed through `getEffectiveOpenTargetId` to
85
+ * fall back, so this should never happen in practice.
86
+ */
87
+ export const buildOpenAttempts = (targetId, path, platform) => {
88
+ const effectiveTargetId = getEffectiveOpenTargetId(targetId, platform);
89
+ if (platform === 'mac')
90
+ return macAttempts(effectiveTargetId, path);
91
+ if (platform === 'linux')
92
+ return linuxAttempts(effectiveTargetId, path);
93
+ if (platform === 'windows')
94
+ return windowsAttempts(effectiveTargetId, path);
95
+ return [{ command: 'open', args: [path] }];
96
+ };
97
+ const defaultRunOpenCommand = (command, args, options) => new Promise((resolve) => {
98
+ const child = execFile(command, args, options, (error, stdout, stderr) => {
99
+ const errno = error;
100
+ resolve({
101
+ stderr: String(stderr ?? ''),
102
+ stdout: String(stdout ?? ''),
103
+ status: typeof errno?.code === 'number' ? errno.code : (child.exitCode ?? 0),
104
+ signal: typeof errno?.signal === 'string' ? errno.signal : null,
105
+ spawnError: errno && typeof errno.code === 'string' ? errno : null,
106
+ });
107
+ });
108
+ });
109
+ const APP_NOT_INSTALLED_PATTERNS = [
110
+ /unable to find application/i,
111
+ /can'?t find/i,
112
+ /not authorized to send keystrokes/i,
113
+ /application can'?t be found/i,
114
+ ];
115
+ const classifyFailure = (result) => {
116
+ if (result.spawnError?.code === 'ENOENT')
117
+ return 'command-not-in-path';
118
+ const stderr = result.stderr.toLowerCase();
119
+ if (APP_NOT_INSTALLED_PATTERNS.some((re) => re.test(stderr)))
120
+ return 'app-not-installed';
121
+ return 'unknown';
122
+ };
123
+ /**
124
+ * Workspace paths originate from the OS folder picker or from manual paste;
125
+ * the picker output is sandbox-validated at create time, but a path stored
126
+ * before path-validation existed (or one pasted into a hypothetical migration
127
+ * future) could contain `\n` / `\0`. Reject those here so we never hand an
128
+ * ambiguous path to `xdg-open`, where shell wrappers split on newline.
129
+ */
130
+ export const isOpenWorkspacePathSafe = (path) => {
131
+ if (path.length === 0)
132
+ return false;
133
+ for (let i = 0; i < path.length; i++) {
134
+ const code = path.charCodeAt(i);
135
+ if (code === 0 || code === 10 || code === 13)
136
+ return false;
137
+ }
138
+ return true;
139
+ };
140
+ export const openWorkspace = async (input, options = {}) => {
141
+ const platform = resolveOpenTargetPlatform(options.platform ?? process.platform);
142
+ const run = options.runCommand ?? defaultRunOpenCommand;
143
+ if (!isOpenTargetId(input.targetId)) {
144
+ return {
145
+ ok: false,
146
+ effectiveTargetId: getDefaultOpenTargetIdForPlatform(platform),
147
+ errorCode: 'invalid-target',
148
+ stderr: `Unknown open target: ${String(input.targetId)}`,
149
+ };
150
+ }
151
+ if (!isOpenWorkspacePathSafe(input.path)) {
152
+ return {
153
+ ok: false,
154
+ effectiveTargetId: input.targetId,
155
+ errorCode: 'invalid-path',
156
+ stderr: 'Workspace path contains newline or null byte and was rejected.',
157
+ };
158
+ }
159
+ const effectiveTargetId = getEffectiveOpenTargetId(input.targetId, platform);
160
+ const attempts = buildOpenAttempts(input.targetId, input.path, platform);
161
+ let lastFailure = null;
162
+ for (const attempt of attempts) {
163
+ const result = await run(attempt.command, attempt.args, {});
164
+ // Windows `explorer.exe` returns exit code 1 even on success — checking
165
+ // exit code here would surface a spurious error to the user on every
166
+ // File Explorer open. spawnError still catches the "explorer not on PATH"
167
+ // case, which is the only real failure mode worth surfacing.
168
+ if (attempt.command === 'explorer') {
169
+ if (result.spawnError?.code === 'ENOENT') {
170
+ lastFailure = result;
171
+ continue;
172
+ }
173
+ return { ok: true, effectiveTargetId };
174
+ }
175
+ if (!result.spawnError && (result.status === 0 || result.status === null)) {
176
+ return { ok: true, effectiveTargetId };
177
+ }
178
+ lastFailure = result;
179
+ }
180
+ const fallback = lastFailure ?? {
181
+ stderr: 'No command attempts were made.',
182
+ stdout: '',
183
+ status: null,
184
+ signal: null,
185
+ spawnError: null,
186
+ };
187
+ return {
188
+ ok: false,
189
+ effectiveTargetId,
190
+ errorCode: classifyFailure(fallback),
191
+ stderr: fallback.stderr.trim() || fallback.stdout.trim() || 'Failed to open workspace.',
192
+ };
193
+ };
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { WorkerRole } from '../shared/types.js';
3
3
  import type { PickFolderResponse } from './fs-pick-folder.js';
4
+ import type { OpenCommandResult, OpenWorkspaceInput as OpenWorkspaceServiceInput } from './open-target-commands.js';
4
5
  import type { RuntimeStore } from './runtime-store.js';
5
6
  import type { TasksFileService } from './tasks-file.js';
6
7
  import type { VersionService } from './version-service.js';
@@ -48,12 +49,17 @@ export interface ConfigureAgentLaunchBody {
48
49
  args?: string[];
49
50
  command_preset_id?: string | null;
50
51
  }
52
+ export interface OpenWorkspaceBody {
53
+ target_id: string;
54
+ }
55
+ export type OpenWorkspaceService = (input: OpenWorkspaceServiceInput) => Promise<OpenCommandResult>;
51
56
  export interface RouteContext {
52
57
  request: IncomingMessage;
53
58
  response: ServerResponse;
54
59
  store: RuntimeStore;
55
60
  tasksFileService: TasksFileService;
56
61
  pickFolderService: () => Promise<PickFolderResponse>;
62
+ openWorkspaceService: OpenWorkspaceService;
57
63
  versionService: VersionService;
58
64
  params: Record<string, string>;
59
65
  }
@@ -0,0 +1,2 @@
1
+ import type { RouteDefinition } from './route-types.js';
2
+ export declare const openWorkspaceRoutes: RouteDefinition[];
@@ -0,0 +1,47 @@
1
+ import { isOpenTargetId } from './open-target-commands.js';
2
+ import { getRequiredParam, readJsonBody, route, sendJson } from './route-helpers.js';
3
+ import { requireUiTokenFromRequest } from './ui-auth-helpers.js';
4
+ export const openWorkspaceRoutes = [
5
+ route('POST', '/api/workspaces/:workspaceId/open', async ({ openWorkspaceService, params, request, response, store }) => {
6
+ const workspaceId = getRequiredParam(response, params, 'workspaceId', 'Workspace id is required');
7
+ if (!workspaceId)
8
+ return;
9
+ requireUiTokenFromRequest(request, store.validateUiToken);
10
+ const body = await readJsonBody(request);
11
+ if (!isOpenTargetId(body.target_id)) {
12
+ sendJson(response, 400, { error: 'Unknown open target', target_id: body.target_id });
13
+ return;
14
+ }
15
+ // store.getWorkspaceSnapshot throws raw Error("Workspace not found: ...")
16
+ // for missing workspaces — translate that to 404 here rather than letting
17
+ // app.ts catch it as a generic 500.
18
+ let workspacePath;
19
+ try {
20
+ workspacePath = store.getWorkspaceSnapshot(workspaceId).summary.path;
21
+ }
22
+ catch {
23
+ sendJson(response, 404, { error: 'Workspace not found' });
24
+ return;
25
+ }
26
+ const result = await openWorkspaceService({
27
+ path: workspacePath,
28
+ targetId: body.target_id,
29
+ });
30
+ if (result.ok) {
31
+ sendJson(response, 200, {
32
+ ok: true,
33
+ effective_target_id: result.effectiveTargetId,
34
+ });
35
+ return;
36
+ }
37
+ // Drop stderr from the wire response — defense in depth. The frontend
38
+ // never renders raw stderr (toast text is localized via error_code), so
39
+ // there's no reason to ship the OS-level message to the browser where
40
+ // it would live in the devtools network log.
41
+ sendJson(response, 502, {
42
+ ok: false,
43
+ effective_target_id: result.effectiveTargetId,
44
+ error_code: result.errorCode,
45
+ });
46
+ }),
47
+ ];
@@ -1,6 +1,7 @@
1
1
  import { matchPath } from './route-helpers.js';
2
2
  import { dispatchRoutes } from './routes-dispatches.js';
3
3
  import { fsRoutes } from './routes-fs.js';
4
+ import { openWorkspaceRoutes } from './routes-open-workspace.js';
4
5
  import { runtimeRoutes } from './routes-runtime.js';
5
6
  import { settingsRoutes } from './routes-settings.js';
6
7
  import { taskRoutes } from './routes-tasks.js';
@@ -10,6 +11,7 @@ import { versionRoutes } from './routes-version.js';
10
11
  import { workspaceRoutes } from './routes-workspaces.js';
11
12
  const routes = [
12
13
  ...workspaceRoutes,
14
+ ...openWorkspaceRoutes,
13
15
  ...dispatchRoutes,
14
16
  ...versionRoutes,
15
17
  ...uiRoutes,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Cross-cutting types for the "Open workspace in editor/app" feature.
3
+ * Both the server (command construction in `src/server/open-target-commands.ts`)
4
+ * and the web client (button + preference store in `web/src/workspace/open-targets.ts`)
5
+ * pull the union and platform whitelist from here so they cannot drift.
6
+ */
7
+ export type OpenTargetId = 'vscode' | 'vscode-insiders' | 'cursor' | 'windsurf' | 'finder' | 'terminal' | 'iterm2' | 'ghostty' | 'intellijidea' | 'zed';
8
+ export type OpenTargetPlatform = 'mac' | 'windows' | 'linux' | 'other';
9
+ export declare const OPEN_TARGET_IDS_BY_PLATFORM: Record<OpenTargetPlatform, readonly OpenTargetId[]>;
10
+ export declare const isOpenTargetId: (value: unknown) => value is OpenTargetId;
11
+ export declare const isOpenTargetSupported: (targetId: OpenTargetId, platform: OpenTargetPlatform) => boolean;
12
+ /**
13
+ * The id the server will actually attempt to launch. If the user's saved
14
+ * preference is unsupported on the current platform (e.g. they picked iTerm2
15
+ * on a Mac, then opened Hive on Windows), fall back to the platform default
16
+ * rather than erroring out — a stale preference shouldn't break the button.
17
+ */
18
+ export declare const getEffectiveOpenTargetId: (targetId: OpenTargetId, platform: OpenTargetPlatform) => OpenTargetId;
19
+ export declare const getDefaultOpenTargetIdForPlatform: (platform: OpenTargetPlatform) => OpenTargetId;
20
+ export type OpenWorkspaceErrorCode = 'invalid-path' | 'invalid-target' | 'app-not-installed' | 'command-not-in-path' | 'unknown';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cross-cutting types for the "Open workspace in editor/app" feature.
3
+ * Both the server (command construction in `src/server/open-target-commands.ts`)
4
+ * and the web client (button + preference store in `web/src/workspace/open-targets.ts`)
5
+ * pull the union and platform whitelist from here so they cannot drift.
6
+ */
7
+ // Note: there is no `cursor-insiders` here. Cursor stopped shipping a separate
8
+ // Nightly bundle / `cursor-nightly` binary in March 2024; the pre-release
9
+ // channel is now an in-app toggle on the regular Cursor.app, so an "Insiders"
10
+ // menu entry would 100% fail with `app-not-installed` on every platform.
11
+ export const OPEN_TARGET_IDS_BY_PLATFORM = {
12
+ mac: [
13
+ 'vscode',
14
+ 'vscode-insiders',
15
+ 'cursor',
16
+ 'windsurf',
17
+ 'finder',
18
+ 'terminal',
19
+ 'iterm2',
20
+ 'ghostty',
21
+ 'intellijidea',
22
+ 'zed',
23
+ ],
24
+ windows: ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'finder', 'zed'],
25
+ linux: ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'finder', 'zed'],
26
+ other: ['vscode', 'vscode-insiders', 'finder'],
27
+ };
28
+ const ALL_TARGET_IDS = new Set(OPEN_TARGET_IDS_BY_PLATFORM.mac);
29
+ export const isOpenTargetId = (value) => typeof value === 'string' && ALL_TARGET_IDS.has(value);
30
+ export const isOpenTargetSupported = (targetId, platform) => OPEN_TARGET_IDS_BY_PLATFORM[platform].includes(targetId);
31
+ /**
32
+ * The id the server will actually attempt to launch. If the user's saved
33
+ * preference is unsupported on the current platform (e.g. they picked iTerm2
34
+ * on a Mac, then opened Hive on Windows), fall back to the platform default
35
+ * rather than erroring out — a stale preference shouldn't break the button.
36
+ */
37
+ export const getEffectiveOpenTargetId = (targetId, platform) => isOpenTargetSupported(targetId, platform) ? targetId : getDefaultOpenTargetIdForPlatform(platform);
38
+ export const getDefaultOpenTargetIdForPlatform = (platform) => {
39
+ // `finder` exists for every platform and never fails closed.
40
+ if (platform === 'mac' || platform === 'windows' || platform === 'linux')
41
+ return 'finder';
42
+ return 'vscode';
43
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tt-a1i/hive",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "description": "Browser-native hive-mind for CLI coding agents — Claude Code, Codex, Gemini, and OpenCode collaborate as real PTY processes via a team protocol.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.30.3",
@@ -60,7 +60,7 @@
60
60
  "postinstall": "node scripts/fix-runtime-artifacts.mjs",
61
61
  "release:dry": "pnpm check && pnpm build && pnpm test && pnpm pack:check && pnpm pack:smoke",
62
62
  "test": "vitest run",
63
- "test:windows": "vitest run tests/unit/agent-command-resolver.test.ts tests/unit/session-capture-multi-cli.test.ts tests/unit/claude-session-support.test.ts tests/unit/worker-name-generator.test.ts tests/server/fs-pick-folder.test.ts tests/server/fs-browse.test.ts tests/server/schema-version.test.ts tests/server/runtime-rehydration.test.ts tests/web/workspace-picker.test.tsx tests/web/confirm-dialog.test.tsx tests/web/toast.test.tsx --no-file-parallelism --maxWorkers=1 --testTimeout=30000 --hookTimeout=30000",
63
+ "test:windows": "vitest run tests/unit/agent-command-resolver.test.ts tests/unit/session-capture-multi-cli.test.ts tests/unit/claude-session-support.test.ts tests/unit/worker-name-generator.test.ts tests/unit/open-target-commands.test.ts tests/server/fs-pick-folder.test.ts tests/server/fs-browse.test.ts tests/server/schema-version.test.ts tests/server/runtime-rehydration.test.ts tests/server/open-workspace-route.test.ts tests/web/workspace-picker.test.tsx tests/web/confirm-dialog.test.tsx tests/web/toast.test.tsx tests/web/open-workspace-button.test.tsx --no-file-parallelism --maxWorkers=1 --testTimeout=30000 --hookTimeout=30000",
64
64
  "test:watch": "vitest"
65
65
  },
66
66
  "dependencies": {