blamouche_obsidian-any-ai-code/main.ts
blamouche 073efef75c Run multiple runtimes in parallel via session tabs
Refactor the sidebar panel from a single-process view into a tabbed
multi-session view: each tab is an independent CliSession with its own
PTY process and xterm terminal. A New session / + control opens a session
from any configured runtime; multiple sessions of the same runtime are
allowed and labels are disambiguated. Stop/Restart/Clear and the @file/
@folder buttons act on the active tab; a single ResizeObserver refits only
the visible terminal and re-fits on tab activation.

Automations now spawn their own session tab for the declared runtime (or
the default when none is declared), await CLI readiness, then send the
prompt — replacing the old skip-if-not-running/runtime-mismatch logic.
Add settings autoCloseAutomationSessions and maxConcurrentSessions; the
Automations modal "Run now" button is always enabled.

Extract pure helpers to session-utils.ts (resolveRuntimeForAutomation,
runtimeMatches, nextSessionLabel, canOpenSession) with unit tests. Update
README and the generated example automation. Bump to 0.2.9 (manifest,
versions.json, package.json, prompt-hub version).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:12:26 +02:00

1685 lines
54 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, FileSystemAdapter, ItemView, Menu, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder, WorkspaceLeaf, parseYaml, setIcon } from "obsidian";
import { spawn, type ChildProcess } from "child_process";
import { randomUUID } from "node:crypto";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import {
defaultGenerateRuntimeId,
detectNodeExecutable,
formatActiveFileMention,
formatActiveFolderMention,
isCodexLikeCommand,
mergePathEntries as mergePathEntriesForPlatform,
migrateRuntimeSettings,
resolvePluginDir as resolvePluginDirWithVault,
type CliRuntimeConfig
} from "./runtime-utils";
import {
canOpenSession,
nextSessionLabel,
resolveRuntimeForAutomation
} from "./session-utils";
import {
buildPromptPreview,
computeNextRun,
parseAutomationFile,
pushHistory,
type AutomationParseError,
type AutomationRunRecord,
type ParsedAutomation
} from "./automation";
import { AutomationsModal } from "./automations-modal";
// Injected at build time by `esbuild.config.mjs` (see `define`). These hold the
// full source of `pty-proxy.js` and `pty-bridge.py` so the plugin can recreate
// them in the plugin folder when Obsidian's community-store auto-install only
// fetched `main.js`, `manifest.json`, and `styles.css`.
declare const PTY_PROXY_SOURCE: string;
declare const PTY_BRIDGE_SOURCE: string;
const VIEW_TYPE_CLAUDE = "claude-cli-view";
const CODEX_DEFAULT_COMMAND =
"codex --no-alt-screen -c check_for_update_on_startup=false -c hide_full_access_warning=true -c hide_world_writable_warning=true -c hide_rate_limit_model_nudge=true";
const DEFAULT_RUNTIMES: CliRuntimeConfig[] = [
{ id: "claude", name: "Claude", command: "claude" },
{ id: "codex", name: "Codex", command: CODEX_DEFAULT_COMMAND }
];
interface ClaudeCliPluginSettings {
runtimes: CliRuntimeConfig[];
selectedRuntimeId: string;
autoRestartOnRuntimeSwitch: boolean;
autoStart: boolean;
nodeExecutable: string;
automationsFolder: string;
automationsLastRun: Record<string, number>;
automationsHistory: AutomationRunRecord[];
automationsHistoryLimit: number;
autoCloseAutomationSessions: boolean;
maxConcurrentSessions: number;
}
const DEFAULT_SETTINGS: ClaudeCliPluginSettings = {
runtimes: DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime })),
selectedRuntimeId: "claude",
autoRestartOnRuntimeSwitch: true,
autoStart: true,
nodeExecutable: "auto",
automationsFolder: "",
automationsLastRun: {},
automationsHistory: [],
automationsHistoryLimit: 200,
autoCloseAutomationSessions: true,
maxConcurrentSessions: 8
};
const AUTOMATION_TICK_MS = 30_000;
const EXAMPLE_AUTOMATION_CONTENT = `---
# ============================================================
# Any AI CLI — automation file. Every available option is shown
# below. The text AFTER the closing "---" is the prompt that gets
# sent to the running CLI.
# ============================================================
# name (string, optional)
# Display name shown in the Automations modal. Defaults to the
# filename (without ".md") when omitted.
name: Hello world
# enabled (true | false, optional, default true)
# When false, the scheduler never auto-fires this automation. It
# still appears in the modal (greyed out) and can be triggered by
# hand with the "Run now" button.
enabled: true
# ----- Schedule: set EXACTLY ONE of "interval" or "cron" -----
# interval (integer minutes, >= 1)
# Fire every N minutes. The first run happens on the next tick
# after the plugin loads; subsequent runs are N minutes apart.
interval: 60
# cron (string, standard 5-field expression)
# Alternative to "interval". To use it: comment out "interval"
# above, then uncomment ONE line below. Fields are:
# minute hour day-of-month month day-of-week
# cron: "*/30 * * * *" # every 30 minutes
# cron: "0 9 * * *" # every day at 09:00
# cron: "0 9 * * 1-5" # weekdays at 09:00
# cron: "0 */2 * * *" # every 2 hours, on the hour
# cron: "0 8 1 * *" # 08:00 on the 1st of each month
# runtime (string, optional)
# Which runtime to spawn for this automation, matched by its id OR its
# display name (case-insensitive). Each run opens its own session tab.
# Remove the line to use the default runtime (set in plugin settings).
# Runs naming an unconfigured runtime are skipped and logged in History.
runtime: Claude
# appendNewline (true | false, optional, default true)
# Append an Enter keystroke after the prompt so the CLI executes it.
# Set false only if you want the text inserted without submitting.
appendNewline: true
---
Say hello and tell me the current date and time.
`;
function cloneDefaultRuntimes(): CliRuntimeConfig[] {
return DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime }));
}
interface ProcessAdapter {
write(data: string): void;
resize?(cols: number, rows: number): void;
kill(signal?: string): void;
onData(callback: (data: string) => void): void;
onExit(callback: (exitCode: number, signal: string) => void): void;
}
type SessionOrigin = "manual" | "automation";
/** How long to wait for the spawned CLI to emit output before considering a
* freshly started session "ready" to receive an automation prompt. */
const SESSION_READY_FALLBACK_MS = 1200;
function createSessionTerminal(): { terminal: Terminal; fitAddon: FitAddon } {
const terminal = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace",
fontSize: 13,
scrollback: 3000,
theme: {
background: "#0f1115",
foreground: "#e6e6e6"
}
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
return { terminal, fitAddon };
}
/**
* One independent CLI session: its own PTY process and its own xterm terminal
* rendered into a dedicated host element. The view owns a list of these and
* shows one at a time via tabs.
*/
class CliSession {
readonly id: string;
runtimeId: string;
label: string;
origin: SessionOrigin;
readonly terminal: Terminal;
readonly fitAddon: FitAddon;
readonly hostEl: HTMLDivElement;
processHandle: ProcessAdapter | null = null;
status = "Idle";
pendingRestart = false;
private ready = false;
whenReady!: Promise<void>;
private resolveReady: (() => void) | null = null;
private readyTimer: number | null = null;
constructor(params: {
id: string;
runtimeId: string;
label: string;
origin: SessionOrigin;
terminal: Terminal;
fitAddon: FitAddon;
hostEl: HTMLDivElement;
}) {
this.id = params.id;
this.runtimeId = params.runtimeId;
this.label = params.label;
this.origin = params.origin;
this.terminal = params.terminal;
this.fitAddon = params.fitAddon;
this.hostEl = params.hostEl;
this.resetReady();
}
/** Arm a fresh readiness promise for a (re)spawn. */
resetReady(): void {
this.ready = false;
if (this.readyTimer !== null) {
activeWindow.clearTimeout(this.readyTimer);
this.readyTimer = null;
}
this.whenReady = new Promise<void>((resolve) => {
this.resolveReady = resolve;
});
}
markReady(): void {
if (this.ready) {
return;
}
this.ready = true;
if (this.readyTimer !== null) {
activeWindow.clearTimeout(this.readyTimer);
this.readyTimer = null;
}
this.resolveReady?.();
this.resolveReady = null;
}
armReadyFallback(delayMs: number): void {
if (this.readyTimer !== null) {
activeWindow.clearTimeout(this.readyTimer);
}
this.readyTimer = activeWindow.setTimeout(() => this.markReady(), delayMs);
}
isRunning(): boolean {
return this.processHandle !== null;
}
writeSystemLine(message: string): void {
this.terminal.write("\r");
this.terminal.writeln(message);
}
sendPrompt(text: string, submitWithEnter: boolean): void {
if (!this.processHandle) {
throw new Error("CLI process is not running");
}
this.processHandle.write(text);
if (submitWithEnter) {
// Send Enter as a separate write after a short delay. Some TUIs (notably
// Codex) use bracketed-paste-style heuristics: when text+`\r` arrives in
// a single write they treat the `\r` as a literal newline inside the
// input field, not as the submit key. Splitting the writes lets the
// paste-detection window close, so the `\r` is read as a real Enter.
const handle = this.processHandle;
activeWindow.setTimeout(() => {
try {
handle.write("\r");
} catch {
/* process may have exited between writes */
}
}, 120);
}
this.writeSystemLine(`[Automation prompt injected]`);
}
dispose(): void {
if (this.readyTimer !== null) {
activeWindow.clearTimeout(this.readyTimer);
this.readyTimer = null;
}
try {
this.terminal.dispose();
} catch {
/* ignore disposal errors */
}
this.hostEl.remove();
}
}
class ClaudeCliView extends ItemView {
private plugin: ClaudeCliPlugin;
private sessions: CliSession[] = [];
private activeSessionId: string | null = null;
private tabBarEl: HTMLDivElement | null = null;
private terminalsHostEl: HTMLDivElement | null = null;
private emptyHintEl: HTMLDivElement | null = null;
private statusEl: HTMLDivElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private stopBtn: HTMLButtonElement | null = null;
private restartBtn: HTMLButtonElement | null = null;
private clearBtn: HTMLButtonElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: ClaudeCliPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return VIEW_TYPE_CLAUDE;
}
getDisplayText(): string {
return "Any AI CLI";
}
getIcon(): string {
return "bot";
}
onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass("claude-cli-view");
this.tabBarEl = this.contentEl.createDiv({ cls: "claude-cli-tabbar" });
const toolbarEl = this.contentEl.createDiv({ cls: "claude-cli-toolbar" });
const primaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" });
const newBtn = primaryRowEl.createEl("button", { text: "New session" });
const stopBtn = primaryRowEl.createEl("button", { text: "Stop" });
const restartBtn = primaryRowEl.createEl("button", { text: "Restart" });
const clearBtn = primaryRowEl.createEl("button", { text: "Clear" });
this.setButtonIcon(newBtn, "plus", "New session");
this.setButtonIcon(stopBtn, "square", "Stop");
this.setButtonIcon(restartBtn, "refresh-cw", "Restart");
this.setButtonIcon(clearBtn, "eraser", "Clear");
newBtn.addClass("claude-cli-btn-primary");
stopBtn.addClass("claude-cli-btn-danger");
this.stopBtn = stopBtn;
this.restartBtn = restartBtn;
this.clearBtn = clearBtn;
const secondaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" });
const mentionBtn = secondaryRowEl.createEl("button", { text: "@active file" });
const folderMentionBtn = secondaryRowEl.createEl("button", { text: "@active folder" });
const automationsBtn = secondaryRowEl.createEl("button", { text: "Automations" });
this.setButtonIcon(mentionBtn, "file-plus", "@active file");
this.setButtonIcon(folderMentionBtn, "folder-plus", "@active folder");
this.setButtonIcon(automationsBtn, "calendar-clock", "Automations");
mentionBtn.addClass("claude-cli-btn-info");
folderMentionBtn.addClass("claude-cli-btn-info");
automationsBtn.addClass("claude-cli-btn-info");
this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" });
newBtn.addEventListener("click", (evt) => this.openNewSessionMenu(evt));
stopBtn.addEventListener("click", () => this.stopActiveSession());
restartBtn.addEventListener("click", () => this.restartActiveSession());
clearBtn.addEventListener("click", () => this.getActiveSession()?.terminal.clear());
mentionBtn.addEventListener("click", () => this.insertActiveFileMention());
folderMentionBtn.addEventListener("click", () => this.insertActiveFolderMention());
automationsBtn.addEventListener("click", () => {
new AutomationsModal(this.app, this.plugin).open();
});
this.terminalsHostEl = this.contentEl.createDiv({ cls: "claude-cli-terminals" });
this.emptyHintEl = this.terminalsHostEl.createDiv({ cls: "claude-cli-empty-hint" });
this.emptyHintEl.setText("No session running. Use the + button to launch a runtime.");
this.resizeObserver = new ResizeObserver(() => {
const session = this.getActiveSession();
if (!session) {
return;
}
session.fitAddon.fit();
if (session.processHandle) {
session.processHandle.resize?.(
Math.max(20, session.terminal.cols || 120),
Math.max(10, session.terminal.rows || 30)
);
}
});
this.resizeObserver.observe(this.contentEl);
this.renderTabBar();
this.updateEmptyState();
this.updateToolbarState();
if (this.plugin.settings.autoStart) {
const runtime = this.getSelectedRuntime();
if (runtime) {
this.startSession({ runtimeId: runtime.id, origin: "manual" });
} else {
this.setStatus("No runtime configured. Add one in plugin settings.");
}
} else {
this.setStatus("Idle");
}
return Promise.resolve();
}
onClose(): Promise<void> {
for (const session of this.sessions) {
try {
session.processHandle?.kill("SIGTERM");
} catch {
/* ignore */
}
session.dispose();
}
this.sessions = [];
this.activeSessionId = null;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.statusEl = null;
this.tabBarEl = null;
this.terminalsHostEl = null;
this.emptyHintEl = null;
return Promise.resolve();
}
getActiveSession(): CliSession | null {
if (!this.activeSessionId) {
return null;
}
return this.findSession(this.activeSessionId);
}
findSession(id: string): CliSession | null {
return this.sessions.find((s) => s.id === id) ?? null;
}
isProcessRunning(): boolean {
return this.sessions.some((s) => s.isRunning());
}
startSession(params: { runtimeId: string; origin: SessionOrigin }): CliSession | null {
if (!this.terminalsHostEl) {
return null;
}
const runtime = this.plugin.settings.runtimes.find((r) => r.id === params.runtimeId);
if (!runtime) {
const message = "Runtime not configured. Add one in plugin settings.";
this.setStatus(message);
new Notice(message, 6000);
return null;
}
if (!canOpenSession(this.sessions.length, this.plugin.settings.maxConcurrentSessions)) {
const message = `Session limit reached (${this.plugin.settings.maxConcurrentSessions}). Close a tab first.`;
this.setStatus(message);
new Notice(message, 6000);
return null;
}
const command = (runtime.command || "").trim();
if (!command) {
const message = `Runtime "${runtime.name || "(Unnamed)"}" has an empty command. Set one in plugin settings.`;
this.setStatus(message);
new Notice(message, 6000);
return null;
}
const hostEl = this.terminalsHostEl.createDiv({ cls: "claude-cli-terminal" });
const { terminal, fitAddon } = createSessionTerminal();
terminal.open(hostEl);
fitAddon.fit();
const label = nextSessionLabel(
this.sessions.map((s) => s.label),
runtime.name || "(Unnamed)"
);
const session = new CliSession({
id: randomUUID(),
runtimeId: runtime.id,
label,
origin: params.origin,
terminal,
fitAddon,
hostEl
});
this.sessions.push(session);
terminal.onData((data) => session.processHandle?.write(data));
session.writeSystemLine(`CLI session ready (${label}).`);
// Activate the new tab before spawning so the terminal is visible and sized.
this.setActiveSession(session.id);
this.spawnIntoSession(session, runtime);
this.renderTabBar();
this.updateToolbarState();
return session;
}
closeSession(id: string): void {
const index = this.sessions.findIndex((s) => s.id === id);
if (index < 0) {
return;
}
const session = this.sessions[index];
try {
session.processHandle?.kill("SIGTERM");
} catch {
/* ignore */
}
session.processHandle = null;
session.markReady();
session.dispose();
this.sessions.splice(index, 1);
if (this.activeSessionId === id) {
this.activeSessionId = null;
const next = this.sessions[index] ?? this.sessions[index - 1] ?? null;
if (next) {
this.setActiveSession(next.id);
}
}
this.renderTabBar();
this.updateEmptyState();
this.updateToolbarState();
if (!this.getActiveSession()) {
this.setStatus("Idle");
}
}
setActiveSession(id: string): void {
const session = this.findSession(id);
if (!session) {
return;
}
this.activeSessionId = id;
for (const s of this.sessions) {
s.hostEl.toggleClass("is-hidden", s.id !== id);
}
this.updateEmptyState();
this.renderTabBar();
// A hidden terminal cannot lay out; refit + resize now that it is visible,
// otherwise xterm output degrades to letter-per-line.
session.fitAddon.fit();
session.processHandle?.resize?.(
Math.max(20, session.terminal.cols || 120),
Math.max(10, session.terminal.rows || 30)
);
session.terminal.focus();
this.setStatus(session.status);
this.updateToolbarState();
}
sendAutomationPromptTo(sessionId: string, text: string, submitWithEnter: boolean): void {
const session = this.findSession(sessionId);
if (!session) {
throw new Error("Session no longer exists");
}
session.sendPrompt(text, submitWithEnter);
}
// Called by the plugin when the configured runtimes change in settings.
refreshRuntimeSelect(): void {
this.renderTabBar();
}
private spawnIntoSession(session: CliSession, runtime: CliRuntimeConfig): boolean {
const label = session.label;
const command = (runtime.command || "").trim();
if (!command) {
const message = `Runtime "${runtime.name || "(Unnamed)"}" has an empty command. Set one in plugin settings.`;
session.writeSystemLine(`[${message}]`);
session.status = message;
if (this.activeSessionId === session.id) {
this.setStatus(message);
}
session.markReady();
return false;
}
const codexLike = isCodexLikeCommand(command);
if (codexLike) {
session.terminal.reset();
session.fitAddon.fit();
}
session.resetReady();
session.writeSystemLine(`[Starting: ${command}]`);
session.status = `Starting in vault folder (${process.platform})...`;
if (this.activeSessionId === session.id) {
this.setStatus(session.status);
}
try {
const vaultPath = getVaultBasePath(this.app);
if (!vaultPath) {
const message = `Unable to resolve current vault path. ${label} was not started.`;
session.writeSystemLine(`[${message}]`);
session.status = message;
if (this.activeSessionId === session.id) {
this.setStatus(message);
}
new Notice(message, 6000);
session.markReady();
return false;
}
if (!fs.existsSync(vaultPath)) {
const message = `Vault path does not exist: ${vaultPath}`;
session.writeSystemLine(`[${message}]`);
session.status = message;
if (this.activeSessionId === session.id) {
this.setStatus(message);
}
new Notice(message, 6000);
session.markReady();
return false;
}
const shellEnv = getShellEnv();
if (codexLike) {
// Keep Codex output readable in embedded terminals.
shellEnv.NO_COLOR = "1";
shellEnv.CLICOLOR = "0";
shellEnv.FORCE_COLOR = "0";
}
const helperHandle = spawnPtyProxy({
command,
cwd: vaultPath,
env: shellEnv,
cols: Math.max(20, session.terminal.cols || 120),
rows: Math.max(10, session.terminal.rows || 30),
nodeExecutable: this.plugin.settings.nodeExecutable,
pluginDir: this.plugin.manifest.dir,
vaultPath
});
session.processHandle = makeProxyAdapter(helperHandle);
} catch (error) {
const message = `Failed to start process: ${(error as Error).message}`;
session.writeSystemLine(`[${message}]`);
session.status = message;
if (this.activeSessionId === session.id) {
this.setStatus(message);
}
new Notice(message, 7000);
session.processHandle = null;
session.markReady();
return false;
}
session.status = "Running";
if (this.activeSessionId === session.id) {
this.setStatus("Running");
}
session.armReadyFallback(SESSION_READY_FALLBACK_MS);
session.processHandle.onData((data: string) => {
session.terminal.write(data);
session.markReady();
});
session.processHandle.onExit((exitCode, signal) => {
session.processHandle = null;
session.markReady();
if (session.pendingRestart) {
session.pendingRestart = false;
const current = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId);
if (current) {
this.spawnIntoSession(session, current);
this.renderTabBar();
this.updateToolbarState();
return;
}
}
const message = `Process exited (code=${exitCode}, signal=${signal})`;
session.writeSystemLine(`[${message}]`);
session.status = message;
if (this.activeSessionId === session.id) {
this.setStatus(message);
}
if (session.origin === "automation" && this.plugin.settings.autoCloseAutomationSessions) {
this.closeSession(session.id);
return;
}
this.renderTabBar();
this.updateToolbarState();
});
if (this.activeSessionId === session.id) {
session.fitAddon.fit();
}
return true;
}
private stopActiveSession(): void {
const session = this.getActiveSession();
if (!session || !session.processHandle) {
return;
}
session.writeSystemLine(`[Stopping ${session.label} process...]`);
session.status = "Stopping...";
this.setStatus("Stopping...");
try {
session.processHandle.kill("SIGTERM");
} catch (error) {
const message = `Failed to stop process: ${(error as Error).message}`;
session.writeSystemLine(`[${message}]`);
session.status = message;
this.setStatus(message);
new Notice(message, 6000);
}
}
private restartActiveSession(): void {
const session = this.getActiveSession();
if (!session) {
return;
}
const runtime = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId);
if (!runtime) {
new Notice("Runtime is no longer configured.", 6000);
return;
}
if (session.processHandle) {
session.pendingRestart = true;
session.writeSystemLine(`[Restart requested: ${session.label}]`);
session.status = "Restarting...";
this.setStatus("Restarting...");
try {
session.processHandle.kill("SIGTERM");
} catch {
session.pendingRestart = false;
}
return;
}
this.spawnIntoSession(session, runtime);
this.renderTabBar();
this.updateToolbarState();
}
private openNewSessionMenu(evt: MouseEvent): void {
const runtimes = this.plugin.settings.runtimes;
if (runtimes.length === 0) {
new Notice("No runtime configured. Add one in plugin settings.", 6000);
return;
}
if (runtimes.length === 1) {
this.startSession({ runtimeId: runtimes[0].id, origin: "manual" });
return;
}
const menu = new Menu();
for (const runtime of runtimes) {
menu.addItem((item) =>
item
.setTitle(runtime.name || "(Unnamed)")
.setIcon("terminal")
.onClick(() => {
this.startSession({ runtimeId: runtime.id, origin: "manual" });
})
);
}
menu.showAtMouseEvent(evt);
}
private renderTabBar(): void {
if (!this.tabBarEl) {
return;
}
this.tabBarEl.empty();
for (const session of this.sessions) {
const tabEl = this.tabBarEl.createDiv({
cls: `claude-cli-tab${session.id === this.activeSessionId ? " is-active" : ""}`
});
const dotCls = session.isRunning() ? "is-running" : "is-stopped";
tabEl.createSpan({ cls: `claude-cli-tab-dot ${dotCls}` });
if (session.origin === "automation") {
const autoIcon = tabEl.createSpan({ cls: "claude-cli-tab-auto" });
setIcon(autoIcon, "calendar-clock");
}
tabEl.createSpan({ text: session.label, cls: "claude-cli-tab-label" });
const closeEl = tabEl.createSpan({ cls: "claude-cli-tab-close" });
setIcon(closeEl, "x");
closeEl.setAttribute("aria-label", "Close session");
closeEl.addEventListener("click", (evt) => {
evt.stopPropagation();
this.closeSession(session.id);
});
tabEl.addEventListener("click", () => {
if (session.id !== this.activeSessionId) {
this.setActiveSession(session.id);
}
});
}
const newTabEl = this.tabBarEl.createDiv({ cls: "claude-cli-tab-new" });
setIcon(newTabEl, "plus");
newTabEl.setAttribute("aria-label", "New session");
newTabEl.addEventListener("click", (evt) => this.openNewSessionMenu(evt));
}
private updateEmptyState(): void {
if (!this.emptyHintEl) {
return;
}
this.emptyHintEl.toggleClass("is-hidden", this.sessions.length > 0);
}
private updateToolbarState(): void {
const session = this.getActiveSession();
const running = session?.isRunning() ?? false;
if (this.stopBtn) {
this.stopBtn.disabled = !running;
}
if (this.restartBtn) {
this.restartBtn.disabled = !session;
}
if (this.clearBtn) {
this.clearBtn.disabled = !session;
}
}
private setStatus(message: string): void {
this.statusEl?.setText(`Status: ${message}`);
}
private getSelectedRuntime(): CliRuntimeConfig | null {
const { runtimes, selectedRuntimeId } = this.plugin.settings;
return runtimes.find((r) => r.id === selectedRuntimeId) ?? runtimes[0] ?? null;
}
private insertActiveFileMention(): void {
const session = this.getActiveSession();
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
const message = "No active file detected.";
this.setStatus(message);
new Notice(message, 4000);
return;
}
if (!session || !session.processHandle) {
const message = "No running session. Start one before inserting a file mention.";
this.setStatus(message);
new Notice(message, 5000);
return;
}
const mention = formatActiveFileMention(activeFile.path);
session.processHandle.write(mention);
session.terminal.focus();
this.setStatus(`Inserted ${mention.trim()}`);
}
private insertActiveFolderMention(): void {
const session = this.getActiveSession();
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
const message = "No active file detected.";
this.setStatus(message);
new Notice(message, 4000);
return;
}
if (!session || !session.processHandle) {
const message = "No running session. Start one before inserting a folder mention.";
this.setStatus(message);
new Notice(message, 5000);
return;
}
const mention = formatActiveFolderMention(activeFile.path);
session.processHandle.write(mention);
session.terminal.focus();
this.setStatus(`Inserted ${mention.trim()}`);
}
private setButtonIcon(buttonEl: HTMLButtonElement, iconName: string, label: string): void {
buttonEl.empty();
buttonEl.addClass("claude-cli-btn");
const iconEl = buttonEl.createSpan({ cls: "claude-cli-btn-icon" });
setIcon(iconEl, iconName);
buttonEl.createSpan({ text: label });
}
}
export default class ClaudeCliPlugin extends Plugin {
settings!: ClaudeCliPluginSettings;
private automationEntries: Map<string, ParsedAutomation> = new Map();
private automationErrors: Map<string, AutomationParseError> = new Map();
private automationListeners: Set<() => void> = new Set();
async onload(): Promise<void> {
await this.loadSettings();
this.registerView(VIEW_TYPE_CLAUDE, (leaf) => new ClaudeCliView(leaf, this));
this.addRibbonIcon("bot", "Open AI CLI panel", () => {
void this.activateView();
});
this.addCommand({
id: "open-panel",
name: "Open panel",
callback: () => {
void this.activateView();
}
});
this.addSettingTab(new ClaudeCliSettingTab(this.app, this));
this.app.workspace.onLayoutReady(() => {
this.loadAutomations();
this.runAutomationTick();
});
const refreshOnVaultChange = (file: { path: string } | null) => {
const folder = this.settings.automationsFolder;
if (!folder || !file) return;
if (file.path === folder || file.path.startsWith(`${folder}/`)) {
this.loadAutomations();
}
};
this.registerEvent(this.app.vault.on("create", refreshOnVaultChange));
this.registerEvent(this.app.vault.on("modify", refreshOnVaultChange));
this.registerEvent(this.app.vault.on("delete", refreshOnVaultChange));
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
refreshOnVaultChange(file);
const folder = this.settings.automationsFolder;
if (folder && (oldPath === folder || oldPath.startsWith(`${folder}/`))) {
this.loadAutomations();
}
})
);
this.registerInterval(activeWindow.setInterval(() => this.runAutomationTick(), AUTOMATION_TICK_MS));
}
onunload(): void {
// Per Obsidian plugin guidelines, do not detach leaves on unload —
// Obsidian preserves leaf state across reloads/updates.
}
getAutomations(): ParsedAutomation[] {
return Array.from(this.automationEntries.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}
getAutomationErrors(): AutomationParseError[] {
return Array.from(this.automationErrors.values());
}
onAutomationsChanged(listener: () => void): () => void {
this.automationListeners.add(listener);
return () => this.automationListeners.delete(listener);
}
private notifyAutomationsChanged(): void {
this.automationListeners.forEach((listener) => {
try {
listener();
} catch {
/* ignore listener errors */
}
});
}
loadAutomations(): void {
this.automationEntries.clear();
this.automationErrors.clear();
const folderPath = this.settings.automationsFolder.trim();
if (!folderPath) {
this.notifyAutomationsChanged();
return;
}
const folder = this.app.vault.getFolderByPath(folderPath);
if (!folder) {
this.notifyAutomationsChanged();
return;
}
const files: TFile[] = [];
const walk = (f: TFolder) => {
for (const child of f.children) {
if (child instanceof TFile && child.extension === "md") {
files.push(child);
} else if (child instanceof TFolder) {
walk(child);
}
}
};
walk(folder);
void Promise.all(
files.map(async (file) => {
try {
const content = await this.app.vault.cachedRead(file);
const result = parseAutomationFile(content, file.path, (yaml) => parseYaml(yaml));
if (result.ok) {
this.automationEntries.set(file.path, result.entry);
} else {
this.automationErrors.set(file.path, result.error);
}
} catch (err) {
this.automationErrors.set(file.path, {
path: file.path,
name: file.basename,
reason: `Read error: ${(err as Error).message}`
});
}
})
).then(() => {
this.notifyAutomationsChanged();
});
}
runAutomationTick(): void {
if (this.automationEntries.size === 0) return;
const now = Date.now();
for (const entry of this.automationEntries.values()) {
if (!entry.enabled) continue;
const last = this.settings.automationsLastRun[entry.path] ?? null;
const next = computeNextRun(entry, last, now);
if (next !== null && next <= now) {
void this.triggerAutomation(entry, "scheduler");
}
}
}
async triggerAutomation(
entry: ParsedAutomation,
source: "scheduler" | "manual"
): Promise<void> {
const now = Date.now();
const baseRecord = {
ts: now,
path: entry.path,
name: entry.name,
source
} as const;
// Each automation run spawns its own session/tab so runs execute in
// parallel. Resolve the runtime by the declared name/id, or fall back to
// the default runtime when none is declared.
const runtime = resolveRuntimeForAutomation(
this.settings.runtimes,
entry.runtime,
this.settings.selectedRuntimeId
);
if (!runtime) {
const reason = entry.runtime
? `Runtime "${entry.runtime}" not configured`
: "No runtime configured";
this.recordHistory({ ...baseRecord, status: "skipped", reason });
if (source === "manual") {
new Notice(`Automation "${entry.name}" skipped — ${reason.toLowerCase()}.`, 5000);
}
return;
}
const started = await this.activateViewAndStartSession({
runtimeId: runtime.id,
origin: "automation"
});
if (!started) {
const reason = "Could not open a session";
this.recordHistory({ ...baseRecord, status: "error", reason });
if (source === "manual") {
new Notice(`Automation "${entry.name}" failed — ${reason.toLowerCase()}.`, 5000);
}
return;
}
const { view, session } = started;
try {
// Wait until the freshly spawned CLI has booted (first output or a short
// fallback) so the prompt lands in its input box, not before it exists.
await session.whenReady;
view.sendAutomationPromptTo(session.id, entry.body, entry.appendNewline);
this.recordHistory({
...baseRecord,
status: "ran",
runtimeId: session.runtimeId,
promptPreview: buildPromptPreview(entry.body)
});
this.settings.automationsLastRun[entry.path] = now;
await this.saveSettings();
if (source === "manual") {
new Notice(`Automation "${entry.name}" sent.`, 3000);
}
} catch (err) {
this.recordHistory({
...baseRecord,
status: "error",
reason: (err as Error).message
});
new Notice(`Automation "${entry.name}" failed: ${(err as Error).message}`, 6000);
}
}
private async activateViewAndStartSession(params: {
runtimeId: string;
origin: SessionOrigin;
}): Promise<{ view: ClaudeCliView; session: CliSession } | null> {
await this.activateView();
const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE)[0];
const view = leaf?.view;
if (!(view instanceof ClaudeCliView)) {
return null;
}
const session = view.startSession(params);
if (!session) {
return null;
}
return { view, session };
}
recordHistory(record: AutomationRunRecord): void {
this.settings.automationsHistory = pushHistory(
this.settings.automationsHistory,
record,
this.settings.automationsHistoryLimit
);
void this.saveSettings();
this.notifyAutomationsChanged();
}
clearAutomationHistory(): void {
this.settings.automationsHistory = [];
void this.saveSettings();
this.notifyAutomationsChanged();
}
async createExampleAutomation(): Promise<TFile> {
const folder = this.settings.automationsFolder.trim().replace(/\/+$/, "");
if (!folder) {
throw new Error("Set an automations folder first.");
}
if (!this.app.vault.getFolderByPath(folder)) {
await this.app.vault.createFolder(folder);
}
let target = `${folder}/hello-world.md`;
let counter = 1;
while (this.app.vault.getAbstractFileByPath(target)) {
target = `${folder}/hello-world-${counter}.md`;
counter += 1;
}
const file = await this.app.vault.create(target, EXAMPLE_AUTOMATION_CONTENT);
this.loadAutomations();
return file;
}
async activateView(): Promise<void> {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = workspace.getLeavesOfType(VIEW_TYPE_CLAUDE)[0] ?? null;
if (!leaf) {
leaf = workspace.getRightLeaf(false);
if (!leaf) {
return;
}
await leaf.setViewState({
type: VIEW_TYPE_CLAUDE,
active: true
});
}
await workspace.revealLeaf(leaf);
}
async loadSettings(): Promise<void> {
const raw = ((await this.loadData()) ?? {}) as Record<string, unknown>;
const { runtimes, selectedRuntimeId } = migrateRuntimeSettings(raw, cloneDefaultRuntimes());
this.settings = {
runtimes,
selectedRuntimeId,
autoRestartOnRuntimeSwitch:
typeof raw.autoRestartOnRuntimeSwitch === "boolean"
? raw.autoRestartOnRuntimeSwitch
: DEFAULT_SETTINGS.autoRestartOnRuntimeSwitch,
autoStart:
typeof raw.autoStart === "boolean" ? raw.autoStart : DEFAULT_SETTINGS.autoStart,
nodeExecutable:
typeof raw.nodeExecutable === "string" && raw.nodeExecutable.trim()
? raw.nodeExecutable
: DEFAULT_SETTINGS.nodeExecutable,
automationsFolder:
typeof raw.automationsFolder === "string" ? raw.automationsFolder : DEFAULT_SETTINGS.automationsFolder,
automationsLastRun: sanitizeLastRun(raw.automationsLastRun),
automationsHistory: sanitizeHistory(raw.automationsHistory),
automationsHistoryLimit:
typeof raw.automationsHistoryLimit === "number" &&
Number.isInteger(raw.automationsHistoryLimit) &&
raw.automationsHistoryLimit > 0
? raw.automationsHistoryLimit
: DEFAULT_SETTINGS.automationsHistoryLimit,
autoCloseAutomationSessions:
typeof raw.autoCloseAutomationSessions === "boolean"
? raw.autoCloseAutomationSessions
: DEFAULT_SETTINGS.autoCloseAutomationSessions,
maxConcurrentSessions:
typeof raw.maxConcurrentSessions === "number" &&
Number.isInteger(raw.maxConcurrentSessions) &&
raw.maxConcurrentSessions >= 0
? raw.maxConcurrentSessions
: DEFAULT_SETTINGS.maxConcurrentSessions
};
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
notifyRuntimesChanged(): void {
this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE).forEach((leaf) => {
const view = leaf.view;
if (view instanceof ClaudeCliView) {
view.refreshRuntimeSelect();
}
});
}
}
class ClaudeCliSettingTab extends PluginSettingTab {
plugin: ClaudeCliPlugin;
constructor(app: App, plugin: ClaudeCliPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Default runtime")
.setDesc("Runtime selected by default when opening the panel (and used by auto-start).")
.addDropdown((dropdown) => {
const runtimes = this.plugin.settings.runtimes;
if (runtimes.length === 0) {
dropdown.addOption("", "No runtime configured");
dropdown.setDisabled(true);
} else {
for (const runtime of runtimes) {
dropdown.addOption(runtime.id, runtime.name || "(Unnamed)");
}
const validSelection = runtimes.some((r) => r.id === this.plugin.settings.selectedRuntimeId);
dropdown.setValue(
validSelection ? this.plugin.settings.selectedRuntimeId : runtimes[0].id
);
}
dropdown.onChange(async (value) => {
if (!value) {
return;
}
this.plugin.settings.selectedRuntimeId = value;
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
});
});
new Setting(containerEl)
.setName("Auto-start")
.setDesc("Automatically start the selected default runtime when the panel opens.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoStart)
.onChange(async (value) => {
this.plugin.settings.autoStart = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Auto-close automation sessions")
.setDesc("When an automation-spawned session's process exits, close its tab automatically so tabs don't pile up.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoCloseAutomationSessions)
.onChange(async (value) => {
this.plugin.settings.autoCloseAutomationSessions = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Max concurrent sessions")
.setDesc("Maximum number of session tabs that can run at once (0 = unlimited). Protects against runaway automation spawns.")
.addText((text) =>
text
.setPlaceholder("8")
.setValue(String(this.plugin.settings.maxConcurrentSessions))
.onChange(async (value) => {
const parsed = Number.parseInt(value.trim(), 10);
this.plugin.settings.maxConcurrentSessions =
Number.isInteger(parsed) && parsed >= 0
? parsed
: DEFAULT_SETTINGS.maxConcurrentSessions;
await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("Runtimes").setHeading();
containerEl.createEl("p", {
text: "Configure the runtimes available from the sidebar new-session menu. Each entry needs a display name and a launch command. Add as many as you want.",
cls: "setting-item-description"
});
const runtimesListEl = containerEl.createDiv({ cls: "claude-cli-runtimes-list" });
if (this.plugin.settings.runtimes.length === 0) {
runtimesListEl.createEl("p", {
text: "No runtimes configured yet. Use the button below to create one.",
cls: "setting-item-description"
});
}
this.plugin.settings.runtimes.forEach((runtime, index) => {
const setting = new Setting(runtimesListEl).setClass("claude-cli-runtime-item");
setting.infoEl.detach();
setting
.addText((text) =>
text
.setPlaceholder("Name")
.setValue(runtime.name)
.onChange(async (value) => {
this.plugin.settings.runtimes[index].name = value;
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
})
)
.addText((text) => {
text
.setPlaceholder("Launch command")
.setValue(runtime.command)
.onChange(async (value) => {
this.plugin.settings.runtimes[index].command = value;
await this.plugin.saveSettings();
});
text.inputEl.addClass("claude-cli-runtime-command-input");
})
.addExtraButton((btn) =>
btn
.setIcon("trash-2")
.setTooltip("Remove runtime")
.onClick(async () => {
if (this.plugin.settings.runtimes.length <= 1) {
new Notice("Keep at least one runtime configured.", 4000);
return;
}
const removed = this.plugin.settings.runtimes.splice(index, 1)[0];
if (this.plugin.settings.selectedRuntimeId === removed.id) {
this.plugin.settings.selectedRuntimeId = this.plugin.settings.runtimes[0].id;
}
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
this.display();
})
);
});
new Setting(containerEl).addButton((btn) =>
btn
.setButtonText("Add runtime")
.setIcon("plus")
.onClick(async () => {
this.plugin.settings.runtimes.push({
id: defaultGenerateRuntimeId(),
name: "New runtime",
command: ""
});
await this.plugin.saveSettings();
this.plugin.notifyRuntimesChanged();
this.display();
})
);
new Setting(containerEl).setName("Automations").setHeading();
containerEl.createEl("p", {
text: "Folder containing prompt automations. Each Markdown file is one automation (frontmatter sets the schedule; body is the prompt sent to the running CLI). See readme for the file format.",
cls: "setting-item-description"
});
new Setting(containerEl)
.setName("Automations folder")
.setDesc("Vault-relative path. Leave empty to disable automations.")
.addText((text) =>
text
.setPlaceholder("Automations")
.setValue(this.plugin.settings.automationsFolder)
.onChange(async (value) => {
this.plugin.settings.automationsFolder = value.trim();
await this.plugin.saveSettings();
this.plugin.loadAutomations();
})
);
new Setting(containerEl)
.setName("Reload automations")
.setDesc("Force a re-scan of the automations folder (otherwise scans happen on vault changes).")
.addButton((btn) =>
btn
.setButtonText("Reload now")
.setIcon("refresh-cw")
.onClick(() => {
this.plugin.loadAutomations();
new Notice("Automations reloaded.", 2500);
})
);
new Setting(containerEl)
.setName("Create example automation")
.setDesc("Write a documented hello-world automation file (all fields explained) into the folder above.")
.addButton((btn) =>
btn
.setButtonText("Create example")
.setIcon("file-plus")
.onClick(async () => {
try {
const file = await this.plugin.createExampleAutomation();
new Notice(`Created ${file.path}`, 3000);
await this.app.workspace.openLinkText(file.path, "", true);
} catch (err) {
new Notice((err as Error).message, 5000);
}
})
);
new Setting(containerEl).setName("Advanced").setHeading();
new Setting(containerEl)
.setName("Node executable")
.setDesc("Path to the node binary used by the proxy. Leave as 'auto' for automatic detection.")
.addText((text) =>
text
.setPlaceholder("Auto")
.setValue(this.plugin.settings.nodeExecutable)
.onChange(async (value) => {
this.plugin.settings.nodeExecutable = value.trim() || "auto";
await this.plugin.saveSettings();
})
);
}
}
function resolvePluginDir(
pluginDir: string | undefined,
vaultBasePath: string | undefined,
path: typeof import("path")
): string | undefined {
return resolvePluginDirWithVault(pluginDir, vaultBasePath, path);
}
function getVaultBasePath(app: App): string | undefined {
const adapter = app.vault.adapter;
if (adapter instanceof FileSystemAdapter) {
return adapter.getBasePath();
}
return undefined;
}
function getShellEnv(): NodeJS.ProcessEnv {
const home = os.homedir();
const env: NodeJS.ProcessEnv = {
...process.env,
TERM: "xterm-256color"
};
const extraPaths: string[] = [];
if (process.platform === "darwin") {
extraPaths.push(
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin"
);
} else if (process.platform === "linux") {
extraPaths.push(
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin"
);
} else if (process.platform === "win32") {
const appData = process.env.APPDATA;
const localAppData = process.env.LOCALAPPDATA;
const userProfile = process.env.USERPROFILE;
if (appData) extraPaths.push(path.join(appData, "npm"));
if (localAppData) extraPaths.push(path.join(localAppData, "Microsoft", "WindowsApps"));
if (userProfile) extraPaths.push(path.join(userProfile, "scoop", "shims"));
}
if (home) {
extraPaths.push(
path.join(home, ".local", "bin"),
path.join(home, ".npm-global", "bin"),
path.join(home, ".volta", "bin"),
path.join(home, ".asdf", "shims"),
path.join(home, "Library", "pnpm"),
path.join(home, ".local", "share", "pnpm")
);
const nvmVersionsDir = path.join(home, ".nvm", "versions", "node");
if (fs.existsSync(nvmVersionsDir)) {
try {
const versions = fs.readdirSync(nvmVersionsDir, { withFileTypes: true });
for (const version of versions) {
if (version.isDirectory()) {
extraPaths.push(path.join(nvmVersionsDir, version.name, "bin"));
}
}
} catch {
// Ignore filesystem errors for optional PATH discovery.
}
}
}
env.PATH = mergePathEntries(env.PATH, extraPaths);
return env;
}
function mergePathEntries(currentPath: string | undefined, extras: string[]): string {
return mergePathEntriesForPlatform(currentPath, extras, process.platform);
}
function writeProxyFileIfNeeded(targetPath: string, source: string): void {
try {
if (fs.existsSync(targetPath) && fs.readFileSync(targetPath, "utf8") === source) {
return;
}
} catch {
// fall through and rewrite — we'd rather overwrite than silently fail
}
fs.writeFileSync(targetPath, source);
}
function ensureProxyFiles(pluginDir: string): void {
writeProxyFileIfNeeded(path.join(pluginDir, "pty-proxy.js"), PTY_PROXY_SOURCE);
writeProxyFileIfNeeded(path.join(pluginDir, "pty-bridge.py"), PTY_BRIDGE_SOURCE);
}
function spawnPtyProxy(params: {
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
cols: number;
rows: number;
nodeExecutable: string;
pluginDir?: string;
vaultPath?: string;
}): ChildProcess {
const resolvedPluginDir = resolvePluginDir(params.pluginDir, params.vaultPath, path);
if (resolvedPluginDir) {
// Recreate the auxiliary files from the embedded build-time constants if
// Obsidian's community-store auto-install did not ship them, or if a previous
// version's content is stale.
ensureProxyFiles(resolvedPluginDir);
}
const scriptPath = resolvedPluginDir
? path.join(resolvedPluginDir, "pty-proxy.js")
: path.resolve("pty-proxy.js");
if (!fs.existsSync(scriptPath)) {
throw new Error(`Missing proxy script: ${scriptPath}`);
}
const payload = Buffer.from(
JSON.stringify({
command: params.command,
cwd: params.cwd,
env: params.env,
cols: params.cols,
rows: params.rows
}),
"utf8"
).toString("base64");
const nodeExecutable = detectNodeExecutable(
params.nodeExecutable,
process.platform,
params.env,
fs.existsSync,
path
);
return spawn(nodeExecutable, [scriptPath, payload], {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe", "ipc"]
});
}
function sanitizeLastRun(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== "object") {
return {};
}
const out: Record<string, number> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof key === "string" && key && typeof value === "number" && Number.isFinite(value)) {
out[key] = value;
}
}
return out;
}
function sanitizeHistory(raw: unknown): AutomationRunRecord[] {
if (!Array.isArray(raw)) {
return [];
}
const out: AutomationRunRecord[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const r = entry as Partial<AutomationRunRecord>;
if (
typeof r.ts !== "number" ||
typeof r.path !== "string" ||
typeof r.name !== "string" ||
(r.source !== "scheduler" && r.source !== "manual") ||
(r.status !== "ran" && r.status !== "skipped" && r.status !== "error")
) {
continue;
}
out.push({
ts: r.ts,
path: r.path,
name: r.name,
source: r.source,
status: r.status,
reason: typeof r.reason === "string" ? r.reason : undefined,
runtimeId: typeof r.runtimeId === "string" ? r.runtimeId : null,
promptPreview: typeof r.promptPreview === "string" ? r.promptPreview : undefined
});
}
return out;
}
function makeProxyAdapter(handle: ChildProcess): ProcessAdapter {
const dataCallbacks: Array<(data: string) => void> = [];
const exitCallbacks: Array<(exitCode: number, signal: string) => void> = [];
const pendingData: string[] = [];
let pendingExit: { code: number; signal: string } | null = null;
const emitData = (chunk: Buffer | string) => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (dataCallbacks.length === 0) {
pendingData.push(text);
return;
}
dataCallbacks.forEach((callback) => callback(text));
};
const emitExit = (code: number | null, signal: NodeJS.Signals | null) => {
const exitCode = code ?? -1;
const exitSignal = signal ?? "none";
if (exitCallbacks.length === 0) {
pendingExit = { code: exitCode, signal: exitSignal };
return;
}
exitCallbacks.forEach((callback) => callback(exitCode, exitSignal));
};
handle.stdout?.on("data", emitData);
handle.stderr?.on("data", emitData);
handle.on("exit", emitExit);
return {
write(data: string) {
if (handle.stdin?.writable) {
handle.stdin.write(data);
}
},
resize(cols: number, rows: number) {
if (typeof handle.send === "function") {
handle.send({ type: "resize", cols, rows });
}
},
kill(signal?: string) {
handle.kill(signal as NodeJS.Signals | number | undefined);
},
onData(callback: (data: string) => void) {
dataCallbacks.push(callback);
if (pendingData.length > 0) {
pendingData.splice(0, pendingData.length).forEach((chunk) => callback(chunk));
}
},
onExit(callback: (exitCode: number, signal: string) => void) {
exitCallbacks.push(callback);
if (pendingExit) {
callback(pendingExit.code, pendingExit.signal);
pendingExit = null;
}
}
};
}