@firstpick/pi-package-webui 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pi-webui.mjs CHANGED
@@ -118,6 +118,30 @@ const AUTO_TAB_TITLE_STOP_WORDS = new Set([
118
118
  "your",
119
119
  ]);
120
120
 
121
+ const APP_RUNNER_CONFIG_FILE = ".pi-webui-runners.json";
122
+ const APP_RUNNER_CUSTOM_LIMIT = 48;
123
+ const APP_RUNNER_CUSTOM_ARG_LIMIT = 32;
124
+ const APP_RUNNER_FILE_PICKER_LIMIT = 500;
125
+ const APP_RUNNER_DETECTION_TIMEOUT_MS = 1_200;
126
+ const APP_RUNNER_COMMAND_CACHE_TTL_MS = 30_000;
127
+ const APP_RUNNER_OUTPUT_LINE_LIMIT = 1_000;
128
+ const APP_RUNNER_OUTPUT_MAX_CHARS = 240_000;
129
+ const APP_RUNNER_STOP_GRACE_MS = 2_500;
130
+ const APP_RUNNER_PYTHON_ENTRIES = ["Main.py", "main.py", "src/main.py", "src/Main.py", "app.py", "src/app.py"];
131
+ const APP_RUNNER_JS_ENTRIES = ["main.js", "src/main.js", "index.js", "src/index.js", "server.js", "src/server.js", "app.js", "src/app.js"];
132
+ const APP_RUNNER_ZIG_ENTRIES = ["src/main.zig", "main.zig"];
133
+ const APP_RUNNER_C_ENTRIES = ["main.c", "src/main.c"];
134
+ const APP_RUNNER_CPP_ENTRIES = ["main.cpp", "src/main.cpp", "main.cc", "src/main.cc", "main.cxx", "src/main.cxx"];
135
+ const APP_RUNNER_DOCKER_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
136
+ const APP_RUNNER_SHELL_SCRIPT_DIRS = ["", "dev", "scripts", "dev/scripts"];
137
+ const APP_RUNNER_SHELL_SCRIPT_LIMIT = 24;
138
+ const APP_RUNNER_SHELL_EXTENSIONS = new Map([
139
+ [".sh", "bash"],
140
+ [".bash", "bash"],
141
+ [".zsh", "zsh"],
142
+ [".fish", "fish"],
143
+ ]);
144
+
121
145
  const MIME_TYPES = new Map([
122
146
  [".html", "text/html; charset=utf-8"],
123
147
  [".jsonl", "application/x-ndjson; charset=utf-8"],
@@ -188,14 +212,18 @@ Usage:
188
212
  Options:
189
213
  --host <host> HTTP bind host (default: ${DEFAULT_HOST})
190
214
  --port <port> HTTP port (default: ${DEFAULT_PORT})
191
- --cwd <path> Working directory for the Pi session (default: current dir)
215
+ --cwd <path> Start the first Pi terminal in this working directory
192
216
  --pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
193
217
  --no-session Start Pi RPC with --no-session
194
218
  --name <name> Initial Web UI tab display name
195
219
  -h, --help Show this help
196
220
  -v, --version Print version
197
221
 
222
+ If --cwd is omitted, the server starts first and the browser asks for
223
+ the first terminal CWD.
224
+
198
225
  Examples:
226
+ pi-webui
199
227
  pi-webui --cwd ~/src/my-project
200
228
  pi-webui --port 3000 -- --model anthropic/claude-sonnet-4-5:high
201
229
  PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
@@ -219,6 +247,7 @@ function parseArgs(argv) {
219
247
  host: process.env.PI_WEBUI_HOST || DEFAULT_HOST,
220
248
  port: Number.parseInt(process.env.PI_WEBUI_PORT || String(DEFAULT_PORT), 10),
221
249
  cwd: process.cwd(),
250
+ cwdExplicit: false,
222
251
  piBin: process.env.PI_WEBUI_PI_BIN || "pi",
223
252
  piBinExplicit: !!process.env.PI_WEBUI_PI_BIN,
224
253
  noSession: false,
@@ -257,7 +286,8 @@ function parseArgs(argv) {
257
286
  continue;
258
287
  }
259
288
  if (arg === "--cwd") {
260
- options.cwd = path.resolve(takeValue(argv, i, arg));
289
+ options.cwd = path.resolve(expandUserPath(takeValue(argv, i, arg)));
290
+ options.cwdExplicit = true;
261
291
  i++;
262
292
  continue;
263
293
  }
@@ -286,6 +316,24 @@ function parseArgs(argv) {
286
316
  return options;
287
317
  }
288
318
 
319
+ async function validateStartupCwd(cwd) {
320
+ const normalized = path.resolve(String(cwd || ""));
321
+ let info;
322
+ try {
323
+ info = await stat(normalized);
324
+ } catch (error) {
325
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
326
+ throw new Error(`--cwd does not exist: ${normalized}`);
327
+ }
328
+ if (error?.code === "EACCES" || error?.code === "EPERM") {
329
+ throw new Error(`--cwd is not accessible: ${normalized}`);
330
+ }
331
+ throw new Error(`Cannot access --cwd ${normalized}: ${formatCliError(error)}`);
332
+ }
333
+ if (!info.isDirectory()) throw new Error(`--cwd is not a directory: ${normalized}`);
334
+ return normalized;
335
+ }
336
+
289
337
  function isLocalHost(host) {
290
338
  return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
291
339
  }
@@ -304,6 +352,12 @@ function sanitizeError(error) {
304
352
  return error.stack || error.message || String(error);
305
353
  }
306
354
 
355
+ function formatCliError(error) {
356
+ if (!error) return "Unknown error";
357
+ if (typeof error === "string") return error;
358
+ return error.message || String(error);
359
+ }
360
+
307
361
  function delay(ms) {
308
362
  return new Promise((resolve) => setTimeout(resolve, ms));
309
363
  }
@@ -992,6 +1046,997 @@ async function readJsonFileIfExists(filePath) {
992
1046
  }
993
1047
  }
994
1048
 
1049
+ const appRunnerCommandAvailability = new Map();
1050
+
1051
+ async function fileStatsIfExists(filePath) {
1052
+ try {
1053
+ return await stat(filePath);
1054
+ } catch (error) {
1055
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
1056
+ throw error;
1057
+ }
1058
+ }
1059
+
1060
+ async function appRunnerFileExists(cwd, relativePath) {
1061
+ const stats = await fileStatsIfExists(path.join(cwd, relativePath));
1062
+ return !!stats?.isFile();
1063
+ }
1064
+
1065
+ async function appRunnerDirectoryExists(cwd, relativePath) {
1066
+ const stats = await fileStatsIfExists(path.join(cwd, relativePath));
1067
+ return !!stats?.isDirectory();
1068
+ }
1069
+
1070
+ async function appRunnerTextIfExists(cwd, relativePath, maxLength = 120_000) {
1071
+ try {
1072
+ const text = await readFile(path.join(cwd, relativePath), "utf8");
1073
+ return text.slice(0, maxLength);
1074
+ } catch (error) {
1075
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return "";
1076
+ return "";
1077
+ }
1078
+ }
1079
+
1080
+ async function firstExistingRunnerFile(cwd, candidates) {
1081
+ for (const candidate of candidates) {
1082
+ if (await appRunnerFileExists(cwd, candidate)) return candidate;
1083
+ }
1084
+ return "";
1085
+ }
1086
+
1087
+ async function appRunnerCommandAvailable(command, cwd) {
1088
+ const name = String(command || "").trim();
1089
+ if (!name) return false;
1090
+ const key = `${name}\0${cwd || ""}`;
1091
+ const cached = appRunnerCommandAvailability.get(key);
1092
+ const now = Date.now();
1093
+ if (cached && cached.expiresAt > now) return cached.available;
1094
+
1095
+ const result = await runCommand(name, ["--version"], {
1096
+ cwd,
1097
+ timeoutMs: APP_RUNNER_DETECTION_TIMEOUT_MS,
1098
+ maxOutputLength: 2_000,
1099
+ });
1100
+ const available = !result.error && !result.timedOut && (result.exitCode === 0 || Boolean(result.stdout || result.stderr));
1101
+ appRunnerCommandAvailability.set(key, { available, expiresAt: now + APP_RUNNER_COMMAND_CACHE_TTL_MS });
1102
+ return available;
1103
+ }
1104
+
1105
+ function appRunnerPackageScripts(pkg) {
1106
+ return pkg && typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
1107
+ }
1108
+
1109
+ function preferredPackageScript(pkg) {
1110
+ const scripts = appRunnerPackageScripts(pkg);
1111
+ for (const script of ["dev", "start", "serve"]) {
1112
+ if (typeof scripts[script] === "string" && scripts[script].trim()) return script;
1113
+ }
1114
+ return "";
1115
+ }
1116
+
1117
+ function packageDependencyNames(pkg) {
1118
+ return new Set([
1119
+ ...Object.keys(pkg?.dependencies || {}),
1120
+ ...Object.keys(pkg?.devDependencies || {}),
1121
+ ...Object.keys(pkg?.optionalDependencies || {}),
1122
+ ]);
1123
+ }
1124
+
1125
+ function appRunnerId(...parts) {
1126
+ return parts
1127
+ .map((part) => String(part || "").trim())
1128
+ .filter(Boolean)
1129
+ .join(":")
1130
+ .replace(/[^a-z0-9_.:-]+/gi, "-")
1131
+ .replace(/^-+|-+$/g, "")
1132
+ .slice(0, 160);
1133
+ }
1134
+
1135
+ function shellQuote(value) {
1136
+ return `'${String(value ?? "").replace(/'/g, `'\\''`)}'`;
1137
+ }
1138
+
1139
+ function appRunnerCandidate({ id, label, kind, command, args = [], projectFile = "", description = "", shortDisplayCommand = "", priority = 100, cwd = "", custom = false, configFile = "" }) {
1140
+ return {
1141
+ id,
1142
+ label,
1143
+ kind,
1144
+ command,
1145
+ args,
1146
+ displayCommand: formatCommandForDisplay(command, args),
1147
+ shortDisplayCommand,
1148
+ projectFile,
1149
+ description,
1150
+ priority,
1151
+ cwd,
1152
+ custom,
1153
+ configFile,
1154
+ };
1155
+ }
1156
+
1157
+ function addAppRunner(runners, runner) {
1158
+ if (!runner?.id || !runner.command) return;
1159
+ if (runners.some((item) => item.id === runner.id || item.displayCommand === runner.displayCommand)) return;
1160
+ runners.push(runner);
1161
+ }
1162
+
1163
+ function appRunnerPathInside(root, target) {
1164
+ const relative = path.relative(root, target);
1165
+ return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
1166
+ }
1167
+
1168
+ function normalizeProjectRelativePath(value, { allowEmpty = false } = {}) {
1169
+ const raw = normalizeSuggestionPath(value).replace(/\0/g, "").trim();
1170
+ const withoutDot = raw.replace(/^\.\/+/, "").replace(/\/+$/g, "");
1171
+ if (!withoutDot) {
1172
+ if (allowEmpty) return "";
1173
+ throw makeHttpError(400, "Path to file is required");
1174
+ }
1175
+ if (path.isAbsolute(withoutDot) || /^[a-z]:\//i.test(withoutDot)) throw makeHttpError(400, "Path must be relative to the project root");
1176
+ const parts = withoutDot.split("/").filter(Boolean);
1177
+ if (parts.some((part) => part === "." || part === "..")) throw makeHttpError(400, "Path cannot contain . or .. segments");
1178
+ return parts.join("/").slice(0, 4096);
1179
+ }
1180
+
1181
+ function resolveProjectRelativePath(projectRoot, relativePath) {
1182
+ const target = path.resolve(projectRoot, relativePath || ".");
1183
+ if (!appRunnerPathInside(projectRoot, target)) throw makeHttpError(400, "Path must stay inside the project root");
1184
+ return target;
1185
+ }
1186
+
1187
+ async function findAppRunnerProjectRoot(cwd) {
1188
+ const start = await resolveCwd(cwd || options.cwd, options.cwd);
1189
+ let fallback = "";
1190
+ for (let current = start; current; current = path.dirname(current)) {
1191
+ if (await appRunnerFileExists(current, APP_RUNNER_CONFIG_FILE)) return current;
1192
+ if (!fallback && (await appRunnerFileExists(current, "package.json") || await appRunnerDirectoryExists(current, ".git"))) fallback = current;
1193
+ const parent = path.dirname(current);
1194
+ if (parent === current) break;
1195
+ }
1196
+ return fallback || start;
1197
+ }
1198
+
1199
+ function cleanCustomRunnerCommand(value) {
1200
+ const command = String(value || "./").trim().replace(/\s+/g, " ") || "./";
1201
+ if (command.includes("\0") || /[\r\n]/.test(command)) throw makeHttpError(400, "Command cannot contain newlines or null bytes");
1202
+ if (command.length > 512) throw makeHttpError(400, "Command is too long");
1203
+ return command === "." ? "./" : command;
1204
+ }
1205
+
1206
+ function customRunnerCommandParts(command) {
1207
+ const clean = cleanCustomRunnerCommand(command);
1208
+ return clean === "./" ? ["./"] : clean.split(" ").filter(Boolean);
1209
+ }
1210
+
1211
+ function parseCustomRunnerArgs(value) {
1212
+ const rawItems = Array.isArray(value)
1213
+ ? value
1214
+ : String(value || "").trim()
1215
+ ? String(value || "").trim().split(/\s+/)
1216
+ : [];
1217
+ const args = [];
1218
+ for (const item of rawItems) {
1219
+ const text = String(item || "").trim();
1220
+ if (!text) continue;
1221
+ if (text.includes("\0") || /[\r\n]/.test(text)) throw makeHttpError(400, "Args cannot contain newlines or null bytes");
1222
+ if (text.length > 2048) throw makeHttpError(400, "One arg is too long");
1223
+ args.push(text);
1224
+ if (args.length > APP_RUNNER_CUSTOM_ARG_LIMIT) throw makeHttpError(400, `Too many args; limit is ${APP_RUNNER_CUSTOM_ARG_LIMIT}`);
1225
+ }
1226
+ return args;
1227
+ }
1228
+
1229
+ function publicCustomRunnerDefinition(runner) {
1230
+ const command = cleanCustomRunnerCommand(runner.command);
1231
+ const args = parseCustomRunnerArgs(runner.args);
1232
+ const filePath = normalizeProjectRelativePath(runner.path || runner.projectFile);
1233
+ const commandParts = customRunnerCommandParts(command);
1234
+ const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
1235
+ const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
1236
+ return {
1237
+ id: runner.id,
1238
+ label: runner.label,
1239
+ command,
1240
+ path: filePath,
1241
+ args,
1242
+ displayCommand: formatCommandForDisplay(effectiveCommand, effectiveArgs),
1243
+ };
1244
+ }
1245
+
1246
+ function normalizeCustomRunnerDefinition(raw, projectRoot, { strict = false } = {}) {
1247
+ const filePath = normalizeProjectRelativePath(raw?.path || raw?.projectFile);
1248
+ const absolutePath = resolveProjectRelativePath(projectRoot, filePath);
1249
+ const command = cleanCustomRunnerCommand(raw?.command);
1250
+ const args = parseCustomRunnerArgs(raw?.args);
1251
+ const label = String(raw?.label || path.basename(filePath)).trim().slice(0, 120) || path.basename(filePath);
1252
+ const rawId = String(raw?.id || "").trim();
1253
+ const id = appRunnerId(rawId || label, command, filePath) || appRunnerId(command, filePath);
1254
+ if (!id) throw makeHttpError(400, "Custom runner id could not be generated");
1255
+ if (strict && !appRunnerPathInside(projectRoot, absolutePath)) throw makeHttpError(400, "Path must stay inside the project root");
1256
+ return { id, label, command, path: filePath, args };
1257
+ }
1258
+
1259
+ async function readAppRunnerConfig(projectRoot) {
1260
+ const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
1261
+ const parsed = await readJsonFileIfExists(configPath);
1262
+ const source = parsed && typeof parsed === "object" ? parsed : {};
1263
+ const rawRunners = Array.isArray(source.runners) ? source.runners : [];
1264
+ const runners = [];
1265
+ for (const raw of rawRunners) {
1266
+ try {
1267
+ const runner = normalizeCustomRunnerDefinition(raw, projectRoot);
1268
+ if (!runners.some((item) => item.id === runner.id)) runners.push(runner);
1269
+ } catch (error) {
1270
+ console.warn(`skipping invalid custom app runner in ${configPath}: ${sanitizeError(error)}`);
1271
+ }
1272
+ if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) break;
1273
+ }
1274
+ return { projectRoot, configPath, runners };
1275
+ }
1276
+
1277
+ async function writeAppRunnerConfig(projectRoot, runners) {
1278
+ const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
1279
+ const normalized = [];
1280
+ for (const runner of runners) {
1281
+ normalized.push(normalizeCustomRunnerDefinition(runner, projectRoot, { strict: true }));
1282
+ if (normalized.length >= APP_RUNNER_CUSTOM_LIMIT) break;
1283
+ }
1284
+ const tmpFile = `${configPath}.${process.pid}.${Date.now()}.tmp`;
1285
+ await writeFile(tmpFile, `${JSON.stringify({ version: 1, runners: normalized }, null, 2)}\n`, { mode: 0o600 });
1286
+ await rename(tmpFile, configPath);
1287
+ return { projectRoot, configPath, runners: normalized };
1288
+ }
1289
+
1290
+ async function customAppRunnerCandidate(projectRoot, configPath, runner) {
1291
+ const filePath = runner.path;
1292
+ const absolutePath = resolveProjectRelativePath(projectRoot, filePath);
1293
+ const stats = await fileStatsIfExists(absolutePath);
1294
+ if (!stats?.isFile()) return null;
1295
+ const command = cleanCustomRunnerCommand(runner.command);
1296
+ const args = parseCustomRunnerArgs(runner.args);
1297
+ const commandParts = customRunnerCommandParts(command);
1298
+ const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
1299
+ const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
1300
+ if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return null;
1301
+ return appRunnerCandidate({
1302
+ id: appRunnerId("custom", runner.id),
1303
+ label: runner.label || path.basename(filePath),
1304
+ kind: "custom",
1305
+ command: effectiveCommand,
1306
+ args: effectiveArgs,
1307
+ projectFile: filePath,
1308
+ description: `Custom project runner from ${APP_RUNNER_CONFIG_FILE}`,
1309
+ priority: 8,
1310
+ cwd: projectRoot,
1311
+ custom: true,
1312
+ configFile: configPath,
1313
+ });
1314
+ }
1315
+
1316
+ async function addCustomAppRunners(runners, cwd) {
1317
+ const projectRoot = await findAppRunnerProjectRoot(cwd);
1318
+ const config = await readAppRunnerConfig(projectRoot);
1319
+ for (const runner of config.runners) {
1320
+ const candidate = await customAppRunnerCandidate(projectRoot, config.configPath, runner);
1321
+ if (candidate) addAppRunner(runners, candidate);
1322
+ }
1323
+ }
1324
+
1325
+ async function getCustomAppRunnerConfigData(tab) {
1326
+ const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1327
+ const config = await readAppRunnerConfig(projectRoot);
1328
+ return {
1329
+ projectRoot,
1330
+ displayProjectRoot: displayPath(projectRoot),
1331
+ configFile: config.configPath,
1332
+ displayConfigFile: displayPath(config.configPath),
1333
+ relativeConfigFile: APP_RUNNER_CONFIG_FILE,
1334
+ runners: config.runners.map(publicCustomRunnerDefinition),
1335
+ };
1336
+ }
1337
+
1338
+ async function saveCustomAppRunner(tab, rawRunner) {
1339
+ const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1340
+ const config = await readAppRunnerConfig(projectRoot);
1341
+ const normalized = normalizeCustomRunnerDefinition(rawRunner, projectRoot, { strict: true });
1342
+ const stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, normalized.path));
1343
+ if (!stats?.isFile()) throw makeHttpError(400, `Path to file does not exist: ${normalized.path}`);
1344
+ const commandParts = customRunnerCommandParts(normalized.command);
1345
+ if (normalized.command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) throw makeHttpError(400, `Command is not available: ${commandParts[0]}`);
1346
+ const runners = config.runners.filter((runner) => runner.id !== normalized.id);
1347
+ if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) throw makeHttpError(400, `Custom runner limit reached (${APP_RUNNER_CUSTOM_LIMIT})`);
1348
+ runners.push(normalized);
1349
+ await writeAppRunnerConfig(projectRoot, runners);
1350
+ return getAppRunnerData(tab);
1351
+ }
1352
+
1353
+ async function deleteCustomAppRunner(tab, runnerId) {
1354
+ const id = appRunnerId(String(runnerId || "").replace(/^custom:/, ""));
1355
+ if (!id) throw makeHttpError(400, "Custom runner id is required");
1356
+ const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1357
+ const config = await readAppRunnerConfig(projectRoot);
1358
+ const runners = config.runners.filter((runner) => runner.id !== id);
1359
+ if (runners.length === config.runners.length) throw makeHttpError(404, "Custom runner not found");
1360
+ await writeAppRunnerConfig(projectRoot, runners);
1361
+ return getAppRunnerData(tab);
1362
+ }
1363
+
1364
+ async function getAppRunnerFileBrowserData(tab, rawPath) {
1365
+ const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
1366
+ const relativeDir = normalizeProjectRelativePath(rawPath || "", { allowEmpty: true });
1367
+ const absoluteDir = resolveProjectRelativePath(projectRoot, relativeDir || ".");
1368
+ const stats = await fileStatsIfExists(absoluteDir);
1369
+ if (!stats?.isDirectory()) throw makeHttpError(400, `Not a directory inside project root: ${relativeDir || "."}`);
1370
+ let entries;
1371
+ try {
1372
+ entries = await readdir(absoluteDir, { withFileTypes: true });
1373
+ } catch (error) {
1374
+ throw makeHttpError(error?.code === "EACCES" ? 403 : 400, `Cannot read directory ${relativeDir || "."}: ${sanitizeError(error)}`);
1375
+ }
1376
+ const sorted = entries.sort((a, b) => {
1377
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
1378
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
1379
+ });
1380
+ const directories = [];
1381
+ const files = [];
1382
+ for (const entry of sorted) {
1383
+ if (entry.name === ".git") continue;
1384
+ const entryRelativePath = normalizeSuggestionPath(relativeDir ? `${relativeDir}/${entry.name}` : entry.name);
1385
+ if (entry.isDirectory()) directories.push({ name: entry.name, path: entryRelativePath, hidden: entry.name.startsWith(".") });
1386
+ else if (entry.isFile()) files.push({ name: entry.name, path: entryRelativePath, hidden: entry.name.startsWith(".") });
1387
+ if (directories.length + files.length >= APP_RUNNER_FILE_PICKER_LIMIT) break;
1388
+ }
1389
+ const parent = relativeDir ? normalizeSuggestionPath(path.posix.dirname(relativeDir)) : "";
1390
+ return {
1391
+ projectRoot,
1392
+ displayProjectRoot: displayPath(projectRoot),
1393
+ relativeDir,
1394
+ displayRelativeDir: relativeDir || ".",
1395
+ parent: relativeDir && parent !== "." ? parent : relativeDir ? "" : null,
1396
+ directories,
1397
+ files,
1398
+ truncated: sorted.length > directories.length + files.length,
1399
+ };
1400
+ }
1401
+
1402
+ function packageManagerArgs(manager, script) {
1403
+ if (manager === "bun") return ["run", script];
1404
+ if (manager === "yarn") return script === "start" ? ["start"] : [script];
1405
+ return script === "start" ? ["start"] : ["run", script];
1406
+ }
1407
+
1408
+ async function addPackageManagerRunners(runners, cwd, pkg) {
1409
+ const script = preferredPackageScript(pkg);
1410
+ if (!script) return;
1411
+ const packageManager = String(pkg?.packageManager || "").toLowerCase();
1412
+ const [hasBunLock, hasPnpmLock, hasYarnLock, hasPackageLock] = await Promise.all([
1413
+ appRunnerFileExists(cwd, "bun.lock").then((exists) => exists || appRunnerFileExists(cwd, "bun.lockb")),
1414
+ appRunnerFileExists(cwd, "pnpm-lock.yaml"),
1415
+ appRunnerFileExists(cwd, "yarn.lock"),
1416
+ appRunnerFileExists(cwd, "package-lock.json"),
1417
+ ]);
1418
+ const managers = [
1419
+ { id: "bun", command: "bun", label: "Bun", hint: hasBunLock || packageManager.startsWith("bun@"), priority: hasBunLock || packageManager.startsWith("bun@") ? 20 : 54 },
1420
+ { id: "pnpm", command: "pnpm", label: "pnpm", hint: hasPnpmLock || packageManager.startsWith("pnpm@"), priority: hasPnpmLock || packageManager.startsWith("pnpm@") ? 24 : 58 },
1421
+ { id: "npm", command: "npm", label: "npm", hint: hasPackageLock || packageManager.startsWith("npm@") || !packageManager, priority: hasPackageLock || packageManager.startsWith("npm@") || !packageManager ? 28 : 62 },
1422
+ { id: "yarn", command: "yarn", label: "Yarn", hint: hasYarnLock || packageManager.startsWith("yarn@"), priority: hasYarnLock || packageManager.startsWith("yarn@") ? 34 : 72 },
1423
+ ];
1424
+
1425
+ for (const manager of managers) {
1426
+ if (!await appRunnerCommandAvailable(manager.command, cwd)) continue;
1427
+ const args = packageManagerArgs(manager.id, script);
1428
+ addAppRunner(runners, appRunnerCandidate({
1429
+ id: appRunnerId("pkg", manager.id, script),
1430
+ label: `${manager.label} ${script}`,
1431
+ kind: manager.id === "bun" ? "bun" : "node",
1432
+ command: manager.command,
1433
+ args,
1434
+ projectFile: "package.json",
1435
+ description: `${manager.label} package script: ${script}`,
1436
+ priority: manager.priority + (manager.hint ? 0 : 12),
1437
+ }));
1438
+ }
1439
+ }
1440
+
1441
+ async function addNpxFrameworkRunners(runners, cwd, pkg) {
1442
+ const dependencyNames = packageDependencyNames(pkg);
1443
+ if (!dependencyNames.size || !await appRunnerCommandAvailable("npx", cwd)) return;
1444
+ const frameworks = [
1445
+ { dep: "vite", label: "npx vite", args: ["--no-install", "vite"], priority: 78 },
1446
+ { dep: "next", label: "npx next dev", args: ["--no-install", "next", "dev"], priority: 80 },
1447
+ { dep: "astro", label: "npx astro dev", args: ["--no-install", "astro", "dev"], priority: 82 },
1448
+ { dep: "@storybook/react", label: "npx storybook dev", args: ["--no-install", "storybook", "dev"], priority: 86 },
1449
+ { dep: "storybook", label: "npx storybook dev", args: ["--no-install", "storybook", "dev"], priority: 86 },
1450
+ ];
1451
+ for (const framework of frameworks) {
1452
+ if (!dependencyNames.has(framework.dep)) continue;
1453
+ addAppRunner(runners, appRunnerCandidate({
1454
+ id: appRunnerId("npx", framework.dep),
1455
+ label: framework.label,
1456
+ kind: "node",
1457
+ command: "npx",
1458
+ args: framework.args,
1459
+ projectFile: "package.json",
1460
+ description: `Detected ${framework.dep} dependency`,
1461
+ priority: framework.priority,
1462
+ }));
1463
+ }
1464
+ }
1465
+
1466
+ async function addNodeEntrypointRunner(runners, cwd, hasPackageJson) {
1467
+ if (hasPackageJson) return;
1468
+ const entry = await firstExistingRunnerFile(cwd, APP_RUNNER_JS_ENTRIES);
1469
+ if (!entry || !await appRunnerCommandAvailable("node", cwd)) return;
1470
+ addAppRunner(runners, appRunnerCandidate({
1471
+ id: appRunnerId("node", entry),
1472
+ label: `node ${entry}`,
1473
+ kind: "node",
1474
+ command: "node",
1475
+ args: [entry],
1476
+ projectFile: entry,
1477
+ description: "Detected JavaScript entry file",
1478
+ priority: 88,
1479
+ }));
1480
+ }
1481
+
1482
+ async function addPythonRunners(runners, cwd) {
1483
+ const entry = await firstExistingRunnerFile(cwd, APP_RUNNER_PYTHON_ENTRIES);
1484
+ if (!entry) return;
1485
+ if (await appRunnerCommandAvailable("uv", cwd)) {
1486
+ addAppRunner(runners, appRunnerCandidate({
1487
+ id: appRunnerId("python", "uv", entry),
1488
+ label: `uv run ${entry}`,
1489
+ kind: "python",
1490
+ command: "uv",
1491
+ args: ["run", entry],
1492
+ projectFile: entry,
1493
+ description: "Detected Python entry file",
1494
+ priority: 36,
1495
+ }));
1496
+ }
1497
+ const pythonCommand = await appRunnerCommandAvailable("python3", cwd) ? "python3" : await appRunnerCommandAvailable("python", cwd) ? "python" : "";
1498
+ if (pythonCommand) {
1499
+ addAppRunner(runners, appRunnerCandidate({
1500
+ id: appRunnerId("python", pythonCommand, entry),
1501
+ label: `${pythonCommand} ${entry}`,
1502
+ kind: "python",
1503
+ command: pythonCommand,
1504
+ args: [entry],
1505
+ projectFile: entry,
1506
+ description: "Detected Python entry file",
1507
+ priority: 68,
1508
+ }));
1509
+ }
1510
+ }
1511
+
1512
+ async function addRustRunner(runners, cwd) {
1513
+ if (!await appRunnerFileExists(cwd, "Cargo.toml") || !await appRunnerCommandAvailable("cargo", cwd)) return;
1514
+ addAppRunner(runners, appRunnerCandidate({
1515
+ id: "rust:cargo-run",
1516
+ label: "cargo run",
1517
+ kind: "rust",
1518
+ command: "cargo",
1519
+ args: ["run"],
1520
+ projectFile: "Cargo.toml",
1521
+ description: "Detected Rust Cargo project",
1522
+ priority: 18,
1523
+ }));
1524
+ }
1525
+
1526
+ async function goRunTarget(cwd) {
1527
+ if (await appRunnerFileExists(cwd, "main.go")) return ".";
1528
+ if (await appRunnerDirectoryExists(cwd, "cmd")) {
1529
+ const entries = await readdir(path.join(cwd, "cmd"), { withFileTypes: true }).catch(() => []);
1530
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1531
+ if (!entry.isDirectory()) continue;
1532
+ if (await appRunnerFileExists(cwd, path.join("cmd", entry.name, "main.go"))) return `./cmd/${entry.name}`;
1533
+ }
1534
+ }
1535
+ return await appRunnerFileExists(cwd, "go.mod") ? "." : "";
1536
+ }
1537
+
1538
+ async function addGoRunner(runners, cwd) {
1539
+ const target = await goRunTarget(cwd);
1540
+ if (!target || !await appRunnerCommandAvailable("go", cwd)) return;
1541
+ addAppRunner(runners, appRunnerCandidate({
1542
+ id: appRunnerId("go", target),
1543
+ label: `go run ${target}`,
1544
+ kind: "go",
1545
+ command: "go",
1546
+ args: ["run", target],
1547
+ projectFile: await appRunnerFileExists(cwd, "go.mod") ? "go.mod" : target,
1548
+ description: "Detected Go/Golang app entry",
1549
+ priority: 46,
1550
+ }));
1551
+ }
1552
+
1553
+ function buildZigHasRunStep(text) {
1554
+ return /\.step\(\s*["']run["']/.test(String(text || ""));
1555
+ }
1556
+
1557
+ async function addZigRunner(runners, cwd) {
1558
+ if (!await appRunnerCommandAvailable("zig", cwd)) return;
1559
+ const buildZig = await appRunnerTextIfExists(cwd, "build.zig");
1560
+ if (buildZig && buildZigHasRunStep(buildZig)) {
1561
+ addAppRunner(runners, appRunnerCandidate({
1562
+ id: "zig:build-run",
1563
+ label: "zig build run",
1564
+ kind: "zig",
1565
+ command: "zig",
1566
+ args: ["build", "run"],
1567
+ projectFile: "build.zig",
1568
+ description: "Detected Zig build.zig run step",
1569
+ priority: 44,
1570
+ }));
1571
+ }
1572
+ const entry = await firstExistingRunnerFile(cwd, APP_RUNNER_ZIG_ENTRIES);
1573
+ if (entry) {
1574
+ addAppRunner(runners, appRunnerCandidate({
1575
+ id: appRunnerId("zig", entry),
1576
+ label: `zig run ${entry}`,
1577
+ kind: "zig",
1578
+ command: "zig",
1579
+ args: ["run", entry],
1580
+ projectFile: entry,
1581
+ description: "Detected Zig app entry file",
1582
+ priority: 66,
1583
+ }));
1584
+ }
1585
+ }
1586
+
1587
+ function firstCmakeExecutableTarget(text) {
1588
+ const match = String(text || "").match(/add_executable\s*\(\s*([A-Za-z0-9_.+-]+)/i);
1589
+ return match ? match[1] : "";
1590
+ }
1591
+
1592
+ async function addCompiledLanguageRunner(runners, cwd, { language, kind, compiler, entry, outputName, priority }) {
1593
+ if (!entry || !await appRunnerCommandAvailable("sh", cwd) || !await appRunnerCommandAvailable(compiler, cwd)) return;
1594
+ const output = `.pi-webui-runner/${outputName}`;
1595
+ const compileAndRun = `mkdir -p .pi-webui-runner && ${compiler} ${shellQuote(entry)} -o ${shellQuote(output)} && ${shellQuote(`./${output}`)}`;
1596
+ addAppRunner(runners, appRunnerCandidate({
1597
+ id: appRunnerId(kind, entry),
1598
+ label: `${compiler} ${entry}`,
1599
+ kind,
1600
+ command: "sh",
1601
+ args: ["-lc", compileAndRun],
1602
+ projectFile: entry,
1603
+ description: `Detected ${language} app entry file`,
1604
+ priority,
1605
+ }));
1606
+ }
1607
+
1608
+ async function addCppRunners(runners, cwd) {
1609
+ const cmakeText = await appRunnerTextIfExists(cwd, "CMakeLists.txt");
1610
+ const cmakeTarget = firstCmakeExecutableTarget(cmakeText);
1611
+ const hasShell = await appRunnerCommandAvailable("sh", cwd);
1612
+ if (cmakeTarget && hasShell && await appRunnerCommandAvailable("cmake", cwd)) {
1613
+ const configureBuildRun = `cmake -S . -B build && cmake --build build --target ${shellQuote(cmakeTarget)} && ${shellQuote(`./build/${cmakeTarget}`)}`;
1614
+ addAppRunner(runners, appRunnerCandidate({
1615
+ id: appRunnerId("cmake", cmakeTarget),
1616
+ label: `cmake run ${cmakeTarget}`,
1617
+ kind: "cpp",
1618
+ command: "sh",
1619
+ args: ["-lc", configureBuildRun],
1620
+ projectFile: "CMakeLists.txt",
1621
+ description: "Detected C/C++ CMake executable target",
1622
+ priority: 42,
1623
+ }));
1624
+ return;
1625
+ }
1626
+
1627
+ await Promise.all([
1628
+ addCompiledLanguageRunner(runners, cwd, {
1629
+ language: "C",
1630
+ kind: "c",
1631
+ compiler: "cc",
1632
+ entry: await firstExistingRunnerFile(cwd, APP_RUNNER_C_ENTRIES),
1633
+ outputName: "main-c",
1634
+ priority: 64,
1635
+ }),
1636
+ addCompiledLanguageRunner(runners, cwd, {
1637
+ language: "C++",
1638
+ kind: "cpp",
1639
+ compiler: "c++",
1640
+ entry: await firstExistingRunnerFile(cwd, APP_RUNNER_CPP_ENTRIES),
1641
+ outputName: "main-cpp",
1642
+ priority: 65,
1643
+ }),
1644
+ ]);
1645
+ }
1646
+
1647
+ async function dockerComposePluginAvailable(cwd) {
1648
+ const result = await runCommand("docker", ["compose", "version"], {
1649
+ cwd,
1650
+ timeoutMs: APP_RUNNER_DETECTION_TIMEOUT_MS,
1651
+ maxOutputLength: 2_000,
1652
+ });
1653
+ return !result.error && !result.timedOut && result.exitCode === 0;
1654
+ }
1655
+
1656
+ async function addDockerComposeRunner(runners, cwd) {
1657
+ const composeFile = await firstExistingRunnerFile(cwd, APP_RUNNER_DOCKER_COMPOSE_FILES);
1658
+ if (!composeFile) return;
1659
+ if (await appRunnerCommandAvailable("docker", cwd) && await dockerComposePluginAvailable(cwd)) {
1660
+ addAppRunner(runners, appRunnerCandidate({
1661
+ id: appRunnerId("docker-compose", composeFile),
1662
+ label: "docker compose up",
1663
+ kind: "docker",
1664
+ command: "docker",
1665
+ args: ["compose", "-f", composeFile, "up"],
1666
+ projectFile: composeFile,
1667
+ description: "Detected Docker Compose file",
1668
+ priority: 82,
1669
+ }));
1670
+ }
1671
+ if (await appRunnerCommandAvailable("docker-compose", cwd)) {
1672
+ addAppRunner(runners, appRunnerCandidate({
1673
+ id: appRunnerId("docker-compose-standalone", composeFile),
1674
+ label: "docker-compose up",
1675
+ kind: "docker",
1676
+ command: "docker-compose",
1677
+ args: ["-f", composeFile, "up"],
1678
+ projectFile: composeFile,
1679
+ description: "Detected Docker Compose file",
1680
+ priority: 84,
1681
+ }));
1682
+ }
1683
+ }
1684
+
1685
+ function shellFromShebang(text) {
1686
+ const firstLine = String(text || "").split(/\r?\n/, 1)[0] || "";
1687
+ if (!firstLine.startsWith("#!")) return "";
1688
+ if (/\bfish\b/.test(firstLine)) return "fish";
1689
+ if (/\bzsh\b/.test(firstLine)) return "zsh";
1690
+ if (/\bbash\b/.test(firstLine)) return "bash";
1691
+ if (/\bsh\b/.test(firstLine)) return "bash";
1692
+ return "";
1693
+ }
1694
+
1695
+ function shellScriptPriority(relativePath, shell) {
1696
+ const base = path.basename(relativePath).replace(/\.(?:sh|bash|zsh|fish)$/i, "").toLowerCase();
1697
+ const directory = path.dirname(relativePath).replace(/\\/g, "/");
1698
+ const nameRank = ["dev", "start", "run", "serve", "server", "app", "main"].indexOf(base);
1699
+ const dirRank = APP_RUNNER_SHELL_SCRIPT_DIRS.indexOf(directory === "." ? "" : directory);
1700
+ const shellRank = shell === "bash" ? 0 : shell === "zsh" ? 1 : shell === "fish" ? 2 : 3;
1701
+ return 70 + (nameRank === -1 ? 18 : nameRank) + (dirRank === -1 ? 8 : dirRank) + shellRank / 10;
1702
+ }
1703
+
1704
+ async function shellScriptRunnerForFile(cwd, relativePath) {
1705
+ const extensionShell = APP_RUNNER_SHELL_EXTENSIONS.get(path.extname(relativePath).toLowerCase()) || "";
1706
+ let shell = extensionShell;
1707
+ if (!shell) shell = shellFromShebang(await appRunnerTextIfExists(cwd, relativePath, 256));
1708
+ if (!shell || !await appRunnerCommandAvailable(shell, cwd)) return null;
1709
+ const fileName = path.basename(relativePath);
1710
+ const directory = path.dirname(relativePath);
1711
+ return appRunnerCandidate({
1712
+ id: appRunnerId("shell", shell, relativePath),
1713
+ label: fileName,
1714
+ kind: "shell",
1715
+ command: shell,
1716
+ args: [relativePath],
1717
+ projectFile: relativePath,
1718
+ description: `Detected ${shell} shell script${directory && directory !== "." ? ` in ${directory}` : ""}`,
1719
+ priority: shellScriptPriority(relativePath, shell),
1720
+ });
1721
+ }
1722
+
1723
+ async function addShellScriptRunners(runners, cwd) {
1724
+ const candidates = [];
1725
+ for (const directory of APP_RUNNER_SHELL_SCRIPT_DIRS) {
1726
+ const absoluteDirectory = path.join(cwd, directory || ".");
1727
+ const stats = await fileStatsIfExists(absoluteDirectory);
1728
+ if (!stats?.isDirectory()) continue;
1729
+ const entries = await readdir(absoluteDirectory, { withFileTypes: true }).catch(() => []);
1730
+ for (const entry of entries) {
1731
+ if (!entry.isFile()) continue;
1732
+ const relativePath = directory ? `${directory}/${entry.name}` : entry.name;
1733
+ const extension = path.extname(entry.name).toLowerCase();
1734
+ const explicitShellExtension = APP_RUNNER_SHELL_EXTENSIONS.has(extension);
1735
+ if (!explicitShellExtension && entry.name.includes(".")) continue;
1736
+ candidates.push(relativePath);
1737
+ }
1738
+ }
1739
+
1740
+ for (const relativePath of candidates.slice(0, APP_RUNNER_SHELL_SCRIPT_LIMIT * 2)) {
1741
+ const runner = await shellScriptRunnerForFile(cwd, relativePath);
1742
+ if (runner) addAppRunner(runners, runner);
1743
+ if (runners.filter((item) => item.kind === "shell").length >= APP_RUNNER_SHELL_SCRIPT_LIMIT) break;
1744
+ }
1745
+ }
1746
+
1747
+ function firstTaskFromText(text, names) {
1748
+ for (const name of names) {
1749
+ const pattern = new RegExp(`^[\\s\"']*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\"']*[:=]`, "m");
1750
+ if (pattern.test(text)) return name;
1751
+ }
1752
+ return "";
1753
+ }
1754
+
1755
+ async function addDenoRunner(runners, cwd) {
1756
+ const hasDenoConfig = await appRunnerFileExists(cwd, "deno.json") || await appRunnerFileExists(cwd, "deno.jsonc");
1757
+ if (!hasDenoConfig || !await appRunnerCommandAvailable("deno", cwd)) return;
1758
+ const configText = (await appRunnerTextIfExists(cwd, "deno.json")) || (await appRunnerTextIfExists(cwd, "deno.jsonc"));
1759
+ const task = firstTaskFromText(configText, ["dev", "start", "serve"]);
1760
+ if (task) {
1761
+ addAppRunner(runners, appRunnerCandidate({
1762
+ id: appRunnerId("deno", task),
1763
+ label: `deno task ${task}`,
1764
+ kind: "deno",
1765
+ command: "deno",
1766
+ args: ["task", task],
1767
+ projectFile: "deno.json",
1768
+ description: "Detected Deno task",
1769
+ priority: 52,
1770
+ }));
1771
+ }
1772
+ }
1773
+
1774
+ async function addTaskFileRunners(runners, cwd) {
1775
+ const [justText, makeText] = await Promise.all([
1776
+ appRunnerTextIfExists(cwd, "justfile").then((text) => text || appRunnerTextIfExists(cwd, "Justfile")),
1777
+ appRunnerTextIfExists(cwd, "Makefile").then((text) => text || appRunnerTextIfExists(cwd, "makefile")),
1778
+ ]);
1779
+ const justTarget = firstTaskFromText(justText, ["dev", "run", "start"]);
1780
+ if (justTarget && await appRunnerCommandAvailable("just", cwd)) {
1781
+ addAppRunner(runners, appRunnerCandidate({
1782
+ id: appRunnerId("just", justTarget),
1783
+ label: `just ${justTarget}`,
1784
+ kind: "task",
1785
+ command: "just",
1786
+ args: [justTarget],
1787
+ projectFile: "Justfile",
1788
+ description: "Detected just recipe",
1789
+ priority: 74,
1790
+ }));
1791
+ }
1792
+ const makeTarget = firstTaskFromText(makeText, ["dev", "run", "start"]);
1793
+ if (makeTarget && await appRunnerCommandAvailable("make", cwd)) {
1794
+ addAppRunner(runners, appRunnerCandidate({
1795
+ id: appRunnerId("make", makeTarget),
1796
+ label: `make ${makeTarget}`,
1797
+ kind: "task",
1798
+ command: "make",
1799
+ args: [makeTarget],
1800
+ projectFile: "Makefile",
1801
+ description: "Detected Make target",
1802
+ priority: 76,
1803
+ }));
1804
+ }
1805
+ }
1806
+
1807
+ function publicAppRunner(runner) {
1808
+ if (!runner) return null;
1809
+ const { priority: _priority, ...publicRunner } = runner;
1810
+ return publicRunner;
1811
+ }
1812
+
1813
+ async function detectAppRunners(tab) {
1814
+ const cwd = tab?.cwd || options.cwd;
1815
+ const runners = [];
1816
+ const pkg = await readJsonFileIfExists(path.join(cwd, "package.json"));
1817
+ await Promise.all([
1818
+ addCustomAppRunners(runners, cwd),
1819
+ addRustRunner(runners, cwd),
1820
+ pkg ? addPackageManagerRunners(runners, cwd, pkg) : Promise.resolve(),
1821
+ pkg ? addNpxFrameworkRunners(runners, cwd, pkg) : Promise.resolve(),
1822
+ addPythonRunners(runners, cwd),
1823
+ addGoRunner(runners, cwd),
1824
+ addZigRunner(runners, cwd),
1825
+ addCppRunners(runners, cwd),
1826
+ addDockerComposeRunner(runners, cwd),
1827
+ addShellScriptRunners(runners, cwd),
1828
+ addDenoRunner(runners, cwd),
1829
+ addTaskFileRunners(runners, cwd),
1830
+ addNodeEntrypointRunner(runners, cwd, !!pkg),
1831
+ ]);
1832
+ return runners
1833
+ .sort((a, b) => a.priority - b.priority || a.label.localeCompare(b.label))
1834
+ .map(publicAppRunner);
1835
+ }
1836
+
1837
+ function publicAppRunnerState(run) {
1838
+ if (!run) return null;
1839
+ return {
1840
+ id: run.id,
1841
+ runnerId: run.runnerId,
1842
+ kind: run.kind,
1843
+ label: run.label,
1844
+ command: run.command,
1845
+ args: run.args,
1846
+ displayCommand: run.displayCommand,
1847
+ cwd: run.cwd,
1848
+ pid: run.pid,
1849
+ status: run.status,
1850
+ startedAt: run.startedAt,
1851
+ endedAt: run.endedAt,
1852
+ exitCode: run.exitCode,
1853
+ signal: run.signal,
1854
+ stopping: run.stopping === true,
1855
+ truncated: run.truncated === true,
1856
+ lineCount: run.lineCount || run.lines?.length || 0,
1857
+ lines: Array.isArray(run.lines) ? [...run.lines] : [],
1858
+ };
1859
+ }
1860
+
1861
+ async function getAppRunnerData(tab) {
1862
+ const [runners, customRunnerConfig] = await Promise.all([
1863
+ detectAppRunners(tab),
1864
+ getCustomAppRunnerConfigData(tab),
1865
+ ]);
1866
+ return {
1867
+ cwd: tab.cwd,
1868
+ runners,
1869
+ customRunnerConfig,
1870
+ activeRun: publicAppRunnerState(tab.appRunner),
1871
+ };
1872
+ }
1873
+
1874
+ function appendAppRunnerLine(run, line) {
1875
+ if (!run) return;
1876
+ const text = String(line ?? "");
1877
+ run.lines.push(text);
1878
+ run.lineCount = (run.lineCount || 0) + 1;
1879
+ run.outputChars = (run.outputChars || 0) + text.length + 1;
1880
+ while (run.lines.length > APP_RUNNER_OUTPUT_LINE_LIMIT || run.outputChars > APP_RUNNER_OUTPUT_MAX_CHARS) {
1881
+ const removed = run.lines.shift();
1882
+ run.outputChars -= String(removed || "").length + 1;
1883
+ run.truncated = true;
1884
+ }
1885
+ }
1886
+
1887
+ function appendAppRunnerChunk(tab, run, chunk, streamName) {
1888
+ if (!run || run.status !== "running") return;
1889
+ const key = streamName === "stderr" ? "stderrRemainder" : "stdoutRemainder";
1890
+ const normalized = `${run[key] || ""}${String(chunk).replace(/\r\n?/g, "\n")}`;
1891
+ const lines = normalized.split("\n");
1892
+ run[key] = lines.pop() || "";
1893
+ for (const line of lines) appendAppRunnerLine(run, line);
1894
+ scheduleAppRunnerBroadcast(tab);
1895
+ }
1896
+
1897
+ function flushAppRunnerRemainders(run) {
1898
+ for (const key of ["stdoutRemainder", "stderrRemainder"]) {
1899
+ if (run?.[key]) {
1900
+ appendAppRunnerLine(run, run[key]);
1901
+ run[key] = "";
1902
+ }
1903
+ }
1904
+ }
1905
+
1906
+ function appRunnerStatusLabel(run) {
1907
+ if (run?.stopping && run.status === "running") return "stopping";
1908
+ if (run?.status === "done") return "exit 0";
1909
+ if (run?.status === "failed") return run.signal ? `signal ${run.signal}` : `exit ${run.exitCode ?? "?"}`;
1910
+ if (run?.status === "error") return "error";
1911
+ return run?.status || "running";
1912
+ }
1913
+
1914
+ function broadcastAppRunnerState(tab) {
1915
+ broadcastTabEvent(tab, {
1916
+ type: "webui_app_runner_update",
1917
+ tabId: tab.id,
1918
+ tabTitle: tab.title,
1919
+ cwd: tab.cwd,
1920
+ command: tab.appRunner?.displayCommand,
1921
+ activeRun: publicAppRunnerState(tab.appRunner),
1922
+ tabActivity: tabActivitySnapshot(tab),
1923
+ });
1924
+ }
1925
+
1926
+ function scheduleAppRunnerBroadcast(tab) {
1927
+ if (!tab || tab.appRunnerBroadcastTimer) return;
1928
+ tab.appRunnerBroadcastTimer = setTimeout(() => {
1929
+ tab.appRunnerBroadcastTimer = null;
1930
+ if (tabs.has(tab.id)) broadcastAppRunnerState(tab);
1931
+ }, 120);
1932
+ }
1933
+
1934
+ function terminateAppRunnerChild(run, signal = "SIGTERM") {
1935
+ if (!run?.child || run.child.killed) return false;
1936
+ try {
1937
+ if (process.platform !== "win32" && run.pid) process.kill(-run.pid, signal);
1938
+ else run.child.kill(signal);
1939
+ return true;
1940
+ } catch {
1941
+ try {
1942
+ run.child.kill(signal);
1943
+ return true;
1944
+ } catch {
1945
+ return false;
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ function finishAppRunner(tab, run, patch = {}) {
1951
+ if (!run || run.settled) return;
1952
+ run.settled = true;
1953
+ clearTimeout(run.stopTimer);
1954
+ flushAppRunnerRemainders(run);
1955
+ run.endedAt = new Date().toISOString();
1956
+ run.exitCode = patch.exitCode;
1957
+ run.signal = patch.signal;
1958
+ run.error = patch.error;
1959
+ run.status = patch.error ? "error" : patch.exitCode === 0 ? "done" : "failed";
1960
+ run.child = null;
1961
+ run.stopping = false;
1962
+ appendAppRunnerLine(run, `# ${appRunnerStatusLabel(run)} after ${Math.max(0, Math.round((Date.parse(run.endedAt) - Date.parse(run.startedAt)) / 1000))}s`);
1963
+ if (patch.error) appendAppRunnerLine(run, `# ${patch.error}`);
1964
+ recordEvent({ type: "webui_app_runner_exit", tabId: tab.id, tabTitle: tab.title, command: run.displayCommand, code: run.exitCode, signal: run.signal, error: run.error });
1965
+ clearTimeout(tab.appRunnerBroadcastTimer);
1966
+ tab.appRunnerBroadcastTimer = null;
1967
+ broadcastAppRunnerState(tab);
1968
+ }
1969
+
1970
+ async function startAppRunner(tab, runnerId) {
1971
+ if (tab.appRunner?.status === "running") throw makeHttpError(409, `App runner already running: ${tab.appRunner.displayCommand}`);
1972
+ const runners = await detectAppRunners(tab);
1973
+ const runner = runners.find((item) => item.id === runnerId) || (runners.length === 1 && !runnerId ? runners[0] : null);
1974
+ if (!runner) throw makeHttpError(400, "Selected app runner is unavailable in this tab cwd");
1975
+
1976
+ const run = {
1977
+ id: randomUUID(),
1978
+ runnerId: runner.id,
1979
+ kind: runner.kind,
1980
+ label: runner.label,
1981
+ command: runner.command,
1982
+ args: runner.args || [],
1983
+ displayCommand: runner.displayCommand,
1984
+ cwd: runner.cwd || tab.cwd,
1985
+ status: "running",
1986
+ startedAt: new Date().toISOString(),
1987
+ lines: [],
1988
+ lineCount: 0,
1989
+ outputChars: 0,
1990
+ };
1991
+ appendAppRunnerLine(run, `$ ${run.displayCommand}`);
1992
+ const child = spawn(run.command, run.args, {
1993
+ cwd: run.cwd,
1994
+ env: process.env,
1995
+ stdio: ["ignore", "pipe", "pipe"],
1996
+ windowsHide: true,
1997
+ detached: process.platform !== "win32",
1998
+ });
1999
+ run.child = child;
2000
+ run.pid = child.pid;
2001
+ tab.appRunner = run;
2002
+
2003
+ child.stdout.on("data", (chunk) => appendAppRunnerChunk(tab, run, chunk, "stdout"));
2004
+ child.stderr.on("data", (chunk) => appendAppRunnerChunk(tab, run, chunk, "stderr"));
2005
+ child.on("error", (error) => finishAppRunner(tab, run, { error: sanitizeError(error) }));
2006
+ child.on("exit", (exitCode, signal) => finishAppRunner(tab, run, { exitCode, signal }));
2007
+
2008
+ recordEvent({ type: "webui_app_runner_start", tabId: tab.id, tabTitle: tab.title, command: run.displayCommand, cwd: run.cwd, pid: run.pid });
2009
+ broadcastAppRunnerState(tab);
2010
+ return { runners, customRunnerConfig: await getCustomAppRunnerConfigData(tab), activeRun: publicAppRunnerState(run), cwd: tab.cwd };
2011
+ }
2012
+
2013
+ function stopAppRunnerForTab(tab, reason = "stop requested", { force = false } = {}) {
2014
+ const run = tab?.appRunner;
2015
+ if (!run || run.status !== "running") return false;
2016
+ run.stopping = true;
2017
+ appendAppRunnerLine(run, `# ${reason}; sending ${force ? "SIGKILL" : "SIGTERM"}`);
2018
+ terminateAppRunnerChild(run, force ? "SIGKILL" : "SIGTERM");
2019
+ if (!force) {
2020
+ clearTimeout(run.stopTimer);
2021
+ run.stopTimer = setTimeout(() => {
2022
+ if (run.status === "running") {
2023
+ appendAppRunnerLine(run, "# app runner did not stop; sending SIGKILL");
2024
+ terminateAppRunnerChild(run, "SIGKILL");
2025
+ scheduleAppRunnerBroadcast(tab);
2026
+ }
2027
+ }, APP_RUNNER_STOP_GRACE_MS);
2028
+ }
2029
+ broadcastAppRunnerState(tab);
2030
+ return true;
2031
+ }
2032
+
2033
+ function clearAppRunnerForTab(tab) {
2034
+ if (!tab?.appRunner || tab.appRunner.status === "running") return false;
2035
+ tab.appRunner = null;
2036
+ broadcastAppRunnerState(tab);
2037
+ return true;
2038
+ }
2039
+
995
2040
  function firstDefined(...values) {
996
2041
  return values.find((value) => value !== undefined && value !== null);
997
2042
  }
@@ -1661,9 +2706,23 @@ function commitMessagePaths(root) {
1661
2706
  return {
1662
2707
  shortPath: path.join(root, "dev", "COMMIT", "staged-commit-short.txt"),
1663
2708
  longPath: path.join(root, "dev", "COMMIT", "staged-commit-long.txt"),
2709
+ branchPath: path.join(root, "dev", "COMMIT", "staged-branch-name.txt"),
1664
2710
  };
1665
2711
  }
1666
2712
 
2713
+ async function readGitWorkflowBranchName(cwd) {
2714
+ const root = await getGitRoot(cwd);
2715
+ const { branchPath } = commitMessagePaths(root);
2716
+ try {
2717
+ const [branchText, branchStat] = await Promise.all([readFile(branchPath, "utf8"), stat(branchPath)]);
2718
+ const branch = branchText.split(/\r?\n/).find((line) => line.trim())?.trim() || "";
2719
+ if (!branch) throw new Error(`${branchPath} is empty`);
2720
+ return { root, branchPath, branch, mtimeMs: branchStat.mtimeMs };
2721
+ } catch (error) {
2722
+ throw new Error(`Missing generated branch name file ${branchPath}. Run /git-branch-name first. ${sanitizeError(error)}`);
2723
+ }
2724
+ }
2725
+
1667
2726
  async function readGitWorkflowMessages(cwd) {
1668
2727
  const root = await getGitRoot(cwd);
1669
2728
  const { shortPath, longPath } = commitMessagePaths(root);
@@ -1688,19 +2747,77 @@ async function readGitWorkflowMessages(cwd) {
1688
2747
  }
1689
2748
  }
1690
2749
 
2750
+ function cleanGitBranchName(value) {
2751
+ const branch = String(value || "").trim();
2752
+ if (!branch) throw new Error("branch is required");
2753
+ if (branch.includes("\0") || branch.includes("@{") || branch.startsWith("-") || branch.startsWith("/")) throw new Error("invalid branch name");
2754
+ return branch;
2755
+ }
2756
+
2757
+ async function validateGitBranchName(root, branch) {
2758
+ const result = await runGitWorkflowCommand(["check-ref-format", "--branch", branch], { cwd: root, timeoutMs: 5000 });
2759
+ if (result.exitCode !== 0 || result.timedOut || result.cancelled || result.error) {
2760
+ throw new Error((result.stderr || result.stdout || result.error || `Invalid branch name: ${branch}`).trim());
2761
+ }
2762
+ }
2763
+
2764
+ async function currentGitBranch(root) {
2765
+ const result = await runGitWorkflowCommand(["branch", "--show-current"], { cwd: root, timeoutMs: 5000 });
2766
+ const branch = result.stdout.trim();
2767
+ if (result.exitCode !== 0 || !branch) throw new Error((result.stderr || result.stdout || "Cannot determine current git branch").trim());
2768
+ return branch;
2769
+ }
2770
+
2771
+ async function defaultGitRemote(root) {
2772
+ const result = await runGitWorkflowCommand(["remote"], { cwd: root, timeoutMs: 5000 });
2773
+ if (result.exitCode !== 0) throw new Error((result.stderr || result.stdout || "Cannot list git remotes").trim());
2774
+ const remotes = result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
2775
+ if (!remotes.length) throw new Error("No git remote is configured for this repository");
2776
+ return remotes.includes("origin") ? "origin" : remotes[0];
2777
+ }
2778
+
2779
+ function prDescriptionPath(root, branch) {
2780
+ const base = path.resolve(root, "dev", "PR");
2781
+ const target = path.resolve(base, `${branch}.md`);
2782
+ if (target !== base && !target.startsWith(`${base}${path.sep}`)) throw new Error("Resolved PR description path escapes dev/PR");
2783
+ return { base, prPath: target };
2784
+ }
2785
+
2786
+ async function readGitWorkflowPrDescription(cwd) {
2787
+ const root = await getGitRoot(cwd);
2788
+ const branch = await currentGitBranch(root);
2789
+ const { prPath } = prDescriptionPath(root, branch);
2790
+ try {
2791
+ const [body, info] = await Promise.all([readFile(prPath, "utf8"), stat(prPath)]);
2792
+ return { root, branch, path: prPath, body: body.trimEnd(), mtimeMs: info.mtimeMs };
2793
+ } catch (error) {
2794
+ throw new Error(`Missing generated PR description ${prPath}. Run /pr first. ${sanitizeError(error)}`);
2795
+ }
2796
+ }
2797
+
2798
+ function cleanPrTitle(value) {
2799
+ const title = String(value || "").replace(/\r?\n/g, " ").trim();
2800
+ if (!title) throw new Error("PR title is required");
2801
+ return title.slice(0, 300);
2802
+ }
2803
+
2804
+ function formatWorkflowCommand(command, args) {
2805
+ return [command, ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(" ");
2806
+ }
2807
+
1691
2808
  function formatGitCommand(args) {
1692
- return ["git", ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(" ");
2809
+ return formatWorkflowCommand("git", args);
1693
2810
  }
1694
2811
 
1695
- function runGitWorkflowCommand(args, { cwd, label = formatGitCommand(args), timeoutMs = 10 * 60 * 1000 } = {}) {
2812
+ function runWorkflowCommand(command, args, { cwd, label = formatWorkflowCommand(command, args), timeoutMs = 10 * 60 * 1000 } = {}) {
1696
2813
  if (activeGitWorkflowProcess) {
1697
2814
  return Promise.reject(new Error(`A git workflow command is already running: ${activeGitWorkflowProcess.label}`));
1698
2815
  }
1699
2816
 
1700
2817
  return new Promise((resolve) => {
1701
- const child = spawn("git", args, {
2818
+ const child = spawn(command, args, {
1702
2819
  cwd,
1703
- env: { ...process.env, GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT || "0" },
2820
+ env: { ...process.env, GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT || "0", GH_PROMPT_DISABLED: process.env.GH_PROMPT_DISABLED || "1" },
1704
2821
  stdio: ["ignore", "pipe", "pipe"],
1705
2822
  windowsHide: true,
1706
2823
  });
@@ -1745,6 +2862,14 @@ function runGitWorkflowCommand(args, { cwd, label = formatGitCommand(args), time
1745
2862
  });
1746
2863
  }
1747
2864
 
2865
+ function runGitWorkflowCommand(args, options = {}) {
2866
+ return runWorkflowCommand("git", args, { ...options, label: options.label || formatGitCommand(args) });
2867
+ }
2868
+
2869
+ function runGitHubWorkflowCommand(args, options = {}) {
2870
+ return runWorkflowCommand("gh", args, { ...options, label: options.label || formatWorkflowCommand("gh", args) });
2871
+ }
2872
+
1748
2873
  function gitWorkflowCommandPayload(result) {
1749
2874
  const ok = result.exitCode === 0 && !result.timedOut && !result.cancelled && !result.error;
1750
2875
  return {
@@ -1759,9 +2884,21 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
1759
2884
  switch (pathname) {
1760
2885
  case "/api/git-workflow/message":
1761
2886
  return { ok: true, data: await readGitWorkflowMessages(cwd) };
2887
+ case "/api/git-workflow/branch-name":
2888
+ return { ok: true, data: await readGitWorkflowBranchName(cwd) };
2889
+ case "/api/git-workflow/pr-description":
2890
+ return { ok: true, data: await readGitWorkflowPrDescription(cwd) };
1762
2891
  case "/api/git-workflow/add":
1763
2892
  await getGitRoot(cwd);
1764
2893
  return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
2894
+ case "/api/git-workflow/branch": {
2895
+ const root = await getGitRoot(cwd);
2896
+ const branch = cleanGitBranchName(body.branch);
2897
+ await validateGitBranchName(root, branch);
2898
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["switch", "-c", branch], { cwd: root }));
2899
+ if (payload.ok) payload.data.branch = branch;
2900
+ return payload;
2901
+ }
1765
2902
  case "/api/git-workflow/commit": {
1766
2903
  const variant = String(body.variant || "").trim();
1767
2904
  if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or 'long'");
@@ -1776,8 +2913,37 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
1776
2913
  }
1777
2914
  case "/api/git-workflow/push": {
1778
2915
  const root = await getGitRoot(cwd);
2916
+ if (body.setUpstream) {
2917
+ const currentBranch = await currentGitBranch(root);
2918
+ const requestedBranch = body.branch ? cleanGitBranchName(body.branch) : currentBranch;
2919
+ if (requestedBranch !== currentBranch) throw new Error(`Current branch is ${currentBranch}, not ${requestedBranch}`);
2920
+ const remote = await defaultGitRemote(root);
2921
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["push", "-u", remote, currentBranch], { cwd: root, label: `git push -u ${remote} ${currentBranch}`, timeoutMs: 15 * 60 * 1000 }));
2922
+ if (payload.ok) {
2923
+ payload.data.branch = currentBranch;
2924
+ payload.data.remote = remote;
2925
+ }
2926
+ return payload;
2927
+ }
1779
2928
  return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
1780
2929
  }
2930
+ case "/api/git-workflow/create-pr": {
2931
+ const root = await getGitRoot(cwd);
2932
+ const branch = await currentGitBranch(root);
2933
+ const title = cleanPrTitle(body.title);
2934
+ const description = String(body.body || "").trimEnd();
2935
+ if (!description.trim()) throw new Error("PR description is required");
2936
+ const { base, prPath } = prDescriptionPath(root, branch);
2937
+ await mkdir(path.dirname(prPath), { recursive: true });
2938
+ await writeFile(prPath, `${description}\n`, "utf8");
2939
+ const payload = gitWorkflowCommandPayload(await runGitHubWorkflowCommand(["pr", "create", "--title", title, "--body-file", prPath, "--head", branch], { cwd: root, label: "gh pr create --title <title> --body-file <dev/PR/current-branch.md> --head <current-branch>", timeoutMs: 15 * 60 * 1000 }));
2940
+ if (payload.ok) {
2941
+ payload.data.branch = branch;
2942
+ payload.data.path = prPath;
2943
+ payload.data.prDirectory = base;
2944
+ }
2945
+ return payload;
2946
+ }
1781
2947
  case "/api/git-workflow/cancel": {
1782
2948
  const cancelled = !!activeGitWorkflowProcess;
1783
2949
  if (activeGitWorkflowProcess) activeGitWorkflowProcess.cancel();
@@ -2071,7 +3237,7 @@ let options;
2071
3237
  try {
2072
3238
  options = parseArgs(process.argv.slice(2));
2073
3239
  } catch (error) {
2074
- console.error(`Error: ${sanitizeError(error)}\n`);
3240
+ console.error(`Error: ${formatCliError(error)}\n`);
2075
3241
  usage();
2076
3242
  process.exit(2);
2077
3243
  }
@@ -2085,6 +3251,14 @@ if (options.version) {
2085
3251
  process.exit(0);
2086
3252
  }
2087
3253
 
3254
+ try {
3255
+ options.cwd = await validateStartupCwd(options.cwd);
3256
+ } catch (error) {
3257
+ console.error(`Error: ${formatCliError(error)}\n`);
3258
+ usage();
3259
+ process.exit(2);
3260
+ }
3261
+
2088
3262
  const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
2089
3263
  delete process.env.PI_WEBUI_START_DELAY_MS;
2090
3264
  if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
@@ -2806,6 +3980,7 @@ async function updateTabCwd(id, cwd) {
2806
3980
  tab.rpcUnsubscribe?.();
2807
3981
  tab.rpcUnsubscribe = undefined;
2808
3982
  rejectTabBashQueue(tab, new Error("Pi tab is restarting; queued bash commands were cancelled"));
3983
+ stopAppRunnerForTab(tab, "cwd changed", { force: true });
2809
3984
  oldRpc.stop();
2810
3985
 
2811
3986
  tab.cwd = nextCwd;
@@ -3811,6 +4986,7 @@ async function closeTab(id) {
3811
4986
  tab.sseClients.clear();
3812
4987
  tab.rpcUnsubscribe?.();
3813
4988
  rejectTabBashQueue(tab, new Error("Pi tab closed; queued bash commands were cancelled"));
4989
+ stopAppRunnerForTab(tab, "tab closed", { force: true });
3814
4990
  tab.rpc.stop();
3815
4991
  tabs.delete(id);
3816
4992
  return tab;
@@ -3851,8 +5027,14 @@ function getRequestedTab(req, url, body = {}) {
3851
5027
  return tab;
3852
5028
  }
3853
5029
 
5030
+ function directoryPickerActiveCwd(req, url, body = {}) {
5031
+ const id = requestedTabId(req, url, body);
5032
+ if (id) return getRequestedTab(req, url, body).cwd;
5033
+ return firstTab()?.cwd || options.cwd;
5034
+ }
5035
+
3854
5036
  async function createInitialTabs() {
3855
- if (!restoreTabs.length) return [await createTab()];
5037
+ if (!restoreTabs.length) return options.cwdExplicit ? [await createTab()] : [];
3856
5038
 
3857
5039
  const created = [];
3858
5040
  for (const descriptor of restoreTabs) {
@@ -3863,7 +5045,7 @@ async function createInitialTabs() {
3863
5045
  }
3864
5046
  }
3865
5047
 
3866
- return created.length ? created : [await createTab()];
5048
+ return created.length ? created : options.cwdExplicit ? [await createTab()] : [];
3867
5049
  }
3868
5050
 
3869
5051
  const serverStartedAt = new Date().toISOString();
@@ -4193,6 +5375,7 @@ const server = createServer(async (req, res) => {
4193
5375
  startedAt: tab.rpc.startedAt,
4194
5376
  tabActivity: tabActivitySnapshot(tab),
4195
5377
  pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
5378
+ activeRun: publicAppRunnerState(tab.appRunner),
4196
5379
  });
4197
5380
  replayExtensionStatuses(tab, res);
4198
5381
  replayPendingExtensionUiRequests(tab, res);
@@ -4306,21 +5489,76 @@ const server = createServer(async (req, res) => {
4306
5489
  return;
4307
5490
  }
4308
5491
 
4309
- if (url.pathname === "/api/directories" && req.method === "GET") {
5492
+ if (url.pathname === "/api/app-runners" && req.method === "GET") {
5493
+ const tab = getRequestedTab(req, url);
5494
+ sendJson(res, 200, { ok: true, data: await getAppRunnerData(tab) });
5495
+ return;
5496
+ }
5497
+
5498
+ if (url.pathname === "/api/app-runner" && req.method === "POST") {
5499
+ const body = await readJsonBody(req);
5500
+ const tab = getRequestedTab(req, url, body);
5501
+ sendJson(res, 200, { ok: true, data: await startAppRunner(tab, String(body.runnerId || body.id || "")) });
5502
+ return;
5503
+ }
5504
+
5505
+ if (url.pathname === "/api/app-runner/stop" && req.method === "POST") {
5506
+ const body = await readJsonBody(req);
5507
+ const tab = getRequestedTab(req, url, body);
5508
+ stopAppRunnerForTab(tab, "stop requested from Web UI");
5509
+ sendJson(res, 200, { ok: true, data: await getAppRunnerData(tab) });
5510
+ return;
5511
+ }
5512
+
5513
+ if (url.pathname === "/api/app-runner/clear" && req.method === "POST") {
5514
+ const body = await readJsonBody(req);
5515
+ const tab = getRequestedTab(req, url, body);
5516
+ clearAppRunnerForTab(tab);
5517
+ sendJson(res, 200, { ok: true, data: await getAppRunnerData(tab) });
5518
+ return;
5519
+ }
5520
+
5521
+ if (url.pathname === "/api/app-runner-config" && req.method === "GET") {
4310
5522
  const tab = getRequestedTab(req, url);
5523
+ sendJson(res, 200, { ok: true, data: await getCustomAppRunnerConfigData(tab) });
5524
+ return;
5525
+ }
5526
+
5527
+ if (url.pathname === "/api/app-runner-config" && req.method === "POST") {
5528
+ const body = await readJsonBody(req);
5529
+ const tab = getRequestedTab(req, url, body);
5530
+ sendJson(res, 200, { ok: true, data: await saveCustomAppRunner(tab, body.runner || body) });
5531
+ return;
5532
+ }
5533
+
5534
+ if (url.pathname === "/api/app-runner-config" && req.method === "DELETE") {
5535
+ const body = await readJsonBody(req);
5536
+ const tab = getRequestedTab(req, url, body);
5537
+ sendJson(res, 200, { ok: true, data: await deleteCustomAppRunner(tab, body.id || body.runnerId) });
5538
+ return;
5539
+ }
5540
+
5541
+ if (url.pathname === "/api/app-runner-files" && req.method === "GET") {
5542
+ const tab = getRequestedTab(req, url);
5543
+ sendJson(res, 200, { ok: true, data: await getAppRunnerFileBrowserData(tab, url.searchParams.get("path")) });
5544
+ return;
5545
+ }
5546
+
5547
+ if (url.pathname === "/api/directories" && req.method === "GET") {
5548
+ const activeCwd = directoryPickerActiveCwd(req, url);
4311
5549
  sendJson(res, 200, {
4312
5550
  ok: true,
4313
- data: await getDirectoryPickerData(url.searchParams.get("path"), tab.cwd),
5551
+ data: await getDirectoryPickerData(url.searchParams.get("path"), activeCwd),
4314
5552
  });
4315
5553
  return;
4316
5554
  }
4317
5555
 
4318
5556
  if (url.pathname === "/api/directories" && req.method === "POST") {
4319
5557
  const body = await readJsonBody(req);
4320
- const tab = getRequestedTab(req, url, body);
5558
+ const activeCwd = directoryPickerActiveCwd(req, url, body);
4321
5559
  sendJson(res, 201, {
4322
5560
  ok: true,
4323
- data: await createDirectoryPickerDirectory(body.parent ?? body.cwd ?? body.path, body.name, tab.cwd),
5561
+ data: await createDirectoryPickerDirectory(body.parent ?? body.cwd ?? body.path, body.name, activeCwd),
4324
5562
  });
4325
5563
  return;
4326
5564
  }
@@ -4582,7 +5820,10 @@ server.on("error", (error) => {
4582
5820
  return;
4583
5821
  }
4584
5822
  console.error("Web UI server failed:", sanitizeError(error));
4585
- for (const tab of tabs.values()) tab.rpc.stop();
5823
+ for (const tab of tabs.values()) {
5824
+ stopAppRunnerForTab(tab, "server error", { force: true });
5825
+ tab.rpc.stop();
5826
+ }
4586
5827
  process.exit(1);
4587
5828
  });
4588
5829
 
@@ -4590,7 +5831,8 @@ server.listen(options.port, currentHost, () => {
4590
5831
  const urlHost = formatUrlHost(currentHost);
4591
5832
  console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
4592
5833
  console.log(`Working directory: ${options.cwd}`);
4593
- console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
5834
+ if (initialTab) console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
5835
+ else console.log("Pi RPC: waiting for CWD selection in the Web UI");
4594
5836
  if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
4595
5837
  if (!isLocalHost(currentHost)) {
4596
5838
  console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
@@ -4608,7 +5850,10 @@ function shutdown(signal) {
4608
5850
  process.exit(0);
4609
5851
  });
4610
5852
  server.closeIdleConnections?.();
4611
- for (const tab of tabs.values()) tab.rpc.stop();
5853
+ for (const tab of tabs.values()) {
5854
+ stopAppRunnerForTab(tab, "server shutdown", { force: true });
5855
+ tab.rpc.stop();
5856
+ }
4612
5857
  setTimeout(() => process.exit(0), 4000).unref();
4613
5858
  }
4614
5859