@bytetrue/byspace 0.11.3 → 0.12.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.
Files changed (63) hide show
  1. package/node_modules/@getpaseo/client/dist/daemon-client.d.ts +6 -5
  2. package/node_modules/@getpaseo/client/dist/daemon-client.js +9 -1
  3. package/node_modules/@getpaseo/client/package.json +3 -3
  4. package/node_modules/@getpaseo/highlight/package.json +1 -1
  5. package/node_modules/@getpaseo/plugin/package.json +5 -5
  6. package/node_modules/@getpaseo/protocol/dist/generated/validation/ws-outbound.aot.js +3610 -3533
  7. package/node_modules/@getpaseo/protocol/dist/messages.d.ts +89 -0
  8. package/node_modules/@getpaseo/protocol/dist/messages.js +35 -0
  9. package/node_modules/@getpaseo/protocol/dist/validation/ws-outbound-schema-metadata.d.ts +14 -0
  10. package/node_modules/@getpaseo/protocol/package.json +1 -1
  11. package/node_modules/@getpaseo/relay/package.json +1 -1
  12. package/node_modules/@getpaseo/server/dist/server/server/agent/providers/pi/agent.d.ts +14 -0
  13. package/node_modules/@getpaseo/server/dist/server/server/agent/providers/pi/agent.js +79 -4
  14. package/node_modules/@getpaseo/server/dist/server/server/authorization/operation-permissions.js +2 -0
  15. package/node_modules/@getpaseo/server/dist/server/server/bootstrap.d.ts +1 -0
  16. package/node_modules/@getpaseo/server/dist/server/server/bootstrap.js +31 -8
  17. package/node_modules/@getpaseo/server/dist/server/server/config.js +5 -1
  18. package/node_modules/@getpaseo/server/dist/server/server/daemon-config-store.js +8 -0
  19. package/node_modules/@getpaseo/server/dist/server/server/persisted-config.d.ts +3 -0
  20. package/node_modules/@getpaseo/server/dist/server/server/persisted-config.js +3 -0
  21. package/node_modules/@getpaseo/server/dist/server/server/session.js +1 -0
  22. package/node_modules/@getpaseo/server/dist/server/server/websocket-server.js +4 -0
  23. package/node_modules/@getpaseo/server/dist/server/terminal/shell-detect.d.ts +51 -0
  24. package/node_modules/@getpaseo/server/dist/server/terminal/shell-detect.js +196 -0
  25. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-manager-factory.d.ts +2 -0
  26. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-manager.d.ts +1 -0
  27. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-manager.js +1 -0
  28. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-persistence.d.ts +15 -0
  29. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-persistence.js +127 -0
  30. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-session-controller.d.ts +4 -0
  31. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-session-controller.js +35 -0
  32. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-session-store.d.ts +19 -0
  33. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-session-store.js +59 -0
  34. package/node_modules/@getpaseo/server/dist/server/terminal/terminal-worker-protocol.d.ts +1 -0
  35. package/node_modules/@getpaseo/server/dist/server/terminal/worker-terminal-manager.d.ts +2 -0
  36. package/node_modules/@getpaseo/server/dist/server/terminal/worker-terminal-manager.js +2 -0
  37. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-8569047c23404e68f926a8a0f411b731.js → desktop-attachment-bridge-d6e29a06a5bfb4d50647dcdf2dfc2edc.js} +1 -1
  38. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-8569047c23404e68f926a8a0f411b731.js.br → desktop-attachment-bridge-d6e29a06a5bfb4d50647dcdf2dfc2edc.js.br} +0 -0
  39. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-d6e29a06a5bfb4d50647dcdf2dfc2edc.js.gz +0 -0
  40. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-49a16787c67d7db16d4dfbaebda6f27b.js → desktop-attachment-store-9917f2388804e462b0df3cf5f1b5df19.js} +1 -1
  41. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-49a16787c67d7db16d4dfbaebda6f27b.js.br → desktop-attachment-store-9917f2388804e462b0df3cf5f1b5df19.js.br} +0 -0
  42. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-49a16787c67d7db16d4dfbaebda6f27b.js.gz → desktop-attachment-store-9917f2388804e462b0df3cf5f1b5df19.js.gz} +0 -0
  43. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{index-dc967c46563553e5ecaba6b78fe47585.js → index-d66590127d71cde67bbb3776075bb688.js} +64 -63
  44. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/index-d66590127d71cde67bbb3776075bb688.js.br +0 -0
  45. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/index-d66590127d71cde67bbb3776075bb688.js.gz +0 -0
  46. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-b87d428d16d555a6e2a897903a24bc52.js → indexeddb-attachment-store-35cbb0fd3d120aac9caca241e4dedf6c.js} +1 -1
  47. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-b87d428d16d555a6e2a897903a24bc52.js.br → indexeddb-attachment-store-35cbb0fd3d120aac9caca241e4dedf6c.js.br} +0 -0
  48. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35cbb0fd3d120aac9caca241e4dedf6c.js.gz +0 -0
  49. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-44c208843bd97caf1582f78c04c2fa76.js → native-file-attachment-store-278a4b844d28852fc6ef5dfde6d70583.js} +3 -3
  50. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-278a4b844d28852fc6ef5dfde6d70583.js.br +0 -0
  51. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-278a4b844d28852fc6ef5dfde6d70583.js.gz +0 -0
  52. package/node_modules/@getpaseo/server/dist/server/web-ui/index.html +1 -1
  53. package/node_modules/@getpaseo/server/dist/server/web-ui/index.html.br +0 -0
  54. package/node_modules/@getpaseo/server/dist/server/web-ui/index.html.gz +0 -0
  55. package/node_modules/@getpaseo/server/dist/src/server/persisted-config.js +3 -0
  56. package/node_modules/@getpaseo/server/package.json +6 -6
  57. package/package.json +8 -8
  58. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-8569047c23404e68f926a8a0f411b731.js.gz +0 -0
  59. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/index-dc967c46563553e5ecaba6b78fe47585.js.br +0 -0
  60. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/index-dc967c46563553e5ecaba6b78fe47585.js.gz +0 -0
  61. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-b87d428d16d555a6e2a897903a24bc52.js.gz +0 -0
  62. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-44c208843bd97caf1582f78c04c2fa76.js.br +0 -0
  63. package/node_modules/@getpaseo/server/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-44c208843bd97caf1582f78c04c2fa76.js.gz +0 -0
@@ -120,6 +120,8 @@ import { archiveByScope, archivePersistedWorkspaceRecord, killTerminalsForWorksp
120
120
  import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
121
121
  import { wrapSessionMessage } from "./messages.js";
122
122
  import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
123
+ import { createPersistingTerminalManager, restorePersistedTerminals, } from "../terminal/terminal-persistence.js";
124
+ import { TerminalSessionStore } from "../terminal/terminal-session-store.js";
123
125
  import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
124
126
  import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
125
127
  import { createRelayRuntime } from "./relay-runtime.js";
@@ -278,6 +280,17 @@ function mountWebUi(app, config, logger) {
278
280
  function resolveExpressTrustProxySetting(config) {
279
281
  return config.trustedProxies ?? ["loopback"];
280
282
  }
283
+ function applyOptionalConfigLists(initialConfig, config) {
284
+ if (config.terminalProfiles !== undefined) {
285
+ initialConfig.terminalProfiles = config.terminalProfiles;
286
+ }
287
+ if (config.terminalDefaultShell !== undefined) {
288
+ initialConfig.terminalDefaultShell = config.terminalDefaultShell;
289
+ }
290
+ if (config.agentProfiles !== undefined) {
291
+ initialConfig.agentProfiles = config.agentProfiles;
292
+ }
293
+ }
281
294
  function createInitialMutableDaemonConfig(config, defaultAppBaseUrl) {
282
295
  const providers = config.providerOverrides ?? {};
283
296
  const initialConfig = {
@@ -307,12 +320,7 @@ function createInitialMutableDaemonConfig(config, defaultAppBaseUrl) {
307
320
  plugins: config.plugins ?? {},
308
321
  skills: { selection: config.skillSelection },
309
322
  };
310
- if (config.terminalProfiles !== undefined) {
311
- initialConfig.terminalProfiles = config.terminalProfiles;
312
- }
313
- if (config.agentProfiles !== undefined) {
314
- initialConfig.agentProfiles = config.agentProfiles;
315
- }
323
+ applyOptionalConfigLists(initialConfig, config);
316
324
  return initialConfig;
317
325
  }
318
326
  export async function createPaseoDaemon(config, rootLogger, dependencies = {}) {
@@ -386,8 +394,14 @@ export async function createPaseoDaemon(config, rootLogger, dependencies = {}) {
386
394
  });
387
395
  let boundListenTarget = null;
388
396
  let workspaceRegistry = null;
389
- const terminalManager = createConfiguredTerminalManager({
390
- getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget),
397
+ const terminalSessionStore = new TerminalSessionStore(path.join(config.paseoHome, "terminals", "terminals.json"));
398
+ const terminalManager = createPersistingTerminalManager({
399
+ inner: createConfiguredTerminalManager({
400
+ getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget),
401
+ getDefaultShell: () => daemonConfigStore.get().terminalDefaultShell,
402
+ }),
403
+ store: terminalSessionStore,
404
+ logger,
391
405
  });
392
406
  applyTerminalAgentHookSetting({ store: daemonConfigStore, logger });
393
407
  const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl
@@ -642,6 +656,15 @@ export async function createPaseoDaemon(config, rootLogger, dependencies = {}) {
642
656
  });
643
657
  await workspaceLabelService.initialize();
644
658
  logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
659
+ await restorePersistedTerminals({
660
+ manager: terminalManager,
661
+ store: terminalSessionStore,
662
+ isWorkspaceActive: async (workspaceId) => {
663
+ const record = await workspaceRegistry.get(workspaceId);
664
+ return record !== null && record.archivedAt === null;
665
+ },
666
+ logger,
667
+ });
645
668
  const teardownArchivedWorkspaceRuntime = (workspaceId) => {
646
669
  scriptRuntimeStore.removeForWorkspace(workspaceId);
647
670
  releaseWorkspaceServicePortPlan(workspaceId);
@@ -355,6 +355,9 @@ function resolveStaticLoadConfigSettings(env, cli, persisted, hostedAppBaseUrl)
355
355
  autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
356
356
  appendSystemPrompt: resolveAppendSystemPrompt(persisted),
357
357
  ...resolveProfileLists(persisted),
358
+ // null in the persisted file means "auto"; the daemon config omits it so
359
+ // MutableDaemonConfig keeps the absent = auto semantics.
360
+ terminalDefaultShell: persisted.daemon?.terminalDefaultShell ?? undefined,
358
361
  hostnames: mergeHostnames([
359
362
  persisted.daemon?.hostnames,
360
363
  parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -373,7 +376,7 @@ export function resolveConfigFromPersisted(paseoHome, persisted, options) {
373
376
  const cli = resolvedOptions.cli;
374
377
  const relayEnabledFallback = resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
375
378
  const listen = resolveListenAddress(env, cli, persisted);
376
- const { mcpEnabled, mcpInjectIntoAgents, browserToolsEnabled, autoArchiveAfterMerge, appendSystemPrompt, terminalProfiles, agentProfiles, hostnames, trustedProxies, appBaseUrl, } = resolveStaticLoadConfigSettings(env, cli, persisted, hostedAppBaseUrl);
379
+ const { mcpEnabled, mcpInjectIntoAgents, browserToolsEnabled, autoArchiveAfterMerge, appendSystemPrompt, terminalProfiles, terminalDefaultShell, agentProfiles, hostnames, trustedProxies, appBaseUrl, } = resolveStaticLoadConfigSettings(env, cli, persisted, hostedAppBaseUrl);
377
380
  const relay = resolveRelayConfig({
378
381
  env,
379
382
  persisted,
@@ -411,6 +414,7 @@ export function resolveConfigFromPersisted(paseoHome, persisted, options) {
411
414
  : {}),
412
415
  appendSystemPrompt,
413
416
  terminalProfiles,
417
+ terminalDefaultShell,
414
418
  agentProfiles,
415
419
  skillSelection: persisted.agents?.skills?.selection,
416
420
  pluginsEnabled: persisted.pluginsEnabled ?? false,
@@ -179,6 +179,9 @@ function pickSupportedPatchFields(patch) {
179
179
  ? { appendSystemPrompt: patch.appendSystemPrompt }
180
180
  : {}),
181
181
  ...(patch.terminalProfiles !== undefined ? { terminalProfiles: patch.terminalProfiles } : {}),
182
+ ...(patch.terminalDefaultShell !== undefined
183
+ ? { terminalDefaultShell: patch.terminalDefaultShell }
184
+ : {}),
182
185
  ...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
183
186
  ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
184
187
  ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
@@ -498,6 +501,11 @@ function mergeMutableDaemonPatch(persistedDaemon, patch, persistRelayEnabled) {
498
501
  next.appendSystemPrompt = patch.appendSystemPrompt;
499
502
  if (patch.terminalProfiles !== undefined)
500
503
  next.terminalProfiles = patch.terminalProfiles;
504
+ // null clears the configured shell (client "Auto" selection); the field stays
505
+ // in the persisted JSON as null so a later non-null patch replaces it.
506
+ if (patch.terminalDefaultShell !== undefined) {
507
+ next.terminalDefaultShell = patch.terminalDefaultShell;
508
+ }
501
509
  if (patch.agentProfiles !== undefined)
502
510
  next.agentProfiles = patch.agentProfiles;
503
511
  return Object.keys(next).length > 0 ? next : undefined;
@@ -42,6 +42,7 @@ export declare const PersistedConfigSchema: z.ZodObject<{
42
42
  args: z.ZodOptional<z.ZodArray<z.ZodString>>;
43
43
  icon: z.ZodOptional<z.ZodString>;
44
44
  }, z.core.$loose>>>;
45
+ terminalDefaultShell: z.ZodOptional<z.ZodNullable<z.ZodString>>;
45
46
  agentProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{
46
47
  id: z.ZodString;
47
48
  name: z.ZodString;
@@ -101,6 +102,7 @@ export declare const PersistedConfigSchema: z.ZodObject<{
101
102
  args?: string[] | undefined;
102
103
  icon?: string | undefined;
103
104
  }[] | undefined;
105
+ terminalDefaultShell?: string | null | undefined;
104
106
  agentProfiles?: {
105
107
  [x: string]: unknown;
106
108
  id: string;
@@ -162,6 +164,7 @@ export declare const PersistedConfigSchema: z.ZodObject<{
162
164
  args?: string[] | undefined;
163
165
  icon?: string | undefined;
164
166
  }[] | undefined;
167
+ terminalDefaultShell?: string | null | undefined;
165
168
  agentProfiles?: {
166
169
  [x: string]: unknown;
167
170
  id: string;
@@ -222,6 +222,9 @@ export const PersistedConfigSchema = z
222
222
  terminalAgentHooks: TerminalAgentHookSettingsSchema.optional(),
223
223
  appendSystemPrompt: z.string().optional(),
224
224
  terminalProfiles: z.array(TerminalProfileSchema).optional(),
225
+ // null is a valid persisted value meaning "auto"; config.ts omits it
226
+ // when resolving so MutableDaemonConfig keeps absent = auto.
227
+ terminalDefaultShell: z.string().min(1).nullish(),
225
228
  agentProfiles: z.array(AgentProfileSchema).optional(),
226
229
  cors: z
227
230
  .object({
@@ -437,6 +437,7 @@ export class Session {
437
437
  listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
438
438
  clientSupportsWrapReflow: () => this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
439
439
  getClientBufferedAmount: () => this.getTransportBufferedAmount(),
440
+ getConfiguredDefaultShell: () => daemonConfigStore.get().terminalDefaultShell,
440
441
  });
441
442
  this.agentUpdates = createAgentUpdatesService({
442
443
  emit: (message) => this.emit(message),
@@ -1184,6 +1184,10 @@ export class VoiceAssistantWebSocketServer {
1184
1184
  projectSetupSkill: true,
1185
1185
  // COMPAT(terminalAgentHookProviders): added in v0.7.1, remove gate after 2027-09-01.
1186
1186
  terminalAgentHookProviders: true,
1187
+ // COMPAT(terminalShellConfig): added in v0.7.x, remove gate after 2027-09-01.
1188
+ // Gates terminal.shell.detect plus the terminalDefaultShell config field;
1189
+ // clients without the gate never send either.
1190
+ terminalShellConfig: true,
1187
1191
  // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
1188
1192
  "terminal-restore-modes": true,
1189
1193
  // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -0,0 +1,51 @@
1
+ export interface DetectedShell {
2
+ path: string;
3
+ name: string;
4
+ }
5
+ export interface ShellCandidate {
6
+ location: string;
7
+ source: "env" | "dscl" | "etc-shells" | "well-known";
8
+ }
9
+ export interface DetectShellsOptions {
10
+ platform?: NodeJS.Platform;
11
+ env?: NodeJS.ProcessEnv;
12
+ homeDir?: string;
13
+ /** Overrides candidate discovery; tests inject fixtures here. */
14
+ candidates?: readonly ShellCandidate[];
15
+ /** Overrides /etc/shells reading; tests inject fixture text here. */
16
+ readEtcShells?: () => string | null;
17
+ /** Overrides the macOS login-shell lookup; tests inject fixture text here. */
18
+ readMacUserShell?: () => string | null;
19
+ /** Resolves bare names against PATH. Defaults to PATH walk. */
20
+ resolveOnPath?: (name: string) => string | null;
21
+ exists?: (path: string) => boolean;
22
+ }
23
+ /**
24
+ * Executable suffixes to probe, derived from PATHEXT. The bare name is always
25
+ * the final fallback for names that already carry an extension.
26
+ */
27
+ export declare function resolvePathExtSuffixes(env: NodeJS.ProcessEnv): readonly string[];
28
+ /**
29
+ * Parses `/etc/shells` text into candidate paths. Comments and blank lines are
30
+ * skipped; entries must be absolute.
31
+ */
32
+ export declare function parseEtcShells(text: string): string[];
33
+ /**
34
+ * Parses `dscl . -read /Users/<user> UserShell` output ("UserShell: /bin/zsh").
35
+ * Returns null when the line is missing or malformed.
36
+ */
37
+ export declare function parseDsclUserShell(text: string): string | null;
38
+ /**
39
+ * Runs `dscl . -read /Users/<user> UserShell` and parses the login shell path.
40
+ * Returns null when dscl is unavailable, fails, or the output is unusable —
41
+ * detection degrades to the remaining sources rather than failing.
42
+ */
43
+ export declare function readMacUserShell(homeDir: string): Promise<string | null>;
44
+ /**
45
+ * Shells installed on this machine, deduped by absolute path, in discovery
46
+ * order: environment shell first (the strongest signal of what the user
47
+ * actually uses), then the macOS login shell, then /etc/shells, then
48
+ * well-known locations. Only paths that exist are returned.
49
+ */
50
+ export declare function detectInstalledShells(options?: DetectShellsOptions): Promise<DetectedShell[]>;
51
+ //# sourceMappingURL=shell-detect.d.ts.map
@@ -0,0 +1,196 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { execFile } from "node:child_process";
3
+ import { basename, delimiter, isAbsolute, join, posix, win32 } from "node:path";
4
+ const WELL_KNOWN_UNIX_SHELLS = [
5
+ { location: "/bin/bash", source: "well-known" },
6
+ { location: "/bin/zsh", source: "well-known" },
7
+ { location: "/usr/bin/bash", source: "well-known" },
8
+ { location: "/usr/bin/zsh", source: "well-known" },
9
+ { location: "/usr/local/bin/bash", source: "well-known" },
10
+ { location: "/usr/local/bin/zsh", source: "well-known" },
11
+ { location: "/usr/local/bin/fish", source: "well-known" },
12
+ { location: "/opt/homebrew/bin/bash", source: "well-known" },
13
+ { location: "/opt/homebrew/bin/zsh", source: "well-known" },
14
+ { location: "/opt/homebrew/bin/fish", source: "well-known" },
15
+ { location: "/opt/homebrew/bin/nu", source: "well-known" },
16
+ { location: "/usr/local/bin/nu", source: "well-known" },
17
+ ];
18
+ const WELL_KNOWN_WINDOWS_SHELLS = [
19
+ { location: "pwsh.exe", source: "well-known" },
20
+ { location: "powershell.exe", source: "well-known" },
21
+ { location: "cmd.exe", source: "well-known" },
22
+ ];
23
+ /**
24
+ * Resolves a bare executable name against PATH. On Windows, executables live
25
+ * under PATHEXT suffixes (.exe and friends), so each PATH entry is probed with
26
+ * each suffix before the bare name.
27
+ */
28
+ function resolveOnPathVariable(name, env, suffixes) {
29
+ const path = env.PATH ?? "";
30
+ for (const dir of path.split(delimiter)) {
31
+ if (!dir)
32
+ continue;
33
+ for (const suffix of suffixes) {
34
+ const candidate = join(dir, `${name}${suffix}`);
35
+ if (existsSync(candidate)) {
36
+ return candidate;
37
+ }
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * Executable suffixes to probe, derived from PATHEXT. The bare name is always
44
+ * the final fallback for names that already carry an extension.
45
+ */
46
+ export function resolvePathExtSuffixes(env) {
47
+ const pathExt = env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
48
+ const suffixes = pathExt
49
+ .split(";")
50
+ .map((suffix) => suffix.trim())
51
+ .filter((suffix) => suffix.length > 0);
52
+ return [...suffixes, ""];
53
+ }
54
+ function defaultResolveOnPath(env, platform) {
55
+ const suffixes = platform === "win32" ? resolvePathExtSuffixes(env) : [""];
56
+ return (name) => resolveOnPathVariable(name, env, suffixes);
57
+ }
58
+ /**
59
+ * Parses `/etc/shells` text into candidate paths. Comments and blank lines are
60
+ * skipped; entries must be absolute.
61
+ */
62
+ export function parseEtcShells(text) {
63
+ return text
64
+ .split("\n")
65
+ .map((line) => line.trim())
66
+ .filter((line) => line.length > 0 && !line.startsWith("#") && isAbsolute(line));
67
+ }
68
+ /**
69
+ * Parses `dscl . -read /Users/<user> UserShell` output ("UserShell: /bin/zsh").
70
+ * Returns null when the line is missing or malformed.
71
+ */
72
+ export function parseDsclUserShell(text) {
73
+ const line = text
74
+ .split("\n")
75
+ .map((value) => value.trim())
76
+ .find((value) => value.startsWith("UserShell:"));
77
+ if (!line) {
78
+ return null;
79
+ }
80
+ const value = line.slice("UserShell:".length).trim();
81
+ return isAbsolute(value) ? value : null;
82
+ }
83
+ /**
84
+ * Runs `dscl . -read /Users/<user> UserShell` and parses the login shell path.
85
+ * Returns null when dscl is unavailable, fails, or the output is unusable —
86
+ * detection degrades to the remaining sources rather than failing.
87
+ */
88
+ export async function readMacUserShell(homeDir) {
89
+ const user = basename(homeDir);
90
+ try {
91
+ const stdout = await new Promise((resolve, reject) => {
92
+ // Domain-joined macOS hosts can make dscl hang on directory-service
93
+ // lookups; the timeout degrades to the remaining detection sources.
94
+ execFile("dscl", [".", "-read", `/Users/${user}`, "UserShell"], { timeout: 2000 }, (error, result) => {
95
+ if (error)
96
+ reject(error);
97
+ else
98
+ resolve(result);
99
+ });
100
+ });
101
+ return parseDsclUserShell(stdout);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function toDetectedShell(path, platform) {
108
+ // basename follows the running host; the name must follow the detected
109
+ // platform so backslash paths name correctly in tests run on POSIX hosts.
110
+ const base = platform === "win32" ? win32.basename(path) : posix.basename(path);
111
+ return { path, name: base };
112
+ }
113
+ /**
114
+ * Resolves and dedupes candidate shell paths into detected shells. Bare names
115
+ * resolve against PATH; duplicates and non-existent paths are dropped.
116
+ * Absoluteness follows the detected platform so Windows paths classify
117
+ * correctly even when detection runs on a POSIX host.
118
+ */
119
+ async function resolveDetectedShells(candidates, platform, resolveOnPath, exists) {
120
+ const isCandidateAbsolute = (location) => platform === "win32" ? win32.isAbsolute(location) : posix.isAbsolute(location);
121
+ const seen = new Set();
122
+ const shells = [];
123
+ for (const candidate of candidates) {
124
+ const path = isCandidateAbsolute(candidate.location)
125
+ ? candidate.location
126
+ : resolveOnPath(candidate.location);
127
+ if (!path || seen.has(path) || !exists(path)) {
128
+ continue;
129
+ }
130
+ seen.add(path);
131
+ shells.push(toDetectedShell(path, platform));
132
+ }
133
+ return shells;
134
+ }
135
+ async function gatherUnixCandidates(options, platform, env, homeDir) {
136
+ const candidates = [];
137
+ const envShell = env.SHELL;
138
+ if (envShell && isAbsolute(envShell)) {
139
+ candidates.push({ location: envShell, source: "env" });
140
+ }
141
+ if (platform === "darwin") {
142
+ const userShell = options.readMacUserShell
143
+ ? options.readMacUserShell()
144
+ : await readMacUserShell(homeDir);
145
+ if (userShell) {
146
+ candidates.push({ location: userShell, source: "dscl" });
147
+ }
148
+ }
149
+ const etcShells = options.readEtcShells ? options.readEtcShells() : readEtcShellsFile();
150
+ if (etcShells) {
151
+ for (const entry of parseEtcShells(etcShells)) {
152
+ candidates.push({ location: entry, source: "etc-shells" });
153
+ }
154
+ }
155
+ candidates.push(...WELL_KNOWN_UNIX_SHELLS);
156
+ return candidates;
157
+ }
158
+ async function gatherCandidates(options, platform, env, homeDir) {
159
+ if (options.candidates) {
160
+ return [...options.candidates];
161
+ }
162
+ if (platform === "win32") {
163
+ // ComSpec mirrors the Unix $SHELL env candidate: the shell the host
164
+ // itself considers the default, ahead of the well-known probes.
165
+ const comspec = env.ComSpec || env.COMSPEC;
166
+ return [
167
+ ...(comspec ? [{ location: comspec, source: "env" }] : []),
168
+ ...WELL_KNOWN_WINDOWS_SHELLS,
169
+ ];
170
+ }
171
+ return gatherUnixCandidates(options, platform, env, homeDir);
172
+ }
173
+ /**
174
+ * Shells installed on this machine, deduped by absolute path, in discovery
175
+ * order: environment shell first (the strongest signal of what the user
176
+ * actually uses), then the macOS login shell, then /etc/shells, then
177
+ * well-known locations. Only paths that exist are returned.
178
+ */
179
+ export async function detectInstalledShells(options = {}) {
180
+ const platform = options.platform ?? process.platform;
181
+ const env = options.env ?? process.env;
182
+ const homeDir = options.homeDir ?? env.HOME ?? "";
183
+ const exists = options.exists ?? existsSync;
184
+ const resolveOnPath = options.resolveOnPath ?? defaultResolveOnPath(env, platform);
185
+ const candidates = await gatherCandidates(options, platform, env, homeDir);
186
+ return resolveDetectedShells(candidates, platform, resolveOnPath, exists);
187
+ }
188
+ function readEtcShellsFile() {
189
+ try {
190
+ return readFileSync("/etc/shells", "utf8");
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
196
+ //# sourceMappingURL=shell-detect.js.map
@@ -1,6 +1,8 @@
1
1
  import type { TerminalManager } from "./terminal-manager.js";
2
2
  export interface ConfiguredTerminalManagerOptions {
3
3
  getTerminalActivityUrl?: () => string | null;
4
+ /** Reads daemon.terminalDefaultShell; nullish means auto. */
5
+ getDefaultShell?: () => string | null | undefined;
4
6
  }
5
7
  export declare function createConfiguredTerminalManager(options?: ConfiguredTerminalManagerOptions): TerminalManager;
6
8
  //# sourceMappingURL=terminal-manager-factory.d.ts.map
@@ -40,6 +40,7 @@ export interface TerminalManager {
40
40
  name?: string;
41
41
  title?: string;
42
42
  env?: Record<string, string>;
43
+ shell?: string;
43
44
  command?: string;
44
45
  args?: string[];
45
46
  rows?: number;
@@ -215,6 +215,7 @@ export function createTerminalManager(managerOptions = {}) {
215
215
  workspaceId: options.workspaceId,
216
216
  name: options.name ?? defaultName,
217
217
  ...(options.title ? { title: options.title } : {}),
218
+ ...(options.shell && !options.command ? { shell: options.shell } : {}),
218
219
  ...(options.command ? { command: options.command } : {}),
219
220
  ...(options.args ? { args: options.args } : {}),
220
221
  ...(options.rows !== undefined ? { rows: options.rows } : {}),
@@ -0,0 +1,15 @@
1
+ import type { Logger } from "pino";
2
+ import type { TerminalManager } from "./terminal-manager.js";
3
+ import type { TerminalSessionStore } from "./terminal-session-store.js";
4
+ export declare function createPersistingTerminalManager(input: {
5
+ inner: TerminalManager;
6
+ store: TerminalSessionStore;
7
+ logger: Logger;
8
+ }): TerminalManager;
9
+ export declare function restorePersistedTerminals(input: {
10
+ manager: TerminalManager;
11
+ store: TerminalSessionStore;
12
+ isWorkspaceActive: (workspaceId: string) => Promise<boolean>;
13
+ logger: Logger;
14
+ }): Promise<void>;
15
+ //# sourceMappingURL=terminal-persistence.d.ts.map
@@ -0,0 +1,127 @@
1
+ import { promises as fs } from "node:fs";
2
+ // Daemon restarts — crash recovery, auto-update, manual restart, a machine
3
+ // sleep that took the daemon down — otherwise lose every terminal tab, because
4
+ // sessions live only in the terminal worker process. This wrapper mirrors
5
+ // membership changes (create / exit / kill) into a TerminalSessionStore so the
6
+ // daemon can recreate the tabs at startup. Processes and scrollback are not
7
+ // preserved: restored terminals are fresh shells that reuse the original id,
8
+ // so client-side layout references keep resolving.
9
+ export function createPersistingTerminalManager(input) {
10
+ const { inner, store, logger } = input;
11
+ const recordsById = new Map();
12
+ let lastWrittenJson = "";
13
+ let frozen = false;
14
+ function scheduleWrite() {
15
+ const records = Array.from(recordsById.values());
16
+ const json = JSON.stringify(records);
17
+ if (json === lastWrittenJson) {
18
+ return;
19
+ }
20
+ lastWrittenJson = json;
21
+ store.replace(records).catch((error) => {
22
+ logger.warn({ err: error }, "Failed to persist terminal session metadata");
23
+ });
24
+ }
25
+ const unsubscribe = inner.subscribeTerminalsChanged((event) => {
26
+ if (frozen) {
27
+ return;
28
+ }
29
+ // The event carries the authoritative post-mutation list for one cwd
30
+ // bucket. Creations are recorded by the wrapped createTerminal; here we
31
+ // only mirror removals (shell exit, killTerminal, workspace teardown).
32
+ const liveIds = new Set(event.terminals.map((terminal) => terminal.id));
33
+ let changed = false;
34
+ for (const record of recordsById.values()) {
35
+ if (record.cwd === event.cwd && !liveIds.has(record.id)) {
36
+ recordsById.delete(record.id);
37
+ changed = true;
38
+ }
39
+ }
40
+ if (changed) {
41
+ scheduleWrite();
42
+ }
43
+ });
44
+ void unsubscribe;
45
+ return {
46
+ ...inner,
47
+ async createTerminal(options) {
48
+ const session = await inner.createTerminal(options);
49
+ if (frozen) {
50
+ return session;
51
+ }
52
+ // A short-lived command can exit before createTerminal resolves; its exit
53
+ // was mirrored (or dropped) before this record existed. Recording it now
54
+ // would resurrect a dead terminal on the next boot.
55
+ if (!inner.getTerminal(session.id)) {
56
+ return session;
57
+ }
58
+ const record = {
59
+ id: session.id,
60
+ cwd: session.cwd,
61
+ workspaceId: session.workspaceId,
62
+ name: session.name,
63
+ ...(options.command ? { command: options.command } : {}),
64
+ ...(options.args ? { args: options.args } : {}),
65
+ };
66
+ recordsById.set(record.id, record);
67
+ scheduleWrite();
68
+ return session;
69
+ },
70
+ killAll() {
71
+ // killAll is shutdown-only (bootstrap stop / worker teardown). Terminals
72
+ // killed here must survive in the store so the next daemon boot restores
73
+ // their tabs, so the mirror is frozen permanently instead of tracking
74
+ // these removals. The daemon process does not use the manager afterwards.
75
+ frozen = true;
76
+ inner.killAll();
77
+ },
78
+ };
79
+ }
80
+ export async function restorePersistedTerminals(input) {
81
+ const { manager, store, isWorkspaceActive, logger } = input;
82
+ let records;
83
+ try {
84
+ records = await store.load();
85
+ }
86
+ catch (error) {
87
+ logger.warn({ err: error }, "Skipping terminal tab restore: store unreadable");
88
+ return;
89
+ }
90
+ if (records.length === 0) {
91
+ return;
92
+ }
93
+ const survivors = [];
94
+ for (const record of records) {
95
+ try {
96
+ if (!(await isWorkspaceActive(record.workspaceId))) {
97
+ logger.info({ terminalId: record.id, workspaceId: record.workspaceId }, "Dropping persisted terminal: workspace gone");
98
+ continue;
99
+ }
100
+ const stat = await fs.stat(record.cwd);
101
+ if (!stat.isDirectory()) {
102
+ logger.info({ terminalId: record.id, cwd: record.cwd }, "Dropping persisted terminal: cwd missing");
103
+ continue;
104
+ }
105
+ await manager.createTerminal({
106
+ id: record.id,
107
+ cwd: record.cwd,
108
+ workspaceId: record.workspaceId,
109
+ name: record.name,
110
+ ...(record.command ? { command: record.command } : {}),
111
+ ...(record.args ? { args: record.args } : {}),
112
+ });
113
+ survivors.push(record);
114
+ }
115
+ catch (error) {
116
+ logger.warn({ err: error, terminalId: record.id, cwd: record.cwd }, "Failed to restore persisted terminal");
117
+ }
118
+ }
119
+ try {
120
+ await store.replace(survivors);
121
+ }
122
+ catch (error) {
123
+ logger.warn({ err: error }, "Failed to rewrite terminal session store after restore");
124
+ }
125
+ logger.info({ restored: survivors.length, dropped: records.length - survivors.length }, "Terminal tabs restored");
126
+ }
127
+ //# sourceMappingURL=terminal-persistence.js.map
@@ -13,6 +13,8 @@ export interface TerminalSessionControllerOptions {
13
13
  listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
14
14
  clientSupportsWrapReflow?: () => boolean;
15
15
  getClientBufferedAmount?: () => number | null;
16
+ /** Reads daemon.terminalDefaultShell; nullish means auto. */
17
+ getConfiguredDefaultShell?: () => string | null | undefined;
16
18
  }
17
19
  interface TerminalWorkspaceRef {
18
20
  workspaceId: string;
@@ -33,6 +35,7 @@ export declare class TerminalSessionController {
33
35
  private readonly listTerminalWorkspaceRoots;
34
36
  private readonly clientSupportsWrapReflow;
35
37
  private readonly getClientBufferedAmount;
38
+ private readonly getConfiguredDefaultShell;
36
39
  private readonly terminalSizeOwner;
37
40
  private readonly subscribedDirectories;
38
41
  private unsubscribeTerminalsChanged;
@@ -74,6 +77,7 @@ export declare class TerminalSessionController {
74
77
  private handleCreateTerminalRequest;
75
78
  private resolveLegacyTerminalWorkspaceId;
76
79
  private handleRenameTerminalRequest;
80
+ private handleShellDetectRequest;
77
81
  private handleSubscribeTerminalRequest;
78
82
  private handleUnsubscribeTerminalRequest;
79
83
  private handleTerminalInput;