mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
6b8301a28e
- Updated the `runAgent` function to include timeout management for tool calls, allowing for better handling of execution limits and user feedback. - Introduced `timeoutMs` and `startedAt` properties in the `tool-call-started` event to facilitate countdown displays in the UI. - Enhanced the UI to show countdown timers for running tools, improving user awareness of execution status. - Implemented immediate UI updates on tool cancellation or timeout, ensuring a responsive experience even during long-running operations. - Refactored the `SidebarProvider` to handle subagent cancellations more effectively, providing clearer status updates in the UI.
592 lines
18 KiB
TypeScript
592 lines
18 KiB
TypeScript
/*
|
|
* Copyright (c) 2026 Pawan Osman <https://github.com/PawanOsman>
|
|
*
|
|
* This file is part of OpenCursor — AI coding agent chat inside VS Code.
|
|
* https://github.com/PawanOsman/OpenCursor
|
|
*
|
|
* Licensed under the MIT License. See LICENSE file in the project root.
|
|
*/
|
|
|
|
import * as fs from "fs/promises";
|
|
import * as path from "path";
|
|
import { spawn } from "child_process";
|
|
import type { ChildProcess } from "child_process";
|
|
import type { SubagentRunner, QuestionAsker } from "./types";
|
|
|
|
// Directories never walked/listed (tools + indexing).
|
|
export const IGNORE = new Set([
|
|
".git",
|
|
"node_modules",
|
|
"dist",
|
|
"out",
|
|
"build",
|
|
".next",
|
|
".nuxt",
|
|
".output",
|
|
".turbo",
|
|
".cache",
|
|
"coverage",
|
|
".venv",
|
|
"venv",
|
|
"__pycache__",
|
|
".tox",
|
|
".mypy_cache",
|
|
".pytest_cache",
|
|
".ruff_cache",
|
|
"target",
|
|
"vendor",
|
|
"Pods",
|
|
".gradle",
|
|
".idea",
|
|
".vscode",
|
|
"bower_components",
|
|
"jspm_packages",
|
|
".pnpm-store",
|
|
".yarn",
|
|
"site-packages",
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-tool hard timeouts (ms). Prevents a hung Grep/Glob/Shell/etc. from
|
|
// blocking the agent loop forever. Task/AskQuestion are excluded (own budgets).
|
|
// ---------------------------------------------------------------------------
|
|
export const TOOL_TIMEOUT_MS: Record<string, number> = {
|
|
// Outer safety net: slightly above each tool's own cap so the tool can
|
|
// clean up (kill process / mark done) before the loop aborts it.
|
|
Shell: 45_000,
|
|
AwaitShell: 60_000,
|
|
Grep: 20_000,
|
|
Glob: 20_000,
|
|
FileSearch: 15_000,
|
|
SemanticSearch: 30_000,
|
|
SearchDocs: 25_000,
|
|
ListDir: 10_000,
|
|
Read: 20_000,
|
|
ReadLints: 15_000,
|
|
WebSearch: 20_000,
|
|
WebFetch: 25_000,
|
|
StrReplace: 20_000,
|
|
Write: 20_000,
|
|
Delete: 10_000,
|
|
EditNotebook: 20_000,
|
|
CallMcpTool: 45_000,
|
|
FetchMcpResource: 30_000,
|
|
ListMcpResources: 15_000,
|
|
TodoWrite: 5_000,
|
|
TodoRead: 5_000,
|
|
WritePlan: 10_000,
|
|
SwitchMode: 5_000,
|
|
// Foreground Task budget (bg subagents use BG_SUBAGENT_MAX_MS in loop).
|
|
Task: 6 * 60_000,
|
|
};
|
|
/** Default when a tool has no explicit entry. */
|
|
export const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
|
|
/** Tools that manage their own lifetime (user wait only). Task has a hard budget. */
|
|
export const NO_TOOL_TIMEOUT = new Set(["AskQuestion"]);
|
|
|
|
/** Built-in defaults in seconds (for settings UI). */
|
|
export const DEFAULT_TOOL_TIMEOUTS_SEC: Record<string, number> = Object.fromEntries(
|
|
Object.entries(TOOL_TIMEOUT_MS).map(([k, v]) => [k, Math.round(v / 1000)]),
|
|
);
|
|
|
|
/** User overrides from settings (tool name → seconds). Empty/missing = built-in default. */
|
|
let toolTimeoutOverridesSec: Record<string, number> = {};
|
|
|
|
/** Apply settings overrides (seconds). Call whenever feature config loads/changes. */
|
|
export function setToolTimeoutOverrides(sec: Record<string, number> | undefined): void {
|
|
const next: Record<string, number> = {};
|
|
if (sec) {
|
|
for (const [k, v] of Object.entries(sec)) {
|
|
const n = Number(v);
|
|
if (Number.isFinite(n) && n > 0) next[k] = Math.floor(n);
|
|
}
|
|
}
|
|
toolTimeoutOverridesSec = next;
|
|
}
|
|
|
|
/**
|
|
* Race a tool promise against a hard timeout. On timeout rejects with an Error
|
|
* whose message starts with "timeout:" so the loop can surface it cleanly.
|
|
* Does not cancel the underlying work by itself — pass a linked AbortSignal
|
|
* into the tool when possible (Shell/Grep honor it).
|
|
* Always settles (never hangs) even if `p` never resolves.
|
|
*/
|
|
/**
|
|
* Race a tool promise against a hard timeout.
|
|
* On timeout: call `onTimeout` first (abort/kill), then reject immediately so
|
|
* the loop can settle UI without waiting for the underlying work.
|
|
* Late resolve/reject of `p` is ignored (no unhandled rejection).
|
|
*/
|
|
export function withToolTimeout<T>(
|
|
p: Promise<T>,
|
|
ms: number,
|
|
label: string,
|
|
onTimeout?: () => void,
|
|
): Promise<T> {
|
|
// ms <= 0: no outer race (AskQuestion manages its own lifetime).
|
|
if (!ms || ms <= 0) {
|
|
return Promise.resolve(p).catch((e) => {
|
|
throw e instanceof Error ? e : new Error(String(e));
|
|
});
|
|
}
|
|
const limit = ms;
|
|
return new Promise<T>((resolve, reject) => {
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { onTimeout?.(); } catch { /* ignore */ }
|
|
reject(new Error(`timeout: ${label} exceeded ${Math.round(limit / 1000)}s`));
|
|
}, limit);
|
|
Promise.resolve(p).then(
|
|
(v) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve(v);
|
|
},
|
|
(e) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(e instanceof Error ? e : new Error(String(e)));
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Resolve the hard timeout for a tool name (0 = none). Honors settings overrides. */
|
|
export function toolTimeoutMs(name: string): number {
|
|
if (NO_TOOL_TIMEOUT.has(name)) return 0;
|
|
const overrideSec = toolTimeoutOverridesSec[name];
|
|
if (overrideSec != null && overrideSec > 0) return overrideSec * 1000;
|
|
return TOOL_TIMEOUT_MS[name] ?? DEFAULT_TOOL_TIMEOUT_MS;
|
|
}
|
|
|
|
// Stopwords for the keyword-based SemanticSearch fallback.
|
|
export const STOP = new Set([
|
|
"where", "what", "which", "does", "with", "this", "that", "have", "from",
|
|
"into", "when", "how", "the", "and", "for", "are", "work", "works", "handle", "handled",
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Diff helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Minimal LCS line diff, emitting only changed regions plus a little context. */
|
|
export function makeDiff(_filePath: string, before: string, after: string): string {
|
|
const a = before.split("\n");
|
|
const b = after.split("\n");
|
|
const n = a.length;
|
|
const m = b.length;
|
|
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
for (let j = m - 1; j >= 0; j--) {
|
|
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
}
|
|
}
|
|
|
|
type Op = { t: " " | "+" | "-"; line: string };
|
|
const ops: Op[] = [];
|
|
let i = 0;
|
|
let j = 0;
|
|
while (i < n && j < m) {
|
|
if (a[i] === b[j]) {
|
|
ops.push({ t: " ", line: a[i] });
|
|
i++;
|
|
j++;
|
|
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
ops.push({ t: "-", line: a[i] });
|
|
i++;
|
|
} else {
|
|
ops.push({ t: "+", line: b[j] });
|
|
j++;
|
|
}
|
|
}
|
|
while (i < n) ops.push({ t: "-", line: a[i++] });
|
|
while (j < m) ops.push({ t: "+", line: b[j++] });
|
|
|
|
const CONTEXT = 3;
|
|
const keep = new Array(ops.length).fill(false);
|
|
for (let k = 0; k < ops.length; k++) {
|
|
if (ops[k].t !== " ") {
|
|
for (let x = Math.max(0, k - CONTEXT); x <= Math.min(ops.length - 1, k + CONTEXT); x++) keep[x] = true;
|
|
}
|
|
}
|
|
|
|
const out: string[] = [];
|
|
let prevKept = true;
|
|
for (let k = 0; k < ops.length; k++) {
|
|
if (!keep[k]) {
|
|
if (prevKept) out.push("…");
|
|
prevKept = false;
|
|
continue;
|
|
}
|
|
prevKept = true;
|
|
const o = ops[k];
|
|
out.push((o.t === " " ? " " : o.t + " ") + o.line);
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
/** 1-based line number of the first difference between two texts. */
|
|
export function firstDiffLine(before: string, after: string): number {
|
|
const a = before.split("\n");
|
|
const b = after.split("\n");
|
|
const n = Math.min(a.length, b.length);
|
|
for (let i = 0; i < n; i++) {
|
|
if (a[i] !== b[i]) return i + 1;
|
|
}
|
|
return Math.min(a.length, b.length) + 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Filesystem walking / globbing / fuzzy matching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Recursively collect file paths under `dir` (depth-capped). IGNORE dirs
|
|
* (node_modules, .git, build caches, …) are skipped unless `includeIgnored` is true.
|
|
* Always respects AbortSignal and maxFiles so explore tools cannot hang forever.
|
|
*/
|
|
export async function walk(
|
|
dir: string,
|
|
out: string[],
|
|
depth: number,
|
|
includeIgnored = false,
|
|
signal?: AbortSignal,
|
|
/** Soft cap so huge trees cannot hang the tool forever. */
|
|
maxFiles = 20_000,
|
|
): Promise<void> {
|
|
if (depth > 10 || out.length >= maxFiles) return;
|
|
if (signal?.aborted) return;
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const e of entries) {
|
|
if (signal?.aborted || out.length >= maxFiles) return;
|
|
if (!includeIgnored && IGNORE.has(e.name)) continue;
|
|
// Always skip the heaviest trees even when includeIgnored (Glob edge cases).
|
|
if (e.name === "node_modules" || e.name === ".git") {
|
|
if (!includeIgnored) continue;
|
|
// Still skip .git internals; allow node_modules only when explicitly requested.
|
|
if (e.name === ".git") continue;
|
|
}
|
|
const full = path.join(dir, e.name);
|
|
try {
|
|
if (e.isDirectory()) {
|
|
await walk(full, out, depth + 1, includeIgnored, signal, maxFiles);
|
|
} else if (e.isFile() || e.isSymbolicLink()) {
|
|
out.push(full);
|
|
}
|
|
} catch {
|
|
/* permission / race — skip entry */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Sort file paths by mtime, most-recently-modified first. Best-effort stat. */
|
|
export async function sortByMtime(files: string[]): Promise<string[]> {
|
|
const withTimes = await Promise.all(
|
|
files.map(async (f) => {
|
|
let mtimeMs = 0;
|
|
try {
|
|
mtimeMs = (await fs.stat(f)).mtimeMs;
|
|
} catch {}
|
|
return { f, mtimeMs };
|
|
})
|
|
);
|
|
withTimes.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
return withTimes.map((x) => x.f);
|
|
}
|
|
|
|
/** Convert a glob (supporting **, *, ?) into an anchored RegExp. */
|
|
export function globToRe(p: string): RegExp {
|
|
const esc = p
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*\*/g, "\u0000")
|
|
.replace(/\*/g, "[^/]*")
|
|
.replace(/\u0000/g, ".*")
|
|
.replace(/\?/g, ".");
|
|
return new RegExp(`^${esc}$`);
|
|
}
|
|
|
|
/** Substring/subsequence fuzzy score (higher = better; 0 = no match). */
|
|
export function fuzzyScore(text: string, query: string): number {
|
|
if (!query) return 0;
|
|
if (text.includes(query)) return 100 + (query.length / text.length) * 50;
|
|
let qi = 0;
|
|
let score = 0;
|
|
for (let i = 0; i < text.length && qi < query.length; i++) {
|
|
if (text[i] === query[qi]) {
|
|
score++;
|
|
qi++;
|
|
}
|
|
}
|
|
return qi === query.length ? score : 0;
|
|
}
|
|
|
|
/** Slugify a string for use as a filename. */
|
|
export function slugify(s: string): string {
|
|
return (
|
|
String(s || "plan")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 60) || "plan"
|
|
);
|
|
}
|
|
|
|
/** Whether ripgrep is available on PATH (cached; 3s probe timeout). */
|
|
let rgCached: boolean | null = null;
|
|
export function rgAvailable(): Promise<boolean> {
|
|
if (rgCached != null) return Promise.resolve(rgCached);
|
|
return new Promise((res) => {
|
|
let settled = false;
|
|
const finish = (v: boolean) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
rgCached = v;
|
|
res(v);
|
|
};
|
|
const timer = setTimeout(() => {
|
|
try { c.kill(); } catch { /* ignore */ }
|
|
finish(false);
|
|
}, 3_000);
|
|
let c: ReturnType<typeof spawn>;
|
|
try {
|
|
c = spawn("rg", ["--version"]);
|
|
} catch {
|
|
finish(false);
|
|
return;
|
|
}
|
|
c.on("error", () => finish(false));
|
|
c.on("close", (code) => finish(code === 0));
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Background shell registry (shared by Shell + AwaitShell)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** A pattern the agent wants to be notified about when it appears in output. */
|
|
export interface ShellNotify {
|
|
re: RegExp;
|
|
reason: string;
|
|
debounceMs: number;
|
|
lastNotified: number;
|
|
/** Set by the loop so a match can emit an agent event. */
|
|
emit?: (text: string) => void;
|
|
}
|
|
|
|
export interface BgShell {
|
|
id: string;
|
|
command: string;
|
|
proc: ChildProcess;
|
|
output: string;
|
|
done: boolean;
|
|
exitCode: number | null;
|
|
startedAt: number;
|
|
notify?: ShellNotify;
|
|
/** Pull any new session output into this shell's buffer (for polling). */
|
|
pump?: () => void;
|
|
}
|
|
|
|
export const bgShells = new Map<string, BgShell>();
|
|
let bgShellSeq = 0;
|
|
export function nextShellId(): string {
|
|
return `sh_${++bgShellSeq}`;
|
|
}
|
|
|
|
/**
|
|
* Feed new output into a shell, appending to its buffer and firing the
|
|
* notify_on_output hook when the pattern matches (respecting debounce).
|
|
*/
|
|
export function pushShellOutput(sh: BgShell, chunk: string): void {
|
|
sh.output += chunk;
|
|
const n = sh.notify;
|
|
if (!n || !n.emit) return;
|
|
if (!n.re.test(chunk)) return;
|
|
const now = Date.now();
|
|
if (now - n.lastNotified < Math.max(5000, n.debounceMs)) return;
|
|
n.lastNotified = now;
|
|
n.emit(`Monitored ${n.reason}: matched in shell ${sh.id}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistent stateful shell sessions (cwd/env persist across commands per run)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ShellSession {
|
|
proc: ChildProcess;
|
|
/** Serializes command execution so output framing stays intact. */
|
|
queue: Promise<unknown>;
|
|
buffer: string;
|
|
}
|
|
|
|
const shellSessions = new Map<string, ShellSession>();
|
|
|
|
function spawnSessionShell(cwd: string): ChildProcess {
|
|
if (process.platform === "win32") {
|
|
// -File - reads stdin as a script; NonInteractive avoids prompts that hang.
|
|
return spawn(
|
|
"powershell.exe",
|
|
["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
|
|
{ cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
}
|
|
// Non-interactive bash (no -i): interactive mode can hang on job control / PS1.
|
|
return spawn("bash", ["--noprofile", "--norc"], {
|
|
cwd,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: { ...process.env, TERM: "dumb", PS1: "", PS2: "" },
|
|
});
|
|
}
|
|
|
|
/** Get (or lazily create) the persistent shell session for a run key. */
|
|
export function getShellSession(key: string, cwd: string): ShellSession {
|
|
let s = shellSessions.get(key);
|
|
if (s && !s.proc.killed && s.proc.exitCode === null && s.proc.stdin && !s.proc.stdin.destroyed) {
|
|
return s;
|
|
}
|
|
if (s) {
|
|
try { s.proc.kill(); } catch { /* ignore */ }
|
|
shellSessions.delete(key);
|
|
}
|
|
const proc = spawnSessionShell(cwd);
|
|
s = { proc, queue: Promise.resolve(), buffer: "" };
|
|
proc.stdout?.on("data", (d) => {
|
|
try { s!.buffer += d.toString(); } catch { /* ignore */ }
|
|
});
|
|
proc.stderr?.on("data", (d) => {
|
|
try { s!.buffer += d.toString(); } catch { /* ignore */ }
|
|
});
|
|
proc.on("error", () => {
|
|
/* keep buffer; next getShellSession recreates */
|
|
});
|
|
proc.on("exit", () => {
|
|
/* session is dead; next command will respawn */
|
|
});
|
|
// Prevent unhandled 'error' on stdin from crashing the extension host.
|
|
proc.stdin?.on("error", () => { /* ignore broken pipe */ });
|
|
shellSessions.set(key, s);
|
|
return s;
|
|
}
|
|
|
|
/** Tear down a run's persistent shell session (call on run end / dispose). */
|
|
export function disposeShellSession(key: string): void {
|
|
const s = shellSessions.get(key);
|
|
if (s) {
|
|
try {
|
|
if (process.platform === "win32") s.proc.kill();
|
|
else s.proc.kill("SIGKILL");
|
|
} catch { /* ignore */ }
|
|
shellSessions.delete(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait until the shell finishes, `pattern` matches its output, or `ms` elapses.
|
|
* Always resolves (never rejects). `ms <= 0` = one immediate pump + return.
|
|
*/
|
|
export function waitForShell(sh: BgShell, ms: number, pattern?: RegExp, signal?: AbortSignal): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) clearTimeout(timer);
|
|
if (interval) clearInterval(interval);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
};
|
|
const onAbort = () => {
|
|
if (!sh.done) {
|
|
sh.output += "\n(aborted)";
|
|
sh.done = true;
|
|
}
|
|
finish();
|
|
};
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
|
|
const deadline = Date.now() + Math.max(0, ms);
|
|
const check = () => {
|
|
try { sh.pump?.(); } catch { /* ignore */ }
|
|
if (sh.done) return finish();
|
|
if (pattern) {
|
|
try {
|
|
if (pattern.test(sh.output)) return finish();
|
|
} catch { /* bad pattern mid-wait */ }
|
|
}
|
|
if (ms <= 0 || Date.now() >= deadline) return finish();
|
|
};
|
|
// Hard wall-clock: never tick forever even if setInterval stalls.
|
|
const timer = setTimeout(finish, Math.max(ms, 0) + 250);
|
|
const interval = setInterval(check, 50);
|
|
check();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render a shell's state. Header + footer carry metadata (pid, timings,
|
|
* exit_code); AwaitShell's `pattern` deliberately matches only the body.
|
|
*/
|
|
export function renderShell(sh: BgShell): string {
|
|
const elapsed = Date.now() - sh.startedAt;
|
|
const head = `[shell ${sh.id}] pid=${sh.proc.pid ?? "?"} running_for_ms=${elapsed}\n$ ${sh.command}`;
|
|
const body = sh.output.slice(0, 20000);
|
|
const footer = sh.done
|
|
? `(exit_code=${sh.exitCode} elapsed_ms=${elapsed})`
|
|
: `(still running — poll with AwaitShell shell_id="${sh.id}")`;
|
|
return `${head}\n${body}\n${footer}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-memory per-run todo list (TodoWrite / TodoRead)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TodoItem {
|
|
id: string;
|
|
content: string;
|
|
status: "pending" | "in_progress" | "completed" | "cancelled";
|
|
}
|
|
|
|
let TODO_LIST: TodoItem[] = [];
|
|
export function resetTodos(): void {
|
|
TODO_LIST = [];
|
|
}
|
|
export function getTodos(): TodoItem[] {
|
|
return TODO_LIST;
|
|
}
|
|
export function setTodos(list: TodoItem[]): void {
|
|
TODO_LIST = list;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Injected runners (set by the agent loop to avoid circular imports)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let SUBAGENT_RUNNER: SubagentRunner | undefined;
|
|
export function setSubagentRunner(runner: SubagentRunner | undefined): void {
|
|
SUBAGENT_RUNNER = runner;
|
|
}
|
|
export function getSubagentRunner(): SubagentRunner | undefined {
|
|
return SUBAGENT_RUNNER;
|
|
}
|
|
|
|
let QUESTION_ASKER: QuestionAsker | undefined;
|
|
export function setQuestionAsker(asker: QuestionAsker | undefined): void {
|
|
QUESTION_ASKER = asker;
|
|
}
|
|
export function getQuestionAsker(): QuestionAsker | undefined {
|
|
return QUESTION_ASKER;
|
|
}
|