@treeport/treeport 0.3.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,5 +1,5 @@
1
- import { B as TERMINAL_SELECTION_RESTORE_SEQUENCE, C as repositoryTerminalPresetsFileSchema, D as updateTerminalPresetSchema, E as updateProjectSchema, G as terminalBellAcknowledgementSchema, H as TERMINAL_SELECTION_STOP_SEQUENCE, J as terminalLegacyTakeControlSchema, K as terminalBinarySchema, L as TERMINAL_OUTPUT_STALL_TIMEOUT_MS, M as TERMINAL_CONTROLLER_GRACE_MS, N as TERMINAL_MAX_CLIENT_MESSAGE_BYTES, O as updateTerminalSchema, Q as terminalTakeControlSchema, R as TERMINAL_SCROLL_EXIT_SEQUENCE, S as repositoryTerminalPresetSchema, T as terminalCaptureQuerySchema, U as parseTerminalAuth, V as TERMINAL_SELECTION_START_SEQUENCE, W as parseTerminalProgress, X as terminalResizeSchema, Y as terminalOutputAckSchema, Z as terminalSizeSchema, _ 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, j as SOCKET_IO_PATH, l as createWebPanelSchema, m as openWebPanelSchema, n as parseDurationMs, o as browseDirectoryQuerySchema, p as getWebPanelStorageSchema, q as terminalInputSchema, s as createTerminalPresetSchema, t as assertLoopbackHost, u as createWorktreeSchema, v as packageRemoveSchema, w as setWebPanelStorageSchema, x as removeWorktreeSchema, y as packageUpdateSchema, z as TERMINAL_SELECTION_CLEAR_SEQUENCE } from "../../loopback-Dyv_owrb.js";
2
- import { n as prepareShellIntegration } from "../../shell-integration-7aBNr-p0.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
+ 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";
5
5
  import { z } from "zod";
@@ -110,14 +110,20 @@ var ExternalCommandError = class extends Data.TaggedError("ExternalCommandError"
110
110
  });
111
111
  }
112
112
  };
113
- function errorMessage$1(error) {
114
- return error instanceof Error ? error.message : String(error);
113
+ function errorMessage$1(cause) {
114
+ return cause instanceof Error ? cause.message : String(cause);
115
+ }
116
+ function signalProcessGroup(processGroupId, signal) {
117
+ try {
118
+ process.kill(-processGroupId, signal);
119
+ } catch (cause) {
120
+ if (cause.code !== "ESRCH") throw cause;
121
+ }
115
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,
@@ -243,21 +251,19 @@ function runCommandEffect(request) {
243
251
  removeUseListeners();
244
252
  });
245
253
  });
246
- if (request.timeoutMs === void 0 || request.timeoutMs <= 0) return awaitResult;
247
- return Effect.raceFirst(awaitResult, Effect.sleep(Duration.millis(request.timeoutMs)).pipe(Effect.flatMap(() => Effect.sync(() => {
248
- resource.terminalError ??= new TimeoutCommandError(request, request.timeoutMs);
254
+ const timeoutMs = request.timeoutMs;
255
+ if (timeoutMs === void 0 || timeoutMs <= 0) return awaitResult;
256
+ return Effect.raceFirst(awaitResult, Effect.sleep(Duration.millis(timeoutMs)).pipe(Effect.flatMap(() => Effect.sync(() => {
257
+ resource.terminalError ??= new TimeoutCommandError(request, timeoutMs);
249
258
  return resource.terminalError;
250
259
  })), Effect.flatMap(Effect.fail)));
251
- }, (resource) => Deferred.isDone(resource.exit).pipe(Effect.flatMap((alreadyExited) => {
252
- 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;
253
262
  return Effect.sync(() => {
254
- resource.child.kill("SIGTERM");
255
- }).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) => {
256
- if (exited) return Effect.void;
257
- return Effect.sync(() => {
258
- resource.child.kill("SIGKILL");
259
- }).pipe(Effect.zipRight(Deferred.await(resource.exit)));
260
- }));
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)))));
261
267
  }), Effect.ensuring(Effect.sync(() => {
262
268
  resource.child.removeListener("exit", resource.onExit);
263
269
  resource.child.removeListener("error", resource.onProcessError);
@@ -311,7 +317,7 @@ function defaultCacheDir(env, platform = process.platform) {
311
317
  }
312
318
  function defaultRuntimeDir(env) {
313
319
  if (env.XDG_RUNTIME_DIR) return path.join(expandHome(env.XDG_RUNTIME_DIR), "treeport");
314
- return path.join(os.tmpdir(), `treeport-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
320
+ return path.join(os.tmpdir(), `treeport-${process.getuid?.() ?? "user"}`);
315
321
  }
316
322
  function loadConfig(env = process.env) {
317
323
  const host = env.TREEPORT_HOST?.trim() || env.HOST?.trim() || "127.0.0.1";
@@ -323,7 +329,7 @@ function loadConfig(env = process.env) {
323
329
  const runtimeDir = path.resolve(expandHome(env.TREEPORT_RUNTIME_DIR?.trim() || defaultRuntimeDir(env)));
324
330
  const daemonLifecycle = env.TREEPORT_DAEMON_LIFECYCLE?.trim() || "treeport";
325
331
  if (daemonLifecycle !== "treeport" && daemonLifecycle !== "service" && daemonLifecycle !== "external") throw new Error("TREEPORT_DAEMON_LIFECYCLE must be treeport, service, or external");
326
- return {
332
+ const config = {
327
333
  host,
328
334
  port: portValue,
329
335
  dataDir,
@@ -339,9 +345,10 @@ function loadConfig(env = process.env) {
339
345
  appVersion: env.TREEPORT_APP_VERSION?.trim() || "development",
340
346
  instanceId: env.TREEPORT_INSTANCE_ID?.trim() || crypto.randomUUID(),
341
347
  installationMethod: env.TREEPORT_INSTALLATION_METHOD?.trim() || "development",
342
- webDevelopment: env.TREEPORT_WEB_DEVELOPMENT?.trim() === "1",
343
- ...env.TREEPORT_WEB_DIST?.trim() ? { webDist: env.TREEPORT_WEB_DIST.trim() } : {}
348
+ webDevelopment: env.TREEPORT_WEB_DEVELOPMENT?.trim() === "1"
344
349
  };
350
+ if (env.TREEPORT_WEB_DIST?.trim()) config.webDist = env.TREEPORT_WEB_DIST.trim();
351
+ return config;
345
352
  }
346
353
  //#endregion
347
354
  //#region src/server/core/database-schema.ts
@@ -357,6 +364,7 @@ var database_schema_exports = /* @__PURE__ */ __exportAll({
357
364
  const projects = sqliteTable("projects", {
358
365
  id: text().primaryKey(),
359
366
  name: text().notNull(),
367
+ kind: text("project_kind", { enum: ["repository", "folder"] }).notNull().default("repository"),
360
368
  repositoryPath: text("repository_path").notNull().unique(),
361
369
  mainWorktreePath: text("main_worktree_path").notNull(),
362
370
  defaultBranch: text("default_branch").notNull(),
@@ -366,15 +374,18 @@ const projects = sqliteTable("projects", {
366
374
  repositoryInode: text("repository_inode").notNull(),
367
375
  nameIsCustom: integer("name_is_custom").notNull().default(0),
368
376
  isOpen: integer("is_open").notNull().default(1),
377
+ showInRecents: integer("show_in_recents").notNull().default(0),
369
378
  lastOpenedAt: text("last_opened_at").notNull(),
370
379
  createdAt: text("created_at").notNull(),
371
380
  updatedAt: text("updated_at").notNull()
372
381
  }, (table) => [
382
+ check("projects_kind_check", sql`${table.kind} IN ('repository','folder')`),
373
383
  check("projects_color_check", sql`${table.color} IS NULL OR ${table.color} IN ('rose','orange','amber','emerald','cyan','blue','violet','pink')`),
374
384
  check("projects_name_is_custom_check", sql`${table.nameIsCustom} IN (0,1)`),
375
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)`),
376
387
  uniqueIndex("projects_repository_identity_idx").on(table.repositoryIdentity).where(sql`${table.repositoryIdentity} IS NOT NULL`),
377
- 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)
378
389
  ]);
379
390
  const worktrees = sqliteTable("worktrees", {
380
391
  id: text().primaryKey(),
@@ -403,7 +414,7 @@ const worktrees = sqliteTable("worktrees", {
403
414
  check("worktrees_detached_check", sql`${table.detached} IN (0,1)`),
404
415
  check("worktrees_locked_check", sql`${table.locked} IN (0,1)`),
405
416
  check("worktrees_prunable_check", sql`${table.prunable} IN (0,1)`),
406
- check("worktrees_kind_check", sql`${table.kind} IN ('main','linked')`),
417
+ check("worktrees_kind_check", sql`${table.kind} IN ('main','linked','folder')`),
407
418
  index("worktrees_project_idx").on(table.projectId),
408
419
  uniqueIndex("worktrees_git_key_idx").on(table.projectId, table.gitWorktreeKey).where(sql`${table.gitWorktreeKey} IS NOT NULL`)
409
420
  ]);
@@ -463,7 +474,7 @@ async function readOptionalJsonc(filePath) {
463
474
  try {
464
475
  source = await fs.readFile(filePath, "utf8");
465
476
  } catch (error) {
466
- if (error.code === "ENOENT") return { found: false };
477
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return { found: false };
467
478
  throw error;
468
479
  }
469
480
  const errors = [];
@@ -509,12 +520,13 @@ async function assertNoSymlinkComponents(parent, candidate) {
509
520
  }
510
521
  function normalizeWorktreeName(input) {
511
522
  const name = input.normalize("NFKD").replace(/\p{Mark}+/gu, "").toLowerCase().replace(/[^\p{Letter}\p{Number}]+/gu, "-").replace(/^-+|-+$/gu, "");
512
- if (!name) throw new Error("Worktree name is required");
513
- if (name.length > 120) throw new Error("Worktree name must be 120 characters or fewer");
523
+ if (!name) throw new Error("Tree name is required");
524
+ if (name.length > 120) throw new Error("Tree name must be 120 characters or fewer");
514
525
  return name;
515
526
  }
516
527
  function inferWorktreeName(mainWorktreePath, worktreePath, kind) {
517
- if (kind === "main") return "main worktree";
528
+ if (kind === "main") return "main tree";
529
+ if (kind === "folder") return path.basename(worktreePath);
518
530
  const checkoutName = path.basename(worktreePath);
519
531
  return checkoutName === path.basename(mainWorktreePath) ? path.basename(path.dirname(worktreePath)) : checkoutName;
520
532
  }
@@ -564,58 +576,64 @@ async function prepareZedWorktreeWrapper(mainWorktreePath, wrapperPath) {
564
576
  };
565
577
  }
566
578
  const ZED_TASKS_CONFIG_PATH = path.join(".zed", "tasks.json");
579
+ z.unknown();
580
+ const zedTaskRecordSchema = z.looseObject({
581
+ command: z.unknown().optional(),
582
+ args: z.unknown().optional(),
583
+ env: z.unknown().optional(),
584
+ cwd: z.unknown().optional(),
585
+ label: z.unknown().optional(),
586
+ hooks: z.unknown().optional()
587
+ });
567
588
  function taskArray(value) {
568
- if (Array.isArray(value)) return value;
569
- if (value && typeof value === "object") {
570
- const tasks = Reflect.get(value, "tasks");
571
- if (Array.isArray(tasks)) return tasks;
572
- }
573
- return null;
589
+ const direct = z.array(z.unknown()).safeParse(value);
590
+ if (direct.success) return direct.data;
591
+ const wrapped = z.object({ tasks: z.array(z.unknown()) }).safeParse(value);
592
+ return wrapped.success ? wrapped.data.tasks : null;
574
593
  }
575
594
  function parseTask(entry, index, options) {
576
595
  const prefix = `Zed task ${index + 1}`;
577
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`${prefix} must be an object`);
578
- const command = Reflect.get(entry, "command");
579
- const argsInput = Reflect.get(entry, "args");
580
- const environmentInput = Reflect.get(entry, "env");
581
- const cwd = Reflect.get(entry, "cwd");
582
- const label = Reflect.get(entry, "label");
583
- if (typeof label !== "string" || !label.trim()) {
584
- if (options.requireLabel) throw new Error(`${prefix} is missing a label`);
585
- }
586
- if (typeof command !== "string" || !command.trim()) throw new Error(`${prefix} is missing a command`);
596
+ const parsedEntry = zedTaskRecordSchema.safeParse(entry);
597
+ if (!parsedEntry.success) throw new Error(`${prefix} must be an object`);
598
+ const { args: argsInput, command, cwd, env: environmentInput, label } = parsedEntry.data;
599
+ const parsedLabel = z.string().safeParse(label);
600
+ if ((!parsedLabel.success || !parsedLabel.data.trim()) && options.requireLabel) throw new Error(`${prefix} is missing a label`);
601
+ const parsedCommand = z.string().safeParse(command);
602
+ if (!parsedCommand.success || !parsedCommand.data.trim()) throw new Error(`${prefix} is missing a command`);
587
603
  if (argsInput !== void 0 && !Array.isArray(argsInput)) throw new Error(`${prefix} has invalid args`);
588
- const args = (argsInput ?? []).map((argument) => {
589
- if (typeof argument !== "string") throw new Error(`${prefix} has a non-string argument`);
590
- return argument;
591
- });
604
+ const parsedArgs = z.array(z.string()).safeParse(argsInput ?? []);
605
+ if (!parsedArgs.success) throw new Error(`${prefix} has a non-string argument`);
592
606
  const env = {};
593
607
  if (environmentInput !== void 0) {
594
- if (!environmentInput || typeof environmentInput !== "object" || Array.isArray(environmentInput)) throw new Error(`${prefix} has invalid env`);
595
- if (options.validateLaunchFields && Object.keys(environmentInput).length > 128) throw new Error(`${prefix} has more than 128 environment variables`);
596
- for (const [key, environmentValue] of Object.entries(environmentInput)) {
608
+ const parsedEnvironment = z.record(z.string(), z.unknown()).safeParse(environmentInput);
609
+ if (!parsedEnvironment.success) throw new Error(`${prefix} has invalid env`);
610
+ if (options.validateLaunchFields && Object.keys(parsedEnvironment.data).length > 128) throw new Error(`${prefix} has more than 128 environment variables`);
611
+ for (const [key, environmentValue] of Object.entries(parsedEnvironment.data)) {
597
612
  if (options.validateLaunchFields && (!key || key.length > 256 || key.includes("=") || key.includes("\0"))) throw new Error(`${prefix} has an invalid env key`);
598
- if (typeof environmentValue !== "string") throw new Error(`${prefix} has a non-string env value`);
599
- if (options.validateLaunchFields && (environmentValue.length > 4096 || environmentValue.includes("\0"))) throw new Error(`${prefix} has an invalid env value`);
600
- env[key] = environmentValue;
601
- }
602
- }
603
- if (cwd !== void 0 && typeof cwd !== "string") throw new Error(`${prefix} has invalid cwd`);
604
- if (options.validateLaunchFields && typeof cwd === "string" && (!cwd.trim() || cwd.length > 4096 || cwd.includes("\0"))) throw new Error(`${prefix} has invalid cwd`);
605
- return {
606
- label: typeof label === "string" && label.trim() ? label : `Task ${index + 1}`,
607
- command,
608
- args,
609
- ...typeof cwd === "string" ? { cwd } : {},
613
+ const parsedValue = z.string().safeParse(environmentValue);
614
+ if (!parsedValue.success) throw new Error(`${prefix} has a non-string env value`);
615
+ if (options.validateLaunchFields && (parsedValue.data.length > 4096 || parsedValue.data.includes("\0"))) throw new Error(`${prefix} has an invalid env value`);
616
+ env[key] = parsedValue.data;
617
+ }
618
+ }
619
+ const parsedCwd = z.string().safeParse(cwd);
620
+ if (cwd !== void 0 && !parsedCwd.success) throw new Error(`${prefix} has invalid cwd`);
621
+ if (options.validateLaunchFields && parsedCwd.success && (!parsedCwd.data.trim() || parsedCwd.data.length > 4096 || parsedCwd.data.includes("\0"))) throw new Error(`${prefix} has invalid cwd`);
622
+ const task = {
623
+ label: parsedLabel.success && parsedLabel.data.trim() ? parsedLabel.data : `Task ${index + 1}`,
624
+ command: parsedCommand.data,
625
+ args: parsedArgs.data,
610
626
  env
611
627
  };
628
+ if (parsedCwd.success) task.cwd = parsedCwd.data;
629
+ return task;
612
630
  }
613
631
  async function loadCreateWorktreeTasks(mainWorktreePath) {
614
632
  const tasksFile = await readOptionalJsonc(path.join(mainWorktreePath, ZED_TASKS_CONFIG_PATH));
615
633
  return (taskArray(tasksFile.found ? tasksFile.value : null) ?? []).flatMap((entry, index) => {
616
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
617
- const hooks = Reflect.get(entry, "hooks");
618
- if (!Array.isArray(hooks) || !hooks.includes("create_worktree")) return [];
634
+ const parsedEntry = zedTaskRecordSchema.safeParse(entry);
635
+ const parsedHooks = z.array(z.string()).safeParse(parsedEntry.success ? parsedEntry.data.hooks : void 0);
636
+ if (!parsedHooks.success || !parsedHooks.data.includes("create_worktree")) return [];
619
637
  return [parseTask(entry, index, {
620
638
  requireLabel: false,
621
639
  validateLaunchFields: false
@@ -641,11 +659,8 @@ function resolveTask(task, input, protectCompatibilityEnvironment) {
641
659
  const useShell = /[\s;&|<>`$()]/u.test(command);
642
660
  return {
643
661
  label: expand(task.label, compatibilityEnvironment),
644
- argv: useShell ? [
645
- input.shell,
646
- "-lc",
647
- [command, ...args.map(shellQuote)].join(" ")
648
- ] : [command, ...args],
662
+ argv: useShell ? null : [command, ...args],
663
+ shellCommand: useShell ? [command, ...args.map(shellQuote)].join(" ") : null,
649
664
  cwd,
650
665
  env: protectCompatibilityEnvironment ? {
651
666
  ...taskEnvironment,
@@ -704,8 +719,9 @@ async function loadZedTerminalPresetDefinitions(input) {
704
719
  definitions.push({
705
720
  id: `repository:${input.projectId}:zed-task:${index}`,
706
721
  name: resolved.label,
707
- executable: resolved.argv[0],
708
- args: resolved.argv.slice(1),
722
+ executable: resolved.argv?.[0] ?? null,
723
+ args: resolved.argv?.slice(1) ?? [],
724
+ shellCommand: resolved.shellCommand,
709
725
  cwd: resolved.cwd,
710
726
  env: resolved.env,
711
727
  closeOnSuccess: false,
@@ -723,9 +739,18 @@ async function loadZedTerminalPresetDefinitions(input) {
723
739
  async function resolveZedCreateWorktreeSetupTasks(input) {
724
740
  return (await loadCreateWorktreeTasks(input.mainWorktreePath)).map((task) => {
725
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
+ }
726
751
  return {
727
752
  label: task.label,
728
- argv: resolved.argv,
753
+ argv,
729
754
  cwd: resolved.cwd,
730
755
  env: resolved.env,
731
756
  timeoutMs: 30 * 6e4
@@ -866,6 +891,8 @@ async function openDatabase(filePath, options = {}) {
866
891
  const databaseExists = fsSync.existsSync(absoluteFilePath);
867
892
  let hasDurableSchema = false;
868
893
  let hasLegacyMigrations = false;
894
+ let migrationsPending = !databaseExists;
895
+ const migrationSnapshotPaths = [];
869
896
  let drizzleRows = [];
870
897
  if (!databaseExists) await fsSync.promises.mkdir(path.dirname(absoluteFilePath), {
871
898
  recursive: true,
@@ -911,7 +938,8 @@ async function openDatabase(filePath, options = {}) {
911
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.`);
912
939
  }
913
940
  if (hasDurableSchema && !hasLegacyMigrations && drizzleRows.length === 0) throw new Error(`Treeport database at ${absoluteFilePath} has no recognized migration history; refusing to modify it.`);
914
- 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) {
915
943
  const backupDirectory = path.resolve(options.backupDirectory ?? path.join(path.dirname(absoluteFilePath), "database-backups"));
916
944
  await fsSync.promises.mkdir(backupDirectory, {
917
945
  recursive: true,
@@ -924,6 +952,7 @@ async function openDatabase(filePath, options = {}) {
924
952
  try {
925
953
  await db.run(sql.raw(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`));
926
954
  await fsSync.promises.chmod(backupPath, 384);
955
+ migrationSnapshotPaths.push(backupPath);
927
956
  } catch (error) {
928
957
  await fsSync.promises.rm(backupPath, { force: true });
929
958
  throw error;
@@ -957,6 +986,8 @@ async function openDatabase(filePath, options = {}) {
957
986
  return {
958
987
  filePath: absoluteFilePath,
959
988
  db,
989
+ migrationState: migrationsPending ? "advanced" : "unchanged",
990
+ migrationSnapshotPaths,
960
991
  close: () => client.close()
961
992
  };
962
993
  } catch (error) {
@@ -968,6 +999,8 @@ function mapProject(row, worktreeRows) {
968
999
  return {
969
1000
  id: row.id,
970
1001
  name: row.name,
1002
+ kind: row.kind,
1003
+ rootPath: row.repositoryPath,
971
1004
  repositoryPath: row.repositoryPath,
972
1005
  mainWorktreePath: row.mainWorktreePath,
973
1006
  defaultBranch: row.defaultBranch,
@@ -987,7 +1020,7 @@ function mapWorktree(row, mainWorktreePath) {
987
1020
  projectId: row.projectId,
988
1021
  name: inferWorktreeName(mainWorktreePath, row.path, row.kind),
989
1022
  path: row.path,
990
- head: row.head,
1023
+ head: row.kind === "folder" ? "" : row.head,
991
1024
  branch: row.branch,
992
1025
  detached: Boolean(row.detached),
993
1026
  locked: Boolean(row.locked),
@@ -1106,6 +1139,14 @@ var ProductEventBus = class {
1106
1139
  };
1107
1140
  //#endregion
1108
1141
  //#region src/server/core/gh.ts
1142
+ const ghPrSchema = z.object({
1143
+ number: z.number().optional(),
1144
+ state: z.string().optional(),
1145
+ url: z.string().optional(),
1146
+ baseRefName: z.string().optional(),
1147
+ headRefName: z.string().optional(),
1148
+ mergedAt: z.string().nullable().optional()
1149
+ }).strict();
1109
1150
  function mapPrState(pr) {
1110
1151
  if (!pr) return "no_pr";
1111
1152
  if (pr.mergedAt || pr.state?.toUpperCase() === "MERGED") return "merged";
@@ -1156,7 +1197,7 @@ var GhAdapter = class {
1156
1197
  timeoutMs: 3e4
1157
1198
  });
1158
1199
  if (result.exitCode !== 0) return unknownPr();
1159
- const pr = JSON.parse(result.stdout)[0] ?? null;
1200
+ const pr = z.array(ghPrSchema).parse(JSON.parse(result.stdout))[0] ?? null;
1160
1201
  return {
1161
1202
  state: mapPrState(pr),
1162
1203
  number: pr?.number ?? null,
@@ -1268,10 +1309,34 @@ var GitAdapter = class {
1268
1309
  timeoutMs: 3e4
1269
1310
  });
1270
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
+ }
1271
1335
  async canonicalizeRepositoryPath(inputPath) {
1336
+ const repositoryRoot = await this.findRepositoryRoot(inputPath);
1337
+ if (repositoryRoot) return repositoryRoot;
1272
1338
  const canonicalInput = await fs.realpath(path.resolve(inputPath));
1273
- const result = await this.checked(canonicalInput, ["rev-parse", "--show-toplevel"]);
1274
- return fs.realpath(result.stdout.trim());
1339
+ throw new Error(`Not a Git repository: ${canonicalInput}`);
1275
1340
  }
1276
1341
  async repositoryIdentityValues(cwd) {
1277
1342
  const result = await this.runner.run({
@@ -1997,16 +2062,35 @@ async function checkRuntimePrerequisites(config) {
1997
2062
  }
1998
2063
  //#endregion
1999
2064
  //#region src/server/core/package-system.ts
2065
+ z.unknown();
2066
+ const manifestPatternsSchema = z.array(z.string());
2067
+ const webPanelManifestEntrySchema = z.union([z.string(), z.strictObject({
2068
+ source: z.string(),
2069
+ permissions: z.array(z.enum(["same-origin"])).optional()
2070
+ })]);
2000
2071
  const EMPTY_SETTINGS = {
2001
2072
  raw: {},
2002
2073
  packages: []
2003
2074
  };
2004
2075
  const PACKAGE_OPERATION_TIMEOUT_MS = 5 * 6e4;
2005
2076
  function sourceString(source) {
2006
- return typeof source === "string" ? source : source.source;
2077
+ const parsed = z.string().safeParse(source);
2078
+ if (parsed.success) return parsed.data;
2079
+ return z.object({ source: z.string() }).parse(source).source;
2007
2080
  }
2008
2081
  function packageFilter(source) {
2009
- return typeof source === "string" ? void 0 : source;
2082
+ if (z.string().safeParse(source).success) return;
2083
+ const data = z.object({
2084
+ source: z.string(),
2085
+ autoload: z.boolean().optional(),
2086
+ webPanels: z.array(z.string()).optional(),
2087
+ terminalPresets: z.array(z.string()).optional()
2088
+ }).parse(source);
2089
+ const result = { source: data.source };
2090
+ if (data.autoload !== void 0) result.autoload = data.autoload;
2091
+ if (data.webPanels !== void 0) result.webPanels = data.webPanels;
2092
+ if (data.terminalPresets !== void 0) result.terminalPresets = data.terminalPresets;
2093
+ return result;
2010
2094
  }
2011
2095
  function toPosix(value) {
2012
2096
  return value.split(path.sep).join("/");
@@ -2024,15 +2108,16 @@ function isWithin$1(candidate, root) {
2024
2108
  return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
2025
2109
  }
2026
2110
  function diagnostic(scope, message, options = {}) {
2027
- return {
2111
+ const result = {
2028
2112
  severity: options.severity ?? "error",
2029
2113
  scope,
2030
- message,
2031
- ...options.source === void 0 ? {} : { source: options.source },
2032
- ...options.projectId === void 0 ? {} : { projectId: options.projectId },
2033
- ...options.resourceType === void 0 ? {} : { resourceType: options.resourceType },
2034
- ...options.path === void 0 ? {} : { path: options.path }
2114
+ message
2035
2115
  };
2116
+ if (options.source !== void 0) result.source = options.source;
2117
+ if (options.projectId !== void 0) result.projectId = options.projectId;
2118
+ if (options.resourceType !== void 0) result.resourceType = options.resourceType;
2119
+ if (options.path !== void 0) result.path = options.path;
2120
+ return result;
2036
2121
  }
2037
2122
  function normalizePattern(value) {
2038
2123
  return toPosix(value.trim().replace(/^\.\//u, "").replace(/\/$/u, ""));
@@ -2119,7 +2204,7 @@ var PackageSystem = class {
2119
2204
  if (scope === "global") return path.join(this.config.dataDir, "settings.json");
2120
2205
  const project = projectId ? this.projectContexts.get(projectId) : void 0;
2121
2206
  if (!project) throw new DomainError("PROJECT_NOT_FOUND", "Project not found for package operation", 404);
2122
- return path.join(project.mainWorktreePath, ".treeport", "settings.json");
2207
+ return path.join(project.rootPath, ".treeport", "settings.json");
2123
2208
  }
2124
2209
  async serialize(key, operation) {
2125
2210
  const previous = this.operationTails.get(key) ?? Promise.resolve();
@@ -2139,85 +2224,103 @@ var PackageSystem = class {
2139
2224
  async readSettingsFile(settingsPath) {
2140
2225
  let readError;
2141
2226
  const content = await fs.readFile(settingsPath, "utf8").catch((error) => {
2142
- if (error.code === "ENOENT") return null;
2227
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
2143
2228
  readError = error instanceof Error ? error : new Error(String(error));
2144
2229
  return null;
2145
2230
  });
2146
- return {
2231
+ const result = {
2147
2232
  fingerprint: readError ? `error:${readError.message}` : content === null ? "missing" : crypto.createHash("sha256").update(content).digest("hex"),
2148
- content,
2149
- ...readError ? { error: readError } : {}
2233
+ content
2150
2234
  };
2235
+ if (readError) result.error = readError;
2236
+ return result;
2151
2237
  }
2152
2238
  parseSettings(content, settingsPath, scope, projectId) {
2153
2239
  if (content === null || content.trim() === "") return { settings: {
2154
2240
  raw: {},
2155
2241
  packages: []
2156
2242
  } };
2157
- let raw;
2243
+ let input;
2158
2244
  try {
2159
- raw = JSON.parse(content);
2245
+ input = JSON.parse(content);
2160
2246
  } catch (error) {
2161
2247
  return { diagnostic: diagnostic(scope, `Could not parse ${settingsPath}: ${error instanceof Error ? error.message : String(error)}`, {
2162
2248
  projectId,
2163
2249
  path: settingsPath
2164
2250
  }) };
2165
2251
  }
2166
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { diagnostic: diagnostic(scope, `${settingsPath} must contain a JSON object`, {
2252
+ const parsedRaw = z.looseObject({
2253
+ npmCommand: z.unknown().optional(),
2254
+ packages: z.unknown().optional()
2255
+ }).safeParse(input);
2256
+ if (!parsedRaw.success) return { diagnostic: diagnostic(scope, `${settingsPath} must contain a JSON object`, {
2167
2257
  projectId,
2168
2258
  path: settingsPath
2169
2259
  }) };
2170
- const npmCommand = Reflect.get(raw, "npmCommand");
2171
- const packageEntries = Reflect.get(raw, "packages");
2172
- if (npmCommand !== void 0 && (!Array.isArray(npmCommand) || npmCommand.length === 0 || npmCommand.some((value) => typeof value !== "string" || value.length === 0))) return { diagnostic: diagnostic(scope, `${settingsPath} npmCommand must be a non-empty argv string array`, {
2260
+ const parsedNpmCommand = z.array(z.string().min(1)).min(1).safeParse(parsedRaw.data.npmCommand);
2261
+ if (parsedRaw.data.npmCommand !== void 0 && !parsedNpmCommand.success) return { diagnostic: diagnostic(scope, `${settingsPath} npmCommand must be a non-empty argv string array`, {
2173
2262
  projectId,
2174
2263
  path: settingsPath
2175
2264
  }) };
2176
- if (packageEntries !== void 0 && !Array.isArray(packageEntries)) return { diagnostic: diagnostic(scope, `${settingsPath} packages must be an array`, {
2265
+ const parsedPackageEntries = z.array(z.unknown()).safeParse(parsedRaw.data.packages ?? []);
2266
+ if (!parsedPackageEntries.success) return { diagnostic: diagnostic(scope, `${settingsPath} packages must be an array`, {
2177
2267
  projectId,
2178
2268
  path: settingsPath
2179
2269
  }) };
2270
+ const packageObjectSchema = z.looseObject({
2271
+ source: z.unknown().optional(),
2272
+ autoload: z.unknown().optional(),
2273
+ webPanels: z.unknown().optional(),
2274
+ terminalPresets: z.unknown().optional()
2275
+ });
2180
2276
  const packages = [];
2181
- for (const [index, entry] of (packageEntries ?? []).entries()) {
2182
- if (typeof entry === "string" && entry.trim()) {
2183
- packages.push(entry);
2277
+ for (const [index, entry] of parsedPackageEntries.data.entries()) {
2278
+ const parsedSourceString = z.string().safeParse(entry);
2279
+ if (parsedSourceString.success && parsedSourceString.data.trim()) {
2280
+ packages.push(parsedSourceString.data);
2184
2281
  continue;
2185
2282
  }
2186
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}] must be a source string or package object`, {
2283
+ const parsedEntry = packageObjectSchema.safeParse(entry);
2284
+ if (!parsedEntry.success) return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}] must be a source string or package object`, {
2187
2285
  projectId,
2188
2286
  path: settingsPath
2189
2287
  }) };
2190
- const source = Reflect.get(entry, "source");
2191
- const autoload = Reflect.get(entry, "autoload");
2192
- const webPanels = Reflect.get(entry, "webPanels");
2193
- const terminalPresets = Reflect.get(entry, "terminalPresets");
2194
- if (typeof source !== "string" || !source.trim()) return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}].source must be a non-empty string`, {
2288
+ const parsedSource = z.string().safeParse(parsedEntry.data.source);
2289
+ if (!parsedSource.success || !parsedSource.data.trim()) return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}].source must be a non-empty string`, {
2195
2290
  projectId,
2196
2291
  path: settingsPath
2197
2292
  }) };
2198
- if (autoload !== void 0 && typeof autoload !== "boolean") return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}].autoload must be a boolean`, {
2293
+ const parsedAutoload = z.boolean().safeParse(parsedEntry.data.autoload);
2294
+ if (parsedEntry.data.autoload !== void 0 && !parsedAutoload.success) return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}].autoload must be a boolean`, {
2199
2295
  projectId,
2200
2296
  path: settingsPath
2201
2297
  }) };
2202
- for (const [key, value] of [["webPanels", webPanels], ["terminalPresets", terminalPresets]]) if (value !== void 0 && (!Array.isArray(value) || value.some((pattern) => typeof pattern !== "string"))) return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}].${key} must be a string array`, {
2298
+ const parsedWebPanels = z.array(z.string()).safeParse(parsedEntry.data.webPanels);
2299
+ const parsedTerminalPresets = z.array(z.string()).safeParse(parsedEntry.data.terminalPresets);
2300
+ for (const [key, original, parsed] of [[
2301
+ "webPanels",
2302
+ parsedEntry.data.webPanels,
2303
+ parsedWebPanels
2304
+ ], [
2305
+ "terminalPresets",
2306
+ parsedEntry.data.terminalPresets,
2307
+ parsedTerminalPresets
2308
+ ]]) if (original !== void 0 && !parsed.success) return { diagnostic: diagnostic(scope, `${settingsPath} packages[${index}].${key} must be a string array`, {
2203
2309
  projectId,
2204
2310
  path: settingsPath
2205
2311
  }) };
2206
- const parsedWebPanels = Array.isArray(webPanels) ? webPanels.filter((pattern) => typeof pattern === "string") : void 0;
2207
- const parsedTerminalPresets = Array.isArray(terminalPresets) ? terminalPresets.filter((pattern) => typeof pattern === "string") : void 0;
2208
- packages.push({
2209
- source,
2210
- ...autoload === void 0 ? {} : { autoload },
2211
- ...parsedWebPanels ? { webPanels: parsedWebPanels } : {},
2212
- ...parsedTerminalPresets ? { terminalPresets: parsedTerminalPresets } : {}
2213
- });
2214
- }
2215
- const parsedNpmCommand = Array.isArray(npmCommand) ? npmCommand.filter((argument) => typeof argument === "string") : void 0;
2216
- return { settings: {
2217
- raw,
2218
- packages,
2219
- ...parsedNpmCommand ? { npmCommand: parsedNpmCommand } : {}
2220
- } };
2312
+ const configured = { source: parsedSource.data };
2313
+ if (parsedAutoload.success) configured.autoload = parsedAutoload.data;
2314
+ if (parsedWebPanels.success) configured.webPanels = parsedWebPanels.data;
2315
+ if (parsedTerminalPresets.success) configured.terminalPresets = parsedTerminalPresets.data;
2316
+ packages.push(configured);
2317
+ }
2318
+ const settings = {
2319
+ raw: parsedRaw.data,
2320
+ packages
2321
+ };
2322
+ if (parsedNpmCommand.success) settings.npmCommand = parsedNpmCommand.data;
2323
+ return { settings };
2221
2324
  }
2222
2325
  async parseSource(source, settingsPath) {
2223
2326
  const trimmed = source.trim();
@@ -2233,16 +2336,17 @@ var PackageSystem = class {
2233
2336
  const version = split > 0 ? spec.slice(split + 1) : void 0;
2234
2337
  if (!name || name.includes("..") || name.includes("\\") || !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/iu.test(name) || version !== void 0 && !version) throw new DomainError("INVALID_PACKAGE_SOURCE", `Invalid npm package source: ${source}`, 400);
2235
2338
  const identity = `npm:${name}`;
2236
- return {
2339
+ const parsed = {
2237
2340
  type: "npm",
2238
2341
  source: trimmed,
2239
2342
  spec,
2240
2343
  name,
2241
- ...version === void 0 ? {} : { version },
2242
2344
  exact: isExactNpmVersion(version),
2243
2345
  identity,
2244
2346
  packageId: identity
2245
2347
  };
2348
+ if (version !== void 0) parsed.version = version;
2349
+ return parsed;
2246
2350
  }
2247
2351
  if (!path.isAbsolute(trimmed) && trimmed !== "." && trimmed !== ".." && !trimmed.startsWith("./") && !trimmed.startsWith("../") && trimmed !== "~" && !trimmed.startsWith("~/")) throw new DomainError("INVALID_PACKAGE_SOURCE", "Package sources must use npm: syntax or an explicit local path", 400);
2248
2352
  const expanded = trimmed === "~" || trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
@@ -2257,7 +2361,7 @@ var PackageSystem = class {
2257
2361
  };
2258
2362
  }
2259
2363
  npmRoot(scope, projectId) {
2260
- 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");
2261
2365
  }
2262
2366
  npmPackagePath(source, scope, projectId) {
2263
2367
  return path.join(this.npmRoot(scope, projectId), "node_modules", source.name);
@@ -2346,40 +2450,47 @@ var PackageSystem = class {
2346
2450
  const installedPath = this.npmPackagePath(source, scope, projectId);
2347
2451
  let shouldInstall = forceInstall;
2348
2452
  if (!await fs.stat(installedPath).then((value) => value.isDirectory()).catch(() => false)) shouldInstall = true;
2349
- else if (source.exact) shouldInstall = await fs.readFile(path.join(installedPath, "package.json"), "utf8").then((content) => JSON.parse(content).version).catch(() => void 0) !== source.version;
2453
+ else if (source.exact) shouldInstall = await fs.readFile(path.join(installedPath, "package.json"), "utf8").then((content) => {
2454
+ const parsed = z.object({ version: z.string().optional() }).safeParse(JSON.parse(content));
2455
+ return parsed.success ? parsed.data.version : void 0;
2456
+ }).catch(() => void 0) !== source.version;
2350
2457
  if (shouldInstall) await this.runNpm("install", source, scope, projectId, settings);
2351
2458
  if (!await fs.stat(installedPath).then((value) => value.isDirectory()).catch(() => false)) throw new DomainError("PACKAGE_INSTALL_FAILED", `Package manager completed without installing ${source.name}`, 500);
2352
2459
  return installedPath;
2353
2460
  }
2354
2461
  validateManifestPatterns(patterns, field, packageJsonPath) {
2355
- if (!Array.isArray(patterns) || patterns.some((pattern) => typeof pattern !== "string")) throw new Error(`${packageJsonPath} treeport.${field} must be a string array`);
2356
- for (const pattern of patterns) {
2462
+ const parsed = manifestPatternsSchema.safeParse(patterns);
2463
+ if (!parsed.success) throw new Error(`${packageJsonPath} treeport.${field} must be a string array`);
2464
+ for (const pattern of parsed.data) {
2357
2465
  const normalized = normalizePattern(pattern.startsWith("!") ? pattern.slice(1) : pattern);
2358
2466
  if (!normalized || path.posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../") || pattern.startsWith("+") || pattern.startsWith("-")) throw new Error(`${packageJsonPath} treeport.${field} contains an invalid package-relative pattern: ${pattern}`);
2359
2467
  }
2360
- return [...patterns];
2468
+ return [...parsed.data];
2361
2469
  }
2362
2470
  validateWebPanelManifest(entries, packageJsonPath) {
2363
- if (!Array.isArray(entries)) throw new Error(`${packageJsonPath} treeport.webPanels must be an array`);
2364
- return entries.map((entry) => {
2365
- if (typeof entry === "string") {
2366
- this.validateManifestPatterns([entry], "webPanels", packageJsonPath);
2471
+ const parsedEntries = z.array(z.unknown()).safeParse(entries);
2472
+ if (!parsedEntries.success) throw new Error(`${packageJsonPath} treeport.webPanels must be an array`);
2473
+ return parsedEntries.data.map((entry) => {
2474
+ const parsed = webPanelManifestEntrySchema.safeParse(entry);
2475
+ if (!parsed.success) throw new Error(`${packageJsonPath} contains an invalid web panel definition`);
2476
+ const parsedSource = z.string().safeParse(parsed.data);
2477
+ if (parsedSource.success) {
2478
+ this.validateManifestPatterns([parsedSource.data], "webPanels", packageJsonPath);
2367
2479
  return {
2368
- source: entry,
2480
+ source: parsedSource.data,
2369
2481
  allowSameOrigin: false
2370
2482
  };
2371
2483
  }
2372
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`${packageJsonPath} treeport.webPanels entries must be package-relative patterns or panel definitions`);
2373
- const keys = Object.keys(entry);
2374
- const source = Reflect.get(entry, "source");
2375
- const permissions = Reflect.get(entry, "permissions") ?? [];
2376
- if (keys.some((key) => !["source", "permissions"].includes(key)) || typeof source !== "string" || source.startsWith("!") || !Array.isArray(permissions) || permissions.some((permission) => typeof permission !== "string")) throw new Error(`${packageJsonPath} contains an invalid web panel definition`);
2484
+ const { source, permissions = [] } = z.strictObject({
2485
+ source: z.string(),
2486
+ permissions: z.array(z.enum(["same-origin"])).optional()
2487
+ }).parse(parsed.data);
2488
+ if (source.startsWith("!")) throw new Error(`${packageJsonPath} contains an invalid web panel definition`);
2377
2489
  this.validateManifestPatterns([source], "webPanels", packageJsonPath);
2378
- const uniquePermissions = new Set(permissions);
2379
- if (uniquePermissions.size !== permissions.length || permissions.some((permission) => permission !== "same-origin")) throw new Error(`${packageJsonPath} contains an invalid web panel permission`);
2490
+ if (new Set(permissions).size !== permissions.length) throw new Error(`${packageJsonPath} contains an invalid web panel permission`);
2380
2491
  return {
2381
2492
  source,
2382
- allowSameOrigin: uniquePermissions.has("same-origin")
2493
+ allowSameOrigin: permissions.includes("same-origin")
2383
2494
  };
2384
2495
  });
2385
2496
  }
@@ -2469,7 +2580,7 @@ var PackageSystem = class {
2469
2580
  const source = sourceString(configured);
2470
2581
  const packageJsonPath = path.join(root, "package.json");
2471
2582
  const packageJsonContent = await fs.readFile(packageJsonPath, "utf8").catch((error) => {
2472
- if (error.code === "ENOENT") return null;
2583
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
2473
2584
  throw error;
2474
2585
  });
2475
2586
  let manifest;
@@ -2480,15 +2591,17 @@ var PackageSystem = class {
2480
2591
  } catch (error) {
2481
2592
  throw new Error(`Could not parse ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`);
2482
2593
  }
2483
- if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) throw new Error(`${packageJsonPath} must contain a JSON object`);
2484
- const treeport = Reflect.get(packageJson, "treeport");
2485
- if (treeport !== void 0) {
2486
- if (!treeport || typeof treeport !== "object" || Array.isArray(treeport)) throw new Error(`${packageJsonPath} treeport manifest must be an object`);
2487
- const webPanels = Reflect.get(treeport, "webPanels");
2488
- const terminalPresets = Reflect.get(treeport, "terminalPresets");
2594
+ const parsedPackageJson = z.looseObject({ treeport: z.unknown().optional() }).safeParse(packageJson);
2595
+ if (!parsedPackageJson.success) throw new Error(`${packageJsonPath} must contain a JSON object`);
2596
+ if (parsedPackageJson.data.treeport !== void 0) {
2597
+ const parsedTreeport = z.looseObject({
2598
+ webPanels: z.unknown().optional(),
2599
+ terminalPresets: z.unknown().optional()
2600
+ }).safeParse(parsedPackageJson.data.treeport);
2601
+ if (!parsedTreeport.success) throw new Error(`${packageJsonPath} treeport manifest must be an object`);
2489
2602
  manifest = {
2490
- webPanels: this.validateWebPanelManifest(webPanels ?? [], packageJsonPath),
2491
- terminalPresets: this.validateManifestPatterns(terminalPresets ?? [], "terminalPresets", packageJsonPath)
2603
+ webPanels: this.validateWebPanelManifest(parsedTreeport.data.webPanels ?? [], packageJsonPath),
2604
+ terminalPresets: this.validateManifestPatterns(parsedTreeport.data.terminalPresets ?? [], "terminalPresets", packageJsonPath)
2492
2605
  };
2493
2606
  }
2494
2607
  }
@@ -2516,7 +2629,7 @@ var PackageSystem = class {
2516
2629
  const webPanels = applyNormalFilter(panelCandidates.map((candidate) => {
2517
2630
  const resourceId = encodeURIComponent(path.posix.basename(candidate.relativePath));
2518
2631
  const allowSameOrigin = manifest?.webPanels.some((entry) => entry.allowSameOrigin && this.manifestAllows(candidate.relativePath, [entry.source], "web-panel")) ?? false;
2519
- return {
2632
+ const resolved = {
2520
2633
  definition: {
2521
2634
  id: `package:${parsed.packageId}:web-panel:${resourceId}`,
2522
2635
  title: titleFromPath(candidate.relativePath),
@@ -2527,10 +2640,11 @@ var PackageSystem = class {
2527
2640
  entry: "index.html",
2528
2641
  packageRoot: root,
2529
2642
  development: parsed.type === "local",
2530
- ...packageLockPath ? { packageLockPath } : {},
2531
2643
  relativePath: candidate.relativePath,
2532
2644
  enabled: true
2533
2645
  };
2646
+ if (packageLockPath) resolved.packageLockPath = packageLockPath;
2647
+ return resolved;
2534
2648
  }), filter?.webPanels, autoload);
2535
2649
  const terminalPresets = [];
2536
2650
  for (const candidate of presetCandidates) {
@@ -2563,6 +2677,7 @@ var PackageSystem = class {
2563
2677
  name: result.data.name,
2564
2678
  executable: result.data.executable,
2565
2679
  args: [...result.data.args],
2680
+ shellCommand: null,
2566
2681
  cwd: null,
2567
2682
  env: {},
2568
2683
  closeOnSuccess: result.data.closeOnSuccess,
@@ -2813,7 +2928,7 @@ var PackageSystem = class {
2813
2928
  return {
2814
2929
  id: project.id,
2815
2930
  name: project.name,
2816
- mainWorktreePath: project.mainWorktreePath
2931
+ rootPath: project.rootPath
2817
2932
  };
2818
2933
  }
2819
2934
  syncProjects(projects) {
@@ -2821,7 +2936,7 @@ var PackageSystem = class {
2821
2936
  const next = this.context(project);
2822
2937
  const previous = this.projectContexts.get(project.id);
2823
2938
  this.projectContexts.set(project.id, next);
2824
- 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);
2825
2940
  }
2826
2941
  }
2827
2942
  async initialize(projects) {
@@ -2931,10 +3046,11 @@ var PackageSystem = class {
2931
3046
  const next = [];
2932
3047
  for (const configured of settings.packages) if ((await this.parseSource(sourceString(configured), settingsPath)).identity !== parsed.identity) next.push(configured);
2933
3048
  else if (!replaced) {
2934
- next.push(typeof configured === "string" ? persisted : {
2935
- ...configured,
3049
+ const filter = packageFilter(configured);
3050
+ next.push(filter ? {
3051
+ ...filter,
2936
3052
  source: persisted
2937
- });
3053
+ } : persisted);
2938
3054
  replaced = true;
2939
3055
  }
2940
3056
  if (!replaced) next.push(persisted);
@@ -3090,7 +3206,7 @@ const CONFIG_PATH = path.join(".treeport", "terminal-presets.json");
3090
3206
  async function loadRepositoryTerminalPresets(projectId, worktreePath) {
3091
3207
  const configPath = path.join(worktreePath, CONFIG_PATH);
3092
3208
  const content = await fs.readFile(configPath, "utf8").catch((error) => {
3093
- if ((typeof error === "object" && error !== null && "code" in error ? error.code : void 0) === "ENOENT") return null;
3209
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
3094
3210
  return error instanceof Error ? error : new Error(String(error));
3095
3211
  });
3096
3212
  if (content === null) return {
@@ -3144,6 +3260,7 @@ async function loadRepositoryTerminalPresets(projectId, worktreePath) {
3144
3260
  name: preset.data.name,
3145
3261
  executable: preset.data.executable,
3146
3262
  args: [...preset.data.args],
3263
+ shellCommand: null,
3147
3264
  cwd: null,
3148
3265
  env: {},
3149
3266
  closeOnSuccess: preset.data.closeOnSuccess,
@@ -3163,7 +3280,7 @@ async function loadRepositoryTerminalPresets(projectId, worktreePath) {
3163
3280
  const DEFAULT_SETUP_TIMEOUT_MS = 30 * 6e4;
3164
3281
  const MAX_SETUP_OUTPUT = 4e3;
3165
3282
  const TREEPORT_SETUP_PATH = path.join(".treeport", "setup.json");
3166
- const TREEPORT_PATH_VARIABLES = ["TREEPORT_WORKTREE_PATH", "TREEPORT_MAIN_WORKTREE_PATH"];
3283
+ const TREEPORT_PATH_VARIABLE_NAMES = /* @__PURE__ */ new Set(["TREEPORT_WORKTREE_PATH", "TREEPORT_MAIN_WORKTREE_PATH"]);
3167
3284
  const environmentSchema = z.record(z.string(), z.string()).superRefine((environment, context) => {
3168
3285
  for (const [name, value] of Object.entries(environment)) {
3169
3286
  if (!name || name.includes("=") || name.includes("\0")) context.addIssue({
@@ -3171,7 +3288,7 @@ const environmentSchema = z.record(z.string(), z.string()).superRefine((environm
3171
3288
  path: [name],
3172
3289
  message: "Environment names must be non-empty and cannot contain = or NUL"
3173
3290
  });
3174
- if (TREEPORT_PATH_VARIABLES.includes(name)) context.addIssue({
3291
+ if (TREEPORT_PATH_VARIABLE_NAMES.has(name)) context.addIssue({
3175
3292
  code: "custom",
3176
3293
  path: [name],
3177
3294
  message: `${name} is reserved by Treeport`
@@ -3207,7 +3324,8 @@ const setupFileSchema = z.object({
3207
3324
  function formatIssuePath(issuePath) {
3208
3325
  if (!issuePath.length) return "configuration";
3209
3326
  return issuePath.reduce((formatted, component) => {
3210
- if (typeof component === "number") return `${formatted}[${component}]`;
3327
+ const parsedIndex = z.number().safeParse(component);
3328
+ if (parsedIndex.success) return `${formatted}[${parsedIndex.data}]`;
3211
3329
  return formatted ? `${formatted}.${String(component)}` : String(component);
3212
3330
  }, "");
3213
3331
  }
@@ -3239,7 +3357,7 @@ async function resolveWorktreeSetupTasks(input) {
3239
3357
  return parsed.data.commands.map((command, index) => {
3240
3358
  const expandedCwd = expandTreeportPaths(command.cwd ?? worktreePath, environment);
3241
3359
  const cwd = path.isAbsolute(expandedCwd) ? path.resolve(expandedCwd) : path.resolve(worktreePath, expandedCwd);
3242
- if (!isPathWithin$1(cwd, worktreePath)) throw new Error(`Invalid Treeport setup in ${filePath}: commands[${index}].cwd must stay inside the new worktree`);
3360
+ if (!isPathWithin$1(cwd, worktreePath)) throw new Error(`Invalid Treeport setup in ${filePath}: commands[${index}].cwd must stay inside the new tree`);
3243
3361
  const configuredEnvironment = Object.fromEntries(Object.entries(command.env ?? {}).map(([name, value]) => [name, expandTreeportPaths(value, environment)]));
3244
3362
  return {
3245
3363
  label: command.name,
@@ -3368,6 +3486,30 @@ var WebPanelViteRuntime = class {
3368
3486
  this.httpServer = server;
3369
3487
  }
3370
3488
  viteConfig(source, base, options = {}) {
3489
+ const server = {
3490
+ middlewareMode: true,
3491
+ headers: {
3492
+ "access-control-allow-origin": "*",
3493
+ "cache-control": "no-store",
3494
+ "x-content-type-options": "nosniff"
3495
+ },
3496
+ fs: {
3497
+ strict: true,
3498
+ allow: [source.packageRoot, PANEL_SDK_ROOT]
3499
+ }
3500
+ };
3501
+ if (options.server) server.hmr = {
3502
+ server: options.server,
3503
+ path: `${base}@vite-hmr`
3504
+ };
3505
+ const viteBuild = {
3506
+ sourcemap: true,
3507
+ rollupOptions: { input: path.join(source.root, source.entry) }
3508
+ };
3509
+ if (options.outDir) {
3510
+ viteBuild.outDir = options.outDir;
3511
+ viteBuild.emptyOutDir = true;
3512
+ }
3371
3513
  return {
3372
3514
  root: source.root,
3373
3515
  base,
@@ -3383,30 +3525,8 @@ var WebPanelViteRuntime = class {
3383
3525
  alias: { "@treeport/panel-sdk": PANEL_SDK_ENTRY },
3384
3526
  dedupe: ["react", "react-dom"]
3385
3527
  },
3386
- server: {
3387
- middlewareMode: true,
3388
- headers: {
3389
- "access-control-allow-origin": "*",
3390
- "cache-control": "no-store",
3391
- "x-content-type-options": "nosniff"
3392
- },
3393
- fs: {
3394
- strict: true,
3395
- allow: [source.packageRoot, PANEL_SDK_ROOT]
3396
- },
3397
- ...options.server ? { hmr: {
3398
- server: options.server,
3399
- path: `${base}@vite-hmr`
3400
- } } : {}
3401
- },
3402
- build: {
3403
- sourcemap: true,
3404
- rollupOptions: { input: path.join(source.root, source.entry) },
3405
- ...options.outDir ? {
3406
- outDir: options.outDir,
3407
- emptyOutDir: true
3408
- } : {}
3409
- }
3528
+ server,
3529
+ build: viteBuild
3410
3530
  };
3411
3531
  }
3412
3532
  async hashSource(source) {
@@ -3456,7 +3576,9 @@ var WebPanelViteRuntime = class {
3456
3576
  const parent = path.join(this.config.cacheDir, "web-panels", COMPILER_ABI);
3457
3577
  const directory = path.join(parent, hash);
3458
3578
  const metadata = path.join(directory, BUILD_METADATA);
3459
- if (await fs.readFile(metadata, "utf8").then((value) => JSON.parse(value)).then((value) => value.hash === hash).catch(() => false)) return {
3579
+ if (await fs.readFile(metadata, "utf8").then((value) => {
3580
+ return JSON.parse(value);
3581
+ }).then((value) => value.hash === hash).catch(() => false)) return {
3460
3582
  hash,
3461
3583
  directory
3462
3584
  };
@@ -3502,8 +3624,8 @@ var WebPanelViteRuntime = class {
3502
3624
  directory: await pending
3503
3625
  };
3504
3626
  }
3505
- errorPage(source, error) {
3506
- const raw = error instanceof Error ? error.message : String(error);
3627
+ errorPage(source, cause) {
3628
+ const raw = cause instanceof Error ? cause.message : String(cause);
3507
3629
  const diagnostic = raw.replaceAll(source.packageRoot, "<package>").replaceAll(source.root, "<panel>");
3508
3630
  const stage = /resolve|not found|cannot find|failed to load|import/iu.test(raw) ? "Dependency resolution" : "Source transformation";
3509
3631
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Panel build failed</title><style>body{font-family:system-ui,sans-serif;margin:2rem;line-height:1.5}pre{white-space:pre-wrap;background:#f4f4f5;padding:1rem;border-radius:.5rem}</style></head><body><h1>Web panel could not be compiled</h1><p><strong>${escapeHtml(source.definitionId)}</strong>${source.packageSource ? ` from ${escapeHtml(source.packageSource)}` : ""}</p><p>Stage: ${stage}</p><pre>${escapeHtml(diagnostic)}</pre><p>For a local panel package, install its <code>node_modules</code>. Put browser runtime imports in <code>dependencies</code>, not <code>devDependencies</code>.</p></body></html>`;
@@ -3642,11 +3764,13 @@ var KeyedTaskQueue = class {
3642
3764
  }
3643
3765
  const result = new Promise((resolve, reject) => {
3644
3766
  state.pending += 1;
3645
- Effect.runSync(Queue.offer(state.queue, {
3646
- run: task,
3647
- resolve: (value) => resolve(value),
3648
- reject
3649
- }));
3767
+ Effect.runSync(Queue.offer(state.queue, { run: async () => {
3768
+ try {
3769
+ resolve(await task());
3770
+ } catch (error) {
3771
+ reject(error);
3772
+ }
3773
+ } }));
3650
3774
  });
3651
3775
  if (!state.running) {
3652
3776
  state.running = true;
@@ -3662,14 +3786,8 @@ var KeyedTaskQueue = class {
3662
3786
  }
3663
3787
  async run(key, state) {
3664
3788
  while (state.pending > 0) {
3665
- const task = await Effect.runPromise(Queue.take(state.queue));
3666
- try {
3667
- task.resolve(await task.run());
3668
- } catch (error) {
3669
- task.reject(error);
3670
- } finally {
3671
- state.pending -= 1;
3672
- }
3789
+ await (await Effect.runPromise(Queue.take(state.queue))).run();
3790
+ state.pending -= 1;
3673
3791
  }
3674
3792
  state.running = false;
3675
3793
  if (state.pending > 0) {
@@ -3689,9 +3807,9 @@ const encodeMetadata = (value) => Buffer.from(JSON.stringify(value), "utf8").toS
3689
3807
  function isAbsentTmuxServer(stderr) {
3690
3808
  return /no server running|no sessions|no current target/i.test(stderr) || /(?:failed to connect|error connecting to).*(?:no such file or directory|connection refused)/i.test(stderr);
3691
3809
  }
3692
- function decodeMetadata(value) {
3810
+ function decodeMetadata(value, schema) {
3693
3811
  if (!value) return;
3694
- return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
3812
+ return schema.parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
3695
3813
  }
3696
3814
  const TMUX_SCROLL_EXIT_SEQUENCE = TERMINAL_SCROLL_EXIT_SEQUENCE;
3697
3815
  const TMUX_SELECTION_CLEAR_SEQUENCE = TERMINAL_SELECTION_CLEAR_SEQUENCE;
@@ -3740,8 +3858,10 @@ bind-key -T copy-mode-vi User4 select-pane -t .
3740
3858
  bind-key -T copy-mode MouseDragEnd1Pane send-keys -X stop-selection
3741
3859
  bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection
3742
3860
  # Keep these explicit so source-file replaces stale bindings in existing servers.
3743
- bind-key -T copy-mode WheelDownPane { select-pane ; send-keys -X -N 5 scroll-down ; if-shell -F '#{&&:#{==:#{scroll_position},0},#{==:#{selection_present},0}}' { send-keys -X cancel } }
3744
- bind-key -T copy-mode-vi WheelDownPane { select-pane ; send-keys -X -N 5 scroll-down ; if-shell -F '#{&&:#{==:#{scroll_position},0},#{==:#{selection_present},0}}' { send-keys -X cancel } }
3861
+ bind-key -T copy-mode WheelUpPane { select-pane ; send-keys -X -N 1 scroll-up }
3862
+ bind-key -T copy-mode-vi WheelUpPane { select-pane ; send-keys -X -N 1 scroll-up }
3863
+ bind-key -T copy-mode WheelDownPane { select-pane ; send-keys -X -N 1 scroll-down ; if-shell -F '#{&&:#{==:#{scroll_position},0},#{==:#{selection_present},0}}' { send-keys -X cancel } }
3864
+ bind-key -T copy-mode-vi WheelDownPane { select-pane ; send-keys -X -N 1 scroll-down ; if-shell -F '#{&&:#{==:#{scroll_position},0},#{==:#{selection_present},0}}' { send-keys -X cancel } }
3745
3865
  bind-key -T root WheelUpPane if-shell -F '#{||:#{alternate_on},#{pane_in_mode},#{mouse_any_flag}}' { send-keys -M } { copy-mode -H }
3746
3866
  `;
3747
3867
  var TmuxAdapter = class {
@@ -3757,6 +3877,7 @@ var TmuxAdapter = class {
3757
3877
  uid;
3758
3878
  creationTails = /* @__PURE__ */ new Map();
3759
3879
  configuredSockets = /* @__PURE__ */ new Set();
3880
+ socketConfigurationPromises = /* @__PURE__ */ new Map();
3760
3881
  initializationPromise = null;
3761
3882
  sshAuthSockPromise = null;
3762
3883
  constructor(runner, runtimeDir, executable = "tmux", launcherPath, host = {}) {
@@ -3846,25 +3967,22 @@ var TmuxAdapter = class {
3846
3967
  });
3847
3968
  await this.configureServer(input.socketName);
3848
3969
  }
3970
+ const environment = { ...input.env };
3971
+ if (sshAuthSock) environment.SSH_AUTH_SOCK = sshAuthSock;
3849
3972
  const spec = {
3850
3973
  argv: [...input.argv],
3851
- ...input.fallbackArgv ? { fallbackArgv: [...input.fallbackArgv] } : {},
3852
3974
  cwd: input.cwd,
3853
- env: {
3854
- ...sshAuthSock ? { SSH_AUTH_SOCK: sshAuthSock } : {},
3855
- ...input.env
3856
- },
3857
- ...shellIntegrationReady ? {
3858
- shellIntegrationDir: this.shellIntegrationDir,
3859
- tmuxExecutable: resolveExecutablePath(this.executable, this.hostEnvironment)
3860
- } : {},
3861
- ...input.setupTasks?.length ? { setupTasks: input.setupTasks.map((task) => ({
3862
- ...task,
3863
- argv: [...task.argv],
3864
- env: { ...task.env }
3865
- })) } : {},
3866
- ...input.setupError ? { setupError: input.setupError } : {}
3975
+ env: environment
3867
3976
  };
3977
+ if (input.fallbackArgv) spec.fallbackArgv = [...input.fallbackArgv];
3978
+ spec.tmuxExecutable = resolveExecutablePath(this.executable, this.hostEnvironment);
3979
+ if (shellIntegrationReady) spec.shellIntegrationDir = this.shellIntegrationDir;
3980
+ if (input.setupTasks?.length) spec.setupTasks = input.setupTasks.map((task) => ({
3981
+ ...task,
3982
+ argv: [...task.argv],
3983
+ env: { ...task.env }
3984
+ }));
3985
+ if (input.setupError) spec.setupError = input.setupError;
3868
3986
  await fs.writeFile(specPath, JSON.stringify(spec), { mode: 384 });
3869
3987
  wroteSpec = true;
3870
3988
  await runChecked(this.runner, {
@@ -3897,6 +4015,8 @@ var TmuxAdapter = class {
3897
4015
  worktreeId: input.worktreeId,
3898
4016
  name: input.name,
3899
4017
  argv: input.argv,
4018
+ shellCommand: input.shellCommand,
4019
+ interactiveShell: input.interactiveShell,
3900
4020
  closeOnSuccess: input.closeOnSuccess ?? false,
3901
4021
  createdAt: input.createdAt,
3902
4022
  updatedAt: input.createdAt
@@ -3924,7 +4044,10 @@ var TmuxAdapter = class {
3924
4044
  }
3925
4045
  }
3926
4046
  async configureServer(socketName) {
3927
- 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, {
3928
4051
  executable: this.executable,
3929
4052
  args: [
3930
4053
  ...this.base(socketName),
@@ -3933,13 +4056,20 @@ var TmuxAdapter = class {
3933
4056
  ],
3934
4057
  env: this.environment(),
3935
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);
3936
4065
  });
3937
- this.configuredSockets.add(socketName);
3938
4066
  }
3939
4067
  async configureSession(socketName, sessionName, metadata) {
3940
4068
  const values = [
3941
4069
  ["@treeport-name", encodeMetadata(metadata.name)],
3942
4070
  ["@treeport-argv", encodeMetadata(metadata.argv)],
4071
+ ["@treeport-shell-command", encodeMetadata(metadata.shellCommand)],
4072
+ ["@treeport-interactive-shell", metadata.interactiveShell ? "1" : "0"],
3943
4073
  ["@treeport-close-on-success", metadata.closeOnSuccess ? "1" : "0"],
3944
4074
  ["@treeport-created-at", encodeMetadata(metadata.createdAt)],
3945
4075
  ["@treeport-updated-at", encodeMetadata(metadata.updatedAt)],
@@ -3992,7 +4122,7 @@ var TmuxAdapter = class {
3992
4122
  "list-panes",
3993
4123
  "-a",
3994
4124
  "-F",
3995
- "#{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}"
3996
4126
  ],
3997
4127
  env: this.environment(),
3998
4128
  timeoutMs: 1e4
@@ -4004,26 +4134,24 @@ var TmuxAdapter = class {
4004
4134
  const sessions = /* @__PURE__ */ new Map();
4005
4135
  for (const line of result.stdout.split("\n")) {
4006
4136
  if (!line) continue;
4007
- 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(" ");
4008
4138
  if (!sessionName || sessions.has(sessionName) || !terminalId || !worktreeId) continue;
4009
4139
  let metadata;
4010
4140
  try {
4011
- const name = decodeMetadata(encodedName ?? "");
4012
- const argv = decodeMetadata(encodedArgv ?? "");
4013
- const createdAt = decodeMetadata(encodedCreatedAt ?? "");
4014
- const updatedAt = decodeMetadata(encodedUpdatedAt ?? "");
4015
- if (name !== void 0 && typeof name !== "string" || argv !== void 0 && (!Array.isArray(argv) || !argv.every((value) => typeof value === "string")) || createdAt !== void 0 && typeof createdAt !== "string" || updatedAt !== void 0 && typeof updatedAt !== "string") continue;
4016
4141
  metadata = {
4017
4142
  terminalId,
4018
4143
  worktreeId,
4019
- name,
4020
- argv,
4021
- createdAt,
4022
- updatedAt
4144
+ name: decodeMetadata(encodedName ?? "", z.string()),
4145
+ argv: decodeMetadata(encodedArgv ?? "", z.array(z.string())),
4146
+ shellCommand: decodeMetadata(encodedShellCommand ?? "", z.string().nullable()),
4147
+ createdAt: decodeMetadata(encodedCreatedAt ?? "", z.string()),
4148
+ updatedAt: decodeMetadata(encodedUpdatedAt ?? "", z.string())
4023
4149
  };
4024
4150
  } catch {
4025
4151
  continue;
4026
4152
  }
4153
+ const interactiveShell = encodedInteractiveShell === "1" ? true : encodedInteractiveShell === "0" ? false : void 0;
4154
+ if (metadata.shellCommand === void 0 || interactiveShell === void 0) continue;
4027
4155
  const fallbackCreatedAt = metadata.createdAt ?? (/* @__PURE__ */ new Date(Number(sessionCreated) * 1e3)).toISOString();
4028
4156
  const dead = paneDead === "1";
4029
4157
  const exitCode = dead && paneDeadStatus ? Number.parseInt(paneDeadStatus, 10) : null;
@@ -4033,6 +4161,8 @@ var TmuxAdapter = class {
4033
4161
  name: metadata.name ?? sessionName,
4034
4162
  sessionName,
4035
4163
  argv: metadata.argv ?? [],
4164
+ shellCommand: metadata.shellCommand,
4165
+ interactiveShell,
4036
4166
  closeOnSuccess: closeOnSuccess === "1",
4037
4167
  status: dead ? "exited" : "running",
4038
4168
  exitCode: Number.isNaN(exitCode) ? null : exitCode,
@@ -4185,7 +4315,7 @@ var TmuxAdapter = class {
4185
4315
  "-p",
4186
4316
  "-t",
4187
4317
  sessionName,
4188
- "#{@treeport-shell-title} #{pane_current_command} #{@treeport-command} #{pane_title}"
4318
+ "#{@treeport-fallback-shell} #{@treeport-shell-title} #{pane_current_command} #{@treeport-command} #{pane_title}"
4189
4319
  ],
4190
4320
  env: this.environment(),
4191
4321
  timeoutMs: 1e4
@@ -4194,20 +4324,22 @@ var TmuxAdapter = class {
4194
4324
  const firstSeparator = result.stdout.indexOf(" ");
4195
4325
  const secondSeparator = result.stdout.indexOf(" ", firstSeparator + 1);
4196
4326
  const thirdSeparator = result.stdout.indexOf(" ", secondSeparator + 1);
4197
- if (firstSeparator === -1 || secondSeparator === -1 || thirdSeparator === -1) return null;
4198
- 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;
4199
4330
  let shellTitle = null;
4200
4331
  try {
4201
- const decoded = decodeMetadata(encodedShellTitle);
4202
- shellTitle = typeof decoded === "string" ? decoded : 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;
4203
4334
  } catch {}
4204
- const currentCommand = result.stdout.slice(firstSeparator + 1, secondSeparator).trim() || null;
4205
- 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;
4206
4337
  return {
4207
- paneTitle: result.stdout.slice(thirdSeparator + 1).trim() || null,
4338
+ paneTitle: result.stdout.slice(fourthSeparator + 1).trim() || null,
4208
4339
  currentCommand,
4209
4340
  commandLine,
4210
- shellTitle
4341
+ shellTitle,
4342
+ fallbackShell
4211
4343
  };
4212
4344
  }
4213
4345
  async setSessionShellTitle(socketName, sessionName, title) {
@@ -4268,7 +4400,7 @@ var TmuxAdapter = class {
4268
4400
  env: this.environment(),
4269
4401
  timeoutMs: 15e3
4270
4402
  });
4271
- if (result.exitCode !== 0 && !isAbsentTmuxServer(result.stderr)) throw new Error(result.stderr.trim() || "Failed to kill worktree tmux server");
4403
+ if (result.exitCode !== 0 && !isAbsentTmuxServer(result.stderr)) throw new Error(result.stderr.trim() || "Failed to kill tree tmux server");
4272
4404
  await Promise.all(terminalIds.map((terminalId) => fs.unlink(path.join(this.specsDir, `${terminalId}.json`)).catch(() => void 0)));
4273
4405
  this.configuredSockets.delete(socketName);
4274
4406
  return terminalIds;
@@ -4282,8 +4414,8 @@ const WEB_PANEL_STORAGE_MAX_ENTRIES = 256;
4282
4414
  const WEB_PANEL_STORAGE_MAX_TOTAL_BYTES = 1024 * 1024;
4283
4415
  const WEB_PANEL_STORAGE_MAX_VALUE_BYTES = 64 * 1024;
4284
4416
  function mapWebPanel(row, allowSameOrigin = false) {
4285
- const input = JSON.parse(row.inputJson);
4286
- if (input !== null && (typeof input !== "object" || Array.isArray(input))) throw new Error(`Web panel ${row.id} has invalid stored launch input`);
4417
+ const parsedInput = webPanelInputSchema.nullable().safeParse(JSON.parse(row.inputJson));
4418
+ if (!parsedInput.success) throw new Error(`Web panel ${row.id} has invalid stored launch input`);
4287
4419
  return {
4288
4420
  id: row.id,
4289
4421
  kind: "web",
@@ -4291,7 +4423,7 @@ function mapWebPanel(row, allowSameOrigin = false) {
4291
4423
  definitionId: row.definitionId,
4292
4424
  title: row.title,
4293
4425
  launch: {
4294
- input,
4426
+ input: parsedInput.data,
4295
4427
  cwd: row.launchCwd
4296
4428
  },
4297
4429
  sandbox: { allowSameOrigin },
@@ -4341,6 +4473,7 @@ var TreeportService = class {
4341
4473
  closeOnSuccessTerminalIds = /* @__PURE__ */ new Set();
4342
4474
  terminalIdsByWorktree = /* @__PURE__ */ new Map();
4343
4475
  projectObservationTails = /* @__PURE__ */ new Map();
4476
+ observedFolderIdentities = /* @__PURE__ */ new Map();
4344
4477
  projectsSnapshotInFlight = null;
4345
4478
  projectsSnapshotRevision = 0;
4346
4479
  packages;
@@ -4404,7 +4537,7 @@ var TreeportService = class {
4404
4537
  await tx.run(sql`
4405
4538
  UPDATE operations
4406
4539
  SET status = 'failed',
4407
- error = ${operation.kind === "create" ? "Daemon restarted before worktree creation completed; existing Git state will be discovered without replaying the creation" : "Daemon restarted before the operation completed; external state was preserved for retry"},
4540
+ error = ${operation.kind === "create" ? "Daemon restarted before tree creation completed; existing Git state will be discovered without replaying the creation" : "Daemon restarted before the operation completed; external state was preserved for retry"},
4408
4541
  updated_at = ${timestamp}
4409
4542
  WHERE id = ${operation.id}
4410
4543
  `);
@@ -4538,9 +4671,11 @@ var TreeportService = class {
4538
4671
  return this.deps.database.db.select({
4539
4672
  id: projects.id,
4540
4673
  name: projects.name,
4674
+ kind: projects.kind,
4675
+ rootPath: projects.repositoryPath,
4541
4676
  repositoryPath: projects.repositoryPath,
4542
4677
  lastOpenedAt: projects.lastOpenedAt
4543
- }).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));
4544
4679
  }
4545
4680
  async collectCurrentProjectsSnapshot() {
4546
4681
  while (true) {
@@ -4553,7 +4688,8 @@ var TreeportService = class {
4553
4688
  return (await Promise.all((await this.storedProjects(true)).map(async (storedProject) => {
4554
4689
  let project = storedProject;
4555
4690
  try {
4556
- 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);
4557
4693
  await this.ensureProjectTerminals(project.id);
4558
4694
  project = await this.storedProject(project.id) ?? project;
4559
4695
  } catch (error) {
@@ -4564,7 +4700,7 @@ var TreeportService = class {
4564
4700
  }
4565
4701
  if (await this.projectOpenState(project.id) !== true) return null;
4566
4702
  await Promise.all(project.worktrees.map(async (worktree) => {
4567
- 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) => {
4568
4704
  project.availability = {
4569
4705
  state: "unavailable",
4570
4706
  message: error instanceof Error ? error.message : String(error)
@@ -4605,6 +4741,8 @@ var TreeportService = class {
4605
4741
  name: terminal.name,
4606
4742
  tmuxSessionName: terminal.sessionName,
4607
4743
  argv: terminal.argv,
4744
+ shellCommand: terminal.shellCommand,
4745
+ interactiveShell: terminal.interactiveShell,
4608
4746
  status: terminal.status,
4609
4747
  exitCode: terminal.exitCode,
4610
4748
  createdAt: terminal.createdAt,
@@ -4643,14 +4781,14 @@ var TreeportService = class {
4643
4781
  const binding = await this.getWorktree(worktreeId);
4644
4782
  await this.requireOpenProject(binding.projectId);
4645
4783
  const worktree = (await this.listProjects()).flatMap((project) => project.worktrees).find((candidate) => candidate.id === worktreeId);
4646
- if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Worktree not found", 404);
4784
+ if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
4647
4785
  return worktree;
4648
4786
  }
4649
4787
  async requireAvailableWorktree(worktreeId, allowPrunable = false) {
4650
4788
  const binding = await this.storedWorktree(worktreeId);
4651
- if (!binding) throw new DomainError("WORKTREE_NOT_FOUND", "Worktree not found", 404);
4789
+ if (!binding) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
4652
4790
  const worktree = (await this.observeAvailableProject(await this.requireOpenProject(binding.projectId))).worktrees.find((candidate) => candidate.id === worktreeId);
4653
- if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Worktree not found", 404);
4791
+ if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
4654
4792
  if (worktree.prunable && !allowPrunable) throw new DomainError("WORKTREE_UNAVAILABLE", "Git reports this worktree as prunable", 409);
4655
4793
  return worktree;
4656
4794
  }
@@ -4668,7 +4806,7 @@ var TreeportService = class {
4668
4806
  const direct = await this.storedProject(identifier);
4669
4807
  if (direct) return direct;
4670
4808
  const canonical = await fs.realpath(path.resolve(identifier)).catch(() => path.resolve(identifier));
4671
- 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)));
4672
4810
  if (!match) throw new DomainError("PROJECT_NOT_FOUND", `No registered project contains ${identifier}`, 404);
4673
4811
  return match;
4674
4812
  }
@@ -4736,7 +4874,7 @@ var TreeportService = class {
4736
4874
  definitions: [],
4737
4875
  diagnostics: []
4738
4876
  }),
4739
- worktree && project ? loadZedTerminalPresetDefinitions({
4877
+ worktree && project?.kind === "repository" ? loadZedTerminalPresetDefinitions({
4740
4878
  projectId: project.id,
4741
4879
  shell: this.deps.config.shell,
4742
4880
  mainWorktreePath: project.mainWorktreePath,
@@ -4758,6 +4896,7 @@ var TreeportService = class {
4758
4896
  name: preset.name,
4759
4897
  executable: preset.executable,
4760
4898
  args: [...preset.args],
4899
+ shellCommand: null,
4761
4900
  cwd: null,
4762
4901
  env: {},
4763
4902
  closeOnSuccess: preset.closeOnSuccess,
@@ -4851,17 +4990,20 @@ var TreeportService = class {
4851
4990
  async effectiveWebPanelDefinitions(worktreeId) {
4852
4991
  const worktree = await this.getWorktree(worktreeId);
4853
4992
  this.packages.syncProjects([await this.getProject(worktree.projectId)]);
4854
- return [...await this.localWebPanelDefinitions(worktreeId), ...(await this.packages.webPanelDefinitions(worktree.projectId)).map(({ definition, root, entry, packageRoot, development, packageLockPath }) => ({
4855
- ...definition,
4856
- root,
4857
- entry,
4858
- packageRoot,
4859
- development,
4860
- ...packageLockPath ? { packageLockPath } : {},
4861
- definitionId: definition.id,
4862
- allowNetworkRequests: definition.sandbox.allowSameOrigin,
4863
- ...definition.source.type === "package" ? { packageSource: definition.source.source } : {}
4864
- }))];
4993
+ return [...await this.localWebPanelDefinitions(worktreeId), ...(await this.packages.webPanelDefinitions(worktree.projectId)).map(({ definition, root, entry, packageRoot, development, packageLockPath }) => {
4994
+ const resolved = {
4995
+ ...definition,
4996
+ root,
4997
+ entry,
4998
+ packageRoot,
4999
+ development,
5000
+ definitionId: definition.id,
5001
+ allowNetworkRequests: definition.sandbox.allowSameOrigin
5002
+ };
5003
+ if (packageLockPath) resolved.packageLockPath = packageLockPath;
5004
+ if (definition.source.type === "package") resolved.packageSource = definition.source.source;
5005
+ return resolved;
5006
+ })];
4865
5007
  }
4866
5008
  async listWebPanelDefinitions(worktreeId) {
4867
5009
  return (await this.effectiveWebPanelDefinitions(worktreeId)).map(({ root: _root, entry: _entry, packageRoot: _packageRoot, development: _development, packageLockPath: _packageLockPath, definitionId: _definitionId, packageSource: _packageSource, allowNetworkRequests: _allowNetworkRequests, ...definition }) => definition);
@@ -4879,7 +5021,7 @@ var TreeportService = class {
4879
5021
  const [worktreeRoot, requestedCwd] = await Promise.all([fs.realpath(worktree.path), fs.realpath(path.resolve(worktree.path, launch.cwd)).catch(() => null)]);
4880
5022
  if (!requestedCwd || !(await fs.stat(requestedCwd)).isDirectory()) throw new DomainError("INVALID_WEB_PANEL_LAUNCH_CWD", "Web panel launch directory does not exist", 400);
4881
5023
  const relativeCwd = path.relative(worktreeRoot, requestedCwd);
4882
- if (relativeCwd === ".." || relativeCwd.startsWith(`..${path.sep}`) || path.isAbsolute(relativeCwd)) throw new DomainError("INVALID_WEB_PANEL_LAUNCH_CWD", "Web panel launch directory must be inside the worktree", 400);
5024
+ if (relativeCwd === ".." || relativeCwd.startsWith(`..${path.sep}`) || path.isAbsolute(relativeCwd)) throw new DomainError("INVALID_WEB_PANEL_LAUNCH_CWD", "Web panel launch directory must be inside the tree", 400);
4883
5025
  return {
4884
5026
  launch: {
4885
5027
  input: launch.input,
@@ -5004,18 +5146,21 @@ var TreeportService = class {
5004
5146
  project: {
5005
5147
  id: project.id,
5006
5148
  name: project.name,
5007
- defaultBranch: project.defaultBranch
5149
+ kind: project.kind,
5150
+ defaultBranch: project.kind === "repository" ? project.defaultBranch : null
5008
5151
  },
5009
5152
  worktree: {
5010
5153
  id: worktree.id,
5011
5154
  name: worktree.name,
5155
+ kind: worktree.kind,
5012
5156
  branch: worktree.branch,
5013
- head: worktree.head
5157
+ head: worktree.kind === "folder" ? null : worktree.head
5014
5158
  }
5015
5159
  };
5016
5160
  }
5017
5161
  async getWebPanelDiff(panelId) {
5018
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);
5019
5164
  const worktree = await this.getWorktree(context.panel.worktreeId);
5020
5165
  return this.deps.git.worktreeDiff(worktree.path, context.project.defaultBranch);
5021
5166
  }
@@ -5077,9 +5222,17 @@ var TreeportService = class {
5077
5222
  }
5078
5223
  async getWorktree(worktreeId) {
5079
5224
  const worktree = await this.storedWorktree(worktreeId);
5080
- if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Worktree not found", 404);
5225
+ if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
5081
5226
  return worktree;
5082
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
+ }
5083
5236
  async getTerminal(terminalId) {
5084
5237
  const matches = (await this.listProjects()).flatMap((project) => project.worktrees).flatMap((worktree) => worktree.terminals).filter((terminal) => terminal.id === terminalId);
5085
5238
  if (matches.length > 1) throw new DomainError("TERMINAL_ID_CONFLICT", "Terminal ID is present in more than one tmux server", 500);
@@ -5113,7 +5266,7 @@ var TreeportService = class {
5113
5266
  const direct = await this.storedProject(identifier);
5114
5267
  if (direct) return await this.requireOpenProject(direct.id);
5115
5268
  const canonical = await fs.realpath(path.resolve(identifier)).catch(() => path.resolve(identifier));
5116
- 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)));
5117
5270
  if (!match) throw new DomainError("PROJECT_NOT_FOUND", `No registered project contains ${identifier}`, 404);
5118
5271
  await this.requireOpenProject(match.id);
5119
5272
  return match;
@@ -5126,7 +5279,7 @@ var TreeportService = class {
5126
5279
  }
5127
5280
  const canonical = await fs.realpath(path.resolve(identifier)).catch(() => path.resolve(identifier));
5128
5281
  const match = (await this.storedProjects()).flatMap((project) => project.worktrees).filter((worktree) => isPathWithin(canonical, worktree.path)).sort((a, b) => b.path.length - a.path.length)[0];
5129
- if (!match) throw new DomainError("WORKTREE_NOT_FOUND", `No registered worktree contains ${identifier}`, 404);
5282
+ if (!match) throw new DomainError("WORKTREE_NOT_FOUND", `No registered tree contains ${identifier}`, 404);
5130
5283
  await this.requireOpenProject(match.projectId);
5131
5284
  return match;
5132
5285
  }
@@ -5186,7 +5339,7 @@ var TreeportService = class {
5186
5339
  path: breadcrumbPath
5187
5340
  });
5188
5341
  }
5189
- 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;
5190
5343
  return {
5191
5344
  input: inputPath,
5192
5345
  exact,
@@ -5199,6 +5352,18 @@ var TreeportService = class {
5199
5352
  entries,
5200
5353
  truncated
5201
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
+ },
5202
5367
  repository: repositoryPath ? {
5203
5368
  state: "valid",
5204
5369
  repositoryPath
@@ -5212,6 +5377,14 @@ var TreeportService = class {
5212
5377
  };
5213
5378
  }
5214
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) {
5215
5388
  const checkout = await this.deps.git.canonicalizeRepositoryPath(inputPath).catch((error) => {
5216
5389
  throw new DomainError("NOT_A_GIT_REPOSITORY", error instanceof Error ? error.message : "Not a Git repository", 400);
5217
5390
  });
@@ -5266,17 +5439,18 @@ var TreeportService = class {
5266
5439
  if (verifiedIdentity !== repositoryIdentity || verifiedStat.dev.toString() !== repositoryDevice || verifiedStat.ino.toString() !== repositoryInode) throw new DomainError("PROJECT_PATH_CONFLICT", "The repository changed during registration", 409);
5267
5440
  await this.deps.database.db.run(sql`
5268
5441
  INSERT INTO projects(
5269
- id,name,repository_path,main_worktree_path,default_branch,
5442
+ id,name,project_kind,repository_path,main_worktree_path,default_branch,
5270
5443
  repository_identity,repository_device,repository_inode,name_is_custom,
5271
- is_open,last_opened_at,created_at,updated_at
5444
+ is_open,show_in_recents,last_opened_at,created_at,updated_at
5272
5445
  ) VALUES(
5273
- ${projectId},${name},${repositoryPath},${mainPath},${defaultBranch},
5446
+ ${projectId},${name},'repository',${repositoryPath},${mainPath},${defaultBranch},
5274
5447
  ${repositoryIdentity},${repositoryDevice},${repositoryInode},
5275
- ${nameIsCustom ? 1 : 0},1,${timestamp},
5448
+ ${nameIsCustom ? 1 : 0},1,0,${timestamp},
5276
5449
  ${existing?.createdAt ?? timestamp},${timestamp}
5277
5450
  )
5278
5451
  ON CONFLICT(id) DO UPDATE SET
5279
5452
  name=excluded.name,
5453
+ project_kind=excluded.project_kind,
5280
5454
  repository_path=excluded.repository_path,
5281
5455
  main_worktree_path=excluded.main_worktree_path,
5282
5456
  default_branch=excluded.default_branch,
@@ -5297,6 +5471,7 @@ var TreeportService = class {
5297
5471
  const timestamp = now();
5298
5472
  await this.deps.database.db.update(projects).set({
5299
5473
  isOpen: 1,
5474
+ showInRecents: 0,
5300
5475
  lastOpenedAt: timestamp,
5301
5476
  updatedAt: timestamp
5302
5477
  }).where(eq(projects.id, projectId));
@@ -5317,9 +5492,123 @@ var TreeportService = class {
5317
5492
  this.events.publish("project.created", { projectId });
5318
5493
  return this.getProjectSnapshot(projectId);
5319
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
+ }
5320
5587
  async observeAvailableProject(project, allowClosed = false) {
5321
5588
  try {
5322
- 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
+ });
5323
5612
  } catch (error) {
5324
5613
  throw new DomainError("PROJECT_UNAVAILABLE", error instanceof Error ? error.message : String(error), 503);
5325
5614
  }
@@ -5332,12 +5621,14 @@ var TreeportService = class {
5332
5621
  try {
5333
5622
  const project = await this.observeAvailableProject(await this.getProject(projectId));
5334
5623
  await this.ensureProjectTerminals(projectId);
5335
- const defaultBranch = await this.deps.git.defaultBranch(project.repositoryPath);
5336
- await this.deps.database.db.run(sql`
5337
- UPDATE projects
5338
- SET default_branch = ${defaultBranch}, updated_at = ${now()}
5339
- WHERE id = ${projectId}
5340
- `);
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
+ }
5341
5632
  await this.reconcile();
5342
5633
  await this.packages.registerProject(await this.getProject(projectId));
5343
5634
  this.invalidateProjectsSnapshot();
@@ -5356,6 +5647,7 @@ var TreeportService = class {
5356
5647
  const timestamp = now();
5357
5648
  await this.deps.database.db.update(projects).set({
5358
5649
  isOpen: 1,
5650
+ showInRecents: 0,
5359
5651
  lastOpenedAt: timestamp,
5360
5652
  updatedAt: timestamp
5361
5653
  }).where(eq(projects.id, projectId));
@@ -5373,7 +5665,7 @@ var TreeportService = class {
5373
5665
  const project = await this.getProject(projectId);
5374
5666
  if (await this.projectOpenState(projectId) !== true) return;
5375
5667
  if (this.projectLocks.has(projectId) || this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
5376
- if (project.worktrees.some((worktree) => this.worktreeLocks.has(worktree.id))) throw new DomainError("PROJECT_BUSY", "A project worktree is already being modified", 409);
5668
+ if (project.worktrees.some((worktree) => this.worktreeLocks.has(worktree.id))) throw new DomainError("PROJECT_BUSY", "A project tree is already being modified", 409);
5377
5669
  this.projectLocks.add(projectId);
5378
5670
  const lockedWorktreeIds = project.worktrees.map((worktree) => worktree.id);
5379
5671
  for (const worktreeId of lockedWorktreeIds) this.worktreeLocks.add(worktreeId);
@@ -5397,6 +5689,7 @@ var TreeportService = class {
5397
5689
  try {
5398
5690
  await this.deps.database.db.update(projects).set({
5399
5691
  isOpen: 0,
5692
+ showInRecents: 1,
5400
5693
  updatedAt: now()
5401
5694
  }).where(eq(projects.id, projectId));
5402
5695
  } catch (error) {
@@ -5414,6 +5707,17 @@ var TreeportService = class {
5414
5707
  }
5415
5708
  });
5416
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
+ }
5417
5721
  async serializeProjectObservation(projectId, operation) {
5418
5722
  const observation = (this.projectObservationTails.get(projectId) ?? Promise.resolve()).then(operation);
5419
5723
  const tail = observation.then(() => void 0, () => void 0);
@@ -5598,7 +5902,7 @@ var TreeportService = class {
5598
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);
5599
5903
  }
5600
5904
  async beginCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
5601
- 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);
5602
5906
  let name;
5603
5907
  try {
5604
5908
  name = normalizeWorktreeName(inputName);
@@ -5608,18 +5912,19 @@ var TreeportService = class {
5608
5912
  if (this.projectLocks.has(projectId) && !this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
5609
5913
  const operationId = id("op");
5610
5914
  const timestamp = now();
5915
+ const request = {
5916
+ name,
5917
+ base
5918
+ };
5919
+ if (initialTerminal) request.initialTerminal = initialTerminal;
5920
+ if (sourceWorktreeId) request.sourceWorktreeId = sourceWorktreeId;
5611
5921
  await this.deps.database.db.run(sql`
5612
5922
  INSERT INTO operations(
5613
5923
  id,kind,project_id,worktree_id,status,request_json,result_json,error,
5614
5924
  created_at,updated_at
5615
5925
  ) VALUES(
5616
5926
  ${operationId},'create',${projectId},NULL,'pending',
5617
- ${serializeOperation({
5618
- name,
5619
- base,
5620
- ...initialTerminal ? { initialTerminal } : {},
5621
- ...sourceWorktreeId ? { sourceWorktreeId } : {}
5622
- })},NULL,NULL,${timestamp},${timestamp}
5927
+ ${serializeOperation(request)},NULL,NULL,${timestamp},${timestamp}
5623
5928
  )
5624
5929
  `);
5625
5930
  const operation = await this.getOperation(operationId);
@@ -5668,7 +5973,7 @@ var TreeportService = class {
5668
5973
  }
5669
5974
  }
5670
5975
  async createWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId) {
5671
- 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);
5672
5977
  if (this.projectLocks.has(projectId) && !this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
5673
5978
  return this.worktreeMutations.enqueue(projectId, () => this.executeCreateWorktree(projectId, inputName, base, initialTerminal, sourceWorktreeId));
5674
5979
  }
@@ -5683,13 +5988,14 @@ var TreeportService = class {
5683
5988
  let wrapperCreated = false;
5684
5989
  try {
5685
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);
5686
5992
  let name;
5687
5993
  try {
5688
5994
  name = normalizeWorktreeName(inputName);
5689
5995
  } catch (error) {
5690
5996
  throw new DomainError("INVALID_WORKTREE_NAME", error instanceof Error ? error.message : String(error), 400);
5691
5997
  }
5692
- if (project.worktrees.some((worktree) => worktree.name.localeCompare(name, void 0, { sensitivity: "accent" }) === 0)) throw new DomainError("WORKTREE_EXISTS", `A worktree named ${name} already exists`, 409);
5998
+ if (project.worktrees.some((worktree) => worktree.name.localeCompare(name, void 0, { sensitivity: "accent" }) === 0)) throw new DomainError("WORKTREE_EXISTS", `A tree named ${name} already exists`, 409);
5693
5999
  const destination = await resolveZedWorktreePath(project.mainWorktreePath, name).catch((error) => {
5694
6000
  throw new DomainError("INVALID_WORKTREE_PATH", error instanceof Error ? error.message : String(error), 400);
5695
6001
  });
@@ -5698,9 +6004,9 @@ var TreeportService = class {
5698
6004
  if (await fs.access(worktreePath).then(() => true, () => false)) throw new DomainError("WORKTREE_PATH_EXISTS", `Destination already exists: ${worktreePath}`, 409);
5699
6005
  let commit;
5700
6006
  if (base === "current") {
5701
- if (!sourceWorktreeId) throw new DomainError("INVALID_SOURCE_WORKTREE", "A source worktree is required when starting from current", 400);
6007
+ if (!sourceWorktreeId) throw new DomainError("INVALID_SOURCE_WORKTREE", "A source tree is required when starting from current", 400);
5702
6008
  const source = await this.getWorktree(sourceWorktreeId);
5703
- if (source.projectId !== projectId || source.prunable) throw new DomainError("INVALID_SOURCE_WORKTREE", "The source worktree must be active and belong to the project", 400);
6009
+ if (source.projectId !== projectId || source.prunable) throw new DomainError("INVALID_SOURCE_WORKTREE", "The source tree must be active and belong to the project", 400);
5704
6010
  commit = await this.deps.git.resolveCommit(source.path);
5705
6011
  } else commit = await this.deps.git.resolveDefaultCommit(project.repositoryPath);
5706
6012
  let preparedWrapper;
@@ -5740,10 +6046,10 @@ var TreeportService = class {
5740
6046
  let terminalError = null;
5741
6047
  let setupError = null;
5742
6048
  if (initialTerminal) {
5743
- const initialTerminalCreation = this.executeCreateTerminal(worktree.id, initialTerminal.name, initialTerminal.argv, {
5744
- ...initialTerminal.returnToShell ? { returnToShell: true } : {},
5745
- ...initialTerminal.initialSize ? { initialSize: initialTerminal.initialSize } : {}
5746
- });
6049
+ const launchOptions = {};
6050
+ if (initialTerminal.returnToShell) launchOptions.returnToShell = true;
6051
+ if (initialTerminal.initialSize) launchOptions.initialSize = initialTerminal.initialSize;
6052
+ const initialTerminalCreation = this.executeCreateTerminal(worktree.id, initialTerminal.name, initialTerminal.argv, launchOptions);
5747
6053
  const setupResolution = resolveWorktreeSetupTasks({
5748
6054
  shell: this.deps.config.shell,
5749
6055
  mainWorktreePath: project.mainWorktreePath,
@@ -5753,7 +6059,7 @@ var TreeportService = class {
5753
6059
  error: null
5754
6060
  }), (error) => ({
5755
6061
  tasks: [],
5756
- error: `worktree setup: ${error instanceof Error ? error.message : String(error)}`.slice(0, 4096)
6062
+ error: `Tree setup: ${error instanceof Error ? error.message : String(error)}`.slice(0, 4096)
5757
6063
  }));
5758
6064
  try {
5759
6065
  terminal = await initialTerminalCreation;
@@ -5767,18 +6073,19 @@ var TreeportService = class {
5767
6073
  }
5768
6074
  const setup = await setupResolution;
5769
6075
  setupError = setup.error;
5770
- if (setup.tasks.length > 0 || setupError) if (!terminal) setupError ??= "worktree setup: no persistent terminal could be started";
6076
+ if (setup.tasks.length > 0 || setupError) if (!terminal) setupError ??= "Tree setup: no persistent terminal could be started";
5771
6077
  else try {
5772
- await this.executeCreateTerminal(worktree.id, "Setup", ["true"], {
6078
+ const setupOptions = {
5773
6079
  setup: {
5774
6080
  tasks: setup.tasks,
5775
6081
  error: setupError
5776
6082
  },
5777
- closeOnSuccess: true,
5778
- ...initialTerminal.initialSize ? { initialSize: initialTerminal.initialSize } : {}
5779
- });
6083
+ closeOnSuccess: true
6084
+ };
6085
+ if (initialTerminal.initialSize) setupOptions.initialSize = initialTerminal.initialSize;
6086
+ await this.executeCreateTerminal(worktree.id, "Setup", ["true"], setupOptions);
5780
6087
  } catch (error) {
5781
- const setupTerminalError = `worktree setup terminal${error instanceof DomainError ? ` [${error.code}]` : ""}: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2048);
6088
+ const setupTerminalError = `Tree setup terminal${error instanceof DomainError ? ` [${error.code}]` : ""}: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2048);
5782
6089
  setupError = setupError ? `${setupError.slice(0, 2047)}\n${setupTerminalError}` : setupTerminalError;
5783
6090
  }
5784
6091
  } else {
@@ -5790,7 +6097,7 @@ var TreeportService = class {
5790
6097
  runner: this.deps.runner,
5791
6098
  tasks
5792
6099
  })).catch((error) => [{
5793
- label: "worktree setup",
6100
+ label: "Tree setup",
5794
6101
  error: error instanceof Error ? error.message : String(error)
5795
6102
  }])).find((result) => result.error);
5796
6103
  setupError = setupFailure ? `${setupFailure.label}: ${setupFailure.error}`.slice(0, 4096) : null;
@@ -5835,34 +6142,43 @@ var TreeportService = class {
5835
6142
  const project = await this.requireOpenProject(worktree.projectId);
5836
6143
  const terminalId = id("term");
5837
6144
  const sessionName = generateTmuxSessionName();
5838
- 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"];
5839
6152
  const timestamp = now();
6153
+ const session = {
6154
+ socketName: worktree.tmuxSocketName,
6155
+ sessionName,
6156
+ terminalId,
6157
+ worktreeId: worktree.id,
6158
+ name,
6159
+ createdAt: timestamp,
6160
+ cwd: options?.cwd ?? worktree.path,
6161
+ argv: commandArgv,
6162
+ shellCommand,
6163
+ interactiveShell,
6164
+ env: {
6165
+ ...options?.env ?? {},
6166
+ TREEPORT_API_URL: this.deps.config.apiUrl,
6167
+ TREEPORT_MANAGED_API_URL: this.deps.config.apiUrl,
6168
+ TREEPORT_DAEMON_RECORD: path.join(this.deps.config.runtimeDir, "daemon.json"),
6169
+ TREEPORT_DAEMON_LIFECYCLE: this.deps.config.daemonLifecycle,
6170
+ TREEPORT_PROJECT_ID: project.id,
6171
+ TREEPORT_WORKTREE_ID: worktree.id,
6172
+ TREEPORT_TERMINAL_ID: terminalId
6173
+ }
6174
+ };
6175
+ if (options?.returnToShell && !interactiveShell) session.fallbackArgv = [this.deps.config.shell, "-l"];
6176
+ if (options?.closeOnSuccess) session.closeOnSuccess = true;
6177
+ if (options?.initialSize) session.initialSize = options.initialSize;
6178
+ if (options?.setup?.tasks.length) session.setupTasks = options.setup.tasks;
6179
+ if (options?.setup?.error) session.setupError = options.setup.error;
5840
6180
  try {
5841
- await this.deps.tmux.createSession({
5842
- socketName: worktree.tmuxSocketName,
5843
- sessionName,
5844
- terminalId,
5845
- worktreeId: worktree.id,
5846
- name,
5847
- createdAt: timestamp,
5848
- cwd: options?.cwd ?? worktree.path,
5849
- argv: commandArgv,
5850
- ...options?.returnToShell && argv ? { fallbackArgv: [this.deps.config.shell, "-l"] } : {},
5851
- ...options?.closeOnSuccess ? { closeOnSuccess: true } : {},
5852
- ...options?.initialSize ? { initialSize: options.initialSize } : {},
5853
- env: {
5854
- ...options?.env ?? {},
5855
- TREEPORT_API_URL: this.deps.config.apiUrl,
5856
- TREEPORT_MANAGED_API_URL: this.deps.config.apiUrl,
5857
- TREEPORT_DAEMON_RECORD: path.join(this.deps.config.runtimeDir, "daemon.json"),
5858
- TREEPORT_DAEMON_LIFECYCLE: this.deps.config.daemonLifecycle,
5859
- TREEPORT_PROJECT_ID: project.id,
5860
- TREEPORT_WORKTREE_ID: worktree.id,
5861
- TREEPORT_TERMINAL_ID: terminalId
5862
- },
5863
- ...options?.setup?.tasks.length ? { setupTasks: options.setup.tasks } : {},
5864
- ...options?.setup?.error ? { setupError: options.setup.error } : {}
5865
- });
6181
+ await this.deps.tmux.createSession(session);
5866
6182
  } catch (error) {
5867
6183
  throw new DomainError("TERMINAL_CREATE_FAILED", error instanceof Error ? error.message : String(error), 500);
5868
6184
  }
@@ -5872,6 +6188,8 @@ var TreeportService = class {
5872
6188
  name,
5873
6189
  tmuxSessionName: sessionName,
5874
6190
  argv: commandArgv,
6191
+ shellCommand,
6192
+ interactiveShell,
5875
6193
  status: "running",
5876
6194
  exitCode: null,
5877
6195
  createdAt: timestamp,
@@ -5898,8 +6216,8 @@ var TreeportService = class {
5898
6216
  await this.requireAvailableWorktree(worktreeId);
5899
6217
  try {
5900
6218
  const worktree = await this.storedWorktree(worktreeId);
5901
- if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Worktree not found", 404);
5902
- if (this.projectLocks.has(worktree.projectId) || this.worktreeLocks.has(worktreeId) || worktree.prunable) throw new DomainError("WORKTREE_BUSY", "Cannot create a terminal while the worktree is being modified", 409);
6219
+ if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
6220
+ if (this.projectLocks.has(worktree.projectId) || this.worktreeLocks.has(worktreeId) || worktree.prunable) throw new DomainError("WORKTREE_BUSY", "Cannot create a terminal while the tree is being modified", 409);
5903
6221
  this.worktreeLocks.add(worktreeId);
5904
6222
  try {
5905
6223
  return await this.createTerminalSession(worktree, name, argv, options);
@@ -5912,7 +6230,7 @@ var TreeportService = class {
5912
6230
  }
5913
6231
  }
5914
6232
  async refreshTerminalStatus(terminalId, observeGit = true) {
5915
- 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);
5916
6234
  const worktree = await this.getWorktree(terminal.worktreeId);
5917
6235
  const state = await this.deps.tmux.sessionState(worktree.tmuxSocketName, terminal.tmuxSessionName);
5918
6236
  await this.requireOpenProject(worktree.projectId);
@@ -5983,7 +6301,7 @@ var TreeportService = class {
5983
6301
  }
5984
6302
  async executeDeleteTerminal(terminalId, worktreeId) {
5985
6303
  const worktree = await this.storedWorktree(worktreeId);
5986
- if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Worktree not found", 404);
6304
+ if (!worktree) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
5987
6305
  await this.requireOpenProject(worktree.projectId);
5988
6306
  if (this.projectLocks.has(worktree.projectId) || this.worktreeLocks.has(worktree.id)) throw new DomainError("WORKTREE_BUSY", "Cannot delete a terminal during a destructive project operation", 409);
5989
6307
  this.worktreeLocks.add(worktree.id);
@@ -5991,7 +6309,7 @@ var TreeportService = class {
5991
6309
  const terminals = await this.listWorktreeTerminals(worktree);
5992
6310
  const terminal = terminals.find((candidate) => candidate.id === terminalId);
5993
6311
  if (!terminal) throw new DomainError("TERMINAL_NOT_FOUND", "Terminal not found", 404);
5994
- if (terminals.length <= 1 || terminals.every((candidate) => candidate.id === terminalId || this.closeOnSuccessTerminalIds.has(candidate.id))) throw new DomainError("LAST_TERMINAL", "Every open worktree must keep at least one terminal", 409);
6312
+ if (terminals.length <= 1 || terminals.every((candidate) => candidate.id === terminalId || this.closeOnSuccessTerminalIds.has(candidate.id))) throw new DomainError("LAST_TERMINAL", "Every open tree must keep at least one terminal", 409);
5995
6313
  await this.deps.tmux.killSession(worktree.tmuxSocketName, terminal.tmuxSessionName, terminal.id, { preserveServer: true });
5996
6314
  } finally {
5997
6315
  this.worktreeLocks.delete(worktree.id);
@@ -6012,8 +6330,8 @@ var TreeportService = class {
6012
6330
  if (!force && age < 6e4) return worktree.pr;
6013
6331
  await this.requireOpenProject(worktree.projectId);
6014
6332
  const pr = await this.deps.gh.pullRequest(worktree.path, worktree.branch);
6015
- if (!await this.storedWorktree(worktreeId)) throw new DomainError("WORKTREE_NOT_FOUND", "Worktree not found", 404);
6016
- if (this.worktreeLocks.has(worktreeId)) throw new DomainError("WORKTREE_UNAVAILABLE", "Cannot refresh a pull request while the worktree is being removed", 409);
6333
+ if (!await this.storedWorktree(worktreeId)) throw new DomainError("WORKTREE_NOT_FOUND", "Tree not found", 404);
6334
+ if (this.worktreeLocks.has(worktreeId)) throw new DomainError("WORKTREE_UNAVAILABLE", "Cannot refresh a pull request while the tree is being removed", 409);
6017
6335
  await this.deps.database.db.run(sql`
6018
6336
  UPDATE worktrees
6019
6337
  SET pr_state=${pr.state},pr_number=${pr.number},pr_url=${pr.url},
@@ -6030,6 +6348,7 @@ var TreeportService = class {
6030
6348
  const worktree = await this.requireAvailableWorktree(worktreeId, true);
6031
6349
  worktree.terminals = await this.listWorktreeTerminals(worktree);
6032
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);
6033
6352
  const live = (await this.deps.git.listWorktrees(project.repositoryPath)).find((item) => item.path === worktree.path);
6034
6353
  if (!live) throw new DomainError("WORKTREE_NOT_FOUND", "Git no longer reports this worktree", 404);
6035
6354
  const head = live.head ?? worktree.head;
@@ -6049,7 +6368,7 @@ var TreeportService = class {
6049
6368
  const reasons = [];
6050
6369
  const warnings = [];
6051
6370
  if (worktree.kind === "main") reasons.push("The main checkout cannot be removed");
6052
- if (live.locked) reasons.push(live.lockReason ? `The worktree is locked: ${live.lockReason}` : "The worktree is locked");
6371
+ if (live.locked) reasons.push(live.lockReason ? `The tree is locked: ${live.lockReason}` : "The tree is locked");
6053
6372
  if (dirty.staged) warnings.push(`${dirty.staged} staged change(s) will be lost`);
6054
6373
  if (dirty.unstaged) warnings.push(`${dirty.unstaged} unstaged change(s) will be lost`);
6055
6374
  if (dirty.untracked) warnings.push(`${dirty.untracked} untracked file(s) will be lost`);
@@ -6098,25 +6417,25 @@ var TreeportService = class {
6098
6417
  AND status IN ('pending','running')
6099
6418
  LIMIT 1
6100
6419
  `);
6101
- if (activeRemoval) throw new DomainError("REMOVE_IN_PROGRESS", "The worktree is already being removed", 409);
6420
+ if (activeRemoval) throw new DomainError("REMOVE_IN_PROGRESS", "The tree is already being removed", 409);
6102
6421
  if (this.terminalMutations.has(worktreeId)) return this.terminalMutations.enqueue(worktreeId, () => {
6103
6422
  if (this.worktreeMutations.has(worktree.projectId)) return this.worktreeMutations.enqueue(worktree.projectId, () => this.acceptRemove(worktreeId, request));
6104
6423
  return this.acceptRemove(worktreeId, request);
6105
6424
  });
6106
6425
  if (this.worktreeMutations.has(worktree.projectId)) return this.worktreeMutations.enqueue(worktree.projectId, () => this.acceptRemove(worktreeId, request));
6107
- if (this.projectLocks.has(worktree.projectId) || this.worktreeLocks.has(worktreeId)) throw new DomainError("REMOVE_IN_PROGRESS", "The worktree or project is already being modified", 409);
6426
+ if (this.projectLocks.has(worktree.projectId) || this.worktreeLocks.has(worktreeId)) throw new DomainError("REMOVE_IN_PROGRESS", "The tree or project is already being modified", 409);
6108
6427
  return this.acceptRemove(worktreeId, request);
6109
6428
  }
6110
6429
  async acceptRemove(worktreeId, request) {
6111
6430
  const worktree = await this.getWorktree(worktreeId);
6112
6431
  await this.requireOpenProject(worktree.projectId);
6113
- if (this.worktreeLocks.has(worktreeId) || this.projectLocks.has(worktree.projectId)) throw new DomainError("REMOVE_IN_PROGRESS", "The worktree or project is already being modified", 409);
6432
+ if (this.worktreeLocks.has(worktreeId) || this.projectLocks.has(worktree.projectId)) throw new DomainError("REMOVE_IN_PROGRESS", "The tree or project is already being modified", 409);
6114
6433
  this.worktreeLocks.add(worktreeId);
6115
6434
  let operationStarted = false;
6116
6435
  try {
6117
6436
  const { preview, prunable } = await this.prepareRemovePreview(worktreeId);
6118
- if (!preview.eligible) throw new DomainError("REMOVE_REFUSED", "The worktree cannot be removed", 409, preview);
6119
- if (request.confirmationToken !== preview.confirmationToken) throw new DomainError("REMOVE_PREVIEW_STALE", "The worktree changed after the removal preview; review it again", 409, preview);
6437
+ if (!preview.eligible) throw new DomainError("REMOVE_REFUSED", "The tree cannot be removed", 409, preview);
6438
+ if (request.confirmationToken !== preview.confirmationToken) throw new DomainError("REMOVE_PREVIEW_STALE", "The tree changed after the removal preview; review it again", 409, preview);
6120
6439
  if (preview.warnings.length > 0 && !request.confirmDestructive) throw new DomainError("REMOVE_CONFIRMATION_REQUIRED", "Confirm the destructive removal after reviewing its warnings", 409, preview);
6121
6440
  const checkout = await this.checkoutStat(preview.path);
6122
6441
  const [checkoutBinding] = await this.deps.database.db.all(sql`
@@ -6129,11 +6448,11 @@ var TreeportService = class {
6129
6448
  const operationId = id("op");
6130
6449
  let checkoutIdentity = null;
6131
6450
  if (prunable) {
6132
- if (!checkoutBinding?.git_worktree_key) throw new DomainError("REMOVE_PREVIEW_STALE", "The prunable worktree changed after the removal preview; review it again", 409, preview);
6451
+ if (!checkoutBinding?.git_worktree_key) throw new DomainError("REMOVE_PREVIEW_STALE", "The prunable tree changed after the removal preview; review it again", 409, preview);
6133
6452
  } else {
6134
6453
  const markerPath = path.join(preview.path, ".git");
6135
6454
  const gitMarker = (await this.checkoutStat(markerPath))?.isFile() ? await fs.readFile(markerPath, "utf8").catch(() => null) : null;
6136
- if (!checkout?.isDirectory() || !checkoutBinding?.git_worktree_key || gitMarker === null || !gitMarkerMatchesKey(preview.path, gitMarker, checkoutBinding.git_worktree_key)) throw new DomainError("REMOVE_PREVIEW_STALE", "The worktree checkout changed after the removal preview; review it again", 409, preview);
6455
+ if (!checkout?.isDirectory() || !checkoutBinding?.git_worktree_key || gitMarker === null || !gitMarkerMatchesKey(preview.path, gitMarker, checkoutBinding.git_worktree_key)) throw new DomainError("REMOVE_PREVIEW_STALE", "The tree checkout changed after the removal preview; review it again", 409, preview);
6137
6456
  checkoutIdentity = {
6138
6457
  path: preview.path,
6139
6458
  device: checkout.dev.toString(),
@@ -6212,12 +6531,12 @@ var TreeportService = class {
6212
6531
  try {
6213
6532
  const liveWorktrees = await this.deps.git.listWorktrees(project.repositoryPath);
6214
6533
  const acceptedKey = request.gitWorktreeKey;
6215
- const liveAccepted = liveWorktrees.find((item) => item.path === preview.path && (request.prunable ? item.prunable : typeof acceptedKey === "string" && item.gitWorktreeKey === acceptedKey));
6534
+ const liveAccepted = liveWorktrees.find((item) => item.path === preview.path && (request.prunable ? item.prunable : acceptedKey !== null && item.gitWorktreeKey === acceptedKey));
6216
6535
  const liveRepositoryIdentity = await this.deps.git.repositoryIdentity(project.repositoryPath);
6217
6536
  if (liveAccepted) {
6218
6537
  if (!request.repositoryIdentity || liveRepositoryIdentity !== request.repositoryIdentity) throw new Error("Removal revalidation failed before destructive effects: the repository identity changed after removal was accepted");
6219
6538
  if (request.prunable) {
6220
- if (!liveAccepted.prunable) throw new Error("Removal revalidation failed before destructive effects: the accepted worktree is no longer prunable");
6539
+ if (!liveAccepted.prunable) throw new Error("Removal revalidation failed before destructive effects: the accepted tree is no longer prunable");
6221
6540
  } else {
6222
6541
  const authorizationError = await this.authorizedCheckoutError(preview.path, request.checkoutIdentity);
6223
6542
  if (authorizationError) throw new Error(`Removal revalidation failed before destructive effects: ${authorizationError}`);
@@ -6350,21 +6669,22 @@ var TreeportService = class {
6350
6669
  async deleteProject(projectId) {
6351
6670
  if (this.projectLocks.has(projectId) || this.worktreeMutations.has(projectId)) throw new DomainError("PROJECT_BUSY", "Project is already being modified", 409);
6352
6671
  let project = await this.getProject(projectId);
6353
- if (project.worktrees.some((worktree) => this.worktreeLocks.has(worktree.id))) throw new DomainError("PROJECT_BUSY", "A project worktree is already being modified", 409);
6672
+ if (project.worktrees.some((worktree) => this.worktreeLocks.has(worktree.id))) throw new DomainError("PROJECT_BUSY", "A project tree is already being modified", 409);
6354
6673
  this.projectLocks.add(projectId);
6355
6674
  const lockedWorktrees = [];
6356
6675
  try {
6357
6676
  project = await this.observeAvailableProject(project, true);
6358
- if (this.worktreeMutations.has(projectId) || project.worktrees.some((worktree) => this.worktreeLocks.has(worktree.id))) throw new DomainError("PROJECT_BUSY", "A project worktree is already being modified", 409);
6677
+ if (this.worktreeMutations.has(projectId) || project.worktrees.some((worktree) => this.worktreeLocks.has(worktree.id))) throw new DomainError("PROJECT_BUSY", "A project tree is already being modified", 409);
6359
6678
  for (const worktree of project.worktrees) {
6360
6679
  this.worktreeLocks.add(worktree.id);
6361
6680
  lockedWorktrees.push(worktree.id);
6362
6681
  }
6363
6682
  project = await this.getProject(projectId);
6364
- if (project.worktrees.filter((worktree) => worktree.kind === "linked").length) throw new DomainError("PROJECT_HAS_WORKTREES", "Remove linked worktrees before unregistering the project", 409);
6683
+ if (project.worktrees.filter((worktree) => worktree.kind === "linked").length) throw new DomainError("PROJECT_HAS_WORKTREES", "Remove linked trees before unregistering the project", 409);
6365
6684
  const terminalIdsByWorktree = /* @__PURE__ */ new Map();
6366
6685
  for (const worktree of project.worktrees) terminalIdsByWorktree.set(worktree.id, await this.deps.tmux.killServer(worktree.tmuxSocketName));
6367
6686
  await this.deps.database.db.run(sql`DELETE FROM projects WHERE id=${projectId}`);
6687
+ this.observedFolderIdentities.delete(projectId);
6368
6688
  this.packages.forgetProject(projectId);
6369
6689
  for (const worktree of project.worktrees) this.clearWorktreeTerminalState(worktree.id, terminalIdsByWorktree.get(worktree.id));
6370
6690
  this.invalidateProjectsSnapshot();
@@ -6391,7 +6711,7 @@ var TreeportService = class {
6391
6711
  async reconcile() {
6392
6712
  const availableProjects = /* @__PURE__ */ new Set();
6393
6713
  for (const project of await this.storedProjects(true)) try {
6394
- await this.importWorktrees(project.id, project.repositoryPath, project.mainWorktreePath);
6714
+ await this.observeAvailableProject(project);
6395
6715
  availableProjects.add(project.id);
6396
6716
  } catch {}
6397
6717
  for (const project of await this.storedProjects(true)) {
@@ -6598,10 +6918,12 @@ var TmuxControlParser = class {
6598
6918
  const text = ascii(line);
6599
6919
  if (/^%(?:begin|end|error)(?: |$)/.test(text)) throw new TmuxControlProtocolError("Unexpected or malformed command guard");
6600
6920
  const lifecycle = text.match(/^%(pause|continue) (%\d+)$/);
6601
- if (lifecycle) {
6921
+ const lifecycleType = lifecycle?.[1];
6922
+ const lifecyclePaneId = lifecycle?.[2];
6923
+ if ((lifecycleType === "pause" || lifecycleType === "continue") && lifecyclePaneId) {
6602
6924
  events.push({
6603
- type: lifecycle[1],
6604
- paneId: lifecycle[2]
6925
+ type: lifecycleType,
6926
+ paneId: lifecyclePaneId
6605
6927
  });
6606
6928
  return;
6607
6929
  }
@@ -6757,6 +7079,7 @@ async function terminateProcess(child, terminationStarted) {
6757
7079
  var TmuxProgressObserver = class {
6758
7080
  options;
6759
7081
  spawnProcess;
7082
+ closed;
6760
7083
  lifecycleFiber;
6761
7084
  process = null;
6762
7085
  metadataParser = null;
@@ -6771,6 +7094,7 @@ var TmuxProgressObserver = class {
6771
7094
  this.options = options;
6772
7095
  this.spawnProcess = spawnProcess;
6773
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);
6774
7098
  }
6775
7099
  dispose() {
6776
7100
  if (this.disposed) return;
@@ -6927,22 +7251,6 @@ const PROGRAM_COMMANDS = /* @__PURE__ */ new Map([
6927
7251
  ["claude", "claude"],
6928
7252
  ["codex", "codex"]
6929
7253
  ]);
6930
- const SHELL_COMMANDS = /* @__PURE__ */ new Set([
6931
- "ash",
6932
- "bash",
6933
- "csh",
6934
- "dash",
6935
- "elvish",
6936
- "fish",
6937
- "ksh",
6938
- "mksh",
6939
- "nu",
6940
- "pwsh",
6941
- "sh",
6942
- "tcsh",
6943
- "xonsh",
6944
- "zsh"
6945
- ]);
6946
7254
  var TerminalMetadataRuntimeError = class {
6947
7255
  phase;
6948
7256
  terminalId;
@@ -6969,6 +7277,7 @@ var TerminalMetadataManager = class {
6969
7277
  bellMutations = new KeyedTaskQueue();
6970
7278
  bellDeletionVersions = /* @__PURE__ */ new Map();
6971
7279
  persistedBells = /* @__PURE__ */ new Map();
7280
+ observerShutdowns = /* @__PURE__ */ new Set();
6972
7281
  bellStateStore;
6973
7282
  initializePromise = null;
6974
7283
  unsubscribeEvents = null;
@@ -7034,8 +7343,10 @@ var TerminalMetadataManager = class {
7034
7343
  entry = void 0;
7035
7344
  }
7036
7345
  if (!entry) {
7037
- const launchCommand = path.basename(terminal.argv?.[0] ?? "").replace(/^-/, "");
7038
- 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;
7039
7350
  this.bellDeletionVersions.set(terminal.id, (this.bellDeletionVersions.get(terminal.id) ?? 0) + 1);
7040
7351
  const persistedBell = this.persistedBells.get(terminal.id);
7041
7352
  const bell = persistedBell?.worktreeId === terminal.worktreeId ? {
@@ -7060,7 +7371,8 @@ var TerminalMetadataManager = class {
7060
7371
  paneTitle: null,
7061
7372
  currentCommand: null,
7062
7373
  commandLine: null,
7063
- shellCommand: SHELL_COMMANDS.has(launchCommand) ? launchCommand : null,
7374
+ launchCommandLine,
7375
+ interactiveShellCommand,
7064
7376
  launchProgram,
7065
7377
  shellTitle: null,
7066
7378
  persistedShellTitle: null,
@@ -7164,8 +7476,9 @@ var TerminalMetadataManager = class {
7164
7476
  this.listeners.clear();
7165
7477
  this.historyListeners.clear();
7166
7478
  }
7167
- drain() {
7168
- return this.bellMutations.drain();
7479
+ async drain() {
7480
+ await this.bellMutations.drain();
7481
+ await Promise.allSettled([...this.observerShutdowns]);
7169
7482
  }
7170
7483
  handleProductEvent(event) {
7171
7484
  if (event.type === "terminal.removed") {
@@ -7333,7 +7646,7 @@ var TerminalMetadataManager = class {
7333
7646
  this.update(entry, { progress: null });
7334
7647
  }
7335
7648
  });
7336
- 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);
7337
7650
  else entry.observer = observer;
7338
7651
  },
7339
7652
  catch: (cause) => new TerminalMetadataRuntimeError("create_observer", entry.terminalId, cause)
@@ -7362,17 +7675,31 @@ var TerminalMetadataManager = class {
7362
7675
  }
7363
7676
  this.update(entry, { progress: null });
7364
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
+ }
7365
7685
  releaseRuntimeResources(entry) {
7366
7686
  entry.observerVersion += 1;
7367
- entry.observer?.dispose();
7368
- entry.observer = null;
7687
+ if (entry.observer) {
7688
+ this.disposeObserver(entry.observer);
7689
+ entry.observer = null;
7690
+ }
7369
7691
  this.clearProgressRuntime(entry);
7370
7692
  entry.shellTitleWriting = false;
7371
7693
  }
7372
7694
  reconcileTitleState(entry, state) {
7373
7695
  const paneTitle = state.paneTitle?.trim().slice(0, 256) || null;
7374
7696
  const currentCommand = state.currentCommand?.trim().slice(0, 256) || null;
7375
- 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;
7376
7703
  const previousCommand = entry.currentCommand;
7377
7704
  const previousCommandLine = entry.commandLine;
7378
7705
  const paneTitleChanged = paneTitle !== entry.paneTitle;
@@ -7392,12 +7719,8 @@ var TerminalMetadataManager = class {
7392
7719
  const commandToken = commandLine?.match(/^(?:exec\s+|command\s+)?(?:"([^"]+)"|'([^']+)'|(\S+))/);
7393
7720
  const commandExecutable = commandToken ? commandToken[1] || commandToken[2] || commandToken[3] || null : null;
7394
7721
  const observedProgram = PROGRAM_COMMANDS.get(path.basename(commandExecutable ?? "").replace(/^-/, "")) ?? PROGRAM_COMMANDS.get(path.basename(currentCommand ?? "").replace(/^-/, "")) ?? null;
7395
- this.update(entry, { program: observedProgram ?? (!entry.shellCommand ? entry.launchProgram : null) });
7396
- if (!entry.shellCommand) {
7397
- this.update(entry, { title: paneTitle ?? commandLine ?? currentCommand });
7398
- return;
7399
- }
7400
- if (currentCommand === entry.shellCommand) {
7722
+ this.update(entry, { program: observedProgram ?? (!entry.interactiveShellCommand ? entry.launchProgram : null) });
7723
+ if (entry.interactiveShellCommand && currentCommand === entry.interactiveShellCommand) {
7401
7724
  const applicationTitleWasActive = entry.applicationTitleActive;
7402
7725
  const freshShellTitle = observedTitlePending || previousCommand !== null && paneTitleChanged;
7403
7726
  entry.applicationTitleActive = false;
@@ -7411,11 +7734,15 @@ var TerminalMetadataManager = class {
7411
7734
  }
7412
7735
  if (commandLine) {
7413
7736
  if (observedTitlePending) entry.applicationTitleActive = paneTitle !== null && paneTitle !== commandLine;
7414
- 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;
7415
7738
  else if (paneTitleChanged && paneTitle !== commandLine) entry.applicationTitleActive = true;
7416
7739
  this.update(entry, { title: entry.applicationTitleActive ? paneTitle ?? entry.title ?? commandLine : commandLine });
7417
7740
  return;
7418
7741
  }
7742
+ if (!entry.interactiveShellCommand) {
7743
+ this.update(entry, { title: paneTitle ?? currentCommand });
7744
+ return;
7745
+ }
7419
7746
  if (observedTitlePending) {
7420
7747
  entry.applicationTitleActive = true;
7421
7748
  this.update(entry, { title: entry.title ?? paneTitle ?? currentCommand });
@@ -7444,7 +7771,7 @@ var TerminalMetadataManager = class {
7444
7771
  }
7445
7772
  updateForegroundProcess(entry, currentCommand) {
7446
7773
  const command = currentCommand?.trim().slice(0, 256) || null;
7447
- 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 });
7448
7775
  }
7449
7776
  persistShellTitle(entry, runtimeGeneration) {
7450
7777
  return Effect.gen(this, function* () {
@@ -7505,21 +7832,32 @@ var TerminalMetadataManager = class {
7505
7832
  };
7506
7833
  //#endregion
7507
7834
  //#region src/server/app.ts
7508
- const UPLOAD_MIME_EXTENSIONS = {
7509
- "application/pdf": "pdf",
7510
- "image/gif": "gif",
7511
- "image/jpeg": "jpg",
7512
- "image/png": "png",
7513
- "image/svg+xml": "svg",
7514
- "image/webp": "webp",
7515
- "text/plain": "txt"
7516
- };
7835
+ const UPLOAD_MIME_EXTENSIONS = /* @__PURE__ */ new Map([
7836
+ ["application/pdf", "pdf"],
7837
+ ["image/gif", "gif"],
7838
+ ["image/jpeg", "jpg"],
7839
+ ["image/png", "png"],
7840
+ ["image/svg+xml", "svg"],
7841
+ ["image/webp", "webp"],
7842
+ ["text/plain", "txt"]
7843
+ ]);
7517
7844
  const UPLOAD_RETENTION_MS = 1440 * 6e4;
7518
7845
  const UPLOAD_DIRECTORY_MAX_BYTES = 512 * 1024 * 1024;
7519
7846
  const terminalPresetDefinitionsQuerySchema = z.object({
7520
7847
  projectId: z.string().optional(),
7521
7848
  worktreeId: z.string().optional()
7522
7849
  });
7850
+ const operationQuerySchema = z.object({
7851
+ kind: z.enum([
7852
+ "create",
7853
+ "finish",
7854
+ "discard",
7855
+ "project_cleanup",
7856
+ "remove",
7857
+ "external_remove"
7858
+ ]).optional(),
7859
+ projectId: z.string().optional()
7860
+ });
7523
7861
  const discardStoredDataQuerySchema = z.object({ discardStoredData: z.string().optional() });
7524
7862
  async function pruneTerminalUploads(directory, preservePath) {
7525
7863
  const entries = await fs.readdir(directory, { withFileTypes: true });
@@ -7557,7 +7895,7 @@ function queryInput(schema) {
7557
7895
  if (!result.success) throw new DomainError("VALIDATION_ERROR", "Request validation failed", 400, z.flattenError(result.error));
7558
7896
  });
7559
7897
  }
7560
- function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7898
+ function createApp({ service, config, tmux, applicationUpdate, terminalMetadata, webDist }) {
7561
7899
  const app = new Hono();
7562
7900
  app.use("/api/*", requestId({
7563
7901
  limitLength: 128,
@@ -7573,11 +7911,14 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7573
7911
  code: "INVALID_JSON",
7574
7912
  message: "Request body must be valid JSON"
7575
7913
  } }, 400);
7576
- if (error instanceof DomainError) return context.json({ error: {
7577
- code: error.code,
7578
- message: error.message,
7579
- ...error.details === void 0 ? {} : { details: error.details }
7580
- } }, error.status);
7914
+ if (error instanceof DomainError) {
7915
+ const body = { error: {
7916
+ code: error.code,
7917
+ message: error.message
7918
+ } };
7919
+ if (error.details !== void 0) body.error.details = error.details;
7920
+ return context.json(body, error.status);
7921
+ }
7581
7922
  const requestIdentifier = context.get("requestId") || crypto.randomUUID();
7582
7923
  context.header("X-Request-Id", requestIdentifier);
7583
7924
  console.error("[Treeport] API request failed", {
@@ -7604,7 +7945,13 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7604
7945
  installationMethod: config.installationMethod ?? "development",
7605
7946
  daemonLifecycle: config.daemonLifecycle,
7606
7947
  url: config.apiUrl
7607
- })).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) => {
7608
7955
  const body = context.req.valid("json");
7609
7956
  return context.json({ preset: await service.createTerminalPreset(body) }, 201);
7610
7957
  }).patch("/api/terminal-presets/:presetId", jsonInput(updateTerminalPresetSchema), async (context) => {
@@ -7636,6 +7983,9 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7636
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) => {
7637
7984
  await service.closeProject(context.req.param("projectId"));
7638
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 });
7639
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) => {
7640
7990
  const body = context.req.valid("json");
7641
7991
  const projectId = context.req.param("projectId");
@@ -7650,17 +8000,21 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7650
8000
  return context.json({ ok: true });
7651
8001
  }).get("/api/projects/:projectId/worktrees", async (context) => context.json({ worktrees: (await service.getProjectSnapshot(context.req.param("projectId"))).worktrees })).post("/api/projects/:projectId/worktree-operations", jsonInput(createWorktreeSchema), async (context) => {
7652
8002
  const body = context.req.valid("json");
7653
- const initialTerminal = body.initialTerminal ? {
7654
- name: body.initialTerminal.name,
7655
- ...body.initialTerminal.argv ? { argv: body.initialTerminal.argv } : {},
7656
- ...body.initialTerminal.returnToShell ? { returnToShell: true } : {},
7657
- ...body.initialTerminal.initialSize ? { initialSize: body.initialTerminal.initialSize } : {}
7658
- } : void 0;
8003
+ let initialTerminal;
8004
+ if (body.initialTerminal) {
8005
+ initialTerminal = { name: body.initialTerminal.name };
8006
+ if (body.initialTerminal.argv) initialTerminal.argv = body.initialTerminal.argv;
8007
+ if (body.initialTerminal.returnToShell) initialTerminal.returnToShell = true;
8008
+ if (body.initialTerminal.initialSize) initialTerminal.initialSize = body.initialTerminal.initialSize;
8009
+ }
7659
8010
  return context.json({ operation: await service.beginCreateWorktree(context.req.param("projectId"), body.name, body.base, initialTerminal, body.sourceWorktreeId) }, 202);
7660
8011
  }).get("/api/worktrees/:worktreeId", async (context) => {
7661
8012
  const worktreeId = context.req.param("worktreeId");
7662
8013
  await service.refreshPr(worktreeId, false);
7663
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 });
7664
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) => {
7665
8019
  const body = context.req.valid("json");
7666
8020
  return context.json({ panel: await service.createWebPanel(context.req.param("worktreeId"), body.definitionId, {
@@ -7712,22 +8066,23 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7712
8066
  }
7713
8067
  const extension = path.extname(resolution.path).toLowerCase();
7714
8068
  const body = await fs.readFile(resolution.path);
7715
- context.header("content-type", {
7716
- ".css": "text/css; charset=utf-8",
7717
- ".gif": "image/gif",
7718
- ".html": "text/html; charset=utf-8",
7719
- ".jpeg": "image/jpeg",
7720
- ".jpg": "image/jpeg",
7721
- ".js": "text/javascript; charset=utf-8",
7722
- ".json": "application/json; charset=utf-8",
7723
- ".map": "application/json; charset=utf-8",
7724
- ".mjs": "text/javascript; charset=utf-8",
7725
- ".png": "image/png",
7726
- ".svg": "image/svg+xml",
7727
- ".webp": "image/webp",
7728
- ".woff": "font/woff",
7729
- ".woff2": "font/woff2"
7730
- }[extension] ?? "application/octet-stream");
8069
+ const mimeTypes = /* @__PURE__ */ new Map([
8070
+ [".css", "text/css; charset=utf-8"],
8071
+ [".gif", "image/gif"],
8072
+ [".html", "text/html; charset=utf-8"],
8073
+ [".jpeg", "image/jpeg"],
8074
+ [".jpg", "image/jpeg"],
8075
+ [".js", "text/javascript; charset=utf-8"],
8076
+ [".json", "application/json; charset=utf-8"],
8077
+ [".map", "application/json; charset=utf-8"],
8078
+ [".mjs", "text/javascript; charset=utf-8"],
8079
+ [".png", "image/png"],
8080
+ [".svg", "image/svg+xml"],
8081
+ [".webp", "image/webp"],
8082
+ [".woff", "font/woff"],
8083
+ [".woff2", "font/woff2"]
8084
+ ]);
8085
+ context.header("content-type", mimeTypes.get(extension) ?? "application/octet-stream");
7731
8086
  context.header("cache-control", "public, max-age=31536000, immutable");
7732
8087
  context.header("access-control-allow-origin", "*");
7733
8088
  context.header("content-security-policy", webPanelContentSecurityPolicy("immutable", browserOrigin, resolution.allowNetworkRequests));
@@ -7735,13 +8090,14 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7735
8090
  return context.body(body);
7736
8091
  }).post("/api/worktrees/:worktreeId/terminals", jsonInput(createTerminalSchema), async (context) => {
7737
8092
  const body = context.req.valid("json");
7738
- const terminal = await service.createTerminal(context.req.param("worktreeId"), body.name, body.argv, body.returnToShell || body.closeOnSuccess || body.initialSize || body.cwd || body.env ? {
7739
- ...body.returnToShell ? { returnToShell: true } : {},
7740
- ...body.closeOnSuccess ? { closeOnSuccess: true } : {},
7741
- ...body.initialSize ? { initialSize: body.initialSize } : {},
7742
- ...body.cwd ? { cwd: body.cwd } : {},
7743
- ...body.env ? { env: body.env } : {}
7744
- } : void 0);
8093
+ const options = {};
8094
+ if (body.returnToShell) options.returnToShell = true;
8095
+ if (body.closeOnSuccess) options.closeOnSuccess = true;
8096
+ if (body.initialSize) options.initialSize = body.initialSize;
8097
+ if (body.cwd) options.cwd = body.cwd;
8098
+ if (body.env) options.env = body.env;
8099
+ if (body.shellCommand) options.shellCommand = body.shellCommand;
8100
+ const terminal = await service.createTerminal(context.req.param("worktreeId"), body.name, body.argv, Object.keys(options).length > 0 ? options : void 0);
7745
8101
  return context.json({ terminal }, 201);
7746
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) => {
7747
8103
  const body = context.req.valid("json");
@@ -7792,7 +8148,7 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7792
8148
  await waitForPreviousUpload;
7793
8149
  try {
7794
8150
  const contentType = context.req.header("content-type")?.split(";", 1)[0]?.toLowerCase() ?? "";
7795
- const extension = requestedExtension || UPLOAD_MIME_EXTENSIONS[contentType] || "";
8151
+ const extension = requestedExtension || UPLOAD_MIME_EXTENSIONS.get(contentType) || "";
7796
8152
  const uploadDirectory = path.join(config.runtimeDir, "uploads");
7797
8153
  await fs.mkdir(uploadDirectory, {
7798
8154
  recursive: true,
@@ -7830,26 +8186,15 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7830
8186
  await service.deleteTerminal(context.req.param("terminalId"));
7831
8187
  return context.json({ ok: true });
7832
8188
  }).get("/api/operations", validator("query", (value) => {
7833
- const kind = typeof value.kind === "string" ? value.kind : void 0;
7834
- const projectId = typeof value.projectId === "string" ? value.projectId : void 0;
7835
- if (kind && ![
7836
- "create",
7837
- "finish",
7838
- "discard",
7839
- "project_cleanup",
7840
- "remove",
7841
- "external_remove"
7842
- ].includes(kind)) throw new DomainError("INVALID_OPERATION_KIND", "Invalid operation kind", 400);
7843
- return {
7844
- ...kind ? { kind } : {},
7845
- ...projectId ? { projectId } : {}
7846
- };
8189
+ const parsed = operationQuerySchema.safeParse(value);
8190
+ if (!parsed.success) throw new DomainError("INVALID_OPERATION_KIND", "Invalid operation query", 400);
8191
+ return parsed.data;
7847
8192
  }), async (context) => {
7848
- const { kind, projectId } = context.req.valid("query");
7849
- return context.json({ operations: await service.listActiveOperations({
7850
- ...projectId ? { projectId } : {},
7851
- ...kind ? { kind } : {}
7852
- }) });
8193
+ const query = context.req.valid("query");
8194
+ const filters = {};
8195
+ if (query.projectId) filters.projectId = query.projectId;
8196
+ if (query.kind) filters.kind = query.kind;
8197
+ return context.json({ operations: await service.listActiveOperations(filters) });
7853
8198
  }).get("/api/operations/:operationId", async (context) => context.json({ operation: await service.getOperation(context.req.param("operationId")) })).post("/api/admin/terminate-terminals", async (context) => context.json({ terminated: await service.terminateAllTerminals() })).all("/api/*", (context) => context.json({ error: {
7854
8199
  code: "NOT_FOUND",
7855
8200
  message: "API endpoint not found"
@@ -7872,6 +8217,247 @@ function createApp({ service, config, tmux, terminalMetadata, webDist }) {
7872
8217
  return routes;
7873
8218
  }
7874
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
7875
8461
  //#region src/server/daemon-ownership.ts
7876
8462
  function processExists(pid) {
7877
8463
  try {
@@ -7909,7 +8495,9 @@ async function acquireDaemonOwnership(config) {
7909
8495
  });
7910
8496
  if (!await openLock().then(() => true, async (error) => {
7911
8497
  if (error.code !== "EEXIST") throw error;
7912
- const existing = await fs.readFile(lockPath, "utf8").then((value) => JSON.parse(value)).catch(() => null);
8498
+ const existing = await fs.readFile(lockPath, "utf8").then((value) => {
8499
+ return JSON.parse(value);
8500
+ }).catch(() => null);
7913
8501
  if (existing?.pid && Number.isInteger(existing.pid) && processExists(existing.pid)) throw new Error(`Treeport is already running for ${config.dataDir} (PID ${existing.pid})`);
7914
8502
  await fs.rm(lockPath, { force: true });
7915
8503
  return false;
@@ -7963,7 +8551,7 @@ function singleHeader(request, name) {
7963
8551
  return {
7964
8552
  present: values.length > 0,
7965
8553
  valid: values.length <= 1,
7966
- value: values.length === 1 ? values[0] : null
8554
+ value: values.length === 1 ? values[0] ?? null : null
7967
8555
  };
7968
8556
  }
7969
8557
  function hasControlCharacters(value) {
@@ -8010,14 +8598,24 @@ function effectiveOriginFor(request, source, incomingHost) {
8010
8598
  if (!forwardedHost || forwardedProtocolHeader.value?.toLowerCase() !== "https") return null;
8011
8599
  return `https://${forwardedHost.host}`;
8012
8600
  }
8601
+ function allowsOpaqueWebPanelOrigin(request, socketUpgrade) {
8602
+ if (!["GET", "HEAD"].includes(request.method?.toUpperCase() ?? "")) return false;
8603
+ const pathname = new URL(request.url ?? "/", "http://treeport.local").pathname;
8604
+ if (socketUpgrade) return /^\/api\/web-panel-dev\/[a-f0-9]{24}\/@vite-hmr$/u.test(pathname);
8605
+ return /^\/api\/web-panels\/panel_[a-f0-9]{32}\/assets(?:\/|$)/u.test(pathname) || /^\/api\/web-panel-dev\/[a-f0-9]{24}\//u.test(pathname);
8606
+ }
8013
8607
  function originIsAllowed(request, effectiveOrigin, socketUpgrade) {
8014
8608
  const originHeader = singleHeader(request, "origin");
8015
8609
  if (!originHeader.valid) return false;
8016
8610
  if (originHeader.present) {
8017
8611
  const value = originHeader.value ?? "";
8018
- if (value === "null" || !URL.canParse(value)) return false;
8019
- const parsed = new URL(value);
8020
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.origin !== value || parsed.origin !== effectiveOrigin) return false;
8612
+ if (value === "null") {
8613
+ if (!allowsOpaqueWebPanelOrigin(request, socketUpgrade)) return false;
8614
+ } else {
8615
+ if (!URL.canParse(value)) return false;
8616
+ const parsed = new URL(value);
8617
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.origin !== value || parsed.origin !== effectiveOrigin) return false;
8618
+ }
8021
8619
  }
8022
8620
  const fetchSiteHeader = singleHeader(request, "sec-fetch-site");
8023
8621
  if (!fetchSiteHeader.valid) return false;
@@ -8092,8 +8690,8 @@ var AttachmentInitializationError = class {
8092
8690
  };
8093
8691
  const TERMINAL_MAX_QUEUED_INPUT_BYTES = 1024 * 1024;
8094
8692
  const TERMINAL_MAX_QUEUED_INPUT_MESSAGES = 256;
8095
- function errorMessage(error) {
8096
- return ((error instanceof Error ? error.message : String(error)).trim() || "Terminal attachment failed").slice(0, 1e3);
8693
+ function errorMessage(cause) {
8694
+ return ((cause instanceof Error ? cause.message : String(cause)).trim() || "Terminal attachment failed").slice(0, 1e3);
8097
8695
  }
8098
8696
  function tmuxEnvironment() {
8099
8697
  return Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== void 0 && key !== "TMUX" && key !== "TMUX_PANE"));
@@ -8274,7 +8872,7 @@ var TerminalAttachmentManager = class {
8274
8872
  catch: (cause) => new AttachmentInitializationError(phase, cause)
8275
8873
  });
8276
8874
  return Effect.gen(this, function* () {
8277
- const terminal = yield* promisePhase("refresh_terminal", () => this.service.refreshTerminalStatus(connection.terminalId));
8875
+ const terminal = yield* promisePhase("refresh_terminal", () => this.service.refreshTerminalStatus(connection.terminalId, false));
8278
8876
  if (!isInitializing()) return;
8279
8877
  if (terminal.status === "missing") return yield* Effect.fail(new AttachmentInitializationError("refresh_terminal", /* @__PURE__ */ new Error("The tmux session for this terminal is missing")));
8280
8878
  const worktree = yield* promisePhase("resolve_worktree", () => this.service.getWorktree(terminal.worktreeId));
@@ -8283,12 +8881,12 @@ var TerminalAttachmentManager = class {
8283
8881
  const initialDimensions = yield* Effect.tryPromise({
8284
8882
  try: () => this.enqueueTerminal(connection.terminalId, async () => {
8285
8883
  if (!isInitializing()) return null;
8884
+ const current = this.dimensions.get(connection.terminalId);
8885
+ if (current) return current;
8286
8886
  await this.tmux.useManualWindowSize(worktree.tmuxSocketName, terminal.tmuxSessionName).catch((cause) => {
8287
8887
  throw new AttachmentInitializationError("configure_window_size", cause);
8288
8888
  });
8289
8889
  if (!isInitializing()) return null;
8290
- const current = this.dimensions.get(connection.terminalId);
8291
- if (current) return current;
8292
8890
  const sessionSize = await this.tmux.sessionSize(worktree.tmuxSocketName, terminal.tmuxSessionName).catch((cause) => {
8293
8891
  throw new AttachmentInitializationError("read_session_size", cause);
8294
8892
  });
@@ -8511,11 +9109,11 @@ var TerminalAttachmentManager = class {
8511
9109
  if (this.isActive(connection) && this.canControl(connection, generation)) connection.pty?.write(data);
8512
9110
  }).catch((error) => this.failInputWrite(connection, error));
8513
9111
  }
8514
- failInputWrite(connection, error) {
9112
+ failInputWrite(connection, cause) {
8515
9113
  if (!this.isActive(connection)) return;
8516
9114
  this.send(connection, "terminal_error", {
8517
9115
  code: "INPUT_FAILED",
8518
- message: errorMessage(error),
9116
+ message: errorMessage(cause),
8519
9117
  retryable: true
8520
9118
  });
8521
9119
  connection.transport.disconnect(true);
@@ -8551,13 +9149,13 @@ var TerminalAttachmentManager = class {
8551
9149
  for (const client of active) if (this.isActive(client)) client.pty?.resize(next.cols, next.rows);
8552
9150
  await this.tmux.resizeWindow(next.socketName, next.sessionName, next.cols, next.rows);
8553
9151
  }
8554
- failDimensionChange(terminalId, error) {
9152
+ failDimensionChange(terminalId, cause) {
8555
9153
  this.dimensions.delete(terminalId);
8556
9154
  for (const client of [...this.clients.values()]) {
8557
9155
  if (client.terminalId !== terminalId || !this.isActive(client)) continue;
8558
9156
  this.send(client, "terminal_error", {
8559
9157
  code: "RESIZE_FAILED",
8560
- message: errorMessage(error),
9158
+ message: errorMessage(cause),
8561
9159
  retryable: true
8562
9160
  });
8563
9161
  client.transport.disconnect(true);
@@ -8757,7 +9355,7 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
8757
9355
  isConnected: () => socket.connected,
8758
9356
  send(event, payload) {
8759
9357
  if (!socket.connected) return false;
8760
- socket.emit(event, payload);
9358
+ socket.emit.bind(socket)(event, payload);
8761
9359
  return true;
8762
9360
  },
8763
9361
  disconnect(retryable) {
@@ -8780,103 +9378,126 @@ function createSocketServer(httpServer, { service, config, tmux, terminalMetadat
8780
9378
  }
8781
9379
  //#endregion
8782
9380
  //#region src/server/index.ts
8783
- const config = loadConfig();
8784
- const ownership = await acquireDaemonOwnership(config);
8785
- const prerequisites = await checkRuntimePrerequisites(config);
8786
- const runner = new SpawnCommandRunner();
8787
- const database = await openDatabase(config.databasePath, { backupDirectory: path.join(config.dataDir, "database-backups") });
8788
- const git = new GitAdapter(runner, config.gitPath);
8789
- const launcherPath = fileURLToPath(new URL("./core/launcher.js", import.meta.url));
8790
- const tmux = new TmuxAdapter(runner, config.runtimeDir, config.tmuxPath, launcherPath);
8791
- const service = new TreeportService({
8792
- config,
8793
- database,
8794
- runner,
8795
- git,
8796
- tmux,
8797
- gh: new GhAdapter(runner, config.ghPath)
8798
- });
8799
- await service.initialize();
8800
- const terminalMetadata = new TerminalMetadataManager(service, tmux, config.tmuxPath);
8801
- await terminalMetadata.initialize();
8802
- const honoListener = getRequestListener(createApp({
8803
- service,
8804
- config,
8805
- tmux,
8806
- terminalMetadata
8807
- }).fetch);
8808
- let vite = null;
8809
- const server = createServer((request, response) => {
8810
- const security = authorizeRequest(request);
8811
- if (!security.allowed) {
8812
- rejectHttpRequest(request, response, security);
8813
- return;
8814
- }
8815
- service.handleWebPanelDevelopmentRequest(request, response, () => {
8816
- if (vite && !request.url?.startsWith("/api")) {
8817
- 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
+ }
8818
9430
  honoListener(request, response);
8819
9431
  });
8820
- return;
8821
- }
8822
- honoListener(request, response);
8823
- });
8824
- });
8825
- server.on("upgrade", (request, socket) => {
8826
- const security = authorizeRequest(request, { socketUpgrade: true });
8827
- if (security.allowed) return;
8828
- const statusText = security.status === 400 ? "Bad Request" : security.status === 403 ? "Forbidden" : "Unauthorized";
8829
- socket.write(`HTTP/1.1 ${security.status} ${statusText}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n`);
8830
- socket.destroy();
8831
- });
8832
- if (config.webDevelopment) {
8833
- const { createServer: createViteServer } = await import("vite");
8834
- vite = await createViteServer({
8835
- configFile: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../vite.config.ts"),
8836
- appType: "spa",
8837
- server: {
8838
- middlewareMode: true,
8839
- 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
+ });
8840
9450
  }
8841
- });
8842
- }
8843
- service.attachHttpServer(server);
8844
- const { io, attachments } = createSocketServer(server, {
8845
- service,
8846
- config,
8847
- tmux,
8848
- terminalMetadata
8849
- });
8850
- await new Promise((resolve, reject) => {
8851
- server.once("error", reject);
8852
- server.listen(config.port, config.host, () => {
8853
- server.off("error", reject);
8854
- resolve();
8855
- });
8856
- });
8857
- await ownership.publish();
8858
- console.log(`Treeport ${config.appVersion} listening on ${config.apiUrl}`);
8859
- console.log(`database: ${config.databasePath}`);
8860
- console.log(`git: ${prerequisites.gitVersion}`);
8861
- console.log(`tmux: ${prerequisites.tmuxVersion}`);
8862
- let shuttingDown = false;
8863
- function shutdown() {
8864
- if (shuttingDown) return;
8865
- shuttingDown = true;
8866
- attachments.dispose();
8867
- terminalMetadata.dispose();
8868
- io.close(() => {
8869
- Promise.all([service.drainMutations(), terminalMetadata.drain()]).then(async () => {
8870
- await service.disposeWebPanelRuntime();
8871
- await vite?.close();
8872
- database.close();
8873
- await ownership.release();
8874
- process.exit(0);
9451
+ service.attachHttpServer(server);
9452
+ const { io, attachments } = createSocketServer(server, {
9453
+ service,
9454
+ config,
9455
+ tmux,
9456
+ terminalMetadata
8875
9457
  });
8876
- });
8877
- 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
+ }
8878
9500
  }
8879
- process.once("SIGINT", shutdown);
8880
- process.once("SIGTERM", shutdown);
9501
+ await main();
8881
9502
  //#endregion
8882
9503
  export {};