oh-my-customcode 1.1.102 → 1.1.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/cli/index.js +449 -40
  2. package/dist/index.js +95 -5
  3. package/package.json +1 -1
  4. package/templates/.claude/output-styles/korean-engineer.md +1 -1
  5. package/templates/.claude/rules/MUST-agent-design.md +2 -2
  6. package/templates/.claude/rules/MUST-agent-teams.md +18 -7
  7. package/templates/.claude/rules/MUST-orchestrator-coordination.md +46 -29
  8. package/templates/.claude/rules/MUST-permissions.md +15 -10
  9. package/templates/.claude/settings.json +0 -3
  10. package/templates/.claude/skills/action-validator/SKILL.md +2 -2
  11. package/templates/.claude/skills/adaptive-harness/SKILL.md +2 -2
  12. package/templates/.claude/skills/adversarial-review/SKILL.md +1 -1
  13. package/templates/.claude/skills/agent-eval-framework/SKILL.md +2 -2
  14. package/templates/.claude/skills/de-lead-routing/SKILL.md +2 -2
  15. package/templates/.claude/skills/deep-plan/SKILL.md +4 -4
  16. package/templates/.claude/skills/deep-verify/SKILL.md +3 -3
  17. package/templates/.claude/skills/design-shotgun/SKILL.md +1 -1
  18. package/templates/.claude/skills/dev-lead-routing/SKILL.md +2 -2
  19. package/templates/.claude/skills/dev-review/SKILL.md +2 -2
  20. package/templates/.claude/skills/evaluator-optimizer/SKILL.md +1 -1
  21. package/templates/.claude/skills/fsd/SKILL.md +48 -10
  22. package/templates/.claude/skills/harness-eval/SKILL.md +1 -1
  23. package/templates/.claude/skills/harness-synthesizer/SKILL.md +1 -1
  24. package/templates/.claude/skills/homework/SKILL.md +3 -3
  25. package/templates/.claude/skills/idea/SKILL.md +4 -2
  26. package/templates/.claude/skills/instinct-extractor/SKILL.md +8 -8
  27. package/templates/.claude/skills/intent-detection/SKILL.md +1 -1
  28. package/templates/.claude/skills/multi-model-verification/SKILL.md +2 -2
  29. package/templates/.claude/skills/omcustom-release-notes/SKILL.md +1 -1
  30. package/templates/.claude/skills/pipeline/SKILL.md +1 -1
  31. package/templates/.claude/skills/pipeline/scripts/trusted-issue-filter.jq +37 -0
  32. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +128 -38
  33. package/templates/.claude/skills/post-release-followup/SKILL.md +2 -2
  34. package/templates/.claude/skills/professor-triage/SKILL.md +10 -10
  35. package/templates/.claude/skills/profile/SKILL.md +4 -4
  36. package/templates/.claude/skills/qa-lead-routing/SKILL.md +2 -2
  37. package/templates/.claude/skills/release-plan/SKILL.md +1 -1
  38. package/templates/.claude/skills/research/SKILL.md +8 -6
  39. package/templates/.claude/skills/result-aggregation/SKILL.md +1 -1
  40. package/templates/.claude/skills/roundtable-debate/SKILL.md +2 -2
  41. package/templates/.claude/skills/scout/SKILL.md +3 -8
  42. package/templates/.claude/skills/sec-agentshield-wrapper/SKILL.md +1 -1
  43. package/templates/.claude/skills/secretary-routing/SKILL.md +2 -2
  44. package/templates/.claude/skills/skill-extractor/SKILL.md +1 -1
  45. package/templates/.claude/skills/structured-dev-cycle/SKILL.md +1 -1
  46. package/templates/.claude/skills/task-decomposition/SKILL.md +1 -1
  47. package/templates/.claude/skills/worker-reviewer-pipeline/SKILL.md +5 -1
  48. package/templates/guides/claude-code/15-version-compatibility.md +20 -4
  49. package/templates/guides/deep-plan/phases.md +11 -9
  50. package/templates/guides/harness-engineering/README.md +2 -1
  51. package/templates/guides/professor-triage/README.md +1 -1
  52. package/templates/guides/professor-triage/phases.md +1 -1
  53. package/templates/manifest.json +1 -1
  54. package/templates/workflows/auto-dev.yaml +128 -38
package/dist/cli/index.js CHANGED
@@ -335,7 +335,7 @@ var init_package = __esm(() => {
335
335
  workspaces: [
336
336
  "packages/*"
337
337
  ],
338
- version: "1.1.102",
338
+ version: "1.1.104",
339
339
  description: "Batteries-included agent harness for Claude Code",
340
340
  type: "module",
341
341
  bin: {
@@ -14666,12 +14666,16 @@ var en_default = {
14666
14666
  started: "Web UI started: http://localhost:{{port}}",
14667
14667
  failed: "Failed to start Web UI server",
14668
14668
  homeUnresolved: "Cannot start the Web UI server: the home directory cannot be resolved, either because HOME is a relative path or because HOME, the OS-reported home and the passwd entry are all empty, so the PID file location cannot be determined. Set HOME to an absolute path.",
14669
- pidNotWritable: "Cannot start the Web UI server: the PID file {{path}} cannot be written ({{error}}). Check the PID file path and its parent directory."
14669
+ pidNotWritable: "Cannot start the Web UI server: the PID file {{path}} cannot be written ({{error}}). Check the PID file path and its parent directory.",
14670
+ alreadyRunning: "Web UI server is already running (PID {{pid}}); no new server was started. Its port is not recorded — check the port you started it with.",
14671
+ alreadyRunningNotPermitted: "The PID file records PID {{pid}}, which belongs to another user, so this command cannot tell whether it is the Web UI server; no new server was started. If no omcustom Web UI server is running, delete the PID file ({{path}}) and try again.",
14672
+ startingElsewhere: "Another Web UI start is in progress; this command did not start a server."
14670
14673
  },
14671
14674
  stop: {
14672
14675
  description: "Stop the Web UI server",
14673
14676
  stopped: "Web UI server stopped",
14674
- notRunning: "Web UI server is not running"
14677
+ notRunning: "Web UI server is not running",
14678
+ notPermitted: "Cannot stop the Web UI server: process {{pid}} recorded in {{path}} exists but you are not permitted to signal it. It belongs to another user, or the PID file is stale and the PID was reused by an unrelated process. Stop it as that user, or remove the PID file if it is stale."
14675
14679
  },
14676
14680
  status: {
14677
14681
  description: "Show Web UI server status",
@@ -15086,12 +15090,16 @@ var ko_default = {
15086
15090
  started: "Web UI 시작됨: http://localhost:{{port}}",
15087
15091
  failed: "Web UI 서버 시작 실패",
15088
15092
  homeUnresolved: "Web UI 서버를 시작할 수 없습니다: 홈 디렉토리를 확인할 수 없어 PID 파일 위치를 정할 수 없습니다. HOME이 상대 경로이거나, HOME·OS 보고 홈·passwd 항목이 모두 비어 있는 경우입니다. HOME을 절대 경로로 설정하세요.",
15089
- pidNotWritable: "Web UI 서버를 시작할 수 없습니다: PID 파일 {{path}}에 쓸 수 없습니다 ({{error}}). PID 파일 경로와 그 상위 디렉토리를 확인하세요."
15093
+ pidNotWritable: "Web UI 서버를 시작할 수 없습니다: PID 파일 {{path}}에 쓸 수 없습니다 ({{error}}). PID 파일 경로와 그 상위 디렉토리를 확인하세요.",
15094
+ alreadyRunning: "Web UI 서버가 이미 실행 중입니다 (PID {{pid}}). 새 서버를 시작하지 않았습니다. 서버의 포트는 기록되지 않으므로 시작할 때 지정한 포트를 확인하세요.",
15095
+ alreadyRunningNotPermitted: "PID 파일에 기록된 PID {{pid}}는 다른 사용자의 프로세스라서 Web UI 서버인지 이 명령으로 확인할 수 없습니다. 새 서버를 시작하지 않았습니다. omcustom Web UI 서버가 실행 중이 아니라면 PID 파일({{path}})을 삭제한 뒤 다시 시도하세요.",
15096
+ startingElsewhere: "다른 Web UI 시작이 진행 중입니다. 이 명령은 서버를 시작하지 않았습니다."
15090
15097
  },
15091
15098
  stop: {
15092
15099
  description: "Web UI 서버 중지",
15093
15100
  stopped: "Web UI 서버가 중지되었습니다",
15094
- notRunning: "Web UI 서버가 실행 중이 아닙니다"
15101
+ notRunning: "Web UI 서버가 실행 중이 아닙니다",
15102
+ notPermitted: "Web UI 서버를 중지할 수 없습니다: {{path}}에 기록된 프로세스 {{pid}}가 존재하지만 신호를 보낼 권한이 없습니다. 다른 사용자의 프로세스이거나, PID 파일이 오래되어 PID가 무관한 프로세스에 재사용된 경우입니다. 해당 사용자로 중지하거나, 오래된 PID 파일이면 삭제하세요."
15095
15103
  },
15096
15104
  status: {
15097
15105
  description: "Web UI 서버 상태 표시",
@@ -15786,6 +15794,7 @@ var MESSAGES = {
15786
15794
  "lockfile.component_dir_missing": "Component directory missing: {{path}}",
15787
15795
  "lockfile.hash_failed": "Failed to hash file: {{path}} — {{error}}",
15788
15796
  "lockfile.entry_added": "Lockfile entry added: {{path}} ({{component}})",
15797
+ "lockfile.entry_git_excluded": "Lockfile entry skipped (not in git listing): {{path}}",
15789
15798
  "update.start": "Checking for updates...",
15790
15799
  "update.success": "Updated from {{from}} to {{to}}",
15791
15800
  "update.components_synced": "Components synced (version {{version}}): {{components}}",
@@ -15907,6 +15916,7 @@ var MESSAGES = {
15907
15916
  "lockfile.component_dir_missing": "컴포넌트 디렉토리 없음: {{path}}",
15908
15917
  "lockfile.hash_failed": "파일 해시 실패: {{path}} — {{error}}",
15909
15918
  "lockfile.entry_added": "잠금 파일 항목 추가: {{path}} ({{component}})",
15919
+ "lockfile.entry_git_excluded": "잠금 파일 항목 제외 (git 목록에 없음): {{path}}",
15910
15920
  "update.start": "업데이트 확인 중...",
15911
15921
  "update.success": "{{from}}에서 {{to}}로 업데이트 완료",
15912
15922
  "update.components_synced": "컴포넌트 동기화 완료 (버전 {{version}}): {{components}}",
@@ -16362,10 +16372,12 @@ function getComponentPath(component) {
16362
16372
 
16363
16373
  // src/core/lockfile.ts
16364
16374
  init_fs();
16375
+ import { execFile } from "node:child_process";
16365
16376
  import { createHash } from "node:crypto";
16366
16377
  import { createReadStream } from "node:fs";
16367
- import { readdir, stat } from "node:fs/promises";
16378
+ import { readdir, realpath, stat } from "node:fs/promises";
16368
16379
  import { join as join7, relative as relative2 } from "node:path";
16380
+ import { promisify } from "node:util";
16369
16381
  var LOCKFILE_NAME = ".omcustom.lock.json";
16370
16382
  var LOCKFILE_VERSION = 1;
16371
16383
  var LOCKFILE_COMPONENTS = [
@@ -16378,6 +16390,87 @@ var LOCKFILE_COMPONENTS = [
16378
16390
  "guides"
16379
16391
  ];
16380
16392
  var COMPONENT_PATHS = LOCKFILE_COMPONENTS.map((component) => [getComponentPath(component), component]);
16393
+ var execFileAsync = promisify(execFile);
16394
+ var GIT_LOCATION_ENV_KEYS = [
16395
+ "GIT_DIR",
16396
+ "GIT_WORK_TREE",
16397
+ "GIT_INDEX_FILE",
16398
+ "GIT_PREFIX",
16399
+ "GIT_COMMON_DIR"
16400
+ ];
16401
+ var GIT_INJECTED_CONFIG_ENV_KEYS = ["GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT"];
16402
+ var GIT_INJECTED_CONFIG_ENV_PREFIXES = ["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"];
16403
+ var GIT_REQUIRED_HINT = "build from a git checkout: excluding ignored local files from the lockfile requires git (#1819)";
16404
+ function gitChildEnv() {
16405
+ const env = { ...process.env };
16406
+ for (const key of [...GIT_LOCATION_ENV_KEYS, ...GIT_INJECTED_CONFIG_ENV_KEYS]) {
16407
+ delete env[key];
16408
+ }
16409
+ for (const key of Object.keys(env)) {
16410
+ if (GIT_INJECTED_CONFIG_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) {
16411
+ delete env[key];
16412
+ }
16413
+ }
16414
+ return env;
16415
+ }
16416
+ async function runGit(targetDir, args, env) {
16417
+ try {
16418
+ const { stdout } = await execFileAsync("git", args, {
16419
+ cwd: targetDir,
16420
+ env,
16421
+ encoding: "utf-8",
16422
+ maxBuffer: 256 * 1024 * 1024
16423
+ });
16424
+ return stdout;
16425
+ } catch (err) {
16426
+ const detail = err instanceof Error ? err.message.trim() : String(err);
16427
+ throw new Error(`git ${args[0]} failed in ${targetDir}: ${detail} — ${GIT_REQUIRED_HINT}`);
16428
+ }
16429
+ }
16430
+ function foldCase(nfcPath) {
16431
+ return nfcPath.toLowerCase();
16432
+ }
16433
+ function isGitVisible(visible, relativePath) {
16434
+ if (visible.exact.has(relativePath)) {
16435
+ return true;
16436
+ }
16437
+ const nfc = relativePath.normalize("NFC");
16438
+ if (visible.nfc.has(nfc)) {
16439
+ return true;
16440
+ }
16441
+ return visible.folded?.has(foldCase(nfc)) ?? false;
16442
+ }
16443
+ function stripFinalNewline(output) {
16444
+ return output.endsWith(`
16445
+ `) ? output.slice(0, -1) : output;
16446
+ }
16447
+ async function assertGitWorkTreeRoot(targetDir, env) {
16448
+ const topLevel = stripFinalNewline(await runGit(targetDir, ["rev-parse", "--show-toplevel"], env));
16449
+ let realTop;
16450
+ let realTarget;
16451
+ try {
16452
+ [realTop, realTarget] = await Promise.all([realpath(topLevel), realpath(targetDir)]);
16453
+ } catch (err) {
16454
+ const detail = err instanceof Error ? err.message : String(err);
16455
+ throw new Error(`cannot resolve the git work tree root of ${targetDir}: ${detail} — ${GIT_REQUIRED_HINT}`);
16456
+ }
16457
+ if (realTop !== realTarget) {
16458
+ throw new Error(`${targetDir} is not the root of its git work tree (root: ${topLevel}) — ${GIT_REQUIRED_HINT}`);
16459
+ }
16460
+ }
16461
+ async function listGitVisiblePaths(targetDir) {
16462
+ const env = gitChildEnv();
16463
+ await assertGitWorkTreeRoot(targetDir, env);
16464
+ const pathspecs = COMPONENT_PATHS.map(([prefix]) => prefix);
16465
+ const [listing, ignoreCase] = await Promise.all([
16466
+ runGit(targetDir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspecs], env),
16467
+ runGit(targetDir, ["config", "--type=bool", "--default=false", "--get", "core.ignorecase"], env)
16468
+ ]);
16469
+ const exact = new Set(listing.split("\x00").filter((entry) => entry.length > 0));
16470
+ const nfc = new Set([...exact].map((entry) => entry.normalize("NFC")));
16471
+ const folded = stripFinalNewline(ignoreCase) === "true" ? new Set([...nfc].map(foldCase)) : null;
16472
+ return { exact, nfc, folded };
16473
+ }
16381
16474
  function computeFileHash(filePath) {
16382
16475
  return new Promise((resolve, reject) => {
16383
16476
  const hash = createHash("sha256");
@@ -16463,8 +16556,9 @@ function sortFilesByPath(files) {
16463
16556
  const sorted = Object.keys(files).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
16464
16557
  return Object.fromEntries(sorted.map((key) => [key, files[key]]));
16465
16558
  }
16466
- async function generateLockfile(targetDir, generatorVersion, templateVersion) {
16559
+ async function generateLockfile(targetDir, generatorVersion, templateVersion, options = {}) {
16467
16560
  const files = {};
16561
+ const gitVisible = options.excludeGitIgnored ? await listGitVisiblePaths(targetDir) : null;
16468
16562
  const componentRoots = COMPONENT_PATHS.map(([prefix]) => join7(targetDir, prefix));
16469
16563
  for (const componentRoot of componentRoots) {
16470
16564
  const exists = await fileExists(componentRoot);
@@ -16475,6 +16569,10 @@ async function generateLockfile(targetDir, generatorVersion, templateVersion) {
16475
16569
  const allFiles = await collectFiles(componentRoot, targetDir, false);
16476
16570
  for (const absolutePath of allFiles) {
16477
16571
  const relativePath = relative2(targetDir, absolutePath).replace(/\\/g, "/");
16572
+ if (gitVisible !== null && !isGitVisible(gitVisible, relativePath)) {
16573
+ debug("lockfile.entry_git_excluded", { path: relativePath });
16574
+ continue;
16575
+ }
16478
16576
  let hash;
16479
16577
  let size;
16480
16578
  try {
@@ -16535,12 +16633,12 @@ function preserveGeneratedAt(next, previous) {
16535
16633
  }
16536
16634
  return { ...next, generatedAt: previous.generatedAt };
16537
16635
  }
16538
- async function generateAndWriteLockfileForDir(targetDir) {
16636
+ async function generateAndWriteLockfileForDir(targetDir, options = {}) {
16539
16637
  try {
16540
16638
  const packageRoot = getPackageRoot();
16541
16639
  const manifest = await readJsonFile(join7(packageRoot, "templates", "manifest.json"));
16542
16640
  const { version: generatorVersion } = await readJsonFile(join7(packageRoot, "package.json"));
16543
- const generated = await generateLockfile(targetDir, generatorVersion, manifest.version);
16641
+ const generated = await generateLockfile(targetDir, generatorVersion, manifest.version, options);
16544
16642
  const previous = await readLockfile(targetDir);
16545
16643
  const lockfile = preserveGeneratedAt(generated, previous);
16546
16644
  await writeLockfile(targetDir, lockfile);
@@ -20938,11 +21036,31 @@ import { join as join17 } from "node:path";
20938
21036
  // src/cli/serve.ts
20939
21037
  init_home();
20940
21038
  import { spawn } from "node:child_process";
21039
+ import { randomUUID } from "node:crypto";
20941
21040
  import { constants as constants3, existsSync as existsSync3 } from "node:fs";
20942
- import { access, readFile as readFile3, unlink, writeFile as writeFile3 } from "node:fs/promises";
21041
+ import {
21042
+ access,
21043
+ link,
21044
+ readdir as readdir3,
21045
+ readFile as readFile3,
21046
+ readlink,
21047
+ rename as rename2,
21048
+ stat as stat3,
21049
+ unlink,
21050
+ writeFile as writeFile3
21051
+ } from "node:fs/promises";
20943
21052
  import { dirname as dirname7, isAbsolute as isAbsolute4, join as join16 } from "node:path";
21053
+ import { setTimeout as delay } from "node:timers/promises";
20944
21054
  var DEFAULT_PORT = 4321;
20945
21055
  var PID_FILE_NAME = ".omcustom-serve.pid";
21056
+ var STARTING_RECORD = /^starting:(\d+):/;
21057
+ var MAX_PID = 2147483647;
21058
+ var MAX_CLAIM_ATTEMPTS = 3;
21059
+ var CLAIM_MAX_AGE_MS = 60000;
21060
+ var PARTIAL_RECORD_GRACE_MS = 1e4;
21061
+ var ABSENT_RETRY_DELAY_MS = 10;
21062
+ var HARD_LINK_UNSUPPORTED = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV"]);
21063
+ var SIBLING_REMNANT = /^\.omcustom-serve\.pid\.(\d+)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:tmp|taken)$/;
20946
21064
 
20947
21065
  class ServePidFileError extends Error {
20948
21066
  reason;
@@ -20954,6 +21072,17 @@ class ServePidFileError extends Error {
20954
21072
  this.pidFile = pidFile;
20955
21073
  }
20956
21074
  }
21075
+
21076
+ class ServeStopPermissionError extends Error {
21077
+ pid;
21078
+ pidFile;
21079
+ constructor(pid, pidFile, options) {
21080
+ super(`Not permitted to signal the serve process ${pid} recorded in ${pidFile}`, options);
21081
+ this.name = "ServeStopPermissionError";
21082
+ this.pid = pid;
21083
+ this.pidFile = pidFile;
21084
+ }
21085
+ }
20957
21086
  function resolveServePidFile(sources) {
20958
21087
  const home = resolveHomeDir(sources);
20959
21088
  if (!isAbsolute4(home)) {
@@ -20966,12 +21095,65 @@ function findServeBuildDir(projectRoot, options) {
20966
21095
  if (existsSync3(join16(localBuild, "index.js")))
20967
21096
  return localBuild;
20968
21097
  if (options?.skipNpmFallback !== true) {
20969
- const npmBuild = join16(import.meta.dirname, "..", "..", "packages", "serve", "build");
21098
+ const npmPackageRoot = options?.npmPackageRoot ?? join16(import.meta.dirname, "..", "..");
21099
+ const npmBuild = join16(npmPackageRoot, "packages", "serve", "build");
20970
21100
  if (existsSync3(join16(npmBuild, "index.js")))
20971
21101
  return npmBuild;
20972
21102
  }
20973
21103
  return null;
20974
21104
  }
21105
+ function parsePidRecord(raw) {
21106
+ const text = raw.trim();
21107
+ const match = STARTING_RECORD.exec(text);
21108
+ const pid = Number(match === null ? text : match[1]);
21109
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > MAX_PID) {
21110
+ return null;
21111
+ }
21112
+ return { pid, starting: match !== null };
21113
+ }
21114
+ function probeProcess(pid) {
21115
+ try {
21116
+ process.kill(pid, 0);
21117
+ return "exists";
21118
+ } catch (error) {
21119
+ const code = errorCode(error);
21120
+ if (code === "ESRCH") {
21121
+ return "gone";
21122
+ }
21123
+ return code === "EPERM" ? "not-permitted" : "unknown";
21124
+ }
21125
+ }
21126
+ function isProcessAlive(pid) {
21127
+ return probeProcess(pid) !== "gone";
21128
+ }
21129
+ function errorCode(error) {
21130
+ return error?.code;
21131
+ }
21132
+ async function isOlderThan(path, ms) {
21133
+ try {
21134
+ const { mtimeMs } = await stat3(path);
21135
+ return Date.now() - mtimeMs >= ms;
21136
+ } catch {
21137
+ return false;
21138
+ }
21139
+ }
21140
+ async function isRecordLive(pidFile, record) {
21141
+ if (!isProcessAlive(record.pid)) {
21142
+ return false;
21143
+ }
21144
+ return !record.starting || !await isOlderThan(pidFile, CLAIM_MAX_AGE_MS);
21145
+ }
21146
+ async function readPidFileSettled(pidFile) {
21147
+ try {
21148
+ return await readFile3(pidFile, "utf-8");
21149
+ } catch (error) {
21150
+ if (errorCode(error) !== "ENOENT") {
21151
+ throw error;
21152
+ }
21153
+ }
21154
+ await delay(ABSENT_RETRY_DELAY_MS);
21155
+ return readFile3(pidFile, "utf-8");
21156
+ }
20975
21157
  async function isServeRunning() {
20976
21158
  const pidFile = resolveServePidFile();
20977
21159
  if (pidFile === null) {
@@ -20980,37 +21162,64 @@ async function isServeRunning() {
20980
21162
  return isServeRunningAt(pidFile);
20981
21163
  }
20982
21164
  async function isServeRunningAt(pidFile) {
21165
+ return await readLiveRecord(pidFile) !== null;
21166
+ }
21167
+ async function readLiveRecord(pidFile) {
21168
+ let raw;
20983
21169
  try {
20984
- const raw = await readFile3(pidFile, "utf-8");
20985
- const pid = Number(raw.trim());
20986
- if (!Number.isFinite(pid) || pid <= 0) {
20987
- await cleanupPidFile(pidFile);
20988
- return false;
20989
- }
20990
- process.kill(pid, 0);
20991
- return true;
21170
+ raw = await readPidFileSettled(pidFile);
20992
21171
  } catch {
20993
- await cleanupPidFile(pidFile);
20994
- return false;
21172
+ return null;
21173
+ }
21174
+ const record = parsePidRecord(raw);
21175
+ return record !== null && await isRecordLive(pidFile, record) ? record : null;
21176
+ }
21177
+ function heldResult(record) {
21178
+ if (record === null || record.starting) {
21179
+ return { status: "starting-elsewhere" };
21180
+ }
21181
+ const probe = probeProcess(record.pid);
21182
+ if (probe === "gone") {
21183
+ return { status: "starting-elsewhere" };
20995
21184
  }
21185
+ return probe === "not-permitted" ? { status: "already-running", pid: record.pid, notPermitted: true } : { status: "already-running", pid: record.pid };
21186
+ }
21187
+ async function getServerPid() {
21188
+ const pidFile = resolveServePidFile();
21189
+ if (pidFile === null) {
21190
+ return null;
21191
+ }
21192
+ const record = await readLiveRecord(pidFile);
21193
+ return record !== null && !record.starting ? record.pid : null;
20996
21194
  }
20997
21195
  async function startServeBackground(projectRoot, port = DEFAULT_PORT, buildDirOpts) {
20998
21196
  const pidFile = resolveServePidFile();
20999
21197
  if (pidFile === null) {
21000
21198
  throw new ServePidFileError("home-unresolved", null);
21001
21199
  }
21002
- if (await isServeRunningAt(pidFile)) {
21003
- return;
21200
+ const running = await readLiveRecord(pidFile);
21201
+ if (running !== null) {
21202
+ return heldResult(running);
21004
21203
  }
21005
21204
  const buildDir = findServeBuildDir(projectRoot, buildDirOpts);
21006
21205
  if (buildDir === null) {
21007
- return;
21206
+ return { status: "build-missing" };
21008
21207
  }
21009
21208
  try {
21010
21209
  await access(dirname7(pidFile), constants3.W_OK);
21011
21210
  } catch (error) {
21012
21211
  throw new ServePidFileError("pid-not-writable", pidFile, { cause: error });
21013
21212
  }
21213
+ let claimed;
21214
+ try {
21215
+ claimed = await claimPidFile(pidFile);
21216
+ } catch (error) {
21217
+ throw new ServePidFileError("pid-not-writable", pidFile, { cause: error });
21218
+ }
21219
+ if ("held" in claimed) {
21220
+ return heldResult(claimed.held);
21221
+ }
21222
+ const { claim } = claimed;
21014
21223
  const child = spawn("node", [join16(buildDir, "index.js")], {
21015
21224
  env: {
21016
21225
  ...process.env,
@@ -21025,38 +21234,214 @@ async function startServeBackground(projectRoot, port = DEFAULT_PORT, buildDirOp
21025
21234
  child.on("error", () => {});
21026
21235
  child.unref();
21027
21236
  if (child.pid === undefined) {
21028
- return;
21237
+ await removePidFileIf(pidFile, claim);
21238
+ return { status: "spawn-failed" };
21029
21239
  }
21240
+ let outcome;
21030
21241
  try {
21031
- await writeFile3(pidFile, String(child.pid), "utf-8");
21242
+ outcome = await recordServerPid(pidFile, claim, String(child.pid));
21032
21243
  } catch (error) {
21033
21244
  child.kill("SIGTERM");
21245
+ await removePidFileIf(pidFile, claim);
21034
21246
  throw new ServePidFileError("pid-not-writable", pidFile, { cause: error });
21035
21247
  }
21248
+ if (!outcome.recorded) {
21249
+ child.kill("SIGTERM");
21250
+ return heldResult(outcome.lost);
21251
+ }
21252
+ return { status: "started", pid: child.pid };
21253
+ }
21254
+ async function recordServerPid(pidFile, claim, pid) {
21255
+ for (let attempt = 0;attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
21256
+ if (await createPidFileExclusive(pidFile, pid)) {
21257
+ return { recorded: true };
21258
+ }
21259
+ let current;
21260
+ try {
21261
+ current = await readFile3(pidFile, "utf-8");
21262
+ } catch (error) {
21263
+ if (errorCode(error) === "ENOENT") {
21264
+ continue;
21265
+ }
21266
+ throw error;
21267
+ }
21268
+ if (current !== claim) {
21269
+ return { recorded: false, lost: parsePidRecord(current) };
21270
+ }
21271
+ await replacePidFile(pidFile, pid);
21272
+ return { recorded: true };
21273
+ }
21274
+ return { recorded: false, lost: null };
21036
21275
  }
21037
21276
  async function stopServe() {
21038
21277
  const pidFile = resolveServePidFile();
21039
21278
  if (pidFile === null) {
21040
21279
  return false;
21041
21280
  }
21281
+ let raw;
21042
21282
  try {
21043
- const raw = await readFile3(pidFile, "utf-8");
21044
- const pid = Number(raw.trim());
21045
- if (!Number.isFinite(pid) || pid <= 0) {
21046
- await cleanupPidFile(pidFile);
21047
- return false;
21283
+ raw = await readPidFileSettled(pidFile);
21284
+ } catch {
21285
+ return false;
21286
+ }
21287
+ const record = parsePidRecord(raw);
21288
+ if (record === null || record.starting) {
21289
+ return false;
21290
+ }
21291
+ try {
21292
+ process.kill(record.pid, "SIGTERM");
21293
+ } catch (error) {
21294
+ if (errorCode(error) === "EPERM") {
21295
+ throw new ServeStopPermissionError(record.pid, pidFile, { cause: error });
21296
+ }
21297
+ return false;
21298
+ }
21299
+ await removePidFileIf(pidFile, raw);
21300
+ return true;
21301
+ }
21302
+ function siblingPath(pidFile, suffix) {
21303
+ return `${pidFile}.${process.pid}.${randomUUID()}.${suffix}`;
21304
+ }
21305
+ async function claimPidFile(pidFile) {
21306
+ await removeDeadSiblings(pidFile);
21307
+ const claim = `starting:${process.pid}:${randomUUID()}`;
21308
+ for (let attempt = 0;attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
21309
+ if (await createPidFileExclusive(pidFile, claim)) {
21310
+ return { claim };
21311
+ }
21312
+ let raw;
21313
+ try {
21314
+ raw = await readFile3(pidFile, "utf-8");
21315
+ } catch (error) {
21316
+ if (errorCode(error) === "ENOENT") {
21317
+ await removeDanglingLink(pidFile);
21318
+ continue;
21319
+ }
21320
+ throw error;
21321
+ }
21322
+ if (await isRecordHeld(pidFile, raw)) {
21323
+ return { held: parsePidRecord(raw) };
21048
21324
  }
21049
- process.kill(pid, "SIGTERM");
21050
- await cleanupPidFile(pidFile);
21325
+ await removePidFileIf(pidFile, raw);
21326
+ }
21327
+ return { held: null };
21328
+ }
21329
+ async function isRecordHeld(pidFile, raw) {
21330
+ const record = parsePidRecord(raw);
21331
+ if (record === null) {
21332
+ return isPossiblyPartialRecord(raw) && !await isOlderThan(pidFile, PARTIAL_RECORD_GRACE_MS);
21333
+ }
21334
+ return isRecordLive(pidFile, record);
21335
+ }
21336
+ function isPossiblyPartialRecord(raw) {
21337
+ return "starting:".startsWith(raw) || /^starting:\d*$/.test(raw);
21338
+ }
21339
+ async function removeDeadSiblings(pidFile) {
21340
+ const dir = dirname7(pidFile);
21341
+ let names;
21342
+ try {
21343
+ names = await readdir3(dir);
21344
+ } catch {
21345
+ return;
21346
+ }
21347
+ for (const name of names) {
21348
+ const match = SIBLING_REMNANT.exec(name);
21349
+ if (match !== null && !isProcessAlive(Number(match[1]))) {
21350
+ await unlinkQuietly(join16(dir, name));
21351
+ }
21352
+ }
21353
+ }
21354
+ async function createPidFileExclusive(pidFile, content) {
21355
+ const staged = siblingPath(pidFile, "tmp");
21356
+ try {
21357
+ await writeFile3(staged, content, { encoding: "utf-8", flag: "wx" });
21358
+ await linkExclusive(staged, pidFile, content);
21051
21359
  return true;
21360
+ } catch (error) {
21361
+ if (errorCode(error) === "EEXIST") {
21362
+ return false;
21363
+ }
21364
+ throw error;
21365
+ } finally {
21366
+ await unlinkQuietly(staged);
21367
+ }
21368
+ }
21369
+ async function linkExclusive(source, target, content) {
21370
+ try {
21371
+ await link(source, target);
21372
+ } catch (error) {
21373
+ if (!HARD_LINK_UNSUPPORTED.has(errorCode(error) ?? "")) {
21374
+ throw error;
21375
+ }
21376
+ await writeFile3(target, content, { encoding: "utf-8", flag: "wx" });
21377
+ }
21378
+ }
21379
+ async function replacePidFile(pidFile, content) {
21380
+ const staged = siblingPath(pidFile, "tmp");
21381
+ try {
21382
+ await writeFile3(staged, content, { encoding: "utf-8", flag: "wx" });
21383
+ await rename2(staged, pidFile);
21384
+ } catch (error) {
21385
+ await unlinkQuietly(staged);
21386
+ throw error;
21387
+ }
21388
+ }
21389
+ async function removePidFileIf(pidFile, expected) {
21390
+ await removePidFileWhen(pidFile, async (taken) => {
21391
+ try {
21392
+ return await readFile3(taken, "utf-8") === expected;
21393
+ } catch {
21394
+ return false;
21395
+ }
21396
+ });
21397
+ }
21398
+ async function removeDanglingLink(pidFile) {
21399
+ if (!await isDanglingLink(pidFile)) {
21400
+ return;
21401
+ }
21402
+ await removePidFileWhen(pidFile, isDanglingLink);
21403
+ }
21404
+ async function isDanglingLink(path) {
21405
+ try {
21406
+ await readlink(path);
21052
21407
  } catch {
21053
- await cleanupPidFile(pidFile);
21054
21408
  return false;
21055
21409
  }
21410
+ try {
21411
+ await stat3(path);
21412
+ return false;
21413
+ } catch {
21414
+ return true;
21415
+ }
21056
21416
  }
21057
- async function cleanupPidFile(pidFile) {
21417
+ async function removePidFileWhen(pidFile, isExpected) {
21418
+ const taken = siblingPath(pidFile, "taken");
21058
21419
  try {
21059
- await unlink(pidFile);
21420
+ await rename2(pidFile, taken);
21421
+ } catch {
21422
+ return;
21423
+ }
21424
+ if (!await isExpected(taken)) {
21425
+ await putBack(taken, pidFile);
21426
+ }
21427
+ await unlinkQuietly(taken);
21428
+ }
21429
+ async function putBack(taken, pidFile) {
21430
+ try {
21431
+ await link(taken, pidFile);
21432
+ return;
21433
+ } catch (error) {
21434
+ if (!HARD_LINK_UNSUPPORTED.has(errorCode(error) ?? "")) {
21435
+ return;
21436
+ }
21437
+ }
21438
+ try {
21439
+ await writeFile3(pidFile, await readFile3(taken, "utf-8"), { encoding: "utf-8", flag: "wx" });
21440
+ } catch {}
21441
+ }
21442
+ async function unlinkQuietly(path) {
21443
+ try {
21444
+ await unlink(path);
21060
21445
  } catch {}
21061
21446
  }
21062
21447
 
@@ -21075,8 +21460,9 @@ async function serveCommand(options) {
21075
21460
  runForeground(cwd, port, buildDirOpts);
21076
21461
  return;
21077
21462
  }
21463
+ let result;
21078
21464
  try {
21079
- await startServeBackground(cwd, port, buildDirOpts);
21465
+ result = await startServeBackground(cwd, port, buildDirOpts);
21080
21466
  } catch (error) {
21081
21467
  if (!(error instanceof ServePidFileError)) {
21082
21468
  throw error;
@@ -21084,8 +21470,22 @@ async function serveCommand(options) {
21084
21470
  console.error(describePidFileError(error));
21085
21471
  process.exit(1);
21086
21472
  }
21087
- const running = await isServeRunning();
21088
- if (running) {
21473
+ switch (result.status) {
21474
+ case "already-running":
21475
+ console.log(result.notPermitted === true ? i18n.t("cli.web.start.alreadyRunningNotPermitted", {
21476
+ pid: result.pid,
21477
+ path: String(resolveServePidFile())
21478
+ }) : i18n.t("cli.web.start.alreadyRunning", { pid: result.pid }));
21479
+ return;
21480
+ case "starting-elsewhere":
21481
+ console.log(i18n.t("cli.web.start.startingElsewhere"));
21482
+ return;
21483
+ case "build-missing":
21484
+ case "spawn-failed":
21485
+ console.error(i18n.t("cli.web.start.failed"));
21486
+ process.exit(1);
21487
+ }
21488
+ if (await getServerPid() === result.pid) {
21089
21489
  console.log(i18n.t("cli.web.start.started", { port }));
21090
21490
  } else {
21091
21491
  console.error(i18n.t("cli.web.start.failed"));
@@ -21100,7 +21500,16 @@ function describePidFileError(error) {
21100
21500
  return i18n.t("cli.web.start.pidNotWritable", { path: String(error.pidFile), error: cause });
21101
21501
  }
21102
21502
  async function serveStopCommand() {
21103
- const stopped = await stopServe();
21503
+ let stopped;
21504
+ try {
21505
+ stopped = await stopServe();
21506
+ } catch (error) {
21507
+ if (!(error instanceof ServeStopPermissionError)) {
21508
+ throw error;
21509
+ }
21510
+ console.error(i18n.t("cli.web.stop.notPermitted", { pid: error.pid, path: error.pidFile }));
21511
+ process.exit(1);
21512
+ }
21104
21513
  if (stopped) {
21105
21514
  console.log(i18n.t("cli.web.stop.stopped"));
21106
21515
  } else {