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.
- package/README.md +26 -4
- package/launcher.js +235 -20
- 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
|
-
|
|
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 (
|
|
46
|
-
Jobs, doctor, Unapply), `fastctx jobs` for scriptable
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"@fastctx/linux-x64": "0.
|
|
27
|
-
"@fastctx/darwin-x64": "0.
|
|
28
|
-
"@fastctx/darwin-arm64": "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"
|