fastctx 0.1.1 → 0.2.1

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 (3) hide show
  1. package/README.md +13 -3
  2. package/launcher.js +197 -14
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -25,6 +25,16 @@ effect immediately when saved.
25
25
  Job commands, working directories, rolling output, and exit status stay in the
26
26
  current user's private local directory and are never uploaded by FastCtx.
27
27
 
28
+ grep/glob keeps its existing automatic CPU parallelism by default. The TUI can
29
+ set an explicit `search.max_cpu_cores` value from 1 through the engine-visible
30
+ ceiling (available parallelism capped at 16); each newly started server reads it
31
+ directly, without Apply or a copied environment key. Invalid values fail with a
32
+ repairable diagnostic instead of being clamped or replaced. The Config screen
33
+ also provides a default-No confirmation that resets every user preference while
34
+ preserving the Apply ownership receipt, installed binary, host integration, and
35
+ running jobs. Restoring the default history quota can evict excess finished
36
+ records through the normal retention policy.
37
+
28
38
  ```console
29
39
  npm install --global fastctx
30
40
  fastctx
@@ -54,9 +64,9 @@ A failed update restores and reopens the previous version with a warning.
54
64
  `fastctx serve` and MCP tool calls do not perform update traffic; commands run
55
65
  through the optional bash tools keep their normal network access.
56
66
 
57
- Run `fastctx` in a terminal for the full-screen control UI (preview, Apply,
58
- Jobs, doctor, Unapply), `fastctx jobs` for scriptable running-job management,
59
- or `fastctx serve` for the stdio MCP server. For MCP pipes
67
+ Run `fastctx` in a terminal for the full-screen control UI (configuration,
68
+ reset, preview, Apply, Jobs, doctor, Unapply), `fastctx jobs` for scriptable
69
+ running-job management, or `fastctx serve` for the stdio MCP server. For MCP pipes
60
70
  the launcher proxies stdio, forwards termination signals and the native exit
61
71
  code, and closes the Rust server cleanly if the Node parent is killed.
62
72
 
package/launcher.js CHANGED
@@ -65,9 +65,12 @@ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && args[
65
65
  const tuiLaunch = interactive && (args.length === 0 || args[0] === 'ui');
66
66
  const FORCE_KILL_DELAY_MS = 5000;
67
67
  const UPDATE_HANDOFF_EXIT_CODE = 75;
68
+ const UPDATE_HANDOFF_SCHEMA_VERSION = 2;
68
69
  const npmPackage = process.env.FASTCTX_NPM_PACKAGE || 'fastctx';
69
70
  const npmLauncher = process.env.FASTCTX_NPM_LAUNCHER || __filename;
70
- const npmMode = process.env.npm_command === 'exec' || npmLauncher.includes(`${path.sep}_npx${path.sep}`)
71
+ const npmMode = process.env.npm_command === 'exec' || npmLauncher
72
+ .split(/[\\/]+/)
73
+ .some((segment) => segment.toLowerCase() === '_npx')
71
74
  ? 'exec'
72
75
  : 'global';
73
76
  const npmHandoff = path.join(
@@ -76,18 +79,157 @@ const npmHandoff = path.join(
76
79
  'update',
77
80
  `npm-launcher-${process.pid}.handoff`,
78
81
  );
79
- const npmCliCandidates = [
80
- process.env.npm_execpath,
81
- path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
82
- path.resolve(path.dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
83
- ].filter(Boolean);
84
- const npmCli = npmCliCandidates.find((candidate) => path.isAbsolute(candidate) && fs.existsSync(candidate)) || '';
82
+
83
+ function canonicalRegularFile(candidate) {
84
+ if (!candidate || !path.isAbsolute(candidate)) return null;
85
+ try {
86
+ const canonical = fs.realpathSync(candidate);
87
+ return fs.statSync(canonical).isFile() ? canonical : null;
88
+ } catch (_) {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function isNpmCliScript(candidate) {
94
+ return /^npm-cli\.(?:js|cjs|mjs)$/i.test(path.basename(candidate));
95
+ }
96
+
97
+ function nodeScriptInvocation(candidate) {
98
+ const canonical = canonicalRegularFile(candidate);
99
+ return canonical && isNpmCliScript(canonical)
100
+ ? { driver: 'node-script', npmCli: canonical }
101
+ : null;
102
+ }
103
+
104
+ function adjacentNpmCliCandidates(command) {
105
+ const directory = path.dirname(command);
106
+ return [
107
+ path.join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
108
+ path.resolve(directory, '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
109
+ path.resolve(directory, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
110
+ ];
111
+ }
112
+
113
+ function commandInvocation(candidate) {
114
+ const canonical = canonicalRegularFile(candidate);
115
+ if (!canonical) return null;
116
+ if (isNpmCliScript(canonical)) return { driver: 'node-script', npmCli: canonical };
117
+ const invocationPath = path.resolve(candidate);
118
+
119
+ const extension = path.extname(candidate).toLowerCase();
120
+ if (process.platform === 'win32') {
121
+ if (extension === '.cmd' || extension === '.bat') {
122
+ for (const npmCli of adjacentNpmCliCandidates(candidate)) {
123
+ const invocation = nodeScriptInvocation(npmCli);
124
+ if (invocation) return invocation;
125
+ }
126
+ return null;
127
+ }
128
+ return extension === '.exe' || extension === '.com'
129
+ ? { driver: 'executable', npmCli: invocationPath }
130
+ : null;
131
+ }
132
+
133
+ try {
134
+ fs.accessSync(invocationPath, fs.constants.X_OK);
135
+ // 2026-07-22: Volta-style shims dispatch by argv[0], so realpath is validation-only here.
136
+ return { driver: 'executable', npmCli: invocationPath };
137
+ } catch (_) {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function launcherInstallCandidates() {
143
+ const canonicalLauncher = canonicalRegularFile(npmLauncher);
144
+ if (!canonicalLauncher) return { npmCli: [], commands: [] };
145
+ const packageRoot = path.dirname(canonicalLauncher);
146
+ const nodeModules = path.dirname(packageRoot);
147
+ if (path.basename(nodeModules).toLowerCase() !== 'node_modules') {
148
+ return { npmCli: [], commands: [] };
149
+ }
150
+ const modulesParent = path.dirname(nodeModules);
151
+ const prefix = path.basename(modulesParent).toLowerCase() === 'lib'
152
+ ? path.dirname(modulesParent)
153
+ : modulesParent;
154
+ return {
155
+ npmCli: [path.join(nodeModules, 'npm', 'bin', 'npm-cli.js')],
156
+ commands: process.platform === 'win32'
157
+ ? [path.join(prefix, 'npm.exe'), path.join(prefix, 'npm.com'), path.join(prefix, 'npm.cmd'), path.join(prefix, 'npm.bat')]
158
+ : [path.join(prefix, 'bin', 'npm')],
159
+ };
160
+ }
161
+
162
+ function nodeLayoutCandidates() {
163
+ const nodeExecutables = [process.execPath];
164
+ const canonicalNode = canonicalRegularFile(process.execPath);
165
+ if (canonicalNode && canonicalNode !== process.execPath) nodeExecutables.push(canonicalNode);
166
+ return [...new Set(nodeExecutables.flatMap((nodeExecutable) => {
167
+ const directory = path.dirname(nodeExecutable);
168
+ return [
169
+ path.join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
170
+ path.resolve(directory, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
171
+ ];
172
+ }))];
173
+ }
174
+
175
+ function environmentValue(name) {
176
+ const entry = Object.entries(process.env)
177
+ .find(([key]) => key.toLowerCase() === name.toLowerCase());
178
+ return entry && entry[1];
179
+ }
180
+
181
+ function pathCommandCandidates() {
182
+ const pathValue = environmentValue('PATH');
183
+ if (!pathValue) return [];
184
+ const names = process.platform === 'win32'
185
+ ? ['npm', ...((environmentValue('PATHEXT') || '.COM;.EXE;.BAT;.CMD')
186
+ .split(';')
187
+ .map((extension) => extension.trim().toLowerCase())
188
+ .filter((extension, index, extensions) =>
189
+ ['.com', '.exe', '.bat', '.cmd'].includes(extension) && extensions.indexOf(extension) === index)
190
+ .map((extension) => `npm${extension}`))]
191
+ : ['npm'];
192
+ return pathValue.split(path.delimiter).flatMap((entry) => {
193
+ const directory = entry.trim().replace(/^"(.*)"$/, '$1');
194
+ // Skip empty and relative PATH entries: resolving them against the working
195
+ // directory would let a planted `npm` drive an update (2026-07-22).
196
+ if (!directory || !path.isAbsolute(directory)) return [];
197
+ return names.map((name) => path.join(directory, name));
198
+ });
199
+ }
200
+
201
+ function resolveNpmInvocation() {
202
+ const explicit = commandInvocation(process.env.npm_execpath);
203
+ if (explicit) return explicit;
204
+
205
+ const launcherCandidates = launcherInstallCandidates();
206
+ for (const candidate of launcherCandidates.npmCli) {
207
+ const invocation = nodeScriptInvocation(candidate);
208
+ if (invocation) return invocation;
209
+ }
210
+ for (const candidate of launcherCandidates.commands) {
211
+ const invocation = commandInvocation(candidate);
212
+ if (invocation) return invocation;
213
+ }
214
+ for (const candidate of nodeLayoutCandidates()) {
215
+ const invocation = nodeScriptInvocation(candidate);
216
+ if (invocation) return invocation;
217
+ }
218
+ for (const candidate of pathCommandCandidates()) {
219
+ const invocation = commandInvocation(candidate);
220
+ if (invocation) return invocation;
221
+ }
222
+ return { driver: 'unavailable', npmCli: '' };
223
+ }
224
+
225
+ const npmInvocation = resolveNpmInvocation();
85
226
  const childEnvironment = { ...process.env };
86
227
  for (const name of [
87
228
  'FASTCTX_NPM_LAUNCHER_VERSION',
88
229
  'FASTCTX_NPM_PACKAGE',
89
230
  'FASTCTX_NPM_MODE',
90
231
  'FASTCTX_NODE_EXECUTABLE',
232
+ 'FASTCTX_NPM_DRIVER',
91
233
  'FASTCTX_NPM_CLI',
92
234
  'FASTCTX_NPM_LAUNCHER',
93
235
  'FASTCTX_NPM_LAUNCHER_PID',
@@ -96,16 +238,19 @@ for (const name of [
96
238
  delete childEnvironment[name];
97
239
  }
98
240
  if (tuiLaunch) {
99
- Object.assign(childEnvironment, {
100
- FASTCTX_NPM_LAUNCHER_VERSION: '1',
241
+ const receiptVersion = npmInvocation.driver === 'node-script' ? '1' : '2';
242
+ const receipt = {
243
+ FASTCTX_NPM_LAUNCHER_VERSION: receiptVersion,
101
244
  FASTCTX_NPM_PACKAGE: npmPackage,
102
245
  FASTCTX_NPM_MODE: npmMode,
103
246
  FASTCTX_NODE_EXECUTABLE: process.execPath,
104
- FASTCTX_NPM_CLI: npmCli,
247
+ FASTCTX_NPM_CLI: npmInvocation.npmCli,
105
248
  FASTCTX_NPM_LAUNCHER: npmLauncher,
106
249
  FASTCTX_NPM_LAUNCHER_PID: String(process.pid),
107
250
  FASTCTX_NPM_HANDOFF: npmHandoff,
108
- });
251
+ };
252
+ if (receiptVersion === '2') receipt.FASTCTX_NPM_DRIVER = npmInvocation.driver;
253
+ Object.assign(childEnvironment, receipt);
109
254
  }
110
255
  const child = spawn(executable, args, {
111
256
  stdio: interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
@@ -139,8 +284,44 @@ function processIsAlive(pid) {
139
284
  }
140
285
  }
141
286
 
142
- function readHandoff() {
143
- return JSON.parse(fs.readFileSync(npmHandoff, 'utf8'));
287
+ function readHandoff(expectedHelperExecutable, expectedHelperPid) {
288
+ const payload = JSON.parse(fs.readFileSync(npmHandoff, 'utf8'));
289
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
290
+ throw new Error('the update handoff is not an object');
291
+ }
292
+ if (payload.schema_version !== UPDATE_HANDOFF_SCHEMA_VERSION) {
293
+ throw new Error(`unsupported update handoff schema ${payload.schema_version}`);
294
+ }
295
+ if (!['running', 'done', 'failed'].includes(payload.state)) {
296
+ throw new Error(`unsupported update handoff state ${JSON.stringify(payload.state)}`);
297
+ }
298
+ if (!Number.isInteger(payload.helper_pid) || payload.helper_pid <= 0) {
299
+ throw new Error('the update handoff has an invalid helper PID');
300
+ }
301
+ if (typeof payload.helper_executable !== 'string' || !path.isAbsolute(payload.helper_executable)) {
302
+ throw new Error('the update handoff has an invalid helper path');
303
+ }
304
+ const updateDirectory = path.resolve(path.dirname(npmHandoff));
305
+ const helperExecutable = path.resolve(payload.helper_executable);
306
+ const relativeHelper = path.relative(updateDirectory, helperExecutable);
307
+ if (
308
+ !relativeHelper ||
309
+ path.isAbsolute(relativeHelper) ||
310
+ path.dirname(relativeHelper) !== '.' ||
311
+ !path.basename(relativeHelper).startsWith('helper-')
312
+ ) {
313
+ throw new Error('the update handoff helper is outside the private update directory');
314
+ }
315
+ if (expectedHelperExecutable && helperExecutable !== expectedHelperExecutable) {
316
+ throw new Error('the update handoff changed helper paths');
317
+ }
318
+ if (expectedHelperPid && payload.helper_pid !== expectedHelperPid) {
319
+ throw new Error('the update handoff changed helper PIDs');
320
+ }
321
+ if (payload.detail !== undefined && typeof payload.detail !== 'string') {
322
+ throw new Error('the update handoff has an invalid detail');
323
+ }
324
+ return { ...payload, helper_executable: helperExecutable };
144
325
  }
145
326
 
146
327
  function finishUpdateHandoff(payload, succeeded, detail) {
@@ -182,9 +363,11 @@ function waitForUpdateHandoff() {
182
363
  console.error(`fastctx: cannot read update handoff: ${error.message}`);
183
364
  process.exit(1);
184
365
  }
366
+ const expectedHelperExecutable = payload.helper_executable;
367
+ const expectedHelperPid = payload.helper_pid;
185
368
  const poll = setInterval(() => {
186
369
  try {
187
- payload = readHandoff();
370
+ payload = readHandoff(expectedHelperExecutable, expectedHelperPid);
188
371
  } catch (error) {
189
372
  clearInterval(poll);
190
373
  console.error(`fastctx: cannot read update handoff: ${error.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastctx",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "FastCtx — fast, context-efficient repository tools for AI agents.",
5
5
  "author": "yc-duan <dy2958830371@gmail.com>",
6
6
  "license": "MIT OR Apache-2.0",
@@ -22,10 +22,10 @@
22
22
  "licenses/**"
23
23
  ],
24
24
  "optionalDependencies": {
25
- "@fastctx/win32-x64": "0.1.1",
26
- "@fastctx/linux-x64": "0.1.1",
27
- "@fastctx/darwin-x64": "0.1.1",
28
- "@fastctx/darwin-arm64": "0.1.1"
25
+ "@fastctx/win32-x64": "0.2.1",
26
+ "@fastctx/linux-x64": "0.2.1",
27
+ "@fastctx/darwin-x64": "0.2.1",
28
+ "@fastctx/darwin-arm64": "0.2.1"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"