imcodes 2026.4.1422-dev.1406 → 2026.4.1427-dev.1416

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"command-handler.d.ts","sourceRoot":"","sources":["../../../src/daemon/command-handler.ts"],"names":[],"mappings":"AAQA,OAAO,EAAqC,KAAK,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEpG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAoBnD,OAAO,EAAsE,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAoO3H,wBAAsB,yBAAyB,CAAC,UAAU,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBtF;AAwFD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzE,uEAAuE;IACvE,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAcD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAoCxD;AAgHD,wGAAwG;AACxG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAEzD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI,CAmM3E;AA+7BD,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAE7F;AAED,yFAAyF;AACzF,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,OAAO,CAStF;AAED,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,MAAM,CAStF;AA4uBD,wBAAgB,qCAAqC,CAAC,IAAI,2CAAgB,4CAEzE;AA+hBD,wBAAgB,0BAA0B,IAAI,IAAI,CASjD;AA6PD,KAAK,QAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC;AAEzD,iFAAiF;AACjF,wBAAgB,0BAA0B,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,MAAM,CAa3E;AAED,gEAAgE;AAChE,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,MAAM,CAI5E;AAED,wCAAwC;AACxC,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,MAAM,CAEtE;AAED,uDAAuD;AACvD,wBAAsB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAEhL"}
1
+ {"version":3,"file":"command-handler.d.ts","sourceRoot":"","sources":["../../../src/daemon/command-handler.ts"],"names":[],"mappings":"AAQA,OAAO,EAAqC,KAAK,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAEpG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAqBnD,OAAO,EAAsE,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAoO3H,wBAAsB,yBAAyB,CAAC,UAAU,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBtF;AAwFD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACzE,uEAAuE;IACvE,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAcD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAoCxD;AAgHD,wGAAwG;AACxG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAEzD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI,CAsP3E;AA+7BD,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAE7F;AAED,yFAAyF;AACzF,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,OAAO,CAStF;AAED,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,MAAM,CAStF;AA4uBD,wBAAgB,qCAAqC,CAAC,IAAI,2CAAgB,4CAEzE;AA0tBD,wBAAgB,0BAA0B,IAAI,IAAI,CAcjD;AA2QD,KAAK,QAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC;AAEzD,iFAAiF;AACjF,wBAAgB,0BAA0B,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,MAAM,CAa3E;AAED,gEAAgE;AAChE,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,MAAM,CAI5E;AAED,wCAAwC;AACxC,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,MAAM,CAEtE;AAED,uDAAuD;AACvD,wBAAsB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAEhL"}
@@ -16,10 +16,12 @@ import logger from '../util/logger.js';
16
16
  import { homedir } from 'os';
17
17
  import { readdir as fsReaddir, realpath as fsRealpath, readFile as fsReadFileRaw, stat as fsStat, writeFile as fsWriteFile } from 'node:fs/promises';
18
18
  import * as nodePath from 'node:path';
19
- import { exec as execCb } from 'node:child_process';
19
+ import { exec as execCb, execFile as execFileCb } from 'node:child_process';
20
20
  import { promisify } from 'node:util';
21
21
  const execAsync = promisify(execCb);
22
+ const execFileAsync = promisify(execFileCb);
22
23
  import { startP2pRun, cancelP2pRun, getP2pRun, listP2pRuns, serializeP2pRun } from './p2p-orchestrator.js';
24
+ import { buildSessionList } from './session-list.js';
23
25
  import { getComboRoundCount, parseModePipeline, P2P_CONFIG_MODE } from '../../shared/p2p-modes.js';
24
26
  import { CRON_MSG } from '../../shared/cron-types.js';
25
27
  import { executeCronJob } from './cron-executor.js';
@@ -184,7 +186,6 @@ import { resolveContextWindow } from '../util/model-context.js';
184
186
  import { QWEN_MODEL_IDS } from '../../shared/qwen-models.js';
185
187
  import { getQwenRuntimeConfig } from '../agent/qwen-runtime-config.js';
186
188
  import { getQwenDisplayMetadata } from '../agent/provider-display.js';
187
- import { buildSessionList } from './session-list.js';
188
189
  import { getQwenOAuthQuotaUsageLabel, recordQwenOAuthRequest } from '../agent/provider-quota.js';
189
190
  import { listProviderSessions as listProviderSessionsImpl } from './provider-sessions.js';
190
191
  function describeTransportSendError(err) {
@@ -574,12 +575,66 @@ export function handleWebCommand(msg, serverLink) {
574
575
  break;
575
576
  case 'subsession.rename': {
576
577
  const sName = cmd.sessionName;
577
- const label = cmd.label;
578
+ const label = cmd.label === null
579
+ ? null
580
+ : (typeof cmd.label === 'string' ? cmd.label : undefined);
578
581
  if (sName && label !== undefined) {
579
582
  const record = getSession(sName);
580
583
  if (record) {
581
- upsertSession({ ...record, label, updatedAt: Date.now() });
584
+ const nextLabel = label ?? undefined;
585
+ upsertSession({ ...record, label: nextLabel, updatedAt: Date.now() });
582
586
  logger.info({ sessionName: sName, label }, 'subsession.rename: label updated');
587
+ const id = sName.replace(/^deck_sub_/, '');
588
+ void buildSubSessionSync(id, { label: nextLabel }).then((payload) => {
589
+ try {
590
+ serverLink.send(payload);
591
+ }
592
+ catch {
593
+ // not connected
594
+ }
595
+ });
596
+ }
597
+ }
598
+ break;
599
+ }
600
+ case 'session.rename': {
601
+ const sessionName = cmd.sessionName;
602
+ const projectName = typeof cmd.projectName === 'string' ? cmd.projectName.trim() : '';
603
+ if (sessionName && projectName) {
604
+ const record = getSession(sessionName);
605
+ if (record) {
606
+ upsertSession({ ...record, projectName, updatedAt: Date.now() });
607
+ logger.info({ sessionName, projectName }, 'session.rename: project name updated');
608
+ void buildSessionList().then((sessions) => {
609
+ try {
610
+ serverLink.send({ type: 'session_list', daemonVersion: serverLink.daemonVersion, sessions });
611
+ }
612
+ catch {
613
+ // not connected
614
+ }
615
+ });
616
+ }
617
+ }
618
+ break;
619
+ }
620
+ case 'session.relabel': {
621
+ const sessionName = cmd.sessionName;
622
+ const label = cmd.label === null
623
+ ? null
624
+ : (typeof cmd.label === 'string' ? cmd.label : undefined);
625
+ if (sessionName && label !== undefined) {
626
+ const record = getSession(sessionName);
627
+ if (record) {
628
+ upsertSession({ ...record, label: label ?? undefined, updatedAt: Date.now() });
629
+ logger.info({ sessionName, label }, 'session.relabel: label updated');
630
+ void buildSessionList().then((sessions) => {
631
+ try {
632
+ serverLink.send({ type: 'session_list', daemonVersion: serverLink.daemonVersion, sessions });
633
+ }
634
+ catch {
635
+ // not connected
636
+ }
637
+ });
583
638
  }
584
639
  }
585
640
  break;
@@ -2664,7 +2719,9 @@ async function handleFsListInner(resolved, rawPath, requestId, includeFiles, inc
2664
2719
  const FS_READ_SIZE_LIMIT = 512 * 1024; // 512 KB
2665
2720
  const fsReadCache = new Map();
2666
2721
  const fsReadInflight = new Map();
2722
+ const fsReadGenerations = new Map();
2667
2723
  const FS_READ_CACHE_TTL_MS = 5_000;
2724
+ const REPO_CONTEXT_CACHE_TTL_MS = 5_000;
2668
2725
  async function loadFsReadSnapshot(realPath, fileSignature) {
2669
2726
  const ext = nodePath.extname(realPath).toLowerCase().slice(1);
2670
2727
  const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', svg: 'image/svg+xml' };
@@ -2677,6 +2734,7 @@ async function loadFsReadSnapshot(realPath, fileSignature) {
2677
2734
  if (mimeType) {
2678
2735
  const buf = await fsReadFileRaw(realPath);
2679
2736
  return {
2737
+ path: realPath,
2680
2738
  fileSignature,
2681
2739
  status: 'ok',
2682
2740
  content: buf.toString('base64'),
@@ -2688,6 +2746,7 @@ async function loadFsReadSnapshot(realPath, fileSignature) {
2688
2746
  const sample = content.slice(0, 8192);
2689
2747
  if (sample.includes('\0')) {
2690
2748
  return {
2749
+ path: realPath,
2691
2750
  fileSignature,
2692
2751
  status: 'error',
2693
2752
  error: 'binary_file',
@@ -2695,6 +2754,7 @@ async function loadFsReadSnapshot(realPath, fileSignature) {
2695
2754
  };
2696
2755
  }
2697
2756
  return {
2757
+ path: realPath,
2698
2758
  fileSignature,
2699
2759
  status: 'ok',
2700
2760
  content,
@@ -2705,18 +2765,23 @@ async function getFsReadSnapshot(realPath, fileSignature) {
2705
2765
  if (cached && cached.expiresAt > Date.now() && cached.value.fileSignature === fileSignature) {
2706
2766
  return cached.value;
2707
2767
  }
2708
- const inflight = fsReadInflight.get(realPath);
2768
+ const generation = getResourceGeneration(fsReadGenerations, realPath);
2769
+ const inflightKey = `${realPath}::${fileSignature}::${generation}`;
2770
+ const inflight = fsReadInflight.get(inflightKey);
2709
2771
  if (inflight)
2710
2772
  return await inflight;
2711
2773
  const promise = loadFsReadSnapshot(realPath, fileSignature)
2712
- .then((value) => {
2713
- fsReadCache.set(realPath, { value, expiresAt: Date.now() + FS_READ_CACHE_TTL_MS });
2774
+ .then(async (value) => {
2775
+ const currentSignature = await safeStatSignature(realPath);
2776
+ if (getResourceGeneration(fsReadGenerations, realPath) === generation && currentSignature === value.fileSignature) {
2777
+ fsReadCache.set(realPath, { value, expiresAt: Date.now() + FS_READ_CACHE_TTL_MS });
2778
+ }
2714
2779
  return value;
2715
2780
  })
2716
2781
  .finally(() => {
2717
- fsReadInflight.delete(realPath);
2782
+ fsReadInflight.delete(inflightKey);
2718
2783
  });
2719
- fsReadInflight.set(realPath, promise);
2784
+ fsReadInflight.set(inflightKey, promise);
2720
2785
  return await promise;
2721
2786
  }
2722
2787
  async function handleFsRead(cmd, serverLink) {
@@ -2793,15 +2858,25 @@ async function handleFsRead(cmd, serverLink) {
2793
2858
  }
2794
2859
  const GIT_STATUS_CACHE_TTL_MS = 5_000;
2795
2860
  const GIT_DIFF_CACHE_TTL_MS = 5_000;
2861
+ const repoContextCache = new Map();
2862
+ const repoSignatureCache = new Map();
2796
2863
  const gitStatusCache = new Map();
2797
2864
  const gitStatusInflight = new Map();
2798
2865
  const gitNumstatCache = new Map();
2799
2866
  const gitNumstatInflight = new Map();
2800
2867
  const gitDiffCache = new Map();
2801
2868
  const gitDiffInflight = new Map();
2869
+ const gitRepoGenerations = new Map();
2870
+ const gitDiffGenerations = new Map();
2802
2871
  function normalizeFsPath(value) {
2803
2872
  return nodePath.resolve(value);
2804
2873
  }
2874
+ function getResourceGeneration(map, key) {
2875
+ return map.get(key) ?? 0;
2876
+ }
2877
+ function bumpResourceGeneration(map, key) {
2878
+ map.set(key, getResourceGeneration(map, key) + 1);
2879
+ }
2805
2880
  function isPathInside(root, candidate) {
2806
2881
  const normalizedRoot = normalizeFsPath(root);
2807
2882
  const normalizedCandidate = normalizeFsPath(candidate);
@@ -2833,56 +2908,126 @@ async function resolveGitDir(dotGitPath, repoRoot) {
2833
2908
  return null;
2834
2909
  }
2835
2910
  }
2836
- async function findRepoRoot(startPath) {
2911
+ async function findRepoContextBase(startPath) {
2837
2912
  let current = normalizeFsPath(startPath);
2913
+ const traversed = [];
2914
+ const now = Date.now();
2838
2915
  while (true) {
2916
+ const cached = repoContextCache.get(current);
2917
+ if (cached && cached.expiresAt > now) {
2918
+ for (const traversedPath of traversed) {
2919
+ repoContextCache.set(traversedPath, { expiresAt: now + REPO_CONTEXT_CACHE_TTL_MS, value: cached.value });
2920
+ }
2921
+ return cached.value;
2922
+ }
2923
+ traversed.push(current);
2839
2924
  const dotGit = nodePath.join(current, '.git');
2840
2925
  const gitDir = await resolveGitDir(dotGit, current);
2841
- if (gitDir)
2842
- return { repoRoot: current, gitDir };
2926
+ if (gitDir) {
2927
+ const value = { repoRoot: current, gitDir };
2928
+ for (const traversedPath of traversed) {
2929
+ repoContextCache.set(traversedPath, { expiresAt: Date.now() + REPO_CONTEXT_CACHE_TTL_MS, value });
2930
+ }
2931
+ return value;
2932
+ }
2843
2933
  const parent = nodePath.dirname(current);
2844
2934
  if (parent === current)
2845
2935
  break;
2846
2936
  current = parent;
2847
2937
  }
2938
+ for (const traversedPath of traversed) {
2939
+ repoContextCache.set(traversedPath, { expiresAt: Date.now() + REPO_CONTEXT_CACHE_TTL_MS, value: null });
2940
+ }
2848
2941
  return null;
2849
2942
  }
2850
- async function buildRepoSignature(gitDir) {
2851
- const indexSig = await safeStatSignature(nodePath.join(gitDir, 'index'));
2943
+ async function buildRepoSignatureState(gitDir, indexSig, headSig) {
2944
+ const resolvedIndexSig = indexSig ?? await safeStatSignature(nodePath.join(gitDir, 'index'));
2852
2945
  const headPath = nodePath.join(gitDir, 'HEAD');
2853
- const headSig = await safeStatSignature(headPath);
2946
+ const resolvedHeadSig = headSig ?? await safeStatSignature(headPath);
2947
+ let refPath = null;
2854
2948
  let refSig = 'none';
2855
2949
  try {
2856
2950
  const headRaw = await fsReadFileRaw(headPath, 'utf8');
2857
2951
  const match = headRaw.match(/^ref:\s*(.+)\s*$/m);
2858
2952
  if (match?.[1]) {
2859
- refSig = await safeStatSignature(nodePath.join(gitDir, match[1].trim()));
2953
+ refPath = match[1].trim();
2954
+ refSig = await safeStatSignature(nodePath.join(gitDir, refPath));
2860
2955
  }
2861
2956
  }
2862
2957
  catch {
2863
2958
  refSig = 'missing';
2864
2959
  }
2865
- return `${indexSig}|${headSig}|${refSig}`;
2960
+ return {
2961
+ repoSignature: `${resolvedIndexSig}|${resolvedHeadSig}|${refSig}`,
2962
+ indexSig: resolvedIndexSig,
2963
+ headSig: resolvedHeadSig,
2964
+ refPath,
2965
+ refSig,
2966
+ };
2967
+ }
2968
+ async function getRepoSignature(repoRoot, gitDir) {
2969
+ const indexSig = await safeStatSignature(nodePath.join(gitDir, 'index'));
2970
+ const headPath = nodePath.join(gitDir, 'HEAD');
2971
+ const headSig = await safeStatSignature(headPath);
2972
+ const cached = repoSignatureCache.get(repoRoot);
2973
+ if (cached && cached.indexSig === indexSig && cached.headSig === headSig) {
2974
+ if (!cached.refPath || await safeStatSignature(nodePath.join(gitDir, cached.refPath)) === cached.refSig) {
2975
+ return cached.repoSignature;
2976
+ }
2977
+ }
2978
+ const next = await buildRepoSignatureState(gitDir, indexSig, headSig);
2979
+ repoSignatureCache.set(repoRoot, next);
2980
+ return next.repoSignature;
2866
2981
  }
2867
2982
  async function resolveRepoContext(startPath) {
2868
- const repo = await findRepoRoot(startPath);
2983
+ const repo = await findRepoContextBase(startPath);
2869
2984
  if (!repo)
2870
2985
  return null;
2871
2986
  return {
2872
2987
  repoRoot: repo.repoRoot,
2873
2988
  gitDir: repo.gitDir,
2874
- repoSignature: await buildRepoSignature(repo.gitDir),
2989
+ repoSignature: await getRepoSignature(repo.repoRoot, repo.gitDir),
2875
2990
  };
2876
2991
  }
2992
+ function decodeGitPath(rawPath) {
2993
+ return rawPath.replace(/\\([\\\"abfnrtv])/g, (_match, escaped) => {
2994
+ switch (escaped) {
2995
+ case 'a': return '\u0007';
2996
+ case 'b': return '\b';
2997
+ case 'f': return '\f';
2998
+ case 'n': return '\n';
2999
+ case 'r': return '\r';
3000
+ case 't': return '\t';
3001
+ case 'v': return '\v';
3002
+ case '\\': return '\\';
3003
+ case '"': return '"';
3004
+ default: return escaped;
3005
+ }
3006
+ }).replace(/\\([0-7]{1,3})/g, (_match, octal) => String.fromCharCode(parseInt(octal, 8)));
3007
+ }
3008
+ function parseZRecords(stdout) {
3009
+ return stdout.split('\0').filter((entry) => entry.length > 0);
3010
+ }
3011
+ function normalizeRepoRelativePath(repoRoot, relativePath) {
3012
+ return nodePath.join(repoRoot, decodeGitPath(relativePath));
3013
+ }
2877
3014
  async function loadRepoGitStatusSnapshot(repoRoot, repoSignature) {
2878
- const { stdout } = await execAsync('git status --porcelain -u', { cwd: repoRoot, timeout: 5000 });
3015
+ const { stdout } = await execAsync('git status --porcelain=v1 -z -u', { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
2879
3016
  const files = [];
2880
- for (const line of stdout.split('\n')) {
2881
- if (!line.trim())
2882
- continue;
2883
- const code = line.slice(0, 2).trim();
2884
- const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
2885
- files.push({ path: nodePath.join(repoRoot, filePath), code });
3017
+ const records = parseZRecords(stdout);
3018
+ for (let idx = 0; idx < records.length; idx++) {
3019
+ const record = records[idx];
3020
+ const code = record.slice(0, 2).trim();
3021
+ const firstPath = record.slice(3);
3022
+ let logicalPath = firstPath;
3023
+ if (code.startsWith('R') || code.startsWith('C')) {
3024
+ const renamedTo = records[idx + 1];
3025
+ if (renamedTo) {
3026
+ logicalPath = renamedTo;
3027
+ idx += 1;
3028
+ }
3029
+ }
3030
+ files.push({ path: normalizeRepoRelativePath(repoRoot, logicalPath), code });
2886
3031
  }
2887
3032
  return { repoRoot, repoSignature, files };
2888
3033
  }
@@ -2894,41 +3039,60 @@ async function getRepoGitStatusSnapshot(startPath) {
2894
3039
  if (cached && cached.expiresAt > Date.now() && cached.value.repoSignature === context.repoSignature) {
2895
3040
  return cached.value;
2896
3041
  }
2897
- const inflight = gitStatusInflight.get(context.repoRoot);
3042
+ const generation = getResourceGeneration(gitRepoGenerations, context.repoRoot);
3043
+ const inflightKey = `${context.repoRoot}::${context.repoSignature}::${generation}`;
3044
+ const inflight = gitStatusInflight.get(inflightKey);
2898
3045
  if (inflight)
2899
3046
  return await inflight;
2900
3047
  const promise = loadRepoGitStatusSnapshot(context.repoRoot, context.repoSignature)
2901
- .then((value) => {
2902
- gitStatusCache.set(context.repoRoot, { value, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS });
3048
+ .then(async (value) => {
3049
+ const currentSignature = await getRepoSignature(context.repoRoot, context.gitDir);
3050
+ if (getResourceGeneration(gitRepoGenerations, context.repoRoot) === generation && currentSignature === value.repoSignature) {
3051
+ gitStatusCache.set(context.repoRoot, { value, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS });
3052
+ }
2903
3053
  return value;
2904
3054
  })
2905
3055
  .finally(() => {
2906
- gitStatusInflight.delete(context.repoRoot);
3056
+ gitStatusInflight.delete(inflightKey);
2907
3057
  });
2908
- gitStatusInflight.set(context.repoRoot, promise);
3058
+ gitStatusInflight.set(inflightKey, promise);
2909
3059
  return await promise;
2910
3060
  }
2911
3061
  async function loadRepoGitNumstatSnapshot(repoRoot, repoSignature) {
2912
3062
  let stdout = '';
2913
3063
  try {
2914
- ({ stdout } = await execAsync('git diff --numstat HEAD', { cwd: repoRoot, timeout: 5000 }));
3064
+ ({ stdout } = await execAsync('git diff --numstat -z HEAD', { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }));
2915
3065
  }
2916
3066
  catch {
2917
3067
  try {
2918
- ({ stdout } = await execAsync('git diff --numstat', { cwd: repoRoot, timeout: 5000 }));
3068
+ ({ stdout } = await execAsync('git diff --numstat -z', { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }));
2919
3069
  }
2920
3070
  catch {
2921
3071
  stdout = '';
2922
3072
  }
2923
3073
  }
2924
3074
  const stats = new Map();
2925
- for (const line of stdout.split('\n')) {
2926
- const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
2927
- if (!match)
3075
+ const records = parseZRecords(stdout);
3076
+ for (let idx = 0; idx < records.length; idx++) {
3077
+ const header = records[idx];
3078
+ const firstTab = header.indexOf('\t');
3079
+ const secondTab = firstTab >= 0 ? header.indexOf('\t', firstTab + 1) : -1;
3080
+ if (firstTab < 0 || secondTab < 0)
2928
3081
  continue;
2929
- const additions = match[1] === '-' ? undefined : parseInt(match[1], 10);
2930
- const deletions = match[2] === '-' ? undefined : parseInt(match[2], 10);
2931
- stats.set(nodePath.join(repoRoot, match[3].trim()), { additions, deletions });
3082
+ const additionsRaw = header.slice(0, firstTab);
3083
+ const deletionsRaw = header.slice(firstTab + 1, secondTab);
3084
+ const pathRaw = header.slice(secondTab + 1);
3085
+ const additions = additionsRaw === '-' ? undefined : parseInt(additionsRaw, 10);
3086
+ const deletions = deletionsRaw === '-' ? undefined : parseInt(deletionsRaw, 10);
3087
+ let logicalPath = pathRaw;
3088
+ if (pathRaw === '') {
3089
+ const renamedTo = records[idx + 2];
3090
+ if (!renamedTo)
3091
+ continue;
3092
+ logicalPath = renamedTo;
3093
+ idx += 2;
3094
+ }
3095
+ stats.set(normalizeRepoRelativePath(repoRoot, logicalPath), { additions, deletions });
2932
3096
  }
2933
3097
  return { repoRoot, repoSignature, stats };
2934
3098
  }
@@ -2940,89 +3104,163 @@ async function getRepoGitNumstatSnapshot(startPath) {
2940
3104
  if (cached && cached.expiresAt > Date.now() && cached.value.repoSignature === context.repoSignature) {
2941
3105
  return cached.value;
2942
3106
  }
2943
- const inflight = gitNumstatInflight.get(context.repoRoot);
3107
+ const generation = getResourceGeneration(gitRepoGenerations, context.repoRoot);
3108
+ const inflightKey = `${context.repoRoot}::${context.repoSignature}::${generation}`;
3109
+ const inflight = gitNumstatInflight.get(inflightKey);
2944
3110
  if (inflight)
2945
3111
  return await inflight;
2946
3112
  const promise = loadRepoGitNumstatSnapshot(context.repoRoot, context.repoSignature)
2947
- .then((value) => {
2948
- gitNumstatCache.set(context.repoRoot, { value, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS });
3113
+ .then(async (value) => {
3114
+ const currentSignature = await getRepoSignature(context.repoRoot, context.gitDir);
3115
+ if (getResourceGeneration(gitRepoGenerations, context.repoRoot) === generation && currentSignature === value.repoSignature) {
3116
+ gitNumstatCache.set(context.repoRoot, { value, expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS });
3117
+ }
2949
3118
  return value;
2950
3119
  })
2951
3120
  .finally(() => {
2952
- gitNumstatInflight.delete(context.repoRoot);
3121
+ gitNumstatInflight.delete(inflightKey);
2953
3122
  });
2954
- gitNumstatInflight.set(context.repoRoot, promise);
3123
+ gitNumstatInflight.set(inflightKey, promise);
2955
3124
  return await promise;
2956
3125
  }
2957
- async function loadFileGitDiffSnapshot(realPath, repoRoot, repoSignature, fileSignature) {
3126
+ async function loadFileGitDiffSnapshot(logicalPath, repoRoot, repoSignature, fileSignature) {
2958
3127
  let diff = '';
3128
+ const repoRelativePath = nodePath.relative(repoRoot, logicalPath).split(nodePath.sep).join('/');
2959
3129
  try {
2960
- const { stdout } = await execAsync(`git diff HEAD -- ${JSON.stringify(realPath)}`, { cwd: repoRoot, timeout: 5000 });
3130
+ const { stdout } = await execFileAsync('git', ['diff', 'HEAD', '--', repoRelativePath], { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
2961
3131
  diff = stdout;
2962
3132
  }
2963
3133
  catch { /* ignore */ }
2964
3134
  if (!diff) {
2965
3135
  try {
2966
- const { stdout } = await execAsync(`git diff -- ${JSON.stringify(realPath)}`, { cwd: repoRoot, timeout: 5000 });
3136
+ const { stdout } = await execFileAsync('git', ['diff', '--', repoRelativePath], { cwd: repoRoot, timeout: 5000, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
2967
3137
  diff = stdout;
2968
3138
  }
2969
3139
  catch { /* ignore */ }
2970
3140
  }
2971
- return { repoRoot, repoSignature, fileSignature, diff };
3141
+ return { logicalPath, repoRoot, repoSignature, fileSignature, diff };
2972
3142
  }
2973
- async function getFileGitDiffSnapshot(realPath) {
2974
- const context = await resolveRepoContext(nodePath.dirname(realPath));
3143
+ async function getFileGitDiffSnapshot(logicalPath) {
3144
+ const context = await resolveRepoContext(nodePath.dirname(logicalPath));
2975
3145
  if (!context)
2976
3146
  return null;
2977
- const fileSignature = await safeStatSignature(realPath);
2978
- const cached = gitDiffCache.get(realPath);
3147
+ const fileSignature = await safeStatSignature(logicalPath);
3148
+ const cached = gitDiffCache.get(logicalPath);
2979
3149
  if (cached
2980
3150
  && cached.expiresAt > Date.now()
2981
3151
  && cached.value.repoSignature === context.repoSignature
2982
3152
  && cached.value.fileSignature === fileSignature) {
2983
3153
  return cached.value;
2984
3154
  }
2985
- const inflight = gitDiffInflight.get(realPath);
3155
+ const generation = getResourceGeneration(gitDiffGenerations, logicalPath);
3156
+ const inflightKey = `${logicalPath}::${context.repoSignature}::${fileSignature}::${generation}`;
3157
+ const inflight = gitDiffInflight.get(inflightKey);
2986
3158
  if (inflight)
2987
3159
  return await inflight;
2988
- const promise = loadFileGitDiffSnapshot(realPath, context.repoRoot, context.repoSignature, fileSignature)
2989
- .then((value) => {
2990
- gitDiffCache.set(realPath, { value, expiresAt: Date.now() + GIT_DIFF_CACHE_TTL_MS });
3160
+ const promise = loadFileGitDiffSnapshot(logicalPath, context.repoRoot, context.repoSignature, fileSignature)
3161
+ .then(async (value) => {
3162
+ const currentContext = await resolveRepoContext(nodePath.dirname(logicalPath));
3163
+ const currentFileSignature = await safeStatSignature(logicalPath);
3164
+ if (getResourceGeneration(gitDiffGenerations, logicalPath) === generation
3165
+ && currentContext
3166
+ && currentContext.repoSignature === value.repoSignature
3167
+ && currentFileSignature === value.fileSignature) {
3168
+ gitDiffCache.set(logicalPath, { value, expiresAt: Date.now() + GIT_DIFF_CACHE_TTL_MS });
3169
+ }
2991
3170
  return value;
2992
3171
  })
2993
3172
  .finally(() => {
2994
- gitDiffInflight.delete(realPath);
3173
+ gitDiffInflight.delete(inflightKey);
2995
3174
  });
2996
- gitDiffInflight.set(realPath, promise);
3175
+ gitDiffInflight.set(inflightKey, promise);
2997
3176
  return await promise;
2998
3177
  }
2999
- function filterRepoFilesForPath(files, requestedPath) {
3000
- return files.filter((file) => isPathInside(requestedPath, file.path));
3178
+ function collectAffectedRepoRoots(targetPath) {
3179
+ const affected = new Set();
3180
+ for (const key of gitStatusCache.keys()) {
3181
+ if (isPathInside(key, targetPath))
3182
+ affected.add(key);
3183
+ }
3184
+ for (const key of gitNumstatCache.keys()) {
3185
+ if (isPathInside(key, targetPath))
3186
+ affected.add(key);
3187
+ }
3188
+ for (const key of gitStatusInflight.keys()) {
3189
+ const repoRoot = key.split('::')[0] ?? '';
3190
+ if (repoRoot && isPathInside(repoRoot, targetPath))
3191
+ affected.add(repoRoot);
3192
+ }
3193
+ for (const key of gitNumstatInflight.keys()) {
3194
+ const repoRoot = key.split('::')[0] ?? '';
3195
+ if (repoRoot && isPathInside(repoRoot, targetPath))
3196
+ affected.add(repoRoot);
3197
+ }
3198
+ for (const entry of repoContextCache.values()) {
3199
+ const repoRoot = entry.value?.repoRoot;
3200
+ if (repoRoot && isPathInside(repoRoot, targetPath))
3201
+ affected.add(repoRoot);
3202
+ }
3203
+ return affected;
3001
3204
  }
3002
3205
  function invalidateGitCachesForPath(targetPath) {
3003
3206
  const normalized = normalizeFsPath(targetPath);
3207
+ bumpResourceGeneration(fsReadGenerations, normalized);
3208
+ bumpResourceGeneration(gitDiffGenerations, normalized);
3209
+ for (const repoRoot of collectAffectedRepoRoots(normalized)) {
3210
+ bumpResourceGeneration(gitRepoGenerations, repoRoot);
3211
+ }
3004
3212
  fsReadCache.delete(normalized);
3005
- fsReadInflight.delete(normalized);
3006
3213
  gitDiffCache.delete(normalized);
3007
- gitDiffInflight.delete(normalized);
3214
+ for (const key of fsReadInflight.keys()) {
3215
+ if (key.startsWith(`${normalized}::`))
3216
+ fsReadInflight.delete(key);
3217
+ }
3218
+ for (const key of gitDiffInflight.keys()) {
3219
+ if (key.startsWith(`${normalized}::`))
3220
+ gitDiffInflight.delete(key);
3221
+ }
3008
3222
  for (const key of gitStatusCache.keys()) {
3009
3223
  if (isPathInside(key, normalized))
3010
3224
  gitStatusCache.delete(key);
3225
+ if (isPathInside(key, normalized))
3226
+ repoSignatureCache.delete(key);
3227
+ }
3228
+ for (const key of repoContextCache.keys()) {
3229
+ if (isPathInside(key, normalized))
3230
+ repoContextCache.delete(key);
3011
3231
  }
3012
3232
  for (const key of gitNumstatCache.keys()) {
3013
3233
  if (isPathInside(key, normalized))
3014
3234
  gitNumstatCache.delete(key);
3235
+ if (isPathInside(key, normalized))
3236
+ repoSignatureCache.delete(key);
3237
+ }
3238
+ for (const key of gitStatusInflight.keys()) {
3239
+ if (isPathInside(key.split('::')[0] ?? '', normalized))
3240
+ gitStatusInflight.delete(key);
3241
+ }
3242
+ for (const key of gitNumstatInflight.keys()) {
3243
+ if (isPathInside(key.split('::')[0] ?? '', normalized))
3244
+ gitNumstatInflight.delete(key);
3015
3245
  }
3016
3246
  }
3017
3247
  export function __resetFsGitCachesForTests() {
3018
3248
  fsReadCache.clear();
3019
3249
  fsReadInflight.clear();
3250
+ fsReadGenerations.clear();
3251
+ repoContextCache.clear();
3252
+ repoSignatureCache.clear();
3020
3253
  gitStatusCache.clear();
3021
3254
  gitStatusInflight.clear();
3022
3255
  gitNumstatCache.clear();
3023
3256
  gitNumstatInflight.clear();
3024
3257
  gitDiffCache.clear();
3025
3258
  gitDiffInflight.clear();
3259
+ gitRepoGenerations.clear();
3260
+ gitDiffGenerations.clear();
3261
+ }
3262
+ function filterRepoFilesForPath(files, requestedPath) {
3263
+ return files.filter((file) => isPathInside(requestedPath, file.path));
3026
3264
  }
3027
3265
  /** fs.git_status — return git modified file list for a directory */
3028
3266
  async function handleFsGitStatus(cmd, serverLink) {
@@ -3075,8 +3313,20 @@ async function handleFsGitDiff(cmd, serverLink) {
3075
3313
  const expanded = rawPath.startsWith('~') ? rawPath.replace(/^~/, homedir()) : rawPath;
3076
3314
  const resolved = nodePath.resolve(expanded);
3077
3315
  try {
3078
- const real = await fsRealpath(resolved);
3079
- const allowed = isPathAllowed(real);
3316
+ let allowedProbe = resolved;
3317
+ while (true) {
3318
+ try {
3319
+ allowedProbe = await fsRealpath(allowedProbe);
3320
+ break;
3321
+ }
3322
+ catch {
3323
+ const parent = nodePath.dirname(allowedProbe);
3324
+ if (parent === allowedProbe)
3325
+ throw new Error(`ENOENT: no such file or directory, realpath '${resolved}'`);
3326
+ allowedProbe = parent;
3327
+ }
3328
+ }
3329
+ const allowed = isPathAllowed(allowedProbe);
3080
3330
  if (!allowed) {
3081
3331
  try {
3082
3332
  serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, status: 'error', error: 'forbidden_path' });
@@ -3084,11 +3334,11 @@ async function handleFsGitDiff(cmd, serverLink) {
3084
3334
  catch { /* ignore */ }
3085
3335
  return;
3086
3336
  }
3087
- const snapshot = await getFileGitDiffSnapshot(real);
3337
+ const snapshot = await getFileGitDiffSnapshot(resolved);
3088
3338
  const diff = snapshot?.diff ?? '';
3089
3339
  // Untracked files: no diff (nothing meaningful to compare against)
3090
3340
  try {
3091
- serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, resolvedPath: real, status: 'ok', diff });
3341
+ serverLink.send({ type: 'fs.git_diff_response', requestId, path: rawPath, resolvedPath: resolved, status: 'ok', diff });
3092
3342
  }
3093
3343
  catch { /* ignore */ }
3094
3344
  }