@treeport/treeport 0.4.0 → 0.5.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.
@@ -1,4 +1,4 @@
1
- import { $ as terminalTakeControlSchema, B as TERMINAL_SELECTION_CLEAR_SEQUENCE, C as repositoryTerminalPresetsFileSchema, D as updateTerminalPresetSchema, E as updateProjectSchema, G as parseTerminalProgress, H as TERMINAL_SELECTION_START_SEQUENCE, J as terminalInputSchema, K as terminalBellAcknowledgementSchema, M as SOCKET_IO_PATH, N as TERMINAL_CONTROLLER_GRACE_MS, O as updateTerminalSchema, P as TERMINAL_MAX_CLIENT_MESSAGE_BYTES, Q as terminalSizeSchema, R as TERMINAL_OUTPUT_STALL_TIMEOUT_MS, S as repositoryTerminalPresetSchema, T as terminalCaptureQuerySchema, U as TERMINAL_SELECTION_STOP_SEQUENCE, V as TERMINAL_SELECTION_RESTORE_SEQUENCE, W as parseTerminalAuth, X as terminalOutputAckSchema, Y as terminalLegacyTakeControlSchema, Z as terminalResizeSchema, _ as packageReloadSchema, b as registerProjectSchema, c as createTerminalSchema, d as deleteTerminalPresetSchema, f as deleteWebPanelStorageSchema, g as packageProjectQuerySchema, h as packageInstallSchema, i as TERMINAL_MAX_UPLOAD_BYTES, k as webPanelInputSchema, l as createWebPanelSchema, m as openWebPanelSchema, n as parseDurationMs, o as browseDirectoryQuerySchema, p as getWebPanelStorageSchema, q as terminalBinarySchema, s as createTerminalPresetSchema, t as assertLoopbackHost, u as createWorktreeSchema, v as packageRemoveSchema, w as setWebPanelStorageSchema, x as removeWorktreeSchema, y as packageUpdateSchema, z as TERMINAL_SCROLL_EXIT_SEQUENCE } from "../../loopback-D7k_J_Wl.js";
1
+ import { $ as repositoryTerminalPresetSchema, At as terminalSizeSchema, B as createWorktreeSchema, Ct as parseTerminalProgress, Dt as terminalLegacyTakeControlSchema, Et as terminalInputSchema, G as openWebPanelSchema, H as deleteWebPanelStorageSchema, I as browseDirectoryQuerySchema, J as packageReloadSchema, K as packageInstallSchema, L as createTerminalPresetSchema, M as parseDurationMs, Ot as terminalOutputAckSchema, P as TERMINAL_MAX_UPLOAD_BYTES, Q as removeWorktreeSchema, R as createTerminalSchema, St as parseTerminalAuth, Tt as terminalBinarySchema, U as formatCommandLine, V as deleteTerminalPresetSchema, W as getWebPanelStorageSchema, X as packageUpdateSchema, Y as packageRemoveSchema, Z as registerProjectSchema, _ as serviceStatus, _t as TERMINAL_SCROLL_EXIT_SEQUENCE, a as readLocalUpdateProgress, at as updateTerminalPresetSchema, bt as TERMINAL_SELECTION_START_SEQUENCE, c as createUpdateStartupReporter, dt as TERMINAL_CONTROLLER_GRACE_MS, et as repositoryTerminalPresetsFileSchema, ft as TERMINAL_MAX_CLIENT_MESSAGE_BYTES, gt as TERMINAL_OUTPUT_STALL_TIMEOUT_MS, i as isCanonicalTreeportVersion, it as updateProjectSchema, j as assertLoopbackHost, jt as terminalTakeControlSchema, kt as terminalResizeSchema, n as compareTreeportVersions, nt as setWebPanelStorageSchema, o as resolveLatestTreeportRelease, ot as updateTerminalSchema, q as packageProjectQuerySchema, r as inspectLocalUpdateInstallation, rt as terminalCaptureQuerySchema, st as webPanelInputSchema, tt as requestWorkspaceOpenSchema, ut as SOCKET_IO_PATH, vt as TERMINAL_SELECTION_CLEAR_SEQUENCE, wt as terminalBellAcknowledgementSchema, xt as TERMINAL_SELECTION_STOP_SEQUENCE, yt as TERMINAL_SELECTION_RESTORE_SEQUENCE, z as createWebPanelSchema } from "../../update-BW-a6Bd-.js";
2
2
  import { n as prepareShellIntegration } from "../../shell-integration-Be_c91lw.js";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
@@ -113,11 +113,17 @@ var ExternalCommandError = class extends Data.TaggedError("ExternalCommandError"
113
113
  function errorMessage$1(cause) {
114
114
  return cause instanceof Error ? cause.message : String(cause);
115
115
  }
116
+ function signalProcessGroup(processGroupId, signal) {
117
+ try {
118
+ process.kill(-processGroupId, signal);
119
+ } catch (cause) {
120
+ if (cause.code !== "ESRCH") throw cause;
121
+ }
122
+ }
116
123
  /**
117
- * Runs a child command as an interruptible Effect. The child is acquired as a
118
- * resource, so interruption completes only after the finalizer has sent
119
- * SIGTERM, escalated to SIGKILL when needed, and observed the direct child
120
- * exit. Descendant process groups are intentionally outside this contract.
124
+ * Runs a child command as an interruptible Effect. The child is acquired in an
125
+ * isolated process group, so interruption completes only after the finalizer
126
+ * has signaled the full group and observed the direct child exit.
121
127
  */
122
128
  function runCommandEffect(request) {
123
129
  const stdoutLimit = request.maxStdoutBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
@@ -134,7 +140,8 @@ function runCommandEffect(request) {
134
140
  "pipe",
135
141
  "pipe"
136
142
  ],
137
- shell: false
143
+ shell: false,
144
+ detached: true
138
145
  });
139
146
  } catch (cause) {
140
147
  resume(Effect.fail(new SpawnCommandError(request, cause)));
@@ -157,6 +164,7 @@ function runCommandEffect(request) {
157
164
  child.removeListener("spawn", onSpawn);
158
165
  resume(Effect.succeed({
159
166
  child,
167
+ processGroupId: child.pid,
160
168
  exit,
161
169
  onExit,
162
170
  onProcessError,
@@ -249,16 +257,13 @@ function runCommandEffect(request) {
249
257
  resource.terminalError ??= new TimeoutCommandError(request, timeoutMs);
250
258
  return resource.terminalError;
251
259
  })), Effect.flatMap(Effect.fail)));
252
- }, (resource) => Deferred.isDone(resource.exit).pipe(Effect.flatMap((alreadyExited) => {
253
- if (alreadyExited) return Effect.void;
260
+ }, (resource, useExit) => Deferred.isDone(resource.exit).pipe(Effect.flatMap((alreadyExited) => {
261
+ if (alreadyExited && Exit.isSuccess(useExit)) return Effect.void;
254
262
  return Effect.sync(() => {
255
- resource.child.kill("SIGTERM");
256
- }).pipe(Effect.zipRight(Effect.raceFirst(Deferred.await(resource.exit).pipe(Effect.as(true), Effect.interruptible), Effect.sleep(Duration.millis(killGraceMs)).pipe(Effect.as(false), Effect.interruptible))), Effect.flatMap((exited) => {
257
- if (exited) return Effect.void;
258
- return Effect.sync(() => {
259
- resource.child.kill("SIGKILL");
260
- }).pipe(Effect.zipRight(Deferred.await(resource.exit)));
261
- }));
263
+ signalProcessGroup(resource.processGroupId, "SIGTERM");
264
+ }).pipe(Effect.zipRight(Effect.raceFirst(Deferred.await(resource.exit).pipe(Effect.as(true), Effect.interruptible), Effect.sleep(Duration.millis(killGraceMs)).pipe(Effect.as(false), Effect.interruptible))), Effect.flatMap((exited) => Effect.sync(() => {
265
+ signalProcessGroup(resource.processGroupId, "SIGKILL");
266
+ }).pipe(Effect.zipRight(exited ? Effect.void : Deferred.await(resource.exit)))));
262
267
  }), Effect.ensuring(Effect.sync(() => {
263
268
  resource.child.removeListener("exit", resource.onExit);
264
269
  resource.child.removeListener("error", resource.onProcessError);
@@ -359,6 +364,7 @@ var database_schema_exports = /* @__PURE__ */ __exportAll({
359
364
  const projects = sqliteTable("projects", {
360
365
  id: text().primaryKey(),
361
366
  name: text().notNull(),
367
+ kind: text("project_kind", { enum: ["repository", "folder"] }).notNull().default("repository"),
362
368
  repositoryPath: text("repository_path").notNull().unique(),
363
369
  mainWorktreePath: text("main_worktree_path").notNull(),
364
370
  defaultBranch: text("default_branch").notNull(),
@@ -368,15 +374,18 @@ const projects = sqliteTable("projects", {
368
374
  repositoryInode: text("repository_inode").notNull(),
369
375
  nameIsCustom: integer("name_is_custom").notNull().default(0),
370
376
  isOpen: integer("is_open").notNull().default(1),
377
+ showInRecents: integer("show_in_recents").notNull().default(0),
371
378
  lastOpenedAt: text("last_opened_at").notNull(),
372
379
  createdAt: text("created_at").notNull(),
373
380
  updatedAt: text("updated_at").notNull()
374
381
  }, (table) => [
382
+ check("projects_kind_check", sql`${table.kind} IN ('repository','folder')`),
375
383
  check("projects_color_check", sql`${table.color} IS NULL OR ${table.color} IN ('rose','orange','amber','emerald','cyan','blue','violet','pink')`),
376
384
  check("projects_name_is_custom_check", sql`${table.nameIsCustom} IN (0,1)`),
377
385
  check("projects_is_open_check", sql`${table.isOpen} IN (0,1)`),
386
+ check("projects_show_in_recents_check", sql`${table.showInRecents} IN (0,1)`),
378
387
  uniqueIndex("projects_repository_identity_idx").on(table.repositoryIdentity).where(sql`${table.repositoryIdentity} IS NOT NULL`),
379
- index("projects_recent_idx").on(table.isOpen, desc(table.lastOpenedAt), table.id)
388
+ index("projects_recent_idx").on(table.isOpen, table.showInRecents, desc(table.lastOpenedAt), table.id)
380
389
  ]);
381
390
  const worktrees = sqliteTable("worktrees", {
382
391
  id: text().primaryKey(),
@@ -405,7 +414,7 @@ const worktrees = sqliteTable("worktrees", {
405
414
  check("worktrees_detached_check", sql`${table.detached} IN (0,1)`),
406
415
  check("worktrees_locked_check", sql`${table.locked} IN (0,1)`),
407
416
  check("worktrees_prunable_check", sql`${table.prunable} IN (0,1)`),
408
- check("worktrees_kind_check", sql`${table.kind} IN ('main','linked')`),
417
+ check("worktrees_kind_check", sql`${table.kind} IN ('main','linked','folder')`),
409
418
  index("worktrees_project_idx").on(table.projectId),
410
419
  uniqueIndex("worktrees_git_key_idx").on(table.projectId, table.gitWorktreeKey).where(sql`${table.gitWorktreeKey} IS NOT NULL`)
411
420
  ]);
@@ -517,6 +526,7 @@ function normalizeWorktreeName(input) {
517
526
  }
518
527
  function inferWorktreeName(mainWorktreePath, worktreePath, kind) {
519
528
  if (kind === "main") return "main tree";
529
+ if (kind === "folder") return path.basename(worktreePath);
520
530
  const checkoutName = path.basename(worktreePath);
521
531
  return checkoutName === path.basename(mainWorktreePath) ? path.basename(path.dirname(worktreePath)) : checkoutName;
522
532
  }
@@ -649,11 +659,8 @@ function resolveTask(task, input, protectCompatibilityEnvironment) {
649
659
  const useShell = /[\s;&|<>`$()]/u.test(command);
650
660
  return {
651
661
  label: expand(task.label, compatibilityEnvironment),
652
- argv: useShell ? [
653
- input.shell,
654
- "-lc",
655
- [command, ...args.map(shellQuote)].join(" ")
656
- ] : [command, ...args],
662
+ argv: useShell ? null : [command, ...args],
663
+ shellCommand: useShell ? [command, ...args.map(shellQuote)].join(" ") : null,
657
664
  cwd,
658
665
  env: protectCompatibilityEnvironment ? {
659
666
  ...taskEnvironment,
@@ -712,8 +719,9 @@ async function loadZedTerminalPresetDefinitions(input) {
712
719
  definitions.push({
713
720
  id: `repository:${input.projectId}:zed-task:${index}`,
714
721
  name: resolved.label,
715
- executable: resolved.argv[0],
716
- args: resolved.argv.slice(1),
722
+ executable: resolved.argv?.[0] ?? null,
723
+ args: resolved.argv?.slice(1) ?? [],
724
+ shellCommand: resolved.shellCommand,
717
725
  cwd: resolved.cwd,
718
726
  env: resolved.env,
719
727
  closeOnSuccess: false,
@@ -731,9 +739,18 @@ async function loadZedTerminalPresetDefinitions(input) {
731
739
  async function resolveZedCreateWorktreeSetupTasks(input) {
732
740
  return (await loadCreateWorktreeTasks(input.mainWorktreePath)).map((task) => {
733
741
  const resolved = resolveTask(task, input, false);
742
+ let argv = resolved.argv;
743
+ if (!argv) {
744
+ if (!resolved.shellCommand) throw new Error(`Zed task ${task.label} has no resolved command`);
745
+ argv = [
746
+ input.shell,
747
+ "-lc",
748
+ resolved.shellCommand
749
+ ];
750
+ }
734
751
  return {
735
752
  label: task.label,
736
- argv: resolved.argv,
753
+ argv,
737
754
  cwd: resolved.cwd,
738
755
  env: resolved.env,
739
756
  timeoutMs: 30 * 6e4
@@ -874,6 +891,8 @@ async function openDatabase(filePath, options = {}) {
874
891
  const databaseExists = fsSync.existsSync(absoluteFilePath);
875
892
  let hasDurableSchema = false;
876
893
  let hasLegacyMigrations = false;
894
+ let migrationsPending = !databaseExists;
895
+ const migrationSnapshotPaths = [];
877
896
  let drizzleRows = [];
878
897
  if (!databaseExists) await fsSync.promises.mkdir(path.dirname(absoluteFilePath), {
879
898
  recursive: true,
@@ -919,7 +938,8 @@ async function openDatabase(filePath, options = {}) {
919
938
  if (!knownMigration || knownMigration.hash !== row.hash) throw new Error(`Treeport database at ${absoluteFilePath} has an unrecognized migration history. Use a compatible Treeport version or restore a pre-migration snapshot.`);
920
939
  }
921
940
  if (hasDurableSchema && !hasLegacyMigrations && drizzleRows.length === 0) throw new Error(`Treeport database at ${absoluteFilePath} has no recognized migration history; refusing to modify it.`);
922
- if ((drizzleRows.length === 0 || Number(drizzleRows.at(-1)?.createdAt) < latestMigration.folderMillis) && hasDurableSchema) {
941
+ migrationsPending = drizzleRows.length === 0 || Number(drizzleRows.at(-1)?.createdAt) < latestMigration.folderMillis;
942
+ if (migrationsPending && hasDurableSchema) {
923
943
  const backupDirectory = path.resolve(options.backupDirectory ?? path.join(path.dirname(absoluteFilePath), "database-backups"));
924
944
  await fsSync.promises.mkdir(backupDirectory, {
925
945
  recursive: true,
@@ -932,6 +952,7 @@ async function openDatabase(filePath, options = {}) {
932
952
  try {
933
953
  await db.run(sql.raw(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`));
934
954
  await fsSync.promises.chmod(backupPath, 384);
955
+ migrationSnapshotPaths.push(backupPath);
935
956
  } catch (error) {
936
957
  await fsSync.promises.rm(backupPath, { force: true });
937
958
  throw error;
@@ -965,6 +986,8 @@ async function openDatabase(filePath, options = {}) {
965
986
  return {
966
987
  filePath: absoluteFilePath,
967
988
  db,
989
+ migrationState: migrationsPending ? "advanced" : "unchanged",
990
+ migrationSnapshotPaths,
968
991
  close: () => client.close()
969
992
  };
970
993
  } catch (error) {
@@ -976,6 +999,8 @@ function mapProject(row, worktreeRows) {
976
999
  return {
977
1000
  id: row.id,
978
1001
  name: row.name,
1002
+ kind: row.kind,
1003
+ rootPath: row.repositoryPath,
979
1004
  repositoryPath: row.repositoryPath,
980
1005
  mainWorktreePath: row.mainWorktreePath,
981
1006
  defaultBranch: row.defaultBranch,
@@ -995,7 +1020,7 @@ function mapWorktree(row, mainWorktreePath) {
995
1020
  projectId: row.projectId,
996
1021
  name: inferWorktreeName(mainWorktreePath, row.path, row.kind),
997
1022
  path: row.path,
998
- head: row.head,
1023
+ head: row.kind === "folder" ? "" : row.head,
999
1024
  branch: row.branch,
1000
1025
  detached: Boolean(row.detached),
1001
1026
  locked: Boolean(row.locked),
@@ -1284,10 +1309,34 @@ var GitAdapter = class {
1284
1309
  timeoutMs: 3e4
1285
1310
  });
1286
1311
  }
1312
+ async findRepositoryRoot(inputPath) {
1313
+ const canonicalInput = await fs.realpath(path.resolve(inputPath));
1314
+ const request = {
1315
+ executable: this.executable,
1316
+ args: ["rev-parse", "--show-toplevel"],
1317
+ cwd: canonicalInput,
1318
+ timeoutMs: 3e4
1319
+ };
1320
+ const result = await this.runner.run(request);
1321
+ if (result.exitCode === 0) return fs.realpath(result.stdout.trim());
1322
+ if (/not a git repository|outside repository/iu.test(result.stderr)) return null;
1323
+ throw new ExternalCommandError(`Could not inspect Git repository state: ${result.stderr.trim() || `Git exited with code ${result.exitCode}`}`, request, result);
1324
+ }
1325
+ async findProjectRepositoryRoot(inputPath) {
1326
+ const canonicalInput = await fs.realpath(path.resolve(inputPath));
1327
+ const repositoryRoot = await this.findRepositoryRoot(canonicalInput);
1328
+ if (!repositoryRoot || repositoryRoot === canonicalInput) return repositoryRoot;
1329
+ return (await this.checked(repositoryRoot, [
1330
+ "rev-list",
1331
+ "--all",
1332
+ "--max-count=1"
1333
+ ])).stdout.trim() ? repositoryRoot : null;
1334
+ }
1287
1335
  async canonicalizeRepositoryPath(inputPath) {
1336
+ const repositoryRoot = await this.findRepositoryRoot(inputPath);
1337
+ if (repositoryRoot) return repositoryRoot;
1288
1338
  const canonicalInput = await fs.realpath(path.resolve(inputPath));
1289
- const result = await this.checked(canonicalInput, ["rev-parse", "--show-toplevel"]);
1290
- return fs.realpath(result.stdout.trim());
1339
+ throw new Error(`Not a Git repository: ${canonicalInput}`);
1291
1340
  }
1292
1341
  async repositoryIdentityValues(cwd) {
1293
1342
  const result = await this.runner.run({
@@ -2155,7 +2204,7 @@ var PackageSystem = class {
2155
2204
  if (scope === "global") return path.join(this.config.dataDir, "settings.json");
2156
2205
  const project = projectId ? this.projectContexts.get(projectId) : void 0;
2157
2206
  if (!project) throw new DomainError("PROJECT_NOT_FOUND", "Project not found for package operation", 404);
2158
- return path.join(project.mainWorktreePath, ".treeport", "settings.json");
2207
+ return path.join(project.rootPath, ".treeport", "settings.json");
2159
2208
  }
2160
2209
  async serialize(key, operation) {
2161
2210
  const previous = this.operationTails.get(key) ?? Promise.resolve();
@@ -2312,7 +2361,7 @@ var PackageSystem = class {
2312
2361
  };
2313
2362
  }
2314
2363
  npmRoot(scope, projectId) {
2315
- return scope === "global" ? path.join(this.config.dataDir, "npm") : path.join(this.projectContexts.get(projectId).mainWorktreePath, ".treeport", "npm");
2364
+ return scope === "global" ? path.join(this.config.dataDir, "npm") : path.join(this.projectContexts.get(projectId).rootPath, ".treeport", "npm");
2316
2365
  }
2317
2366
  npmPackagePath(source, scope, projectId) {
2318
2367
  return path.join(this.npmRoot(scope, projectId), "node_modules", source.name);
@@ -2628,6 +2677,7 @@ var PackageSystem = class {
2628
2677
  name: result.data.name,
2629
2678
  executable: result.data.executable,
2630
2679
  args: [...result.data.args],
2680
+ shellCommand: null,
2631
2681
  cwd: null,
2632
2682
  env: {},
2633
2683
  closeOnSuccess: result.data.closeOnSuccess,
@@ -2878,7 +2928,7 @@ var PackageSystem = class {
2878
2928
  return {
2879
2929
  id: project.id,
2880
2930
  name: project.name,
2881
- mainWorktreePath: project.mainWorktreePath
2931
+ rootPath: project.rootPath
2882
2932
  };
2883
2933
  }
2884
2934
  syncProjects(projects) {
@@ -2886,7 +2936,7 @@ var PackageSystem = class {
2886
2936
  const next = this.context(project);
2887
2937
  const previous = this.projectContexts.get(project.id);
2888
2938
  this.projectContexts.set(project.id, next);
2889
- if (previous && (previous.mainWorktreePath !== next.mainWorktreePath || previous.name !== next.name)) this.projectFingerprints.delete(project.id);
2939
+ if (previous && (previous.rootPath !== next.rootPath || previous.name !== next.name)) this.projectFingerprints.delete(project.id);
2890
2940
  }
2891
2941
  }
2892
2942
  async initialize(projects) {
@@ -3210,6 +3260,7 @@ async function loadRepositoryTerminalPresets(projectId, worktreePath) {
3210
3260
  name: preset.data.name,
3211
3261
  executable: preset.data.executable,
3212
3262
  args: [...preset.data.args],
3263
+ shellCommand: null,
3213
3264
  cwd: null,
3214
3265
  env: {},
3215
3266
  closeOnSuccess: preset.data.closeOnSuccess,
@@ -3826,6 +3877,7 @@ var TmuxAdapter = class {
3826
3877
  uid;
3827
3878
  creationTails = /* @__PURE__ */ new Map();
3828
3879
  configuredSockets = /* @__PURE__ */ new Set();
3880
+ socketConfigurationPromises = /* @__PURE__ */ new Map();
3829
3881
  initializationPromise = null;
3830
3882
  sshAuthSockPromise = null;
3831
3883
  constructor(runner, runtimeDir, executable = "tmux", launcherPath, host = {}) {
@@ -3923,10 +3975,8 @@ var TmuxAdapter = class {
3923
3975
  env: environment
3924
3976
  };
3925
3977
  if (input.fallbackArgv) spec.fallbackArgv = [...input.fallbackArgv];
3926
- if (shellIntegrationReady) {
3927
- spec.shellIntegrationDir = this.shellIntegrationDir;
3928
- spec.tmuxExecutable = resolveExecutablePath(this.executable, this.hostEnvironment);
3929
- }
3978
+ spec.tmuxExecutable = resolveExecutablePath(this.executable, this.hostEnvironment);
3979
+ if (shellIntegrationReady) spec.shellIntegrationDir = this.shellIntegrationDir;
3930
3980
  if (input.setupTasks?.length) spec.setupTasks = input.setupTasks.map((task) => ({
3931
3981
  ...task,
3932
3982
  argv: [...task.argv],
@@ -3965,6 +4015,8 @@ var TmuxAdapter = class {
3965
4015
  worktreeId: input.worktreeId,
3966
4016
  name: input.name,
3967
4017
  argv: input.argv,
4018
+ shellCommand: input.shellCommand,
4019
+ interactiveShell: input.interactiveShell,
3968
4020
  closeOnSuccess: input.closeOnSuccess ?? false,
3969
4021
  createdAt: input.createdAt,
3970
4022
  updatedAt: input.createdAt
@@ -3992,7 +4044,10 @@ var TmuxAdapter = class {
3992
4044
  }
3993
4045
  }
3994
4046
  async configureServer(socketName) {
3995
- await runChecked(this.runner, {
4047
+ if (this.configuredSockets.has(socketName)) return;
4048
+ const existing = this.socketConfigurationPromises.get(socketName);
4049
+ if (existing) return existing;
4050
+ const configuring = runChecked(this.runner, {
3996
4051
  executable: this.executable,
3997
4052
  args: [
3998
4053
  ...this.base(socketName),
@@ -4001,13 +4056,20 @@ var TmuxAdapter = class {
4001
4056
  ],
4002
4057
  env: this.environment(),
4003
4058
  timeoutMs: 1e4
4059
+ }).then(() => {
4060
+ this.configuredSockets.add(socketName);
4061
+ });
4062
+ this.socketConfigurationPromises.set(socketName, configuring);
4063
+ return configuring.finally(() => {
4064
+ if (this.socketConfigurationPromises.get(socketName) === configuring) this.socketConfigurationPromises.delete(socketName);
4004
4065
  });
4005
- this.configuredSockets.add(socketName);
4006
4066
  }
4007
4067
  async configureSession(socketName, sessionName, metadata) {
4008
4068
  const values = [
4009
4069
  ["@treeport-name", encodeMetadata(metadata.name)],
4010
4070
  ["@treeport-argv", encodeMetadata(metadata.argv)],
4071
+ ["@treeport-shell-command", encodeMetadata(metadata.shellCommand)],
4072
+ ["@treeport-interactive-shell", metadata.interactiveShell ? "1" : "0"],
4011
4073
  ["@treeport-close-on-success", metadata.closeOnSuccess ? "1" : "0"],
4012
4074
  ["@treeport-created-at", encodeMetadata(metadata.createdAt)],
4013
4075
  ["@treeport-updated-at", encodeMetadata(metadata.updatedAt)],
@@ -4060,7 +4122,7 @@ var TmuxAdapter = class {
4060
4122
  "list-panes",
4061
4123
  "-a",
4062
4124
  "-F",
4063
- "#{session_name} #{@treeport-terminal-id} #{@treeport-worktree-id} #{@treeport-name} #{@treeport-argv} #{@treeport-close-on-success} #{@treeport-created-at} #{@treeport-updated-at} #{session_created} #{pane_dead} #{pane_dead_status}"
4125
+ "#{session_name} #{@treeport-terminal-id} #{@treeport-worktree-id} #{@treeport-name} #{@treeport-argv} #{@treeport-shell-command} #{@treeport-interactive-shell} #{@treeport-close-on-success} #{@treeport-created-at} #{@treeport-updated-at} #{session_created} #{pane_dead} #{pane_dead_status}"
4064
4126
  ],
4065
4127
  env: this.environment(),
4066
4128
  timeoutMs: 1e4
@@ -4072,7 +4134,7 @@ var TmuxAdapter = class {
4072
4134
  const sessions = /* @__PURE__ */ new Map();
4073
4135
  for (const line of result.stdout.split("\n")) {
4074
4136
  if (!line) continue;
4075
- const [sessionName, terminalId, worktreeId, encodedName, encodedArgv, closeOnSuccess, encodedCreatedAt, encodedUpdatedAt, sessionCreated, paneDead, paneDeadStatus] = line.split(" ");
4137
+ const [sessionName, terminalId, worktreeId, encodedName, encodedArgv, encodedShellCommand, encodedInteractiveShell, closeOnSuccess, encodedCreatedAt, encodedUpdatedAt, sessionCreated, paneDead, paneDeadStatus] = line.split(" ");
4076
4138
  if (!sessionName || sessions.has(sessionName) || !terminalId || !worktreeId) continue;
4077
4139
  let metadata;
4078
4140
  try {
@@ -4081,12 +4143,15 @@ var TmuxAdapter = class {
4081
4143
  worktreeId,
4082
4144
  name: decodeMetadata(encodedName ?? "", z.string()),
4083
4145
  argv: decodeMetadata(encodedArgv ?? "", z.array(z.string())),
4146
+ shellCommand: decodeMetadata(encodedShellCommand ?? "", z.string().nullable()),
4084
4147
  createdAt: decodeMetadata(encodedCreatedAt ?? "", z.string()),
4085
4148
  updatedAt: decodeMetadata(encodedUpdatedAt ?? "", z.string())
4086
4149
  };
4087
4150
  } catch {
4088
4151
  continue;
4089
4152
  }
4153
+ const interactiveShell = encodedInteractiveShell === "1" ? true : encodedInteractiveShell === "0" ? false : void 0;
4154
+ if (metadata.shellCommand === void 0 || interactiveShell === void 0) continue;
4090
4155
  const fallbackCreatedAt = metadata.createdAt ?? (/* @__PURE__ */ new Date(Number(sessionCreated) * 1e3)).toISOString();
4091
4156
  const dead = paneDead === "1";
4092
4157
  const exitCode = dead && paneDeadStatus ? Number.parseInt(paneDeadStatus, 10) : null;
@@ -4096,6 +4161,8 @@ var TmuxAdapter = class {
4096
4161
  name: metadata.name ?? sessionName,
4097
4162
  sessionName,
4098
4163
  argv: metadata.argv ?? [],
4164
+ shellCommand: metadata.shellCommand,
4165
+ interactiveShell,
4099
4166
  closeOnSuccess: closeOnSuccess === "1",
4100
4167
  status: dead ? "exited" : "running",
4101
4168
  exitCode: Number.isNaN(exitCode) ? null : exitCode,
@@ -4248,7 +4315,7 @@ var TmuxAdapter = class {
4248
4315
  "-p",
4249
4316
  "-t",
4250
4317
  sessionName,
4251
- "#{@treeport-shell-title} #{pane_current_command} #{@treeport-command} #{pane_title}"
4318
+ "#{@treeport-fallback-shell} #{@treeport-shell-title} #{pane_current_command} #{@treeport-command} #{pane_title}"
4252
4319
  ],
4253
4320
  env: this.environment(),
4254
4321
  timeoutMs: 1e4
@@ -4257,19 +4324,22 @@ var TmuxAdapter = class {
4257
4324
  const firstSeparator = result.stdout.indexOf(" ");
4258
4325
  const secondSeparator = result.stdout.indexOf(" ", firstSeparator + 1);
4259
4326
  const thirdSeparator = result.stdout.indexOf(" ", secondSeparator + 1);
4260
- if (firstSeparator === -1 || secondSeparator === -1 || thirdSeparator === -1) return null;
4261
- const encodedShellTitle = result.stdout.slice(0, firstSeparator).trim();
4327
+ const fourthSeparator = result.stdout.indexOf(" ", thirdSeparator + 1);
4328
+ if (firstSeparator === -1 || secondSeparator === -1 || thirdSeparator === -1 || fourthSeparator === -1) return null;
4329
+ let fallbackShell = null;
4262
4330
  let shellTitle = null;
4263
4331
  try {
4264
- shellTitle = decodeMetadata(encodedShellTitle, z.string()) ?? null;
4332
+ fallbackShell = decodeMetadata(result.stdout.slice(0, firstSeparator).trim(), z.string()) ?? null;
4333
+ shellTitle = decodeMetadata(result.stdout.slice(firstSeparator + 1, secondSeparator).trim(), z.string()) ?? null;
4265
4334
  } catch {}
4266
- const currentCommand = result.stdout.slice(firstSeparator + 1, secondSeparator).trim() || null;
4267
- const commandLine = result.stdout.slice(secondSeparator + 1, thirdSeparator).trim() || null;
4335
+ const currentCommand = result.stdout.slice(secondSeparator + 1, thirdSeparator).trim() || null;
4336
+ const commandLine = result.stdout.slice(thirdSeparator + 1, fourthSeparator).trim() || null;
4268
4337
  return {
4269
- paneTitle: result.stdout.slice(thirdSeparator + 1).trim() || null,
4338
+ paneTitle: result.stdout.slice(fourthSeparator + 1).trim() || null,
4270
4339
  currentCommand,
4271
4340
  commandLine,
4272
- shellTitle
4341
+ shellTitle,
4342
+ fallbackShell
4273
4343
  };
4274
4344
  }
4275
4345
  async setSessionShellTitle(socketName, sessionName, title) {
@@ -4403,6 +4473,7 @@ var TreeportService = class {
4403
4473
  closeOnSuccessTerminalIds = /* @__PURE__ */ new Set();
4404
4474
  terminalIdsByWorktree = /* @__PURE__ */ new Map();
4405
4475
  projectObservationTails = /* @__PURE__ */ new Map();
4476
+ observedFolderIdentities = /* @__PURE__ */ new Map();
4406
4477
  projectsSnapshotInFlight = null;
4407
4478
  projectsSnapshotRevision = 0;
4408
4479
  packages;
@@ -4600,9 +4671,11 @@ var TreeportService = class {
4600
4671
  return this.deps.database.db.select({
4601
4672
  id: projects.id,
4602
4673
  name: projects.name,
4674
+ kind: projects.kind,
4675
+ rootPath: projects.repositoryPath,
4603
4676
  repositoryPath: projects.repositoryPath,
4604
4677
  lastOpenedAt: projects.lastOpenedAt
4605
- }).from(projects).where(eq(projects.isOpen, 0)).orderBy(desc(projects.lastOpenedAt), asc(projects.id));
4678
+ }).from(projects).where(and(eq(projects.isOpen, 0), eq(projects.showInRecents, 1))).orderBy(desc(projects.lastOpenedAt), asc(projects.id));
4606
4679
  }
4607
4680
  async collectCurrentProjectsSnapshot() {
4608
4681
  while (true) {
@@ -4615,7 +4688,8 @@ var TreeportService = class {
4615
4688
  return (await Promise.all((await this.storedProjects(true)).map(async (storedProject) => {
4616
4689
  let project = storedProject;
4617
4690
  try {
4618
- await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath);
4691
+ if (project.kind === "repository") await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath);
4692
+ else project = await this.observeAvailableProject(project);
4619
4693
  await this.ensureProjectTerminals(project.id);
4620
4694
  project = await this.storedProject(project.id) ?? project;
4621
4695
  } catch (error) {
@@ -4626,7 +4700,7 @@ var TreeportService = class {
4626
4700
  }
4627
4701
  if (await this.projectOpenState(project.id) !== true) return null;
4628
4702
  await Promise.all(project.worktrees.map(async (worktree) => {
4629
- const [dirty, terminals] = await Promise.all([project.availability.state === "available" && !worktree.prunable ? this.deps.git.dirtyState(worktree.path).catch(() => null) : null, this.listWorktreeTerminals(worktree).catch((error) => {
4703
+ const [dirty, terminals] = await Promise.all([project.kind === "repository" && project.availability.state === "available" && !worktree.prunable ? this.deps.git.dirtyState(worktree.path).catch(() => null) : null, this.listWorktreeTerminals(worktree).catch((error) => {
4630
4704
  project.availability = {
4631
4705
  state: "unavailable",
4632
4706
  message: error instanceof Error ? error.message : String(error)
@@ -4667,6 +4741,8 @@ var TreeportService = class {
4667
4741
  name: terminal.name,
4668
4742
  tmuxSessionName: terminal.sessionName,
4669
4743
  argv: terminal.argv,
4744
+ shellCommand: terminal.shellCommand,
4745
+ interactiveShell: terminal.interactiveShell,
4670
4746
  status: terminal.status,
4671
4747
  exitCode: terminal.exitCode,
4672
4748
  createdAt: terminal.createdAt,
@@ -4730,7 +4806,7 @@ var TreeportService = class {
4730
4806
  const direct = await this.storedProject(identifier);
4731
4807
  if (direct) return direct;
4732
4808
  const canonical = await fs.realpath(path.resolve(identifier)).catch(() => path.resolve(identifier));
4733
- const match = (await this.storedProjects()).find((project) => isPathWithin(canonical, project.repositoryPath) || project.worktrees.some((worktree) => isPathWithin(canonical, worktree.path)));
4809
+ const match = (await this.storedProjects()).find((project) => isPathWithin(canonical, project.rootPath) || project.worktrees.some((worktree) => isPathWithin(canonical, worktree.path)));
4734
4810
  if (!match) throw new DomainError("PROJECT_NOT_FOUND", `No registered project contains ${identifier}`, 404);
4735
4811
  return match;
4736
4812
  }
@@ -4798,7 +4874,7 @@ var TreeportService = class {
4798
4874
  definitions: [],
4799
4875
  diagnostics: []
4800
4876
  }),
4801
- worktree && project ? loadZedTerminalPresetDefinitions({
4877
+ worktree && project?.kind === "repository" ? loadZedTerminalPresetDefinitions({
4802
4878
  projectId: project.id,
4803
4879
  shell: this.deps.config.shell,
4804
4880
  mainWorktreePath: project.mainWorktreePath,
@@ -4820,6 +4896,7 @@ var TreeportService = class {
4820
4896
  name: preset.name,
4821
4897
  executable: preset.executable,
4822
4898
  args: [...preset.args],
4899
+ shellCommand: null,
4823
4900
  cwd: null,
4824
4901
  env: {},
4825
4902
  closeOnSuccess: preset.closeOnSuccess,
@@ -5069,18 +5146,21 @@ var TreeportService = class {
5069
5146
  project: {
5070
5147
  id: project.id,
5071
5148
  name: project.name,
5072
- defaultBranch: project.defaultBranch
5149
+ kind: project.kind,
5150
+ defaultBranch: project.kind === "repository" ? project.defaultBranch : null
5073
5151
  },
5074
5152
  worktree: {
5075
5153
  id: worktree.id,
5076
5154
  name: worktree.name,
5155
+ kind: worktree.kind,
5077
5156
  branch: worktree.branch,
5078
- head: worktree.head
5157
+ head: worktree.kind === "folder" ? null : worktree.head
5079
5158
  }
5080
5159
  };
5081
5160
  }
5082
5161
  async getWebPanelDiff(panelId) {
5083
5162
  const context = await this.getWebPanelContext(panelId);
5163
+ if (context.project.kind !== "repository" || !context.project.defaultBranch) throw new DomainError("GIT_NOT_AVAILABLE", "Git diff is not available for a folder project", 409);
5084
5164
  const worktree = await this.getWorktree(context.panel.worktreeId);
5085
5165
  return this.deps.git.worktreeDiff(worktree.path, context.project.defaultBranch);
5086
5166
  }
@@ -5145,6 +5225,14 @@ var TreeportService = class {
5145
5225
  if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
5146
5226
  return worktree;
5147
5227
  }
5228
+ async requestWorkspaceOpen(worktreeId, sourceTerminalId) {
5229
+ const worktree = await this.getWorktree(worktreeId);
5230
+ await this.requireOpenProject(worktree.projectId);
5231
+ this.events.publish("workspace.open_requested", {
5232
+ worktreeId,
5233
+ sourceTerminalId
5234
+ });
5235
+ }
5148
5236
  async getTerminal(terminalId) {
5149
5237
  const matches = (await this.listProjects()).flatMap((project) => project.worktrees).flatMap((worktree) => worktree.terminals).filter((terminal) => terminal.id === terminalId);
5150
5238
  if (matches.length > 1) throw new DomainError("TERMINAL_ID_CONFLICT", "Terminal ID is present in more than one tmux server", 500);
@@ -5178,7 +5266,7 @@ var TreeportService = class {
5178
5266
  const direct = await this.storedProject(identifier);
5179
5267
  if (direct) return await this.requireOpenProject(direct.id);
5180
5268
  const canonical = await fs.realpath(path.resolve(identifier)).catch(() => path.resolve(identifier));
5181
- const match = (await this.storedProjects()).find((project) => isPathWithin(canonical, project.repositoryPath) || project.worktrees.some((worktree) => isPathWithin(canonical, worktree.path)));
5269
+ const match = (await this.storedProjects()).find((project) => isPathWithin(canonical, project.rootPath) || project.worktrees.some((worktree) => isPathWithin(canonical, worktree.path)));
5182
5270
  if (!match) throw new DomainError("PROJECT_NOT_FOUND", `No registered project contains ${identifier}`, 404);
5183
5271
  await this.requireOpenProject(match.id);
5184
5272
  return match;
@@ -5251,7 +5339,7 @@ var TreeportService = class {
5251
5339
  path: breadcrumbPath
5252
5340
  });
5253
5341
  }
5254
- const repositoryPath = exact ? await this.deps.git.canonicalizeRepositoryPath(directoryPath).then((checkout) => this.deps.git.resolveMainCheckout(checkout)).then((mainCheckout) => fs.realpath(mainCheckout)).catch(() => null) : null;
5342
+ const repositoryPath = exact ? await this.deps.git.findProjectRepositoryRoot(directoryPath).then((checkout) => checkout ? this.deps.git.resolveMainCheckout(checkout) : null).then((mainCheckout) => mainCheckout ? fs.realpath(mainCheckout) : null) : null;
5255
5343
  return {
5256
5344
  input: inputPath,
5257
5345
  exact,
@@ -5264,6 +5352,18 @@ var TreeportService = class {
5264
5352
  entries,
5265
5353
  truncated
5266
5354
  },
5355
+ project: exact ? repositoryPath ? {
5356
+ state: "valid",
5357
+ kind: "repository",
5358
+ path: repositoryPath
5359
+ } : {
5360
+ state: "valid",
5361
+ kind: "folder",
5362
+ path: directoryPath
5363
+ } : {
5364
+ state: "incomplete",
5365
+ message: "Choose a matching folder to continue."
5366
+ },
5267
5367
  repository: repositoryPath ? {
5268
5368
  state: "valid",
5269
5369
  repositoryPath
@@ -5277,6 +5377,14 @@ var TreeportService = class {
5277
5377
  };
5278
5378
  }
5279
5379
  async registerProject(inputPath, requestedName) {
5380
+ const canonicalPath = await fs.realpath(path.resolve(inputPath)).catch((error) => {
5381
+ throw new DomainError("FOLDER_UNREADABLE", error instanceof Error ? error.message : "Folder cannot be read", 400);
5382
+ });
5383
+ if (!(await fs.stat(canonicalPath, { bigint: true })).isDirectory()) throw new DomainError("FOLDER_NOT_DIRECTORY", `Path is not a folder: ${canonicalPath}`, 400);
5384
+ const repositoryRoot = await this.deps.git.findProjectRepositoryRoot(canonicalPath);
5385
+ return repositoryRoot ? this.registerRepositoryProject(repositoryRoot, requestedName) : this.registerFolderProject(canonicalPath, requestedName);
5386
+ }
5387
+ async registerRepositoryProject(inputPath, requestedName) {
5280
5388
  const checkout = await this.deps.git.canonicalizeRepositoryPath(inputPath).catch((error) => {
5281
5389
  throw new DomainError("NOT_A_GIT_REPOSITORY", error instanceof Error ? error.message : "Not a Git repository", 400);
5282
5390
  });
@@ -5331,17 +5439,18 @@ var TreeportService = class {
5331
5439
  if (verifiedIdentity !== repositoryIdentity || verifiedStat.dev.toString() !== repositoryDevice || verifiedStat.ino.toString() !== repositoryInode) throw new DomainError("PROJECT_PATH_CONFLICT", "The repository changed during registration", 409);
5332
5440
  await this.deps.database.db.run(sql`
5333
5441
  INSERT INTO projects(
5334
- id,name,repository_path,main_worktree_path,default_branch,
5442
+ id,name,project_kind,repository_path,main_worktree_path,default_branch,
5335
5443
  repository_identity,repository_device,repository_inode,name_is_custom,
5336
- is_open,last_opened_at,created_at,updated_at
5444
+ is_open,show_in_recents,last_opened_at,created_at,updated_at
5337
5445
  ) VALUES(
5338
- ${projectId},${name},${repositoryPath},${mainPath},${defaultBranch},
5446
+ ${projectId},${name},'repository',${repositoryPath},${mainPath},${defaultBranch},
5339
5447
  ${repositoryIdentity},${repositoryDevice},${repositoryInode},
5340
- ${nameIsCustom ? 1 : 0},1,${timestamp},
5448
+ ${nameIsCustom ? 1 : 0},1,0,${timestamp},
5341
5449
  ${existing?.createdAt ?? timestamp},${timestamp}
5342
5450
  )
5343
5451
  ON CONFLICT(id) DO UPDATE SET
5344
5452
  name=excluded.name,
5453
+ project_kind=excluded.project_kind,
5345
5454
  repository_path=excluded.repository_path,
5346
5455
  main_worktree_path=excluded.main_worktree_path,
5347
5456
  default_branch=excluded.default_branch,
@@ -5362,6 +5471,7 @@ var TreeportService = class {
5362
5471
  const timestamp = now();
5363
5472
  await this.deps.database.db.update(projects).set({
5364
5473
  isOpen: 1,
5474
+ showInRecents: 0,
5365
5475
  lastOpenedAt: timestamp,
5366
5476
  updatedAt: timestamp
5367
5477
  }).where(eq(projects.id, projectId));
@@ -5382,9 +5492,123 @@ var TreeportService = class {
5382
5492
  this.events.publish("project.created", { projectId });
5383
5493
  return this.getProjectSnapshot(projectId);
5384
5494
  }
5495
+ async registerFolderProject(folderPath, requestedName) {
5496
+ const folderStat = await fs.stat(folderPath, { bigint: true });
5497
+ const device = folderStat.dev.toString();
5498
+ const inode = folderStat.ino.toString();
5499
+ const [pathMatchRow] = await this.deps.database.db.select({ id: projects.id }).from(projects).where(eq(projects.repositoryPath, folderPath)).limit(1);
5500
+ const identityMatchId = [...this.observedFolderIdentities].find(([, identity]) => identity.device === device && identity.inode === inode)?.[0];
5501
+ const [pathMatch, identityMatch] = await Promise.all([pathMatchRow ? this.storedProject(pathMatchRow.id) : null, identityMatchId ? this.storedProject(identityMatchId) : null]);
5502
+ if (pathMatch?.kind === "repository") throw new DomainError("PROJECT_PATH_CONFLICT", "The selected folder is registered as a Git repository, but Git no longer recognizes it", 409);
5503
+ const observedPathIdentity = pathMatch ? this.observedFolderIdentities.get(pathMatch.id) : null;
5504
+ if (observedPathIdentity && (observedPathIdentity.device !== device || observedPathIdentity.inode !== inode)) throw new DomainError("PROJECT_PATH_CONFLICT", "The registered folder path now refers to a different folder", 409);
5505
+ if (pathMatch && identityMatch && pathMatch.id !== identityMatch.id) throw new DomainError("PROJECT_PATH_CONFLICT", "The folder identity and registered path belong to different projects", 409);
5506
+ const existing = identityMatch ?? pathMatch;
5507
+ const projectId = existing?.id ?? id("proj");
5508
+ const updateRegistration = async () => {
5509
+ const timestamp = now();
5510
+ const [metadata] = existing ? await this.deps.database.db.select({ nameIsCustom: projects.nameIsCustom }).from(projects).where(eq(projects.id, existing.id)).limit(1) : [];
5511
+ const requested = requestedName?.trim() || null;
5512
+ const nameIsCustom = requested ? true : Boolean(metadata?.nameIsCustom);
5513
+ const name = requested || (existing && !nameIsCustom && existing.name === path.basename(existing.rootPath) ? path.basename(folderPath) : existing?.name) || path.basename(folderPath);
5514
+ const [verifiedPath, verifiedStat] = await Promise.all([fs.realpath(folderPath), fs.stat(folderPath, { bigint: true })]);
5515
+ if (verifiedPath !== folderPath || !verifiedStat.isDirectory() || verifiedStat.dev.toString() !== device || verifiedStat.ino.toString() !== inode) throw new DomainError("PROJECT_PATH_CONFLICT", "The folder changed during registration", 409);
5516
+ const existingWorktreeRows = existing ? await this.deps.database.db.select().from(worktrees).where(eq(worktrees.projectId, projectId)) : [];
5517
+ if (existingWorktreeRows.length > 1 || existingWorktreeRows.some((worktree) => worktree.kind !== "folder")) throw new DomainError("PROJECT_PATH_CONFLICT", "The folder registration contains incompatible Git worktrees", 409);
5518
+ const existingWorktree = existingWorktreeRows[0];
5519
+ const worktreeId = existingWorktree?.id ?? id("wt");
5520
+ await this.deps.database.db.transaction(async (tx) => {
5521
+ await tx.run(sql`
5522
+ INSERT INTO projects(
5523
+ id,name,project_kind,repository_path,main_worktree_path,default_branch,
5524
+ repository_identity,repository_device,repository_inode,name_is_custom,
5525
+ is_open,show_in_recents,last_opened_at,created_at,updated_at
5526
+ ) VALUES(
5527
+ ${projectId},${name},'folder',${folderPath},${folderPath},'',
5528
+ NULL,${device},${inode},${nameIsCustom ? 1 : 0},1,0,${timestamp},
5529
+ ${existing?.createdAt ?? timestamp},${timestamp}
5530
+ )
5531
+ ON CONFLICT(id) DO UPDATE SET
5532
+ name=excluded.name,
5533
+ project_kind='folder',
5534
+ repository_path=excluded.repository_path,
5535
+ main_worktree_path=excluded.main_worktree_path,
5536
+ default_branch='',
5537
+ repository_identity=NULL,
5538
+ repository_device=excluded.repository_device,
5539
+ repository_inode=excluded.repository_inode,
5540
+ name_is_custom=excluded.name_is_custom,
5541
+ is_open=1,
5542
+ show_in_recents=0,
5543
+ last_opened_at=excluded.last_opened_at,
5544
+ updated_at=excluded.updated_at
5545
+ `);
5546
+ if (existingWorktree) await tx.run(sql`
5547
+ UPDATE worktrees
5548
+ SET path=${folderPath},git_worktree_key=NULL,head='',branch=NULL,
5549
+ detached=0,locked=0,lock_reason=NULL,prunable=0,kind='folder',
5550
+ managed_wrapper_path=NULL,pr_state='unknown',pr_number=NULL,
5551
+ pr_url=NULL,pr_base_branch=NULL,pr_head_branch=NULL,
5552
+ pr_merged_at=NULL,pr_refreshed_at=NULL,updated_at=${timestamp}
5553
+ WHERE id=${worktreeId}
5554
+ `);
5555
+ else await tx.run(sql`
5556
+ INSERT INTO worktrees(
5557
+ id,project_id,path,git_worktree_key,head,branch,detached,locked,
5558
+ lock_reason,prunable,kind,tmux_socket_name,created_at,updated_at
5559
+ ) VALUES(
5560
+ ${worktreeId},${projectId},${folderPath},NULL,'',NULL,0,0,NULL,0,
5561
+ 'folder',${generateTmuxSocketName()},${timestamp},${timestamp}
5562
+ )
5563
+ `);
5564
+ });
5565
+ };
5566
+ const register = async () => {
5567
+ if (this.projectLocks.has(projectId) || this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
5568
+ this.projectLocks.add(projectId);
5569
+ try {
5570
+ await updateRegistration();
5571
+ } finally {
5572
+ this.projectLocks.delete(projectId);
5573
+ }
5574
+ };
5575
+ if (existing) await this.serializeProjectObservation(projectId, register);
5576
+ else await register();
5577
+ this.observedFolderIdentities.set(projectId, {
5578
+ device,
5579
+ inode
5580
+ });
5581
+ await this.packages.registerProject(await this.getProject(projectId));
5582
+ await this.ensureProjectTerminals(projectId).catch(() => void 0);
5583
+ this.invalidateProjectsSnapshot();
5584
+ this.events.publish(existing ? "project.updated" : "project.created", { projectId });
5585
+ return this.getProjectSnapshot(projectId);
5586
+ }
5385
5587
  async observeAvailableProject(project, allowClosed = false) {
5386
5588
  try {
5387
- await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath, true, allowClosed);
5589
+ if (project.kind === "repository") await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath, true, allowClosed);
5590
+ else await this.serializeProjectObservation(project.id, async () => {
5591
+ if (!allowClosed && await this.projectOpenState(project.id) !== true || this.worktreeMutations.has(project.id)) return;
5592
+ const [metadata] = await this.deps.database.db.select({
5593
+ device: projects.repositoryDevice,
5594
+ inode: projects.repositoryInode
5595
+ }).from(projects).where(eq(projects.id, project.id)).limit(1);
5596
+ const [canonicalPath, folderStat] = await Promise.all([fs.realpath(project.rootPath), fs.stat(project.rootPath, { bigint: true })]);
5597
+ if (!metadata || canonicalPath !== project.rootPath || !folderStat.isDirectory()) throw new Error("The registered folder path is not an available directory");
5598
+ const device = folderStat.dev.toString();
5599
+ const inode = folderStat.ino.toString();
5600
+ const observedIdentity = this.observedFolderIdentities.get(project.id);
5601
+ if (observedIdentity && (observedIdentity.device !== device || observedIdentity.inode !== inode)) throw new Error("The registered folder path changed during this daemon session");
5602
+ if (project.worktrees.filter((worktree) => worktree.kind === "folder" && worktree.path === project.rootPath).length !== 1 || project.worktrees.length !== 1) throw new Error("The registered folder does not have one folder workspace");
5603
+ if (metadata.device !== device || metadata.inode !== inode) await this.deps.database.db.update(projects).set({
5604
+ repositoryDevice: device,
5605
+ repositoryInode: inode
5606
+ }).where(eq(projects.id, project.id));
5607
+ this.observedFolderIdentities.set(project.id, {
5608
+ device,
5609
+ inode
5610
+ });
5611
+ });
5388
5612
  } catch (error) {
5389
5613
  throw new DomainError("PROJECT_UNAVAILABLE", error instanceof Error ? error.message : String(error), 503);
5390
5614
  }
@@ -5397,12 +5621,14 @@ var TreeportService = class {
5397
5621
  try {
5398
5622
  const project = await this.observeAvailableProject(await this.getProject(projectId));
5399
5623
  await this.ensureProjectTerminals(projectId);
5400
- const defaultBranch = await this.deps.git.defaultBranch(project.repositoryPath);
5401
- await this.deps.database.db.run(sql`
5402
- UPDATE projects
5403
- SET default_branch = ${defaultBranch}, updated_at = ${now()}
5404
- WHERE id = ${projectId}
5405
- `);
5624
+ if (project.kind === "repository") {
5625
+ const defaultBranch = await this.deps.git.defaultBranch(project.repositoryPath);
5626
+ await this.deps.database.db.run(sql`
5627
+ UPDATE projects
5628
+ SET default_branch = ${defaultBranch}, updated_at = ${now()}
5629
+ WHERE id = ${projectId}
5630
+ `);
5631
+ }
5406
5632
  await this.reconcile();
5407
5633
  await this.packages.registerProject(await this.getProject(projectId));
5408
5634
  this.invalidateProjectsSnapshot();
@@ -5421,6 +5647,7 @@ var TreeportService = class {
5421
5647
  const timestamp = now();
5422
5648
  await this.deps.database.db.update(projects).set({
5423
5649
  isOpen: 1,
5650
+ showInRecents: 0,
5424
5651
  lastOpenedAt: timestamp,
5425
5652
  updatedAt: timestamp
5426
5653
  }).where(eq(projects.id, projectId));
@@ -5462,6 +5689,7 @@ var TreeportService = class {
5462
5689
  try {
5463
5690
  await this.deps.database.db.update(projects).set({
5464
5691
  isOpen: 0,
5692
+ showInRecents: 1,
5465
5693
  updatedAt: now()
5466
5694
  }).where(eq(projects.id, projectId));
5467
5695
  } catch (error) {
@@ -5479,6 +5707,17 @@ var TreeportService = class {
5479
5707
  }
5480
5708
  });
5481
5709
  }
5710
+ async dismissRecentProject(projectId) {
5711
+ await this.serializeProjectObservation(projectId, async () => {
5712
+ await this.getProject(projectId);
5713
+ if (await this.projectOpenState(projectId) !== false) throw new DomainError("PROJECT_NOT_RECENT", "Project is open and cannot be removed from Recent projects", 409);
5714
+ await this.deps.database.db.update(projects).set({
5715
+ showInRecents: 0,
5716
+ updatedAt: now()
5717
+ }).where(and(eq(projects.id, projectId), eq(projects.isOpen, 0)));
5718
+ this.events.publish("project.updated", { projectId });
5719
+ });
5720
+ }
5482
5721
  async serializeProjectObservation(projectId, operation) {
5483
5722
  const observation = (this.projectObservationTails.get(projectId) ?? Promise.resolve()).then(operation);
5484
5723
  const tail = observation.then(() => void 0, () => void 0);
@@ -5663,7 +5902,7 @@ var TreeportService = class {
5663
5902
  return (await this.deps.database.db.select().from(operations).where(and(or(eq(operations.status, "pending"), eq(operations.status, "running")), ...filters.projectId ? [eq(operations.projectId, filters.projectId)] : [], ...filters.kind ? [eq(operations.kind, filters.kind)] : [])).orderBy(asc(operations.createdAt), asc(operations.id))).map(mapOperation);
5664
5903
  }
5665
5904
  async beginCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
5666
- await this.requireOpenProject(projectId);
5905
+ if ((await this.requireOpenProject(projectId)).kind === "folder") throw new DomainError("PROJECT_HAS_NO_GIT_REPOSITORY", "Linked worktrees require a Git repository project", 409);
5667
5906
  let name;
5668
5907
  try {
5669
5908
  name = normalizeWorktreeName(inputName);
@@ -5734,7 +5973,7 @@ var TreeportService = class {
5734
5973
  }
5735
5974
  }
5736
5975
  async createWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
5737
- await this.requireOpenProject(projectId);
5976
+ if ((await this.requireOpenProject(projectId)).kind === "folder") throw new DomainError("PROJECT_HAS_NO_GIT_REPOSITORY", "Linked worktrees require a Git repository project", 409);
5738
5977
  if (this.projectLocks.has(projectId) && !this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
5739
5978
  return this.worktreeMutations.enqueue(projectId, () => this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId));
5740
5979
  }
@@ -5749,6 +5988,7 @@ var TreeportService = class {
5749
5988
  let wrapperCreated = false;
5750
5989
  try {
5751
5990
  project = await this.observeAvailableProject(await this.requireOpenProject(projectId));
5991
+ if (project.kind === "folder") throw new DomainError("PROJECT_HAS_NO_GIT_REPOSITORY", "Linked worktrees require a Git repository project", 409);
5752
5992
  let name;
5753
5993
  try {
5754
5994
  name = normalizeWorktreeName(inputName);
@@ -5902,7 +6142,13 @@ var TreeportService = class {
5902
6142
  const project = await this.requireOpenProject(worktree.projectId);
5903
6143
  const terminalId = id("term");
5904
6144
  const sessionName = generateTmuxSessionName();
5905
- const commandArgv = argv ? [...argv] : [this.deps.config.shell, "-l"];
6145
+ const shellCommand = options?.shellCommand ?? null;
6146
+ const interactiveShell = !argv && shellCommand === null;
6147
+ const commandArgv = argv ? [...argv] : shellCommand ? [
6148
+ this.deps.config.shell,
6149
+ "-lc",
6150
+ shellCommand
6151
+ ] : [this.deps.config.shell, "-l"];
5906
6152
  const timestamp = now();
5907
6153
  const session = {
5908
6154
  socketName: worktree.tmuxSocketName,
@@ -5913,6 +6159,8 @@ var TreeportService = class {
5913
6159
  createdAt: timestamp,
5914
6160
  cwd: options?.cwd ?? worktree.path,
5915
6161
  argv: commandArgv,
6162
+ shellCommand,
6163
+ interactiveShell,
5916
6164
  env: {
5917
6165
  ...options?.env ?? {},
5918
6166
  TREEPORT_API_URL: this.deps.config.apiUrl,
@@ -5924,7 +6172,7 @@ var TreeportService = class {
5924
6172
  TREEPORT_TERMINAL_ID: terminalId
5925
6173
  }
5926
6174
  };
5927
- if (options?.returnToShell && argv) session.fallbackArgv = [this.deps.config.shell, "-l"];
6175
+ if (options?.returnToShell && !interactiveShell) session.fallbackArgv = [this.deps.config.shell, "-l"];
5928
6176
  if (options?.closeOnSuccess) session.closeOnSuccess = true;
5929
6177
  if (options?.initialSize) session.initialSize = options.initialSize;
5930
6178
  if (options?.setup?.tasks.length) session.setupTasks = options.setup.tasks;
@@ -5940,6 +6188,8 @@ var TreeportService = class {
5940
6188
  name,
5941
6189
  tmuxSessionName: sessionName,
5942
6190
  argv: commandArgv,
6191
+ shellCommand,
6192
+ interactiveShell,
5943
6193
  status: "running",
5944
6194
  exitCode: null,
5945
6195
  createdAt: timestamp,
@@ -5980,7 +6230,7 @@ var TreeportService = class {
5980
6230
  }
5981
6231
  }
5982
6232
  async refreshTerminalStatus(terminalId, observeGit = true) {
5983
- const terminal = observeGit ? await this.getTerminal(terminalId) : await this.getTerminalFromBindings(terminalId);
6233
+ const terminal = observeGit ? await this.getTerminal(terminalId) : this.terminalStates.get(terminalId) ?? await this.getTerminalFromBindings(terminalId);
5984
6234
  const worktree = await this.getWorktree(terminal.worktreeId);
5985
6235
  const state = await this.deps.tmux.sessionState(worktree.tmuxSocketName, terminal.tmuxSessionName);
5986
6236
  await this.requireOpenProject(worktree.projectId);
@@ -6098,6 +6348,7 @@ var TreeportService = class {
6098
6348
  const worktree = await this.requireAvailableWorktree(worktreeId, true);
6099
6349
  worktree.terminals = await this.listWorktreeTerminals(worktree);
6100
6350
  const project = await this.getProject(worktree.projectId);
6351
+ if (project.kind === "folder") throw new DomainError("FOLDER_WORKSPACE_NOT_REMOVABLE", "Remove the folder project instead of its folder workspace", 409);
6101
6352
  const live = (await this.deps.git.listWorktrees(project.repositoryPath)).find((item) => item.path === worktree.path);
6102
6353
  if (!live) throw new DomainError("WORKTREE_NOT_FOUND", "Git no longer reports this worktree", 404);
6103
6354
  const head = live.head ?? worktree.head;
@@ -6433,6 +6684,7 @@ var TreeportService = class {
6433
6684
  const terminalIdsByWorktree = /* @__PURE__ */ new Map();
6434
6685
  for (const worktree of project.worktrees) terminalIdsByWorktree.set(worktree.id, await this.deps.tmux.killServer(worktree.tmuxSocketName));
6435
6686
  await this.deps.database.db.run(sql`DELETE FROM projects WHERE id=${projectId}`);
6687
+ this.observedFolderIdentities.delete(projectId);
6436
6688
  this.packages.forgetProject(projectId);
6437
6689
  for (const worktree of project.worktrees) this.clearWorktreeTerminalState(worktree.id, terminalIdsByWorktree.get(worktree.id));
6438
6690
  this.invalidateProjectsSnapshot();
@@ -6459,7 +6711,7 @@ var TreeportService = class {
6459
6711
  async reconcile() {
6460
6712
  const availableProjects = /* @__PURE__ */ new Set();
6461
6713
  for (const project of await this.storedProjects(true)) try {
6462
- await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath);
6714
+ await this.observeAvailableProject(project);
6463
6715
  availableProjects.add(project.id);
6464
6716
  } catch {}
6465
6717
  for (const project of await this.storedProjects(true)) {
@@ -6827,6 +7079,7 @@ async function terminateProcess(child, terminationStarted) {
6827
7079
  var TmuxProgressObserver = class {
6828
7080
  options;
6829
7081
  spawnProcess;
7082
+ closed;
6830
7083
  lifecycleFiber;
6831
7084
  process = null;
6832
7085
  metadataParser = null;
@@ -6841,6 +7094,7 @@ var TmuxProgressObserver = class {
6841
7094
  this.options = options;
6842
7095
  this.spawnProcess = spawnProcess;
6843
7096
  this.lifecycleFiber = Effect.runFork(Effect.scoped(this.lifecycle()).pipe(Effect.catchAll(() => Effect.sync(() => this.notifyExit()))));
7097
+ this.closed = Effect.runPromise(Fiber.await(this.lifecycleFiber)).then(() => void 0);
6844
7098
  }
6845
7099
  dispose() {
6846
7100
  if (this.disposed) return;
@@ -6997,22 +7251,6 @@ const PROGRAM_COMMANDS = /* @__PURE__ */ new Map([
6997
7251
  ["claude", "claude"],
6998
7252
  ["codex", "codex"]
6999
7253
  ]);
7000
- const SHELL_COMMANDS = /* @__PURE__ */ new Set([
7001
- "ash",
7002
- "bash",
7003
- "csh",
7004
- "dash",
7005
- "elvish",
7006
- "fish",
7007
- "ksh",
7008
- "mksh",
7009
- "nu",
7010
- "pwsh",
7011
- "sh",
7012
- "tcsh",
7013
- "xonsh",
7014
- "zsh"
7015
- ]);
7016
7254
  var TerminalMetadataRuntimeError = class {
7017
7255
  phase;
7018
7256
  terminalId;
@@ -7039,6 +7277,7 @@ var TerminalMetadataManager = class {
7039
7277
  bellMutations = new KeyedTaskQueue();
7040
7278
  bellDeletionVersions = /* @__PURE__ */ new Map();
7041
7279
  persistedBells = /* @__PURE__ */ new Map();
7280
+ observerShutdowns = /* @__PURE__ */ new Set();
7042
7281
  bellStateStore;
7043
7282
  initializePromise = null;
7044
7283
  unsubscribeEvents = null;
@@ -7104,8 +7343,10 @@ var TerminalMetadataManager = class {
7104
7343
  entry = void 0;
7105
7344
  }
7106
7345
  if (!entry) {
7107
- const launchCommand = path.basename(terminal.argv?.[0] ?? "").replace(/^-/, "");
7108
- const launchProgram = PROGRAM_COMMANDS.get(launchCommand) ?? null;
7346
+ const launchCommand = path.basename(terminal.argv[0] ?? "").replace(/^-/, "");
7347
+ const launchProgram = !terminal.interactiveShell && terminal.shellCommand === null ? PROGRAM_COMMANDS.get(launchCommand) ?? null : null;
7348
+ const interactiveShellCommand = terminal.interactiveShell ? launchCommand : null;
7349
+ const launchCommandLine = terminal.interactiveShell ? null : (terminal.shellCommand?.replace(/\p{Cc}/gu, "") ?? formatCommandLine(terminal.argv.map((value) => value.replace(/\p{Cc}/gu, "")))).trim().slice(0, 256) || null;
7109
7350
  this.bellDeletionVersions.set(terminal.id, (this.bellDeletionVersions.get(terminal.id) ?? 0) + 1);
7110
7351
  const persistedBell = this.persistedBells.get(terminal.id);
7111
7352
  const bell = persistedBell?.worktreeId === terminal.worktreeId ? {
@@ -7130,7 +7371,8 @@ var TerminalMetadataManager = class {
7130
7371
  paneTitle: null,
7131
7372
  currentCommand: null,
7132
7373
  commandLine: null,
7133
- shellCommand: SHELL_COMMANDS.has(launchCommand) ? launchCommand : null,
7374
+ launchCommandLine,
7375
+ interactiveShellCommand,
7134
7376
  launchProgram,
7135
7377
  shellTitle: null,
7136
7378
  persistedShellTitle: null,
@@ -7234,8 +7476,9 @@ var TerminalMetadataManager = class {
7234
7476
  this.listeners.clear();
7235
7477
  this.historyListeners.clear();
7236
7478
  }
7237
- drain() {
7238
- return this.bellMutations.drain();
7479
+ async drain() {
7480
+ await this.bellMutations.drain();
7481
+ await Promise.allSettled([...this.observerShutdowns]);
7239
7482
  }
7240
7483
  handleProductEvent(event) {
7241
7484
  if (event.type === "terminal.removed") {
@@ -7403,7 +7646,7 @@ var TerminalMetadataManager = class {
7403
7646
  this.update(entry, { progress: null });
7404
7647
  }
7405
7648
  });
7406
- if (exited || this.entries.get(entry.terminalId) !== entry || entry.runtimeGeneration !== runtimeGeneration || entry.observerVersion !== version) observer.dispose();
7649
+ if (exited || this.entries.get(entry.terminalId) !== entry || entry.runtimeGeneration !== runtimeGeneration || entry.observerVersion !== version) this.disposeObserver(observer);
7407
7650
  else entry.observer = observer;
7408
7651
  },
7409
7652
  catch: (cause) => new TerminalMetadataRuntimeError("create_observer", entry.terminalId, cause)
@@ -7432,17 +7675,31 @@ var TerminalMetadataManager = class {
7432
7675
  }
7433
7676
  this.update(entry, { progress: null });
7434
7677
  }
7678
+ disposeObserver(observer) {
7679
+ observer.dispose();
7680
+ if (!observer.closed) return;
7681
+ const shutdown = observer.closed;
7682
+ this.observerShutdowns.add(shutdown);
7683
+ shutdown.then(() => this.observerShutdowns.delete(shutdown), () => this.observerShutdowns.delete(shutdown));
7684
+ }
7435
7685
  releaseRuntimeResources(entry) {
7436
7686
  entry.observerVersion += 1;
7437
- entry.observer?.dispose();
7438
- entry.observer = null;
7687
+ if (entry.observer) {
7688
+ this.disposeObserver(entry.observer);
7689
+ entry.observer = null;
7690
+ }
7439
7691
  this.clearProgressRuntime(entry);
7440
7692
  entry.shellTitleWriting = false;
7441
7693
  }
7442
7694
  reconcileTitleState(entry, state) {
7443
7695
  const paneTitle = state.paneTitle?.trim().slice(0, 256) || null;
7444
7696
  const currentCommand = state.currentCommand?.trim().slice(0, 256) || null;
7445
- const commandLine = state.commandLine?.trim().slice(0, 256) || null;
7697
+ const fallbackShellCommand = path.basename(state.fallbackShell ?? "").replace(/^-/, "").replace(/\p{Cc}/gu, "").trim().slice(0, 256);
7698
+ if (fallbackShellCommand) {
7699
+ entry.launchCommandLine = null;
7700
+ entry.interactiveShellCommand = fallbackShellCommand;
7701
+ }
7702
+ const commandLine = state.commandLine?.trim().slice(0, 256) || entry.launchCommandLine;
7446
7703
  const previousCommand = entry.currentCommand;
7447
7704
  const previousCommandLine = entry.commandLine;
7448
7705
  const paneTitleChanged = paneTitle !== entry.paneTitle;
@@ -7462,12 +7719,8 @@ var TerminalMetadataManager = class {
7462
7719
  const commandToken = commandLine?.match(/^(?:exec\s+|command\s+)?(?:"([^"]+)"|'([^']+)'|(\S+))/);
7463
7720
  const commandExecutable = commandToken ? commandToken[1] || commandToken[2] || commandToken[3] || null : null;
7464
7721
  const observedProgram = PROGRAM_COMMANDS.get(path.basename(commandExecutable ?? "").replace(/^-/, "")) ?? PROGRAM_COMMANDS.get(path.basename(currentCommand ?? "").replace(/^-/, "")) ?? null;
7465
- this.update(entry, { program: observedProgram ?? (!entry.shellCommand ? entry.launchProgram : null) });
7466
- if (!entry.shellCommand) {
7467
- this.update(entry, { title: paneTitle ?? commandLine ?? currentCommand });
7468
- return;
7469
- }
7470
- if (currentCommand === entry.shellCommand) {
7722
+ this.update(entry, { program: observedProgram ?? (!entry.interactiveShellCommand ? entry.launchProgram : null) });
7723
+ if (entry.interactiveShellCommand && currentCommand === entry.interactiveShellCommand) {
7471
7724
  const applicationTitleWasActive = entry.applicationTitleActive;
7472
7725
  const freshShellTitle = observedTitlePending || previousCommand !== null && paneTitleChanged;
7473
7726
  entry.applicationTitleActive = false;
@@ -7481,11 +7734,15 @@ var TerminalMetadataManager = class {
7481
7734
  }
7482
7735
  if (commandLine) {
7483
7736
  if (observedTitlePending) entry.applicationTitleActive = paneTitle !== null && paneTitle !== commandLine;
7484
- else if (commandLineChanged) entry.applicationTitleActive = previousCommand === null && paneTitle !== null && paneTitle !== commandLine && paneTitle !== entry.shellTitle;
7737
+ else if (commandLineChanged) entry.applicationTitleActive = entry.interactiveShellCommand !== null && previousCommand === null && paneTitle !== null && paneTitle !== commandLine && paneTitle !== entry.shellTitle;
7485
7738
  else if (paneTitleChanged && paneTitle !== commandLine) entry.applicationTitleActive = true;
7486
7739
  this.update(entry, { title: entry.applicationTitleActive ? paneTitle ?? entry.title ?? commandLine : commandLine });
7487
7740
  return;
7488
7741
  }
7742
+ if (!entry.interactiveShellCommand) {
7743
+ this.update(entry, { title: paneTitle ?? currentCommand });
7744
+ return;
7745
+ }
7489
7746
  if (observedTitlePending) {
7490
7747
  entry.applicationTitleActive = true;
7491
7748
  this.update(entry, { title: entry.title ?? paneTitle ?? currentCommand });
@@ -7514,7 +7771,7 @@ var TerminalMetadataManager = class {
7514
7771
  }
7515
7772
  updateForegroundProcess(entry, currentCommand) {
7516
7773
  const command = currentCommand?.trim().slice(0, 256) || null;
7517
- this.update(entry, { hasForegroundProcess: entry.status !== "running" ? false : command === null ? null : command !== entry.shellCommand });
7774
+ this.update(entry, { hasForegroundProcess: entry.status !== "running" ? false : command === null ? null : command !== entry.interactiveShellCommand });
7518
7775
  }
7519
7776
  persistShellTitle(entry, runtimeGeneration) {
7520
7777
  return Effect.gen(this, function* () {
@@ -7638,7 +7895,7 @@ function queryInput(schema) {
7638
7895
  if (!result.success) throw new DomainError("VALIDATION_ERROR", "Request validation failed", 400, z.flattenError(result.error));
7639
7896
  });
7640
7897
  }
7641
- function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7898
+ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata, webDist }) {
7642
7899
  const app = new Hono();
7643
7900
  app.use("/api/*", requestId({
7644
7901
  limitLength: 128,
@@ -7688,7 +7945,13 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7688
7945
  installationMethod: config.installationMethod ?? "development",
7689
7946
  daemonLifecycle: config.daemonLifecycle,
7690
7947
  url: config.apiUrl
7691
- })).get("/api/terminal-presets", async (context) => context.json({ presets: await service.listTerminalPresets() })).get("/api/terminal-preset-definitions", queryInput(terminalPresetDefinitionsQuerySchema), async (context) => context.json(await service.listTerminalPresetDefinitions(context.req.valid("query")))).post("/api/terminal-presets", jsonInput(createTerminalPresetSchema), async (context) => {
7948
+ })).get("/api/update", async (context) => {
7949
+ context.header("Cache-Control", "no-store");
7950
+ return context.json(await applicationUpdate.status());
7951
+ }).post("/api/update", async (context) => {
7952
+ context.header("Cache-Control", "no-store");
7953
+ return context.json(await applicationUpdate.start(), 202);
7954
+ }).get("/api/terminal-presets", async (context) => context.json({ presets: await service.listTerminalPresets() })).get("/api/terminal-preset-definitions", queryInput(terminalPresetDefinitionsQuerySchema), async (context) => context.json(await service.listTerminalPresetDefinitions(context.req.valid("query")))).post("/api/terminal-presets", jsonInput(createTerminalPresetSchema), async (context) => {
7692
7955
  const body = context.req.valid("json");
7693
7956
  return context.json({ preset: await service.createTerminalPreset(body) }, 201);
7694
7957
  }).patch("/api/terminal-presets/:presetId", jsonInput(updateTerminalPresetSchema), async (context) => {
@@ -7720,6 +7983,9 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7720
7983
  }).post("/api/projects/:projectId/open", async (context) => context.json({ project: await service.openProject(context.req.param("projectId")) })).post("/api/projects/:projectId/close", async (context) => {
7721
7984
  await service.closeProject(context.req.param("projectId"));
7722
7985
  return context.json({ ok: true });
7986
+ }).delete("/api/projects/:projectId/recent", async (context) => {
7987
+ await service.dismissRecentProject(context.req.param("projectId"));
7988
+ return context.json({ ok: true });
7723
7989
  }).get("/api/projects/:projectId", async (context) => context.json({ project: await service.getProjectSnapshot(context.req.param("projectId")) })).patch("/api/projects/:projectId", jsonInput(updateProjectSchema), async (context) => {
7724
7990
  const body = context.req.valid("json");
7725
7991
  const projectId = context.req.param("projectId");
@@ -7746,6 +8012,9 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7746
8012
  const worktreeId = context.req.param("worktreeId");
7747
8013
  await service.refreshPr(worktreeId, false);
7748
8014
  return context.json({ worktree: await service.getWorktreeSnapshot(worktreeId) });
8015
+ }).post("/api/worktrees/:worktreeId/open", jsonInput(requestWorkspaceOpenSchema), async (context) => {
8016
+ await service.requestWorkspaceOpen(context.req.param("worktreeId"), context.req.valid("json").sourceTerminalId);
8017
+ return context.json({ ok: true });
7749
8018
  }).get("/api/worktrees/:worktreeId/web-panel-definitions", async (context) => context.json({ definitions: await service.listWebPanelDefinitions(context.req.param("worktreeId")) })).post("/api/worktrees/:worktreeId/panels", jsonInput(createWebPanelSchema), async (context) => {
7750
8019
  const body = context.req.valid("json");
7751
8020
  return context.json({ panel: await service.createWebPanel(context.req.param("worktreeId"), body.definitionId, {
@@ -7827,6 +8096,7 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7827
8096
  if (body.initialSize) options.initialSize = body.initialSize;
7828
8097
  if (body.cwd) options.cwd = body.cwd;
7829
8098
  if (body.env) options.env = body.env;
8099
+ if (body.shellCommand) options.shellCommand = body.shellCommand;
7830
8100
  const terminal = await service.createTerminal(context.req.param("worktreeId"), body.name, body.argv, Object.keys(options).length > 0 ? options : void 0);
7831
8101
  return context.json({ terminal }, 201);
7832
8102
  }).get("/api/worktrees/:worktreeId/remove-preview", async (context) => context.json({ preview: await service.removePreview(context.req.param("worktreeId")) })).post("/api/worktrees/:worktreeId/remove", jsonInput(removeWorktreeSchema), async (context) => {
@@ -7947,6 +8217,247 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7947
8217
  return routes;
7948
8218
  }
7949
8219
  //#endregion
8220
+ //#region src/server/application-update.ts
8221
+ const POLL_INTERVAL_MS = 10 * 6e4;
8222
+ const POLL_JITTER_MS = 6e4;
8223
+ const updateResultSchema = z.looseObject({
8224
+ schemaVersion: z.literal(1),
8225
+ operationId: z.string().uuid(),
8226
+ status: z.enum(["current", "updated"]),
8227
+ phase: z.literal("complete"),
8228
+ fromVersion: z.string(),
8229
+ toVersion: z.string()
8230
+ });
8231
+ const updateErrorSchema = z.looseObject({ error: z.looseObject({
8232
+ code: z.string(),
8233
+ message: z.string(),
8234
+ details: z.looseObject({
8235
+ operationId: z.string().optional(),
8236
+ recovery: z.string().optional()
8237
+ }).optional()
8238
+ }) });
8239
+ async function readValidatedJson(filePath, schema) {
8240
+ return fs.readFile(filePath, "utf8").then((value) => schema.safeParse(JSON.parse(value))).then((result) => result.success ? result.data : null).catch(() => null);
8241
+ }
8242
+ async function fileExists(filePath) {
8243
+ return fs.access(filePath).then(() => true).catch(() => false);
8244
+ }
8245
+ function createApplicationUpdateManager(config, dependencies = {}) {
8246
+ const environment = dependencies.environment ?? process.env;
8247
+ const resolveRelease = dependencies.resolveRelease ?? resolveLatestTreeportRelease;
8248
+ const inspectInstallation = dependencies.inspectInstallation ?? inspectLocalUpdateInstallation;
8249
+ const readProgress = dependencies.readProgress ?? readLocalUpdateProgress;
8250
+ const readServiceStatus = dependencies.readServiceStatus ?? serviceStatus;
8251
+ const spawnProcess = dependencies.spawnProcess ?? spawn;
8252
+ const random = dependencies.random ?? Math.random;
8253
+ const pollIntervalMs = dependencies.pollIntervalMs ?? POLL_INTERVAL_MS;
8254
+ const pollJitterMs = dependencies.pollJitterMs ?? POLL_JITTER_MS;
8255
+ const updateDirectory = path.join(config.dataDir, "updates");
8256
+ const resultPath = path.join(updateDirectory, "web-update-result.json");
8257
+ const errorPath = path.join(updateDirectory, "web-update-error.json");
8258
+ const currentVersion = config.appVersion ?? "development";
8259
+ const staticBlockedReason = config.installationMethod !== "npm" ? "This Treeport installation cannot update itself. Update it with its installation method." : config.daemonLifecycle === "external" ? "This Treeport daemon is managed by another process. Update it on the host." : !isCanonicalTreeportVersion(currentVersion) ? "Development and prerelease Treeport versions do not update from the stable npm channel." : null;
8260
+ let latestVersion = null;
8261
+ let checkedAt = null;
8262
+ let capabilityChecked = staticBlockedReason !== null;
8263
+ let canUpdate = false;
8264
+ let blockedReason = staticBlockedReason;
8265
+ let installation = null;
8266
+ let checking = false;
8267
+ let checkPromise = null;
8268
+ let pollingStarted = false;
8269
+ let disposed = false;
8270
+ let pollTimer = null;
8271
+ let launching = false;
8272
+ let launchError = null;
8273
+ const refreshCapability = async () => {
8274
+ if (staticBlockedReason) {
8275
+ capabilityChecked = true;
8276
+ canUpdate = false;
8277
+ blockedReason = staticBlockedReason;
8278
+ installation = null;
8279
+ return;
8280
+ }
8281
+ const [installationResult, serviceResult] = await Promise.all([inspectInstallation(environment).then((value) => ({
8282
+ value,
8283
+ error: null
8284
+ }), (cause) => ({
8285
+ value: null,
8286
+ error: cause
8287
+ })), config.daemonLifecycle === "service" ? readServiceStatus().then((value) => ({
8288
+ value,
8289
+ error: null
8290
+ }), (cause) => ({
8291
+ value: null,
8292
+ error: cause
8293
+ })) : Promise.resolve({
8294
+ value: null,
8295
+ error: null
8296
+ })]);
8297
+ capabilityChecked = true;
8298
+ installation = installationResult.value;
8299
+ if (!installation) {
8300
+ canUpdate = false;
8301
+ blockedReason = installationResult.error instanceof Error ? installationResult.error.message : "Treeport could not verify this npm installation.";
8302
+ return;
8303
+ }
8304
+ if (serviceResult.error) {
8305
+ canUpdate = false;
8306
+ blockedReason = "Treeport could not verify the service update lifecycle.";
8307
+ return;
8308
+ }
8309
+ if (serviceResult.value?.mode === "headless" && (serviceResult.value.active || serviceResult.value.daemon?.running)) {
8310
+ canUpdate = false;
8311
+ blockedReason = "Stop the advanced headless service with its administrator action before you update Treeport.";
8312
+ return;
8313
+ }
8314
+ canUpdate = true;
8315
+ blockedReason = null;
8316
+ };
8317
+ const status = async () => {
8318
+ const [progress, result, updateError, resultFileExists, errorFileExists] = await Promise.all([
8319
+ readProgress(config.dataDir),
8320
+ readValidatedJson(resultPath, updateResultSchema),
8321
+ readValidatedJson(errorPath, updateErrorSchema),
8322
+ fileExists(resultPath),
8323
+ fileExists(errorPath)
8324
+ ]);
8325
+ if (progress.active) launching = false;
8326
+ const resultMatchesOperation = !result || !progress.operationId || result.operationId === progress.operationId;
8327
+ const errorOperationId = updateError?.error.details?.operationId ?? null;
8328
+ const errorMatchesOperation = !updateError || !errorOperationId || !progress.operationId || errorOperationId === progress.operationId;
8329
+ const available = Boolean(latestVersion && isCanonicalTreeportVersion(currentVersion) && compareTreeportVersions(latestVersion, currentVersion) > 0);
8330
+ const recoveryError = progress.recoveryAction;
8331
+ const cliError = errorMatchesOperation ? updateError?.error : null;
8332
+ const interrupted = Boolean(!progress.active && progress.phase && progress.phase !== "complete" && (resultFileExists || errorFileExists) && !result && !updateError);
8333
+ const error = launchError ?? (cliError ? [cliError.message, cliError.details?.recovery].filter((value, index, values) => Boolean(value && values.indexOf(value) === index)).join(" ") : recoveryError) ?? (interrupted ? "The update process stopped before it returned a result. Retry the update or run `treeport update` on the host." : null);
8334
+ const inactiveFailedPhase = interrupted || progress.phase === "rollback" || progress.phase === "recovery_required";
8335
+ const phase = progress.active ? progress.phase ?? "starting" : launching ? "starting" : launchError || cliError || inactiveFailedPhase ? progress.phase === "recovery_required" ? "recovery_required" : "failed" : result && resultMatchesOperation ? "complete" : progress.phase === "complete" ? "complete" : checking ? "checking" : "idle";
8336
+ return {
8337
+ currentVersion,
8338
+ latestVersion,
8339
+ updateAvailable: available,
8340
+ checkedAt,
8341
+ canUpdate: canUpdate && !progress.active && !launching,
8342
+ blockedReason: capabilityChecked ? blockedReason : "Treeport is checking whether this installation can update itself.",
8343
+ phase,
8344
+ operationId: progress.operationId ?? result?.operationId ?? null,
8345
+ targetVersion: progress.toVersion ?? result?.toVersion ?? latestVersion ?? null,
8346
+ error: error || null
8347
+ };
8348
+ };
8349
+ const check = async () => {
8350
+ if (staticBlockedReason || disposed) return;
8351
+ if ((await readProgress(config.dataDir)).active) return;
8352
+ if (checkPromise) return checkPromise;
8353
+ checking = true;
8354
+ checkPromise = Promise.all([resolveRelease(environment).then((value) => ({
8355
+ value,
8356
+ error: null
8357
+ }), (cause) => ({
8358
+ value: null,
8359
+ error: cause
8360
+ })), refreshCapability()]).then(([releaseResult]) => {
8361
+ if (releaseResult.value) {
8362
+ latestVersion = releaseResult.value.version;
8363
+ checkedAt = (/* @__PURE__ */ new Date()).toISOString();
8364
+ } else console.warn("[Treeport] Application update check failed:", releaseResult.error instanceof Error ? releaseResult.error.message : String(releaseResult.error));
8365
+ });
8366
+ await checkPromise.finally(() => {
8367
+ checking = false;
8368
+ checkPromise = null;
8369
+ });
8370
+ };
8371
+ const scheduleNextCheck = () => {
8372
+ if (disposed || !pollingStarted || staticBlockedReason) return;
8373
+ const delay = pollIntervalMs + Math.floor(random() * pollJitterMs);
8374
+ pollTimer = setTimeout(() => {
8375
+ pollTimer = null;
8376
+ check().finally(scheduleNextCheck);
8377
+ }, delay);
8378
+ pollTimer.unref?.();
8379
+ };
8380
+ return {
8381
+ status,
8382
+ check,
8383
+ beginPolling() {
8384
+ if (pollingStarted || disposed || staticBlockedReason) return;
8385
+ pollingStarted = true;
8386
+ check().finally(scheduleNextCheck);
8387
+ },
8388
+ async start() {
8389
+ const currentStatus = await status();
8390
+ if (!currentStatus.updateAvailable) throw new DomainError("APPLICATION_UPDATE_NOT_AVAILABLE", "A newer stable Treeport release is not available.", 409);
8391
+ if (launching || [
8392
+ "starting",
8393
+ "inspect",
8394
+ "resolve",
8395
+ "stage",
8396
+ "verify",
8397
+ "stop",
8398
+ "activate",
8399
+ "restart",
8400
+ "health_check",
8401
+ "rollback"
8402
+ ].includes(currentStatus.phase)) throw new DomainError("APPLICATION_UPDATE_IN_PROGRESS", "Another Treeport update is already running.", 409);
8403
+ launching = true;
8404
+ launchError = null;
8405
+ const launchResult = await (async () => {
8406
+ await refreshCapability();
8407
+ if (!canUpdate || !installation) throw new DomainError("APPLICATION_UPDATE_BLOCKED", blockedReason ?? "This Treeport installation cannot update itself.", 409);
8408
+ const entrypoint = installation.entrypoint;
8409
+ await fs.mkdir(updateDirectory, {
8410
+ recursive: true,
8411
+ mode: 448
8412
+ });
8413
+ await Promise.all([fs.rm(resultPath, { force: true }), fs.rm(errorPath, { force: true })]);
8414
+ const [resultFile, errorFile] = await Promise.all([fs.open(resultPath, "wx", 384), fs.open(errorPath, "wx", 384)]);
8415
+ const spawnResult = await new Promise((resolve, reject) => {
8416
+ const child = spawnProcess(entrypoint, ["update", "--json"], {
8417
+ env: environment,
8418
+ detached: true,
8419
+ shell: false,
8420
+ stdio: [
8421
+ "ignore",
8422
+ resultFile.fd,
8423
+ errorFile.fd
8424
+ ]
8425
+ });
8426
+ child.once("spawn", () => resolve(child));
8427
+ child.once("error", reject);
8428
+ child.once("exit", () => {
8429
+ launching = false;
8430
+ });
8431
+ }).then((child) => ({
8432
+ child,
8433
+ error: null
8434
+ }), (cause) => ({
8435
+ child: null,
8436
+ error: cause
8437
+ }));
8438
+ await Promise.all([resultFile.close(), errorFile.close()]);
8439
+ if (!spawnResult.child) throw spawnResult.error instanceof Error ? spawnResult.error : /* @__PURE__ */ new Error("Treeport could not start the update process.");
8440
+ spawnResult.child.unref();
8441
+ })().then(() => ({ error: null }), (cause) => ({ error: cause }));
8442
+ if (launchResult.error) {
8443
+ launching = false;
8444
+ if (launchResult.error instanceof DomainError) throw launchResult.error;
8445
+ launchError = launchResult.error instanceof Error ? launchResult.error.message : "Treeport could not start the update process.";
8446
+ throw new DomainError("APPLICATION_UPDATE_START_FAILED", "Treeport could not start the update process.", 500);
8447
+ }
8448
+ return status();
8449
+ },
8450
+ dispose() {
8451
+ disposed = true;
8452
+ pollingStarted = false;
8453
+ if (pollTimer) {
8454
+ clearTimeout(pollTimer);
8455
+ pollTimer = null;
8456
+ }
8457
+ }
8458
+ };
8459
+ }
8460
+ //#endregion
7950
8461
  //#region src/server/daemon-ownership.ts
7951
8462
  function processExists(pid) {
7952
8463
  try {
@@ -8361,7 +8872,7 @@ var TerminalAttachmentManager = class {
8361
8872
  catch: (cause) => new AttachmentInitializationError(phase, cause)
8362
8873
  });
8363
8874
  return Effect.gen(this, function* () {
8364
- const terminal = yield* promisePhase("refresh_terminal", () => this.service.refreshTerminalStatus(connection.terminalId));
8875
+ const terminal = yield* promisePhase("refresh_terminal", () => this.service.refreshTerminalStatus(connection.terminalId, false));
8365
8876
  if (!isInitializing()) return;
8366
8877
  if (terminal.status === "missing") return yield* Effect.fail(new AttachmentInitializationError("refresh_terminal", /* @__PURE__ */ new Error("The tmux session for this terminal is missing")));
8367
8878
  const worktree = yield* promisePhase("resolve_worktree", () => this.service.getWorktree(terminal.worktreeId));
@@ -8370,12 +8881,12 @@ var TerminalAttachmentManager = class {
8370
8881
  const initialDimensions = yield* Effect.tryPromise({
8371
8882
  try: () => this.enqueueTerminal(connection.terminalId, async () => {
8372
8883
  if (!isInitializing()) return null;
8884
+ const current = this.dimensions.get(connection.terminalId);
8885
+ if (current) return current;
8373
8886
  await this.tmux.useManualWindowSize(worktree.tmuxSocketName, terminal.tmuxSessionName).catch((cause) => {
8374
8887
  throw new AttachmentInitializationError("configure_window_size", cause);
8375
8888
  });
8376
8889
  if (!isInitializing()) return null;
8377
- const current = this.dimensions.get(connection.terminalId);
8378
- if (current) return current;
8379
8890
  const sessionSize = await this.tmux.sessionSize(worktree.tmuxSocketName, terminal.tmuxSessionName).catch((cause) => {
8380
8891
  throw new AttachmentInitializationError("read_session_size", cause);
8381
8892
  });
@@ -8867,107 +9378,126 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
8867
9378
  }
8868
9379
  //#endregion
8869
9380
  //#region src/server/index.ts
8870
- const config = loadConfig();
8871
- const ownership = await acquireDaemonOwnership(config);
8872
- const prerequisites = await checkRuntimePrerequisites(config);
8873
- const runner = new SpawnCommandRunner();
8874
- const database = await openDatabase(config.databasePath, { backupDirectory: path.join(config.dataDir, "database-backups") });
8875
- const git = new GitAdapter(runner, config.gitPath);
8876
- const launcherPath = fileURLToPath(new URL("./core/launcher.js", import.meta.url));
8877
- const tmux = new TmuxAdapter(runner, config.runtimeDir, config.tmuxPath, launcherPath);
8878
- const service = new TreeportService({
8879
- config,
8880
- database,
8881
- runner,
8882
- git,
8883
- tmux,
8884
- gh: new GhAdapter(runner, config.ghPath)
8885
- });
8886
- await service.initialize();
8887
- const terminalMetadata = new TerminalMetadataManager(service, tmux, config.tmuxPath);
8888
- await terminalMetadata.initialize();
8889
- const honoListener = getRequestListener(createApp({
8890
- service,
8891
- config,
8892
- tmux,
8893
- terminalMetadata
8894
- }).fetch);
8895
- let vite = null;
8896
- const server = createServer((request, response) => {
8897
- const security = authorizeRequest(request);
8898
- if (!security.allowed) {
8899
- rejectHttpRequest(request, response, security);
8900
- return;
8901
- }
8902
- service.handleWebPanelDevelopmentRequest(request, response, () => {
8903
- if (vite && !request.url?.startsWith("/api")) {
8904
- vite.middlewares(request, response, () => {
9381
+ async function main() {
9382
+ const config = loadConfig();
9383
+ const updateStartup = await createUpdateStartupReporter(config);
9384
+ try {
9385
+ const ownership = await acquireDaemonOwnership(config);
9386
+ const prerequisites = await checkRuntimePrerequisites(config);
9387
+ const runner = new SpawnCommandRunner();
9388
+ await updateStartup.databaseOpening();
9389
+ const database = await openDatabase(config.databasePath, { backupDirectory: path.join(config.dataDir, "database-backups") });
9390
+ await updateStartup.databaseOpened({
9391
+ migrationState: database.migrationState,
9392
+ snapshotPaths: database.migrationSnapshotPaths
9393
+ });
9394
+ const git = new GitAdapter(runner, config.gitPath);
9395
+ const launcherPath = fileURLToPath(new URL("./core/launcher.js", import.meta.url));
9396
+ const tmux = new TmuxAdapter(runner, config.runtimeDir, config.tmuxPath, launcherPath);
9397
+ const service = new TreeportService({
9398
+ config,
9399
+ database,
9400
+ runner,
9401
+ git,
9402
+ tmux,
9403
+ gh: new GhAdapter(runner, config.ghPath)
9404
+ });
9405
+ await service.initialize();
9406
+ const terminalMetadata = new TerminalMetadataManager(service, tmux, config.tmuxPath);
9407
+ await terminalMetadata.initialize();
9408
+ const applicationUpdate = createApplicationUpdateManager(config);
9409
+ const honoListener = getRequestListener(createApp({
9410
+ service,
9411
+ config,
9412
+ tmux,
9413
+ applicationUpdate,
9414
+ terminalMetadata
9415
+ }).fetch);
9416
+ let vite = null;
9417
+ const server = createServer((request, response) => {
9418
+ const security = authorizeRequest(request);
9419
+ if (!security.allowed) {
9420
+ rejectHttpRequest(request, response, security);
9421
+ return;
9422
+ }
9423
+ service.handleWebPanelDevelopmentRequest(request, response, () => {
9424
+ if (vite && !request.url?.startsWith("/api")) {
9425
+ vite.middlewares(request, response, () => {
9426
+ honoListener(request, response);
9427
+ });
9428
+ return;
9429
+ }
8905
9430
  honoListener(request, response);
8906
9431
  });
8907
- return;
8908
- }
8909
- honoListener(request, response);
8910
- });
8911
- });
8912
- server.on("upgrade", (request, socket) => {
8913
- const security = authorizeRequest(request, { socketUpgrade: true });
8914
- if (security.allowed) return;
8915
- const statusText = security.status === 400 ? "Bad Request" : security.status === 403 ? "Forbidden" : "Unauthorized";
8916
- socket.write(`HTTP/1.1 ${security.status} ${statusText}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n`);
8917
- socket.destroy();
8918
- });
8919
- if (config.webDevelopment) {
8920
- const { createServer: createViteServer } = await import("vite");
8921
- vite = await createViteServer({
8922
- configFile: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../vite.config.ts"),
8923
- appType: "spa",
8924
- server: {
8925
- middlewareMode: true,
8926
- hmr: { server }
9432
+ });
9433
+ server.on("upgrade", (request, socket) => {
9434
+ const security = authorizeRequest(request, { socketUpgrade: true });
9435
+ if (security.allowed) return;
9436
+ const statusText = security.status === 400 ? "Bad Request" : security.status === 403 ? "Forbidden" : "Unauthorized";
9437
+ socket.write(`HTTP/1.1 ${security.status} ${statusText}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n`);
9438
+ socket.destroy();
9439
+ });
9440
+ if (config.webDevelopment) {
9441
+ const { createServer: createViteServer } = await import("vite");
9442
+ vite = await createViteServer({
9443
+ configFile: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../vite.config.ts"),
9444
+ appType: "spa",
9445
+ server: {
9446
+ middlewareMode: true,
9447
+ hmr: { server }
9448
+ }
9449
+ });
8927
9450
  }
8928
- });
8929
- }
8930
- service.attachHttpServer(server);
8931
- const { io, attachments } = createSocketServer(server, {
8932
- service,
8933
- config,
8934
- tmux,
8935
- terminalMetadata
8936
- });
8937
- await new Promise((resolve, reject) => {
8938
- server.once("error", reject);
8939
- server.listen(config.port, config.host, () => {
8940
- server.off("error", reject);
8941
- resolve();
8942
- });
8943
- });
8944
- await ownership.publish();
8945
- console.log(`Treeport ${config.appVersion} listening on ${config.apiUrl}`);
8946
- console.log(`database: ${config.databasePath}`);
8947
- console.log(`git: ${prerequisites.gitVersion}`);
8948
- console.log(`tmux: ${prerequisites.tmuxVersion}`);
8949
- let shuttingDown = false;
8950
- function shutdown() {
8951
- if (shuttingDown) return;
8952
- shuttingDown = true;
8953
- attachments.dispose();
8954
- terminalMetadata.dispose();
8955
- const viteClosed = vite?.close();
8956
- io.close(() => {
8957
- Promise.all([
8958
- service.drainMutations(),
8959
- terminalMetadata.drain(),
8960
- viteClosed
8961
- ]).then(async () => {
8962
- await service.disposeWebPanelRuntime();
8963
- database.close();
8964
- await ownership.release();
8965
- process.exit(0);
9451
+ service.attachHttpServer(server);
9452
+ const { io, attachments } = createSocketServer(server, {
9453
+ service,
9454
+ config,
9455
+ tmux,
9456
+ terminalMetadata
8966
9457
  });
8967
- });
8968
- setTimeout(() => process.exit(1), 5e3).unref();
9458
+ await new Promise((resolve, reject) => {
9459
+ server.once("error", reject);
9460
+ server.listen(config.port, config.host, () => {
9461
+ server.off("error", reject);
9462
+ resolve();
9463
+ });
9464
+ });
9465
+ await ownership.publish();
9466
+ await updateStartup.ready();
9467
+ applicationUpdate.beginPolling();
9468
+ console.log(`Treeport ${config.appVersion} listening on ${config.apiUrl}`);
9469
+ console.log(`database: ${config.databasePath}`);
9470
+ console.log(`git: ${prerequisites.gitVersion}`);
9471
+ console.log(`tmux: ${prerequisites.tmuxVersion}`);
9472
+ let shuttingDown = false;
9473
+ function shutdown() {
9474
+ if (shuttingDown) return;
9475
+ shuttingDown = true;
9476
+ applicationUpdate.dispose();
9477
+ attachments.dispose();
9478
+ terminalMetadata.dispose();
9479
+ const viteClosed = vite?.close();
9480
+ io.close(() => {
9481
+ Promise.all([
9482
+ service.drainMutations(),
9483
+ terminalMetadata.drain(),
9484
+ viteClosed
9485
+ ]).then(async () => {
9486
+ await service.disposeWebPanelRuntime();
9487
+ database.close();
9488
+ await ownership.release();
9489
+ process.exit(0);
9490
+ });
9491
+ });
9492
+ setTimeout(() => process.exit(1), 5e3).unref();
9493
+ }
9494
+ process.once("SIGINT", shutdown);
9495
+ process.once("SIGTERM", shutdown);
9496
+ } catch (error) {
9497
+ await updateStartup.failed(error instanceof Error ? error : new Error(String(error)));
9498
+ throw error;
9499
+ }
8969
9500
  }
8970
- process.once("SIGINT", shutdown);
8971
- process.once("SIGTERM", shutdown);
9501
+ await main();
8972
9502
  //#endregion
8973
9503
  export {};