pi-session-peek 0.5.0 → 0.5.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/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/index.ts +7 -3
- package/package.json +1 -1
- package/src/peek-component.ts +18 -7
- package/src/sessions.ts +46 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
- Terminal escape sequences and control characters in message text, tool arguments and session names are stripped when parsing, so a pasted colour dump or an OSC sequence in a reply can no longer break the preview layout or reach the terminal.
|
|
6
|
+
- While a delete or rename is still running, `Ctrl+D`, `Ctrl+R`, `Enter`, `Ctrl+O` and double-click are ignored, so the same file cannot be deleted twice or resumed mid-delete.
|
|
7
|
+
- Delete: if a trash command times out but the file is already gone, the result is reported as "moved to trash" instead of falling through to a permanent delete.
|
|
8
|
+
- The scan follows the session directory pi is actually using (`--session-dir`, `$PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in settings), and forks are written there too. With the default layout all projects are still listed.
|
|
9
|
+
- The parse cache is keyed on file size as well as mtime, so two quick writes within the same timestamp tick no longer serve stale content.
|
|
10
|
+
- Rename strips newlines from the new name, matching pi's own `/name`, so the appended record always stays on one line.
|
|
11
|
+
|
|
3
12
|
## 0.5.0
|
|
4
13
|
|
|
5
14
|
- Mouse support in both panes. Click a session to select it, double-click to resume, wheel over the list to move the selection, wheel over the preview to scroll it (`Alt` for five times faster), click the scope label to toggle it, click the search box or rename line to move the cursor. Pi only enables mouse reporting in fullscreen mode, so in the regular mode the picker turns it on itself while open and locates itself on screen with a cursor position query; the terminal's own text selection is unavailable meanwhile (usually `Shift` restores it). `PI_SESSION_PEEK_MOUSE=0` disables it.
|
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ On Windows PowerShell: `$env:PI_SESSION_PEEK_LANG = "en"` before starting pi. Th
|
|
|
128
128
|
- The mouse needs a terminal that supports SGR mouse reporting and the cursor position report (`CSI 6 n`). Windows Terminal, iTerm2, kitty, WezTerm, Alacritty, GNOME Terminal and VS Code all do; the legacy Windows console does not.
|
|
129
129
|
- The preview shows the last 500 messages of very long sessions. Hits in earlier messages are still counted and announced.
|
|
130
130
|
- `pi --peek` without a value is rejected by pi at startup. Use `pi --rp` to open without a keyword.
|
|
131
|
-
-
|
|
131
|
+
- With the default layout every project under `~/.pi/agent/sessions` (or `$PI_CODING_AGENT_DIR/sessions`) is scanned. If pi runs with a custom session directory (`--session-dir`, `$PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in settings), only that directory is scanned and forks are written there.
|
|
132
132
|
|
|
133
133
|
## 🗂️ Package layout
|
|
134
134
|
|
package/README.zh-CN.md
CHANGED
|
@@ -128,7 +128,7 @@ Windows PowerShell 里先执行 `$env:PI_SESSION_PEEK_LANG = "en"` 再启动 pi
|
|
|
128
128
|
- 鼠标需要终端支持 SGR 鼠标上报和光标位置回报(`CSI 6 n`)。Windows Terminal、iTerm2、kitty、WezTerm、Alacritty、GNOME Terminal、VS Code 都支持,老式 Windows 控制台不支持。
|
|
129
129
|
- 特别长的会话预览只显示最后 500 条消息,更早的命中仍会计数并提示。
|
|
130
130
|
- `pi --peek` 不带值会被 pi 在启动时拒绝,不带关键词请用 `pi --rp`。
|
|
131
|
-
-
|
|
131
|
+
- pi 默认布局下扫描 `~/.pi/agent/sessions`(或 `$PI_CODING_AGENT_DIR/sessions`)下所有项目的会话。pi 用了自定义会话目录(`--session-dir`、`$PI_CODING_AGENT_SESSION_DIR` 或 settings 里的 `sessionDir`)时只扫描那个目录,分叉也写到那里。
|
|
132
132
|
|
|
133
133
|
## 🗂️ 目录结构
|
|
134
134
|
|
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from "node:path";
|
|
|
3
3
|
import { msg } from "./src/i18n.ts";
|
|
4
4
|
import { attachMouse } from "./src/mouse.ts";
|
|
5
5
|
import { PeekComponent } from "./src/peek-component.ts";
|
|
6
|
-
import { deleteSession, renameSession, scanSessions, type PeekSession } from "./src/sessions.ts";
|
|
6
|
+
import { deleteSession, renameSession, scanRoot, scanSessions, sessionsDir, type PeekSession } from "./src/sessions.ts";
|
|
7
7
|
|
|
8
8
|
// 这里只做注册:/peek 命令、--peek / --rp 启动参数,以及把删除 / 重命名 / 分叉注入给组件
|
|
9
9
|
|
|
@@ -55,7 +55,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
55
55
|
|
|
56
56
|
// 当前正在用的会话不列出来
|
|
57
57
|
const currentFile = ctx.sessionManager.getSessionFile();
|
|
58
|
-
|
|
58
|
+
// 扫描范围跟着 pi 实际在用的会话目录走(--session-dir / 环境变量 / settings.json 都在里面)
|
|
59
|
+
const root = scanRoot(ctx.sessionManager.getSessionDir());
|
|
60
|
+
const customDir = root !== sessionsDir() ? root : undefined;
|
|
61
|
+
const all = (await scanSessions(root)).filter((s) => s.path !== currentFile);
|
|
59
62
|
if (!all.length) {
|
|
60
63
|
ctx.ui.notify(msg("noSessions"), "info");
|
|
61
64
|
return;
|
|
@@ -118,7 +121,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
118
121
|
if (picked.action === "fork") {
|
|
119
122
|
// 和 pi --fork 一样:把全部记录复制到新文件,header 里记下 parentSession
|
|
120
123
|
try {
|
|
121
|
-
|
|
124
|
+
// 自定义了会话目录时分叉也放进去,别写回默认目录
|
|
125
|
+
const forked = SessionManager.forkFrom(picked.s.path, ctx.cwd, customDir);
|
|
122
126
|
target = forked.getSessionFile() ?? target;
|
|
123
127
|
ctx.ui.notify(msg("forked", { file: basename(target) }), "info");
|
|
124
128
|
} catch (e) {
|
package/package.json
CHANGED
package/src/peek-component.ts
CHANGED
|
@@ -39,6 +39,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
39
39
|
private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
|
|
40
40
|
private confirmingDelete = false; // Ctrl+D 之后等待确认
|
|
41
41
|
private renaming = false; // Ctrl+R 之后正在输入名字
|
|
42
|
+
private busy = false; // 删除 / 重命名的异步回调还没回来:期间不能再删、改名、进入或分叉,免得对同一个文件动两次
|
|
42
43
|
private renameInput: Input;
|
|
43
44
|
private cachedWidth = -1;
|
|
44
45
|
private cachedLines: string[] = [];
|
|
@@ -162,13 +163,19 @@ export class PeekComponent implements Component, Focusable {
|
|
|
162
163
|
this.renaming = false;
|
|
163
164
|
this.focused = this._focused;
|
|
164
165
|
this.invalidate();
|
|
165
|
-
if (s && name && this.onRename) {
|
|
166
|
+
if (s && name && this.onRename && !this.busy) {
|
|
167
|
+
this.busy = true;
|
|
166
168
|
void this.onRename(s, name).then((ok) => {
|
|
167
169
|
if (ok) {
|
|
168
170
|
s.name = name;
|
|
169
171
|
// 名字参与搜索,改完可能就不再匹配当前关键词了
|
|
170
172
|
this.refilter(true);
|
|
171
173
|
}
|
|
174
|
+
}).catch(() => {
|
|
175
|
+
// 回调自己负责提示;这里吞掉,别变成 unhandled rejection
|
|
176
|
+
}).finally(() => {
|
|
177
|
+
// 放在 finally:回调抛了错也要解锁,否则删除 / 改名 / 进入全部永久失效
|
|
178
|
+
this.busy = false;
|
|
172
179
|
this.invalidate();
|
|
173
180
|
this.requestRender?.();
|
|
174
181
|
});
|
|
@@ -191,7 +198,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
191
198
|
this.invalidate();
|
|
192
199
|
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
193
200
|
const s = this.filtered[this.selected];
|
|
194
|
-
if (s && this.onDelete) {
|
|
201
|
+
if (s && this.onDelete && !this.busy) {
|
|
202
|
+
this.busy = true;
|
|
195
203
|
void this.onDelete(s).then((ok) => {
|
|
196
204
|
if (ok) {
|
|
197
205
|
let i = this.all.indexOf(s);
|
|
@@ -203,6 +211,9 @@ export class PeekComponent implements Component, Focusable {
|
|
|
203
211
|
this.previewKey = "";
|
|
204
212
|
this.clearSelection();
|
|
205
213
|
}
|
|
214
|
+
}).catch(() => {
|
|
215
|
+
}).finally(() => {
|
|
216
|
+
this.busy = false;
|
|
206
217
|
this.invalidate();
|
|
207
218
|
this.requestRender?.();
|
|
208
219
|
});
|
|
@@ -220,7 +231,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
220
231
|
return;
|
|
221
232
|
}
|
|
222
233
|
if (matchesKey(data, Key.ctrl("d"))) {
|
|
223
|
-
if (this.filtered.length && this.onDelete) {
|
|
234
|
+
if (this.filtered.length && this.onDelete && !this.busy) {
|
|
224
235
|
this.confirmingDelete = true;
|
|
225
236
|
this.invalidate();
|
|
226
237
|
}
|
|
@@ -228,7 +239,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
228
239
|
}
|
|
229
240
|
if (matchesKey(data, Key.ctrl("r"))) {
|
|
230
241
|
const s = this.filtered[this.selected];
|
|
231
|
-
if (s && this.onRename) {
|
|
242
|
+
if (s && this.onRename && !this.busy) {
|
|
232
243
|
this.renaming = true;
|
|
233
244
|
this.renameInput.setValue(s.name || "");
|
|
234
245
|
this.focused = this._focused;
|
|
@@ -295,12 +306,12 @@ export class PeekComponent implements Component, Focusable {
|
|
|
295
306
|
}
|
|
296
307
|
if (matchesKey(data, Key.enter)) {
|
|
297
308
|
const s = this.filtered[this.selected];
|
|
298
|
-
if (s) this.onResume?.(s);
|
|
309
|
+
if (s && !this.busy) this.onResume?.(s);
|
|
299
310
|
return;
|
|
300
311
|
}
|
|
301
312
|
if (matchesKey(data, Key.ctrl("o"))) {
|
|
302
313
|
const s = this.filtered[this.selected];
|
|
303
|
-
if (s) this.onFork?.(s);
|
|
314
|
+
if (s && !this.busy) this.onFork?.(s);
|
|
304
315
|
return;
|
|
305
316
|
}
|
|
306
317
|
// 其余按键交给搜索框
|
|
@@ -374,7 +385,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
374
385
|
// 单击在 press 里已经选中了,双击才进入
|
|
375
386
|
const s = this.filtered[itemAt()];
|
|
376
387
|
if (!s) return undefined;
|
|
377
|
-
if (!this.renaming && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
|
|
388
|
+
if (!this.renaming && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
|
|
378
389
|
return { handled: true, render: false };
|
|
379
390
|
}
|
|
380
391
|
if (ev.type !== "press") return undefined;
|
package/src/sessions.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { appendFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { normPath } from "./text.ts";
|
|
6
7
|
|
|
7
8
|
// 读 ~/.pi/agent/sessions 下的会话 JSONL,按 mtime 缓存;重命名和删除也在这里。
|
|
8
9
|
// 搜索不预存小写全文:会话正文只在 msgs 里放一份,匹配时用不区分大小写的正则直接扫(见 query.ts 的 matchesSession)
|
|
@@ -26,18 +27,29 @@ export interface PeekSession {
|
|
|
26
27
|
first: string; // 首条消息,列表里显示用
|
|
27
28
|
msgs: PeekMsg[];
|
|
28
29
|
mtime: number;
|
|
30
|
+
size: number; // 和 mtime 一起当缓存键:mtime 粒度粗的文件系统上连续两次写入可能同一个 mtime
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const sessionCache = new Map<string, PeekSession>();
|
|
32
34
|
const READ_CONCURRENCY = 16; // 同时读的文件数,太高会把 libuv 线程池排满
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const expandTilde = (p: string) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p);
|
|
37
|
+
|
|
38
|
+
// 默认布局下所有项目的会话根目录:和 pi 一样认 PI_CODING_AGENT_DIR,下面每个 cwd 一个子目录
|
|
39
|
+
export function sessionsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
40
|
+
const agent = env.PI_CODING_AGENT_DIR ? expandTilde(env.PI_CODING_AGENT_DIR) : join(homedir(), ".pi", "agent");
|
|
38
41
|
return join(agent, "sessions");
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** 要扫描的根目录。pi 的 getSessionDir() 是参数、环境变量、settings.json 合并后的结果:
|
|
45
|
+
* 默认布局时它是 <root>/sessions/<按 cwd 编码的子目录>,取上一级才能看到所有项目;
|
|
46
|
+
* 用户自定义了目录(--session-dir 等)时就是那个目录本身,直接扫它 */
|
|
47
|
+
export function scanRoot(currentSessionDir: string | undefined, env: NodeJS.ProcessEnv = process.env): string {
|
|
48
|
+
const root = sessionsDir(env);
|
|
49
|
+
if (!currentSessionDir) return root;
|
|
50
|
+
return normPath(dirname(currentSessionDir)) === normPath(root) ? root : currentSessionDir;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
async function walkJsonl(dir: string, out: string[]): Promise<void> {
|
|
42
54
|
let entries;
|
|
43
55
|
try {
|
|
@@ -52,13 +64,20 @@ async function walkJsonl(dir: string, out: string[]): Promise<void> {
|
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
|
|
67
|
+
// 终端转义序列(CSI / OSC / DCS / APC 等)和除 \t \n 外的 C0 控制字符。正文里带这些会原样写到屏幕上:
|
|
68
|
+
// 排版错乱、宽度算错,OSC 52 之类的还能改剪贴板 / 窗口标题
|
|
69
|
+
const CONTROL_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]P_^X][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])|[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
70
|
+
export const sanitizeText = (s: string) => s.replace(/\r\n?/g, "\n").replace(CONTROL_RE, "");
|
|
71
|
+
|
|
55
72
|
function extractText(content: unknown): string {
|
|
56
|
-
if (typeof content === "string") return content;
|
|
73
|
+
if (typeof content === "string") return sanitizeText(content);
|
|
57
74
|
if (!Array.isArray(content)) return "";
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
return sanitizeText(
|
|
76
|
+
content
|
|
77
|
+
.filter((c: any) => c?.type === "text" && typeof c.text === "string")
|
|
78
|
+
.map((c: any) => c.text)
|
|
79
|
+
.join("\n"),
|
|
80
|
+
);
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
// 工具调用的主参数:按常见键名找第一个字符串,都没有就取第一个字符串参数。不存结果,bash 输出动辄几十 KB
|
|
@@ -71,7 +90,7 @@ export function toolSummary(args: unknown): string {
|
|
|
71
90
|
let v = TOOL_ARG_KEYS.map((k) => o[k]).find((x) => typeof x === "string" && x.trim());
|
|
72
91
|
if (typeof v !== "string") v = Object.values(o).find((x) => typeof x === "string" && (x as string).trim());
|
|
73
92
|
if (typeof v !== "string") return "";
|
|
74
|
-
return v.replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
|
|
93
|
+
return sanitizeText(v).replace(/\s+/g, " ").trim().slice(0, TOOL_SUMMARY_MAX);
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
function extractTools(content: unknown): PeekTool[] {
|
|
@@ -81,7 +100,7 @@ function extractTools(content: unknown): PeekTool[] {
|
|
|
81
100
|
.map((c: any) => ({ name: c.name, summary: toolSummary(c.arguments) }));
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
function parseSession(path: string, raw: string, mtime: number): PeekSession | null {
|
|
103
|
+
function parseSession(path: string, raw: string, mtime: number, size: number): PeekSession | null {
|
|
85
104
|
let cwd = "";
|
|
86
105
|
let time = "";
|
|
87
106
|
let name = "";
|
|
@@ -98,7 +117,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
|
|
|
98
117
|
}
|
|
99
118
|
if (line.includes('"type":"session_info"')) {
|
|
100
119
|
const o = JSON.parse(line);
|
|
101
|
-
if (typeof o.name === "string"
|
|
120
|
+
if (typeof o.name === "string") name = sanitizeText(o.name).trim(); // 空名字是显式清掉
|
|
102
121
|
continue;
|
|
103
122
|
}
|
|
104
123
|
// 先用子串粗筛,省掉大部分行的 JSON.parse
|
|
@@ -127,6 +146,7 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
|
|
|
127
146
|
name,
|
|
128
147
|
msgs,
|
|
129
148
|
mtime,
|
|
149
|
+
size,
|
|
130
150
|
first: firstText.text.replace(/\s+/g, " ").slice(0, 80),
|
|
131
151
|
};
|
|
132
152
|
}
|
|
@@ -134,29 +154,32 @@ function parseSession(path: string, raw: string, mtime: number): PeekSession | n
|
|
|
134
154
|
// 单个文件:mtime 没变直接用缓存,否则重新读和解析
|
|
135
155
|
async function loadSession(file: string): Promise<PeekSession | null> {
|
|
136
156
|
let mtime: number;
|
|
157
|
+
let size: number;
|
|
137
158
|
try {
|
|
138
|
-
|
|
159
|
+
const st = await stat(file);
|
|
160
|
+
mtime = st.mtimeMs;
|
|
161
|
+
size = st.size;
|
|
139
162
|
} catch {
|
|
140
163
|
return null;
|
|
141
164
|
}
|
|
142
165
|
const hit = sessionCache.get(file);
|
|
143
|
-
if (hit && hit.mtime === mtime) return hit;
|
|
166
|
+
if (hit && hit.mtime === mtime && hit.size === size) return hit;
|
|
144
167
|
let raw: string;
|
|
145
168
|
try {
|
|
146
169
|
raw = await readFile(file, "utf8");
|
|
147
170
|
} catch {
|
|
148
171
|
return null;
|
|
149
172
|
}
|
|
150
|
-
const parsed = parseSession(file, raw, mtime);
|
|
173
|
+
const parsed = parseSession(file, raw, mtime, size);
|
|
151
174
|
if (parsed) sessionCache.set(file, parsed);
|
|
152
175
|
return parsed;
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
/** 全部会话,按最后修改时间倒序;文件没变的直接用缓存。
|
|
156
179
|
* 目录遍历和文件读取都走异步 I/O,几百个会话也不会把 TUI 卡住 */
|
|
157
|
-
export async function scanSessions(): Promise<PeekSession[]> {
|
|
180
|
+
export async function scanSessions(root: string = sessionsDir()): Promise<PeekSession[]> {
|
|
158
181
|
const files: string[] = [];
|
|
159
|
-
await walkJsonl(
|
|
182
|
+
await walkJsonl(root, files);
|
|
160
183
|
|
|
161
184
|
const out: PeekSession[] = [];
|
|
162
185
|
let next = 0;
|
|
@@ -179,6 +202,7 @@ export async function scanSessions(): Promise<PeekSession[]> {
|
|
|
179
202
|
|
|
180
203
|
// 和 pi 自带的 /name 写同一种 session_info 记录,parentId 用文件里最后一条记录的 id
|
|
181
204
|
export function renameSession(path: string, name: string): void {
|
|
205
|
+
name = sanitizeText(name).replace(/\n+/g, " ").trim(); // 和 pi 的 appendSessionInfo 一样,换行不能进 JSONL 的一行
|
|
182
206
|
const raw = readFileSync(path, "utf8");
|
|
183
207
|
const lines = raw.split("\n");
|
|
184
208
|
let parentId: string | null = null;
|
|
@@ -227,18 +251,21 @@ export function trashCommands(path: string, platform: NodeJS.Platform = process.
|
|
|
227
251
|
}
|
|
228
252
|
|
|
229
253
|
// 优先进回收站(能恢复),每个途径都不行再直接删。exec 由调用方传入,方便测试;
|
|
230
|
-
// 命令退出码为 0
|
|
254
|
+
// 命令退出码为 0 但文件还在也当失败,继续试下一个。命令超时被杀但其实已经把文件移走了的,
|
|
255
|
+
// 事后再看一眼文件,别把"已进回收站"报成"永久删除"
|
|
231
256
|
export async function deleteSession(
|
|
232
257
|
path: string,
|
|
233
258
|
exec: (cmd: string, args: string[]) => Promise<{ code: number }>,
|
|
234
259
|
platform: NodeJS.Platform = process.platform,
|
|
235
260
|
): Promise<DeleteResult> {
|
|
261
|
+
const wasThere = existsSync(path);
|
|
236
262
|
for (const [cmd, args] of trashCommands(path, platform)) {
|
|
237
263
|
try {
|
|
238
264
|
const r = await exec(cmd, args);
|
|
239
265
|
if (r.code === 0 && !existsSync(path)) return "trash";
|
|
240
266
|
} catch {
|
|
241
267
|
}
|
|
268
|
+
if (wasThere && !existsSync(path)) return "trash";
|
|
242
269
|
}
|
|
243
270
|
try {
|
|
244
271
|
rmSync(path, { force: true });
|