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.
- package/README.md +13 -3
- package/launcher.js +197 -14
- 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 (
|
|
58
|
-
Jobs, doctor, Unapply), `fastctx jobs` for scriptable
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"@fastctx/linux-x64": "0.
|
|
27
|
-
"@fastctx/darwin-x64": "0.
|
|
28
|
-
"@fastctx/darwin-arm64": "0.
|
|
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"
|