fastctx 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +26 -4
  2. package/launcher.js +235 -20
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -25,8 +25,30 @@ 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
+
38
+ ```console
39
+ npm install --global fastctx
40
+ fastctx
41
+ ```
42
+
43
+ For a one-off run without installing, `npx fastctx` opens the same control
44
+ terminal.
45
+
46
+ If your npm registry is a mirror that has not synchronized this release yet,
47
+ the install can fail with `404 Not Found` on the platform package. Install once
48
+ from the official registry:
49
+
28
50
  ```console
29
- npx fastctx
51
+ npm install --global fastctx --registry=https://registry.npmjs.org/
30
52
  ```
31
53
 
32
54
  This package is the launcher: it selects the matching scoped platform package
@@ -42,9 +64,9 @@ A failed update restores and reopens the previous version with a warning.
42
64
  `fastctx serve` and MCP tool calls do not perform update traffic; commands run
43
65
  through the optional bash tools keep their normal network access.
44
66
 
45
- Run `fastctx` in a terminal for the full-screen control UI (preview, Apply,
46
- Jobs, doctor, Unapply), `fastctx jobs` for scriptable running-job management,
47
- 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
48
70
  the launcher proxies stdio, forwards termination signals and the native exit
49
71
  code, and closes the Rust server cleanly if the Node parent is killed.
50
72
 
package/launcher.js CHANGED
@@ -5,6 +5,9 @@ const { spawn } = require('node:child_process');
5
5
  const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
+ const fastctxHome = process.platform === 'win32'
9
+ ? process.env.USERPROFILE || os.homedir()
10
+ : process.env.HOME || os.homedir();
8
11
 
9
12
  const targets = {
10
13
  'win32-x64': ['@fastctx/win32-x64', 'fastctx.exe'],
@@ -19,43 +22,214 @@ if (!target) {
19
22
  process.exit(1);
20
23
  }
21
24
 
22
- let packageRoot;
25
+ let executable;
26
+ let platformPackageMissing = false;
23
27
  try {
24
- packageRoot = path.dirname(require.resolve(`${target[0]}/package.json`));
28
+ const packageRoot = path.dirname(require.resolve(`${target[0]}/package.json`));
29
+ const packagedExecutable = path.join(packageRoot, 'bin', target[1]);
30
+ if (!fs.statSync(packagedExecutable).isFile()) {
31
+ platformPackageMissing = true;
32
+ } else {
33
+ executable = packagedExecutable;
34
+ }
25
35
  } catch (_) {
26
- console.error(`fastctx: platform package ${target[0]} is missing; reinstall fastctx`);
27
- process.exit(1);
36
+ platformPackageMissing = true;
37
+ }
38
+ if (platformPackageMissing) {
39
+ const stableExecutable = path.join(fastctxHome, '.fastctx', 'bin', target[1]);
40
+ let stableCopyReady = false;
41
+ try {
42
+ stableCopyReady = fs.statSync(stableExecutable).isFile();
43
+ } catch (_) {
44
+ stableCopyReady = false;
45
+ }
46
+ if (stableCopyReady) {
47
+ executable = stableExecutable;
48
+ console.error(
49
+ `fastctx: platform package ${target[0]} is missing; using the stable copy at ${stableExecutable}`,
50
+ );
51
+ } else {
52
+ console.error(
53
+ [
54
+ `fastctx: platform package ${target[0]} is missing, and no stable copy is installed.`,
55
+ 'Your configured npm registry may not have synchronized the platform package yet.',
56
+ 'Retry once from the official registry:',
57
+ ' npm install --global fastctx --registry=https://registry.npmjs.org/',
58
+ ].join('\n'),
59
+ );
60
+ process.exit(1);
61
+ }
28
62
  }
29
- const executable = path.join(packageRoot, 'bin', target[1]);
30
63
  const args = process.argv.slice(2);
31
64
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && args[0] !== 'serve');
32
65
  const tuiLaunch = interactive && (args.length === 0 || args[0] === 'ui');
33
66
  const FORCE_KILL_DELAY_MS = 5000;
34
67
  const UPDATE_HANDOFF_EXIT_CODE = 75;
68
+ const UPDATE_HANDOFF_SCHEMA_VERSION = 2;
35
69
  const npmPackage = process.env.FASTCTX_NPM_PACKAGE || 'fastctx';
36
70
  const npmLauncher = process.env.FASTCTX_NPM_LAUNCHER || __filename;
37
- 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')
38
74
  ? 'exec'
39
75
  : 'global';
40
- const fastctxHome = process.env.HOME || process.env.USERPROFILE || os.homedir();
41
76
  const npmHandoff = path.join(
42
77
  fastctxHome,
43
78
  '.fastctx',
44
79
  'update',
45
80
  `npm-launcher-${process.pid}.handoff`,
46
81
  );
47
- const npmCliCandidates = [
48
- process.env.npm_execpath,
49
- path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
50
- path.resolve(path.dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
51
- ].filter(Boolean);
52
- 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();
53
226
  const childEnvironment = { ...process.env };
54
227
  for (const name of [
55
228
  'FASTCTX_NPM_LAUNCHER_VERSION',
56
229
  'FASTCTX_NPM_PACKAGE',
57
230
  'FASTCTX_NPM_MODE',
58
231
  'FASTCTX_NODE_EXECUTABLE',
232
+ 'FASTCTX_NPM_DRIVER',
59
233
  'FASTCTX_NPM_CLI',
60
234
  'FASTCTX_NPM_LAUNCHER',
61
235
  'FASTCTX_NPM_LAUNCHER_PID',
@@ -64,16 +238,19 @@ for (const name of [
64
238
  delete childEnvironment[name];
65
239
  }
66
240
  if (tuiLaunch) {
67
- Object.assign(childEnvironment, {
68
- FASTCTX_NPM_LAUNCHER_VERSION: '1',
241
+ const receiptVersion = npmInvocation.driver === 'node-script' ? '1' : '2';
242
+ const receipt = {
243
+ FASTCTX_NPM_LAUNCHER_VERSION: receiptVersion,
69
244
  FASTCTX_NPM_PACKAGE: npmPackage,
70
245
  FASTCTX_NPM_MODE: npmMode,
71
246
  FASTCTX_NODE_EXECUTABLE: process.execPath,
72
- FASTCTX_NPM_CLI: npmCli,
247
+ FASTCTX_NPM_CLI: npmInvocation.npmCli,
73
248
  FASTCTX_NPM_LAUNCHER: npmLauncher,
74
249
  FASTCTX_NPM_LAUNCHER_PID: String(process.pid),
75
250
  FASTCTX_NPM_HANDOFF: npmHandoff,
76
- });
251
+ };
252
+ if (receiptVersion === '2') receipt.FASTCTX_NPM_DRIVER = npmInvocation.driver;
253
+ Object.assign(childEnvironment, receipt);
77
254
  }
78
255
  const child = spawn(executable, args, {
79
256
  stdio: interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
@@ -107,8 +284,44 @@ function processIsAlive(pid) {
107
284
  }
108
285
  }
109
286
 
110
- function readHandoff() {
111
- 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 };
112
325
  }
113
326
 
114
327
  function finishUpdateHandoff(payload, succeeded, detail) {
@@ -150,9 +363,11 @@ function waitForUpdateHandoff() {
150
363
  console.error(`fastctx: cannot read update handoff: ${error.message}`);
151
364
  process.exit(1);
152
365
  }
366
+ const expectedHelperExecutable = payload.helper_executable;
367
+ const expectedHelperPid = payload.helper_pid;
153
368
  const poll = setInterval(() => {
154
369
  try {
155
- payload = readHandoff();
370
+ payload = readHandoff(expectedHelperExecutable, expectedHelperPid);
156
371
  } catch (error) {
157
372
  clearInterval(poll);
158
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.0",
3
+ "version": "0.2.0",
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.0",
26
- "@fastctx/linux-x64": "0.1.0",
27
- "@fastctx/darwin-x64": "0.1.0",
28
- "@fastctx/darwin-arm64": "0.1.0"
25
+ "@fastctx/win32-x64": "0.2.0",
26
+ "@fastctx/linux-x64": "0.2.0",
27
+ "@fastctx/darwin-x64": "0.2.0",
28
+ "@fastctx/darwin-arm64": "0.2.0"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"