mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
chore: prepare 1.0.19 release
Change-Id: I86c01c3d6a2c13a193636ac08ba562d54a2e9a20
This commit is contained in:
parent
07faeddab5
commit
04428950de
15 changed files with 151 additions and 211 deletions
|
|
@ -4,12 +4,7 @@ import { builtinModules } from 'module';
|
|||
const production = process.argv[2] === 'production';
|
||||
const watch = process.argv[2] === 'watch';
|
||||
|
||||
const external = [
|
||||
'obsidian',
|
||||
'electron',
|
||||
...builtinModules,
|
||||
...builtinModules.map(m => `node:${m}`),
|
||||
];
|
||||
const external = ['obsidian', 'electron', ...builtinModules, ...builtinModules.map((m) => `node:${m}`)];
|
||||
|
||||
const context = await esbuild.context({
|
||||
entryPoints: ['main.ts'],
|
||||
|
|
|
|||
47
eslint.obsidian-review.config.mjs
Normal file
47
eslint.obsidian-review.config.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import tsparser from "@typescript-eslint/parser";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
|
||||
export default defineConfig([
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["main.ts", "src/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: { project: "./tsconfig.json" },
|
||||
},
|
||||
rules: {
|
||||
// Keep this review check close to Obsidian policy rules while avoiding
|
||||
// project-specific dynamic-type noise that the ReviewBot did not report.
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
|
||||
// The ReviewBot currently reports underscore-only unused bindings as
|
||||
// Optional findings, while the published recommended config ignores them.
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
args: "all",
|
||||
caughtErrors: "all",
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/",
|
||||
"main.js",
|
||||
"main.js.map",
|
||||
"tests/",
|
||||
"scripts/",
|
||||
".e2e/",
|
||||
".orchestration/",
|
||||
".agent/",
|
||||
".codex-tmp/",
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "parallel-reader",
|
||||
"name": "Parallel Reader",
|
||||
"version": "1.0.18",
|
||||
"version": "1.0.19",
|
||||
"minAppVersion": "1.8.7",
|
||||
"description": "AI-powered split-view reading: source note on the left, LLM-generated summary cards on the right with scroll-sync highlighting.",
|
||||
"author": "lancivez",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
"lint": "biome check main.ts src/ tests/ scripts/ .e2e/scripts/",
|
||||
"lint:fix": "biome check --write main.ts src/ tests/ scripts/ .e2e/scripts/",
|
||||
"lint:obsidian": "eslint main.ts src/",
|
||||
"lint:obsidian:review": "eslint --config eslint.obsidian-review.config.mjs --no-config-lookup main.ts src/",
|
||||
"lint:obsidian:review:all": "npm run lint:obsidian:review -- --max-warnings=0",
|
||||
"version": "node scripts/bump-version.mjs",
|
||||
"test": "npm run build && npm run typecheck && node scripts/run-tests.mjs",
|
||||
"test:only": "node scripts/run-tests.mjs",
|
||||
|
|
|
|||
113
src/cli.ts
113
src/cli.ts
|
|
@ -52,16 +52,8 @@ export function resolveCliPath(name: string, override: string): string {
|
|||
return name;
|
||||
}
|
||||
|
||||
export interface RunCliOptions {
|
||||
/** Idle timeout in ms — fail if no stdout/stderr data arrives for this long. 0 disables. */
|
||||
idleTimeoutMs?: number;
|
||||
/** When true, log spawn/settle lifecycle to console for diagnostics. */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export type CliFailureReason =
|
||||
| 'wall-timeout'
|
||||
| 'idle-timeout'
|
||||
| 'exit-nonzero'
|
||||
| 'streams-unavailable'
|
||||
| 'spawn-failure'
|
||||
|
|
@ -72,6 +64,7 @@ export interface CliErrorDetails {
|
|||
cmd: string;
|
||||
pid: number | null;
|
||||
elapsedMs: number;
|
||||
/** ms since the last byte of stdout/stderr — useful to tell hung-mid-call from hung-from-start. */
|
||||
idleMs: number | null;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
|
|
@ -82,7 +75,6 @@ export interface CliErrorDetails {
|
|||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
timeoutMs: number;
|
||||
idleTimeoutMs: number;
|
||||
}
|
||||
|
||||
export class CliProcessError extends Error {
|
||||
|
|
@ -95,8 +87,8 @@ export class CliProcessError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const DIAG_STDERR_TAIL_CHARS = 800;
|
||||
const DIAG_STDOUT_TAIL_CHARS = 200;
|
||||
const DIAG_STDERR_TAIL_CHARS = 1500;
|
||||
const DIAG_STDOUT_TAIL_CHARS = 1500;
|
||||
|
||||
function tail(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
|
|
@ -123,15 +115,11 @@ export function runCli(
|
|||
timeoutMs: number,
|
||||
job?: GenerationJob,
|
||||
spawnImpl: typeof spawn = spawn,
|
||||
options: RunCliOptions = {},
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const idleTimeoutMs = Math.max(0, Math.floor(options.idleTimeoutMs ?? 0));
|
||||
const debug = !!options.debug;
|
||||
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
let settled = false;
|
||||
let timer: number | null = null;
|
||||
let idleTimer: number | null = null;
|
||||
const startedAt = Date.now();
|
||||
let lastActivityAt = startedAt;
|
||||
const clearTimers = () => {
|
||||
|
|
@ -139,42 +127,40 @@ export function runCli(
|
|||
activeWindow.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (idleTimer) {
|
||||
activeWindow.clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
const fail = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimers();
|
||||
if (debug) {
|
||||
console.warn('[parallel-reader] cli failed', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
console.warn('[parallel-reader] cli failed', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: err.message,
|
||||
});
|
||||
reject(err);
|
||||
};
|
||||
const succeed = (value: { stdout: string; stderr: string }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimers();
|
||||
if (debug) {
|
||||
console.debug('[parallel-reader] cli ok', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
stdoutBytes: utf8ByteLength(value.stdout),
|
||||
stderrBytes: utf8ByteLength(value.stderr),
|
||||
});
|
||||
}
|
||||
console.debug('[parallel-reader] cli ok', {
|
||||
cmd,
|
||||
pid: child?.pid,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
stdoutBytes: utf8ByteLength(value.stdout),
|
||||
stderrBytes: utf8ByteLength(value.stderr),
|
||||
});
|
||||
resolve(value);
|
||||
};
|
||||
try {
|
||||
child = spawnImpl(cmd, args, {
|
||||
// Lock cwd to the user's home dir. Inheriting Obsidian's cwd (often the Vault path)
|
||||
// can break Claude CLI's per-project memory writes — iCloud-synced Vault paths
|
||||
// contain characters like `~md~` that produce malformed `~/.claude/projects/...`
|
||||
// slugs and an immediate exit-1 with empty modelUsage. Home dir is stable and
|
||||
// writable; the LLM call itself is independent of cwd.
|
||||
cwd: os.homedir(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -191,7 +177,7 @@ export function runCli(
|
|||
});
|
||||
} catch (e: unknown) {
|
||||
const message = `Failed to start ${cmd}: ${e instanceof Error ? e.message : String(e)}`;
|
||||
if (debug) console.warn('[parallel-reader] cli spawn failed', { cmd, error: message });
|
||||
console.warn('[parallel-reader] cli spawn failed', { cmd, error: message });
|
||||
return reject(
|
||||
new CliProcessError(message, {
|
||||
reason: 'spawn-failure',
|
||||
|
|
@ -206,20 +192,16 @@ export function runCli(
|
|||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.debug('[parallel-reader] cli spawn', {
|
||||
cmd,
|
||||
argCount: args.length,
|
||||
pid: child?.pid,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
});
|
||||
}
|
||||
console.debug('[parallel-reader] cli spawn', {
|
||||
cmd,
|
||||
argCount: args.length,
|
||||
pid: child?.pid,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
|
@ -241,7 +223,6 @@ export function runCli(
|
|||
exitCode: extras.exitCode ?? null,
|
||||
signal: extras.signal ?? null,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
});
|
||||
|
||||
const buildDiagSuffix = (details: CliErrorDetails): string => {
|
||||
|
|
@ -264,22 +245,6 @@ export function runCli(
|
|||
fail(new CliProcessError(`CLI timed out (${timeoutMs}ms)${buildDiagSuffix(details)}`, details));
|
||||
}, timeoutMs);
|
||||
|
||||
const armIdleTimer = () => {
|
||||
if (settled || !idleTimeoutMs) return;
|
||||
if (idleTimer) activeWindow.clearTimeout(idleTimer);
|
||||
idleTimer = activeWindow.setTimeout(() => {
|
||||
const idleMs = Date.now() - lastActivityAt;
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch (e: unknown) {
|
||||
console.warn('[parallel-reader] failed to kill idle-timed-out CLI process', e);
|
||||
}
|
||||
const details = collectDetails('idle-timeout', idleMs, { signal: 'SIGKILL' });
|
||||
fail(new CliProcessError(`CLI idle timeout (${idleTimeoutMs}ms)${buildDiagSuffix(details)}`, details));
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
armIdleTimer();
|
||||
|
||||
if (job) {
|
||||
job.onCancel(() => {
|
||||
try {
|
||||
|
|
@ -303,7 +268,6 @@ export function runCli(
|
|||
const noteActivity = () => {
|
||||
if (settled) return;
|
||||
lastActivityAt = Date.now();
|
||||
armIdleTimer();
|
||||
};
|
||||
|
||||
childStdout.on('data', (d) => {
|
||||
|
|
@ -328,7 +292,14 @@ export function runCli(
|
|||
if (code !== 0) {
|
||||
const idleMs = Date.now() - lastActivityAt;
|
||||
const details = collectDetails('exit-nonzero', idleMs, { exitCode: code, signal });
|
||||
return fail(new CliProcessError(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`, details));
|
||||
// Some CLIs (notably `claude --output-format json`) emit error events to stdout
|
||||
// instead of stderr. Always include both tails so the failure mode is debuggable
|
||||
// even when stderr is empty.
|
||||
let suffix = '';
|
||||
if (details.stderrTail) suffix += `\nstderr:\n${details.stderrTail}`;
|
||||
if (details.stdoutTail) suffix += `\nstdout:\n${details.stdoutTail}`;
|
||||
if (!suffix) suffix = '\n(no output on either stream)';
|
||||
return fail(new CliProcessError(`CLI exited with code ${code} (signal=${signal ?? 'none'})${suffix}`, details));
|
||||
}
|
||||
succeed({ stdout, stderr });
|
||||
});
|
||||
|
|
@ -369,10 +340,7 @@ export async function summarizeViaClaudeCode(
|
|||
];
|
||||
const claudeModel = (settings.model || '').trim();
|
||||
if (claudeModel) args.push('--model', claudeModel);
|
||||
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job, spawnImpl, {
|
||||
idleTimeoutMs: settings.cliIdleTimeoutMs,
|
||||
debug: settings.debugLogging,
|
||||
});
|
||||
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job, spawnImpl);
|
||||
|
||||
// --output-format json produces a JSON array of event objects.
|
||||
// Find the "result" entry and extract its .result text field.
|
||||
|
|
@ -424,9 +392,6 @@ export async function summarizeViaCodex(
|
|||
// (claude-sonnet-4-6), which would break Codex if passed verbatim. Codex
|
||||
// uses its own config.toml profile for model selection.
|
||||
const args = ['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '-'];
|
||||
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job, spawnImpl, {
|
||||
idleTimeoutMs: settings.cliIdleTimeoutMs,
|
||||
debug: settings.debugLogging,
|
||||
});
|
||||
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job, spawnImpl);
|
||||
return parseCardsJson(stdout, settings);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,21 @@ function showActionableNotice(message: string, actions: ActionableNoticeAction[]
|
|||
return notice;
|
||||
}
|
||||
|
||||
class TimeoutDiagnosticsModal extends Modal {
|
||||
function reasonCopyKeys(reason: CliErrorDetails['reason']): { titleKey: string; reasonKey: string } {
|
||||
switch (reason) {
|
||||
case 'wall-timeout':
|
||||
return { titleKey: 'errorModalTimeoutTitle', reasonKey: 'errorModalReasonWall' };
|
||||
case 'exit-nonzero':
|
||||
return { titleKey: 'errorModalExitTitle', reasonKey: 'errorModalReasonExit' };
|
||||
case 'spawn-failure':
|
||||
case 'startup-error':
|
||||
return { titleKey: 'errorModalStartupTitle', reasonKey: 'errorModalReasonStartup' };
|
||||
default:
|
||||
return { titleKey: 'errorModalTimeoutTitle', reasonKey: 'errorModalReasonWall' };
|
||||
}
|
||||
}
|
||||
|
||||
class CliDiagnosticsModal extends Modal {
|
||||
private readonly settings: PluginSettings;
|
||||
private readonly details: CliErrorDetails;
|
||||
private readonly fullMessage: string;
|
||||
|
|
@ -72,10 +86,18 @@ class TimeoutDiagnosticsModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('parallel-reader-error-modal');
|
||||
contentEl.createEl('h2', { text: translate(this.settings, 'errorModalTimeoutTitle') });
|
||||
|
||||
const reasonKey = this.details.reason === 'idle-timeout' ? 'errorModalReasonIdle' : 'errorModalReasonWall';
|
||||
const { titleKey, reasonKey } = reasonCopyKeys(this.details.reason);
|
||||
contentEl.createEl('h2', { text: translate(this.settings, titleKey) });
|
||||
contentEl.createEl('p', { text: translate(this.settings, reasonKey) });
|
||||
if (this.details.exitCode != null) {
|
||||
contentEl.createEl('p', {
|
||||
cls: 'parallel-reader-error-modal-exit',
|
||||
text: translate(this.settings, 'errorModalFieldExit', {
|
||||
code: String(this.details.exitCode),
|
||||
signal: this.details.signal ?? 'none',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const grid = contentEl.createDiv({ cls: 'parallel-reader-error-modal-grid' });
|
||||
const addRow = (label: string, value: string) => {
|
||||
|
|
@ -95,13 +117,18 @@ class TimeoutDiagnosticsModal extends Modal {
|
|||
`stdout ${this.details.stdoutBytes}B / stderr ${this.details.stderrBytes}B`,
|
||||
);
|
||||
|
||||
let hasOutput = false;
|
||||
if (this.details.stderrTail) {
|
||||
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStderrTail') });
|
||||
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stderrTail });
|
||||
} else if (this.details.stdoutTail) {
|
||||
hasOutput = true;
|
||||
}
|
||||
if (this.details.stdoutTail) {
|
||||
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStdoutTail') });
|
||||
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stdoutTail });
|
||||
} else {
|
||||
hasOutput = true;
|
||||
}
|
||||
if (!hasOutput) {
|
||||
contentEl.createEl('p', {
|
||||
cls: 'parallel-reader-error-modal-empty',
|
||||
text: translate(this.settings, 'errorModalNoOutput'),
|
||||
|
|
@ -139,13 +166,17 @@ export function showGenerationError(
|
|||
): void {
|
||||
const tr = (k: string, vars?: Record<string, string | number>) => translate(ctx.settings, k, vars);
|
||||
|
||||
// Any structured CLI error opens the diagnostics modal — even when classify falls
|
||||
// through to 'unknown' (e.g. exit-nonzero with empty stderr) we want the stdout tail
|
||||
// surfaced so the failure is debuggable.
|
||||
const cliDetails = (error as { details?: CliErrorDetails }).details;
|
||||
const isStructuredCliError = !!cliDetails && typeof cliDetails.reason === 'string';
|
||||
if (isStructuredCliError) {
|
||||
new CliDiagnosticsModal(ctx.app, ctx.settings, cliDetails, message, ctx.openSettings).open();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'timeout') {
|
||||
const details = (error as { details?: CliErrorDetails }).details;
|
||||
if (details) {
|
||||
// Open a modal so users can read the stderr tail at leisure and copy it.
|
||||
new TimeoutDiagnosticsModal(ctx.app, ctx.settings, details, message, ctx.openSettings).open();
|
||||
return;
|
||||
}
|
||||
// API streaming timeout: no structured details, show actionable notice with raw message tail.
|
||||
showActionableNotice(tr('errorNoticeTimeout'), [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -202,7 +202,6 @@ export function classifyGenerationError(error: unknown): ErrorKind {
|
|||
if (details && typeof details.reason === 'string') {
|
||||
switch (details.reason) {
|
||||
case 'wall-timeout':
|
||||
case 'idle-timeout':
|
||||
return 'timeout';
|
||||
case 'spawn-failure':
|
||||
case 'startup-error':
|
||||
|
|
|
|||
|
|
@ -93,10 +93,15 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
errorActionCopyDetails: '复制详情',
|
||||
errorActionCopyRaw: '复制原始输出',
|
||||
errorModalTimeoutTitle: 'CLI 超时诊断',
|
||||
errorModalExitTitle: 'CLI 异常退出诊断',
|
||||
errorModalStartupTitle: 'CLI 启动失败诊断',
|
||||
errorModalReasonWall:
|
||||
'CLI 在墙钟超时之前没有完成。建议先看 stderr 是否有线索,再考虑增大 CLI 超时或换用 API 后端。',
|
||||
errorModalReasonIdle:
|
||||
'CLI 在静默超时窗口内没有任何输出,已被终止。常见原因:网络挂死、模型在思考但 CLI 不流式输出。',
|
||||
errorModalReasonExit:
|
||||
'CLI 进程返回了非零退出码。下方是 stdout 和 stderr 的末尾(已脱敏)—— claude CLI 在 --output-format json 模式下会把错误事件写到 stdout,请优先看那里。',
|
||||
errorModalReasonStartup:
|
||||
'CLI 进程未能启动。常见原因:路径错误、二进制不可执行、Obsidian GUI 没继承 shell PATH。请在设置里填绝对路径再试。',
|
||||
errorModalFieldExit: '退出码 {code}(信号 {signal})',
|
||||
errorModalFieldCmd: '命令',
|
||||
errorModalFieldPid: '进程 PID',
|
||||
errorModalFieldElapsed: '已耗时',
|
||||
|
|
@ -139,10 +144,6 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
settingCliPathPlaceholder: '例:/Users/you/bin/codex',
|
||||
settingCliTimeoutName: 'CLI 超时 (ms)',
|
||||
settingCliTimeoutDesc: 'CLI 调用的最大等待时间(毫秒),最小 1000,默认 120000',
|
||||
settingCliIdleTimeoutName: 'CLI 静默超时 (ms)',
|
||||
settingCliIdleTimeoutDesc: '若 CLI 持续 X 毫秒没有任何输出则视为卡死并终止;0 表示关闭,建议 30000–60000',
|
||||
settingDebugLoggingName: '调试日志',
|
||||
settingDebugLoggingDesc: '在浏览器控制台打印 CLI spawn / 完成 / 失败的诊断信息(含 PID、耗时、字节数)',
|
||||
sectionQuickSetup: '快速配置',
|
||||
sectionReadingOutput: '阅读输出',
|
||||
sectionAdvancedConnection: '高级连接设置',
|
||||
|
|
@ -306,10 +307,15 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
errorActionCopyDetails: 'Copy details',
|
||||
errorActionCopyRaw: 'Copy raw output',
|
||||
errorModalTimeoutTitle: 'CLI timeout diagnostics',
|
||||
errorModalExitTitle: 'CLI non-zero exit',
|
||||
errorModalStartupTitle: 'CLI startup failure',
|
||||
errorModalReasonWall:
|
||||
'The CLI did not finish before the wall-clock timeout. Inspect stderr below, then consider raising the CLI timeout or switching to the API backend.',
|
||||
errorModalReasonIdle:
|
||||
'The CLI produced no output during the idle-timeout window and was terminated. Common causes: network hang, or model thinking with a non-streaming CLI.',
|
||||
errorModalReasonExit:
|
||||
'The CLI process exited with a non-zero status. stdout and stderr tails are below (redacted). Note that `claude --output-format json` writes error events to stdout, so check there first.',
|
||||
errorModalReasonStartup:
|
||||
'The CLI process failed to launch. Common causes: wrong path, non-executable binary, or Obsidian GUI not inheriting shell PATH. Try setting an absolute CLI path in Settings.',
|
||||
errorModalFieldExit: 'exit {code} (signal {signal})',
|
||||
errorModalFieldCmd: 'Command',
|
||||
errorModalFieldPid: 'PID',
|
||||
errorModalFieldElapsed: 'Elapsed',
|
||||
|
|
@ -355,12 +361,6 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
settingCliPathPlaceholder: 'Example: /Users/you/bin/codex',
|
||||
settingCliTimeoutName: 'CLI timeout (ms)',
|
||||
settingCliTimeoutDesc: 'Max wait time for CLI calls (ms). Minimum 1000, default 120000.',
|
||||
settingCliIdleTimeoutName: 'CLI idle timeout (ms)',
|
||||
settingCliIdleTimeoutDesc:
|
||||
'Kill the CLI if it produces no output for this long. 0 disables. Recommended 30000–60000.',
|
||||
settingDebugLoggingName: 'Debug logging',
|
||||
settingDebugLoggingDesc:
|
||||
'Log CLI spawn / completion / failure to the browser console (PID, elapsed time, byte counts).',
|
||||
sectionQuickSetup: 'Quick setup',
|
||||
sectionReadingOutput: 'Reading output',
|
||||
sectionAdvancedConnection: 'Advanced connection',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
getApiFormat,
|
||||
getApiPreset,
|
||||
normalizeCardCount,
|
||||
normalizeCliIdleTimeoutMs,
|
||||
normalizeCliTimeoutMs,
|
||||
normalizeStreamingTimeoutMs,
|
||||
PROMPT_LANGUAGES,
|
||||
|
|
@ -425,13 +424,7 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
|
|||
private renderAdvancedConnectionCli(containerEl: HTMLElement) {
|
||||
const settings = this.plugin.settings;
|
||||
const userChangedTimeout = !!(settings.cliTimeoutMs && settings.cliTimeoutMs !== DEFAULT_SETTINGS.cliTimeoutMs);
|
||||
const userChangedIdleTimeout = !!settings.cliIdleTimeoutMs;
|
||||
const userEnabledDebug = !!settings.debugLogging;
|
||||
const details = this.openCollapsibleSection(
|
||||
containerEl,
|
||||
'sectionAdvancedConnection',
|
||||
userChangedTimeout || userChangedIdleTimeout || userEnabledDebug,
|
||||
);
|
||||
const details = this.openCollapsibleSection(containerEl, 'sectionAdvancedConnection', userChangedTimeout);
|
||||
new Setting(details)
|
||||
.setName(this.tr('settingCliTimeoutName'))
|
||||
.setDesc(this.tr('settingCliTimeoutDesc'))
|
||||
|
|
@ -444,29 +437,6 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
|
|||
this.plugin.saveSettingsDebounced();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(details)
|
||||
.setName(this.tr('settingCliIdleTimeoutName'))
|
||||
.setDesc(this.tr('settingCliIdleTimeoutDesc'))
|
||||
.addText((t) =>
|
||||
t
|
||||
.setPlaceholder('0')
|
||||
.setValue(String(this.plugin.settings.cliIdleTimeoutMs ?? 0))
|
||||
.onChange((v) => {
|
||||
this.plugin.settings.cliIdleTimeoutMs = normalizeCliIdleTimeoutMs(v);
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(details)
|
||||
.setName(this.tr('settingDebugLoggingName'))
|
||||
.setDesc(this.tr('settingDebugLoggingDesc'))
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(!!this.plugin.settings.debugLogging).onChange(async (v) => {
|
||||
this.plugin.settings.debugLogging = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 4. Advanced prompt (collapsed by default) ---------- */
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export const CACHE_SCHEMA_VERSION = 2;
|
|||
export const DEFAULT_MAX_CACHE_ENTRIES = 100;
|
||||
export const MIN_STREAMING_TIMEOUT_MS = 1000;
|
||||
export const MIN_CLI_TIMEOUT_MS = 1000;
|
||||
export const MIN_CLI_IDLE_TIMEOUT_MS = 1000;
|
||||
export const PROMPT_LANGUAGES = {
|
||||
zh: '中文',
|
||||
en: 'English',
|
||||
|
|
@ -46,10 +45,8 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
model: 'claude-sonnet-4-6',
|
||||
exportFolder: 'Reading/Articles',
|
||||
cliTimeoutMs: 120000,
|
||||
cliIdleTimeoutMs: 0,
|
||||
streaming: true,
|
||||
streamingTimeoutMs: 120000,
|
||||
debugLogging: false,
|
||||
};
|
||||
|
||||
export const API_FORMATS: Record<string, ApiFormat> = {
|
||||
|
|
@ -218,9 +215,7 @@ export function normalizeSettings(settings: Readonly<PluginSettings>): PluginSet
|
|||
if (out.maxCards < out.minCards) out.maxCards = out.minCards;
|
||||
out.streamingTimeoutMs = normalizeStreamingTimeoutMs(out.streamingTimeoutMs);
|
||||
out.cliTimeoutMs = normalizeCliTimeoutMs(out.cliTimeoutMs);
|
||||
out.cliIdleTimeoutMs = normalizeCliIdleTimeoutMs(out.cliIdleTimeoutMs);
|
||||
if (typeof out.customSystemPrompt !== 'string') out.customSystemPrompt = '';
|
||||
out.debugLogging = !!out.debugLogging;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -248,14 +243,6 @@ export function normalizeCliTimeoutMs(value: unknown): number {
|
|||
return n;
|
||||
}
|
||||
|
||||
export function normalizeCliIdleTimeoutMs(value: unknown): number {
|
||||
const n = Math.floor(Number(value));
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
if (n === 0) return 0;
|
||||
if (n < MIN_CLI_IDLE_TIMEOUT_MS) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function cacheEntryTime(entry: CacheEntry): number {
|
||||
const value = entry && (entry.lastAccessedAt || entry.generatedAt || entry.updatedAt);
|
||||
const timestamp = Date.parse(value || '');
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export {
|
|||
getApiBaseUrl,
|
||||
modelForApi,
|
||||
normalizeCardCount,
|
||||
normalizeCliIdleTimeoutMs,
|
||||
normalizeCliTimeoutMs,
|
||||
normalizeSettings,
|
||||
normalizeStreamingTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -65,10 +65,8 @@ export interface PluginSettings {
|
|||
model: string;
|
||||
exportFolder: string;
|
||||
cliTimeoutMs: number;
|
||||
cliIdleTimeoutMs: number;
|
||||
streaming: boolean;
|
||||
streamingTimeoutMs: number;
|
||||
debugLogging: boolean;
|
||||
}
|
||||
|
||||
/* ---------- Provider types ---------- */
|
||||
|
|
@ -173,7 +171,7 @@ export interface PluginHost {
|
|||
force: boolean,
|
||||
options?: RunForFileOptions,
|
||||
preloadedContent?: string,
|
||||
): Promise<RunForFileResult | void>;
|
||||
): Promise<RunForFileResult>;
|
||||
copyCurrentViewMarkdown(): Promise<void>;
|
||||
scrollEditorToLine(line: number, file: TFile | null): Promise<void>;
|
||||
cacheReplaceCards(filePath: string, cards: ResolvedCard[]): Promise<boolean>;
|
||||
|
|
|
|||
|
|
@ -79,44 +79,6 @@ async function testRunCliEdgeCases() {
|
|||
);
|
||||
assert.strictEqual(timeoutChild.killedWith, 'SIGKILL', 'runCli kills timed out processes');
|
||||
|
||||
// Idle timeout fires when no data within idle window even if wall clock is generous.
|
||||
const idleChild = createFakeChild();
|
||||
idleChild.pid = 5252;
|
||||
await assert.rejects(
|
||||
() => t.runCli('fake', [], '', 60000, undefined, () => idleChild, { idleTimeoutMs: 15 }),
|
||||
(err) => {
|
||||
assert.match(err.message, /CLI idle timeout \(15ms\)/, 'idle timeout error message');
|
||||
assert.match(err.message, /pid=5252/, 'idle timeout carries pid');
|
||||
assert.ok(err instanceof t.CliProcessError, 'idle-timeout throws CliProcessError');
|
||||
assert.strictEqual(err.details.reason, 'idle-timeout', 'idle-timeout reason set');
|
||||
assert.strictEqual(err.details.idleTimeoutMs, 15, 'details.idleTimeoutMs reflects idle window');
|
||||
return true;
|
||||
},
|
||||
'runCli idle timeout fires when no output',
|
||||
);
|
||||
assert.strictEqual(idleChild.killedWith, 'SIGKILL', 'idle timeout kills the process');
|
||||
|
||||
// Idle timer must reset on data — child keeps emitting and only wall clock should fire.
|
||||
const heartbeatChild = createFakeChild();
|
||||
let cancelHeartbeat = false;
|
||||
const heartbeatPromise = t.runCli('fake', [], '', 80, undefined, () => heartbeatChild, { idleTimeoutMs: 30 });
|
||||
const beat = () => {
|
||||
if (cancelHeartbeat) return;
|
||||
heartbeatChild.stdout.emit('data', Buffer.from('.'));
|
||||
setTimeout(beat, 10);
|
||||
};
|
||||
beat();
|
||||
await assert.rejects(
|
||||
() => heartbeatPromise,
|
||||
(err) => {
|
||||
cancelHeartbeat = true;
|
||||
assert.match(err.message, /CLI timed out \(80ms\)/, 'wall-clock fires when idle resets keep happening');
|
||||
assert.doesNotMatch(err.message, /CLI idle timeout/, 'idle timeout must not fire while data flows');
|
||||
return true;
|
||||
},
|
||||
'runCli idle timer resets on incoming data',
|
||||
);
|
||||
|
||||
const cancelChild = createFakeChild();
|
||||
let cancelHandler = null;
|
||||
const cancelPromise = t.runCli(
|
||||
|
|
@ -483,7 +445,6 @@ const wallTimeout = new t.CliProcessError('whatever message text', {
|
|||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(t.classifyGenerationError(wallTimeout), 'timeout', 'wall-timeout details → timeout kind');
|
||||
const spawnFailure = new t.CliProcessError('Failed to start claude', {
|
||||
|
|
@ -499,7 +460,6 @@ const spawnFailure = new t.CliProcessError('Failed to start claude', {
|
|||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(t.classifyGenerationError(spawnFailure), 'config', 'spawn-failure details → config kind');
|
||||
// exit-nonzero falls through so stderr-derived auth/rate-limit messages can still classify.
|
||||
|
|
@ -516,7 +476,6 @@ const exitWithAuthStderr = new t.CliProcessError('CLI exited with code 1\nstderr
|
|||
exitCode: 1,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(
|
||||
t.classifyGenerationError(exitWithAuthStderr),
|
||||
|
|
|
|||
|
|
@ -111,19 +111,6 @@ assert.strictEqual(
|
|||
'normalizeSettings protects invalid cli timeout values',
|
||||
);
|
||||
|
||||
// normalizeCliIdleTimeoutMs: 0 means disabled, anything below MIN coerces to 0, valid passes through
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(0), 0, 'idle timeout 0 stays disabled');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(45000), 45000, 'idle timeout valid value passes through');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs('30000'), 30000, 'idle timeout numeric string accepted');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(500), 0, 'idle timeout below 1000ms disables (treats as off)');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs(-1), 0, 'idle timeout negative coerces to 0');
|
||||
assert.strictEqual(t.normalizeCliIdleTimeoutMs('bad'), 0, 'idle timeout non-numeric coerces to 0');
|
||||
assert.strictEqual(
|
||||
t.normalizeSettings({ ...baseSettings, cliIdleTimeoutMs: 250 }).cliIdleTimeoutMs,
|
||||
0,
|
||||
'normalizeSettings disables sub-minimum idle timeout',
|
||||
);
|
||||
|
||||
// applyApiProviderPreset clears credentials but keeps preset baseUrl (security: prevent cross-provider key leak)
|
||||
{
|
||||
const previous = {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@
|
|||
"1.0.15": "1.7.2",
|
||||
"1.0.16": "1.7.2",
|
||||
"1.0.17": "1.7.2",
|
||||
"1.0.18": "1.8.7"
|
||||
"1.0.18": "1.8.7",
|
||||
"1.0.19": "1.8.7"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue