chore: prepare 1.0.19 release

Change-Id: I86c01c3d6a2c13a193636ac08ba562d54a2e9a20
This commit is contained in:
wujunchen 2026-05-09 10:48:36 +08:00
parent 07faeddab5
commit 04428950de
15 changed files with 151 additions and 211 deletions

View file

@ -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'],

View 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/",
],
},
]);

View file

@ -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",

View file

@ -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",

View file

@ -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);
}

View file

@ -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'), [
{

View file

@ -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':

View file

@ -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 表示关闭,建议 3000060000',
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 3000060000.',
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',

View file

@ -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) ---------- */

View file

@ -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 || '');

View file

@ -52,7 +52,6 @@ export {
getApiBaseUrl,
modelForApi,
normalizeCardCount,
normalizeCliIdleTimeoutMs,
normalizeCliTimeoutMs,
normalizeSettings,
normalizeStreamingTimeoutMs,

View file

@ -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>;

View file

@ -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),

View file

@ -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 = {

View file

@ -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"
}