metheus-governance-mcp-cli 0.2.170 → 0.2.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.mjs CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  appendWorkitemListHints as appendWorkitemListHintsImpl,
81
81
  injectCtxpackPreflightToken as injectCtxpackPreflightTokenImpl,
82
82
  injectCtxpackUpdateDefaults as injectCtxpackUpdateDefaultsImpl,
83
+ injectWorkitemBotActorDefaults as injectWorkitemBotActorDefaultsImpl,
83
84
  maybeAutoSyncCtxpackForCall as maybeAutoSyncCtxpackForCallImpl,
84
85
  maybeAutoSyncCtxpackForSessionRequest as maybeAutoSyncCtxpackForSessionRequestImpl,
85
86
  stripLocalOnlyToolArgs as stripLocalOnlyToolArgsImpl,
@@ -142,6 +143,7 @@ import {
142
143
  linkWorkItemEvidence as linkWorkItemEvidenceImpl,
143
144
  listProjectChatDestinations as listProjectChatDestinationsImpl,
144
145
  listProjectContextItems as listProjectContextItemsImpl,
146
+ listProjectCtxpackFiles as listProjectCtxpackFilesImpl,
145
147
  listThreadComments as listThreadCommentsImpl,
146
148
  listUserBotsForRunner as listUserBotsForRunnerImpl,
147
149
  selectProjectChatDestination as selectProjectChatDestinationImpl,
@@ -187,9 +189,13 @@ const BOT_RUNNER_CONFIG_RELATIVE_PATH = path.join(".metheus", "bot-runner.json")
187
189
  const BOT_RUNNER_WORKSPACE_REGISTRY_RELATIVE_PATH = path.join(".metheus", "project-workspaces.json");
188
190
  const BOT_RUNNER_STATE_RELATIVE_PATH = path.join(".metheus", "bot-runner-state.json");
189
191
  const BOT_RUNNER_PROCESSES_RELATIVE_PATH = path.join(".metheus", "bot-runner-processes.json");
192
+ const BOT_RUNNER_LOGS_RELATIVE_DIR = path.join(".metheus", "runner-logs");
190
193
  const BOT_RUNNER_LOCKS_RELATIVE_DIR = path.join(".metheus", "bot-runner-locks");
191
194
  const BOT_RUNNER_CONFIG_VERSION = 2;
192
195
  const BOT_RUNNER_ROUTE_LOCK_STALE_MS = 30 * 60 * 1000;
196
+ const BOT_RUNNER_ACTIVE_EXECUTION_STUCK_WARNING_MS = 20 * 60 * 1000;
197
+ const BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_INTERVAL_MS = 5000;
198
+ const BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_STALE_MS = 60 * 1000;
193
199
  const BOT_RUNNER_DEFAULT_CONCURRENCY = 2;
194
200
  const BOT_RUNNER_MAX_CONCURRENCY = 8;
195
201
  const TELEGRAM_ROOT_LEGACY_ENV_RELATIVE_PATH = path.join(".metheus", "telegram.env");
@@ -220,6 +226,7 @@ const SELF_UPDATE_STATE_RELATIVE_PATH = path.join(".metheus", "governance-mcp-cl
220
226
  const CTXPACK_CACHE_RELATIVE_DIR = path.join(".metheus", "ctxpack-cache");
221
227
  const CTXPACK_META_FILENAME = ".metheus_ctxpack_sync.json";
222
228
  const CTXPACK_UPDATE_TOOL_NAMES = ["ctxpack.update"];
229
+ const WORKITEM_MUTATION_TOOL_NAMES = ["workitem.push", "workitem.update", "workitem.transition"];
223
230
  const CLI_META = loadCLIMeta();
224
231
  const CLI_NAME = CLI_META.name || "metheus-governance-mcp-cli";
225
232
  const CLI_VERSION = CLI_META.version || "0.0.0";
@@ -295,14 +302,14 @@ function printUsage() {
295
302
  ` ${cmd} runner list [--json <true|false>]`,
296
303
  ` ${cmd} runner route list [--json <true|false>]`,
297
304
  ` ${cmd} runner route add [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--bot-name <server_name> | --bot-id <uuid>] [--destination-label <label> | --destination-id <uuid>] [--poll-interval-ms <n>] [--enabled <true|false>]`,
298
- ` ${cmd} runner project up [--project-id <uuid>] [--provider <telegram>] [--destination-label <label> | --destination-id <uuid>] [--bot-name <server_name> | --bot-id <uuid>] [--role <monitor|review|worker|approval> | --roles <csv>] [--apply <true|false>] [--start <true|false>] [--start-detached <true|false>] [--dry-run-delivery <true|false>] [--concurrency <n>] [--json <true|false>]`,
305
+ ` ${cmd} runner project up [--project-id <uuid>] [--provider <telegram>] [--destination-label <label> | --destination-id <uuid>] [--bot-name <server_name> | --bot-id <uuid>] [--role <monitor|review|worker|approval> | --roles <csv>] [--apply <true|false>] [--start <true|false>] [--start-detached <true|false>] [--tui <true|false>] [--log-file <path>] [--dry-run-delivery <true|false>] [--concurrency <n>] [--json <true|false>]`,
299
306
  ` ${cmd} runner artifact scan [--workspace-dir <path>] [--file-limit <n>] [--json <true|false>]`,
300
307
  ` ${cmd} runner route edit [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>]`,
301
308
  ` ${cmd} runner route remove [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>]`,
302
309
  ` ${cmd} runner show [--route-name <name> | --bot-name <server_name> | --bot-id <uuid>] [--json <true|false>]`,
303
310
  ` ${cmd} runner once [--route-name <name> | --bot-name <server_name when one enabled route matches> | --bot-id <uuid when one enabled route matches>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--context-comments <n>] [--archive-replies <true|false>]`,
304
- ` ${cmd} runner start [--route-name <name> | --bot-name <server_name when one enabled route matches> | --bot-id <uuid when one enabled route matches>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--poll-interval-ms <n>] [--concurrency <n>] [--context-comments <n>] [--archive-replies <true|false>]`,
305
- ` ${cmd} runner start-detached [--route-name <name> | --bot-name <server_name when one enabled route matches> | --bot-id <uuid when one enabled route matches>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--poll-interval-ms <n>] [--concurrency <n>] [--context-comments <n>] [--archive-replies <true|false>] [--json <true|false>]`,
311
+ ` ${cmd} runner start [--route-name <name> | --bot-name <server_name when one enabled route matches> | --bot-id <uuid when one enabled route matches>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--tui <true|false>] [--log-file <path>] [--dry-run-delivery <true|false>] [--poll-interval-ms <n>] [--concurrency <n>] [--context-comments <n>] [--archive-replies <true|false>]`,
312
+ ` ${cmd} runner start-detached [--route-name <name> | --bot-name <server_name when one enabled route matches> | --bot-id <uuid when one enabled route matches>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--log-file <path>] [--dry-run-delivery <true|false>] [--poll-interval-ms <n>] [--concurrency <n>] [--context-comments <n>] [--archive-replies <true|false>] [--json <true|false>]`,
306
313
  ` ${cmd} runner status [--launch-id <id> | --project-id <uuid> | --route-name <name>] [--json <true|false>]`,
307
314
  ` ${cmd} runner stop [--launch-id <id> | --project-id <uuid> | --route-name <name>] [--all <true|false>] [--json <true|false>]`,
308
315
  ` ${cmd} ctxpack pull [--project-id <uuid>] [--base-url <url>] [--workspace-dir <path|auto>] [--paths <csv>] [--timeout-seconds <n>]`,
@@ -784,6 +791,92 @@ function botRunnerProcessesFilePath() {
784
791
  return resolveHomeFilePath(BOT_RUNNER_PROCESSES_RELATIVE_PATH);
785
792
  }
786
793
 
794
+ function botRunnerLogsDirPath() {
795
+ return resolveHomeFilePath(BOT_RUNNER_LOGS_RELATIVE_DIR);
796
+ }
797
+
798
+ function sanitizeRunnerLogSlug(rawValue, fallback = "runner") {
799
+ const normalized = String(rawValue || "")
800
+ .trim()
801
+ .toLowerCase()
802
+ .replace(/[^a-z0-9]+/g, "-")
803
+ .replace(/^-+|-+$/g, "");
804
+ return normalized || fallback;
805
+ }
806
+
807
+ function buildDefaultRunnerLogFilePath(routes, sourceLabel = "runner start", startedAt = new Date()) {
808
+ const timestamp = [
809
+ startedAt.getFullYear(),
810
+ String(startedAt.getMonth() + 1).padStart(2, "0"),
811
+ String(startedAt.getDate()).padStart(2, "0"),
812
+ "T",
813
+ String(startedAt.getHours()).padStart(2, "0"),
814
+ String(startedAt.getMinutes()).padStart(2, "0"),
815
+ String(startedAt.getSeconds()).padStart(2, "0"),
816
+ ].join("");
817
+ const sourceSlug = sanitizeRunnerLogSlug(sourceLabel, "runner-start");
818
+ const routeSlug = sanitizeRunnerLogSlug(
819
+ ensureArray(routes)
820
+ .map((route) => normalizeRunnerRoute(route).name || "")
821
+ .filter(Boolean)
822
+ .slice(0, 2)
823
+ .join("-"),
824
+ "routes",
825
+ );
826
+ const dayDir = [
827
+ startedAt.getFullYear(),
828
+ String(startedAt.getMonth() + 1).padStart(2, "0"),
829
+ String(startedAt.getDate()).padStart(2, "0"),
830
+ ].join("-");
831
+ return path.join(botRunnerLogsDirPath(), dayDir, `${sourceSlug}-${routeSlug}-${timestamp}.jsonl`);
832
+ }
833
+
834
+ function resolveRunnerLogFilePath(rawValue, routes, sourceLabel = "runner start") {
835
+ const text = String(rawValue || "").trim();
836
+ if (["false", "none", "off", "disable"].includes(text.toLowerCase())) {
837
+ return "";
838
+ }
839
+ const filePath = text
840
+ ? path.resolve(text)
841
+ : buildDefaultRunnerLogFilePath(routes, sourceLabel);
842
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
843
+ return filePath;
844
+ }
845
+
846
+ function createRunnerStartLogger({ routes, flags, sourceLabel = "runner start", jsonMode = false }) {
847
+ const filePath = resolveRunnerLogFilePath(flags["log-file"], routes, sourceLabel);
848
+ if (!filePath) {
849
+ return null;
850
+ }
851
+ const append = (kind, payload = {}) => {
852
+ try {
853
+ fs.appendFileSync(
854
+ filePath,
855
+ `${JSON.stringify({
856
+ ts: new Date().toISOString(),
857
+ source: sourceLabel,
858
+ kind: String(kind || "").trim() || "event",
859
+ ...safeObject(payload),
860
+ })}\n`,
861
+ "utf8",
862
+ );
863
+ } catch {
864
+ // best-effort logging only
865
+ }
866
+ };
867
+ append("runner_start", {
868
+ json_mode: jsonMode === true,
869
+ route_names: ensureArray(routes).map((route) => normalizeRunnerRoute(route).name || runnerRouteKey(normalizeRunnerRoute(route))),
870
+ });
871
+ return {
872
+ filePath,
873
+ append,
874
+ close(payload = {}) {
875
+ append("runner_stop", payload);
876
+ },
877
+ };
878
+ }
879
+
787
880
  function normalizeBotProvider(rawValue) {
788
881
  return normalizeBotProviderName(rawValue, "telegram");
789
882
  }
@@ -1953,6 +2046,7 @@ function normalizeRunnerProcessLaunchEntry(rawEntry) {
1953
2046
  project_ids: ensureArray(entry.project_ids || entry.projectIds).map((item) => String(item || "").trim()).filter(Boolean),
1954
2047
  provider: normalizeBotProvider(entry.provider),
1955
2048
  destination_labels: ensureArray(entry.destination_labels || entry.destinationLabels).map((item) => String(item || "").trim()).filter(Boolean),
2049
+ log_file: String(entry.log_file || entry.logFile || "").trim(),
1956
2050
  created_by_pid: intFromRawAllowZero(entry.created_by_pid || entry.createdByPid, 0) || undefined,
1957
2051
  source_command: String(entry.source_command || entry.sourceCommand || "").trim(),
1958
2052
  });
@@ -2827,6 +2921,10 @@ async function listProjectContextItems(params) {
2827
2921
  return listProjectContextItemsImpl(params, buildRunnerDataDeps());
2828
2922
  }
2829
2923
 
2924
+ async function listProjectCtxpackFiles(params) {
2925
+ return listProjectCtxpackFilesImpl(params, buildRunnerDataDeps());
2926
+ }
2927
+
2830
2928
  async function createProjectContextItem(params) {
2831
2929
  return createProjectContextItemImpl(params, buildRunnerDataDeps());
2832
2930
  }
@@ -3568,6 +3666,327 @@ function buildRunnerDeliveryDeps() {
3568
3666
  };
3569
3667
  }
3570
3668
 
3669
+ function normalizeComparableProjectText(value) {
3670
+ return String(value || "")
3671
+ .trim()
3672
+ .toLowerCase()
3673
+ .replace(/[\\/_-]+/g, " ")
3674
+ .replace(/\s+/g, " ");
3675
+ }
3676
+
3677
+ function extractArtifactQueryTerms(messageText) {
3678
+ const text = String(messageText || "").trim();
3679
+ const exactNames = Array.from(
3680
+ new Set(
3681
+ ensureArray(text.match(/[A-Za-z0-9._-]+\.(?:md|txt|json|ya?ml|ts|tsx|js|jsx|py|go|java|cs|html|css|pdf)\b/gi))
3682
+ .map((item) => String(item || "").trim().toLowerCase())
3683
+ .filter(Boolean),
3684
+ ),
3685
+ );
3686
+ const tokens = Array.from(
3687
+ new Set(
3688
+ normalizeComparableProjectText(text)
3689
+ .split(/[^a-z0-9.]+/i)
3690
+ .map((item) => item.trim())
3691
+ .filter((item) => item.length >= 4 && !["where", "path", "file", "project", "guide", "current"].includes(item)),
3692
+ ),
3693
+ );
3694
+ return {
3695
+ exactNames,
3696
+ tokens,
3697
+ };
3698
+ }
3699
+
3700
+ function scoreProjectFileMatch(filePath, queryTerms) {
3701
+ const normalizedPath = String(filePath || "").trim().toLowerCase();
3702
+ if (!normalizedPath) return 0;
3703
+ const baseName = path.posix.basename(normalizedPath);
3704
+ let score = 0;
3705
+ for (const exactName of ensureArray(queryTerms.exactNames)) {
3706
+ if (!exactName) continue;
3707
+ if (baseName === exactName) score += 1000;
3708
+ else if (normalizedPath.includes(exactName)) score += 600;
3709
+ }
3710
+ const comparablePath = normalizeComparableProjectText(normalizedPath);
3711
+ for (const token of ensureArray(queryTerms.tokens)) {
3712
+ if (!token) continue;
3713
+ if (baseName.includes(token)) score += 120;
3714
+ else if (comparablePath.includes(token)) score += 45;
3715
+ }
3716
+ return score;
3717
+ }
3718
+
3719
+ async function locateProjectFilesForQuery({
3720
+ siteBaseURL,
3721
+ projectID,
3722
+ token,
3723
+ timeoutSeconds,
3724
+ query,
3725
+ limit = 5,
3726
+ }) {
3727
+ const queryTerms = extractArtifactQueryTerms(query);
3728
+ const files = ensureArray(await listProjectCtxpackFiles({
3729
+ siteBaseURL,
3730
+ projectID,
3731
+ token,
3732
+ timeoutSeconds,
3733
+ }));
3734
+ const matches = files
3735
+ .map((file) => ({
3736
+ ...safeObject(file),
3737
+ score: scoreProjectFileMatch(String(file.path || ""), queryTerms),
3738
+ }))
3739
+ .filter((file) => file.score > 0)
3740
+ .sort((left, right) => {
3741
+ if (right.score !== left.score) return right.score - left.score;
3742
+ return String(left.path || "").localeCompare(String(right.path || ""));
3743
+ })
3744
+ .slice(0, Math.max(1, intFromRaw(limit, 5)));
3745
+ return {
3746
+ query: String(query || "").trim(),
3747
+ exact_names: queryTerms.exactNames,
3748
+ tokens: queryTerms.tokens,
3749
+ count: matches.length,
3750
+ matches: matches.map((file) => ({
3751
+ path: String(file.path || "").trim(),
3752
+ doc_type: String(file.docType || "").trim(),
3753
+ generated: file.generated === true,
3754
+ sha256: String(file.sha256 || "").trim(),
3755
+ size_bytes: intFromRawAllowZero(file.sizeBytes, 0),
3756
+ score: intFromRawAllowZero(file.score, 0),
3757
+ })),
3758
+ };
3759
+ }
3760
+
3761
+ function buildProjectFileLocateText(payload) {
3762
+ const query = String(safeObject(payload).query || "").trim() || "the requested file";
3763
+ const matches = ensureArray(safeObject(payload).matches);
3764
+ if (!matches.length) {
3765
+ return `현재 프로젝트 ctxpack에서 "${query}"와 일치하는 파일 경로를 찾지 못했습니다.`;
3766
+ }
3767
+ if (matches.length === 1) {
3768
+ const match = safeObject(matches[0]);
3769
+ return `현재 확인되는 "${query}" 경로는 "${String(match.path || "").trim()}" 입니다.`;
3770
+ }
3771
+ return [
3772
+ `현재 프로젝트 ctxpack에서 "${query}"와 일치하는 파일 경로는 ${matches.length}개입니다:`,
3773
+ ...matches.map((match) => `- ${String(safeObject(match).path || "").trim()}`),
3774
+ ].join("\n");
3775
+ }
3776
+
3777
+ function resolveProjectWorkspaceBindingSummary(projectID, workspaceDir = "") {
3778
+ const normalizedProjectID = String(projectID || "").trim();
3779
+ const registry = loadBotRunnerWorkspaceRegistry({ persistIfNeeded: false });
3780
+ const mapping = normalizeBotRunnerProjectMapping(
3781
+ normalizedProjectID,
3782
+ safeObject(registry.projectMappings)[normalizedProjectID],
3783
+ );
3784
+ const boundWorkspaceDir = String(mapping.workspaceDir || "").trim();
3785
+ const effectiveWorkspaceDir = boundWorkspaceDir
3786
+ || String(resolveStableProjectWorkspaceCandidate(normalizedProjectID, workspaceDir) || "").trim();
3787
+ const warnings = [];
3788
+ const requestedWorkspaceDir = sanitizeWorkspaceCandidate(workspaceDir);
3789
+ if (requestedWorkspaceDir && boundWorkspaceDir && requestedWorkspaceDir !== boundWorkspaceDir) {
3790
+ warnings.push(`request workspace_dir differs from registry binding: ${requestedWorkspaceDir}`);
3791
+ }
3792
+ if (effectiveWorkspaceDir && !fs.existsSync(effectiveWorkspaceDir)) {
3793
+ warnings.push(`workspace_dir does not exist: ${effectiveWorkspaceDir}`);
3794
+ }
3795
+ const workspaceMeta = effectiveWorkspaceDir ? loadWorkspaceMeta(effectiveWorkspaceDir) : {};
3796
+ return {
3797
+ ok: Boolean(boundWorkspaceDir),
3798
+ project_id: normalizedProjectID,
3799
+ workspace_dir: effectiveWorkspaceDir,
3800
+ workspace_source: boundWorkspaceDir
3801
+ ? `project-workspaces.json:${String(mapping.source || "unknown").trim() || "unknown"}`
3802
+ : "",
3803
+ workspace_registry_file: String(registry.filePath || botRunnerWorkspaceRegistryFilePath()).trim(),
3804
+ workspace_meta_file: effectiveWorkspaceDir ? findSyncMetaFile(effectiveWorkspaceDir) : "",
3805
+ ctxpack_key: buildCtxpackKeyFromMeta(workspaceMeta),
3806
+ warnings,
3807
+ };
3808
+ }
3809
+
3810
+ function listProjectRunnerRoleBindings({
3811
+ projectID,
3812
+ provider = "",
3813
+ destinationID = "",
3814
+ destinationLabel = "",
3815
+ }) {
3816
+ const normalizedProjectID = String(projectID || "").trim();
3817
+ const normalizedProvider = normalizeBotProvider(provider || "");
3818
+ const normalizedDestinationID = String(destinationID || "").trim();
3819
+ const normalizedDestinationLabel = String(destinationLabel || "").trim().toLowerCase();
3820
+ const runnerConfig = loadBotRunnerConfig({ persistIfNeeded: true });
3821
+ const telegramEntries = ensureArray(readTelegramEnvState().entries);
3822
+ return ensureArray(runnerConfig.routes)
3823
+ .map((routeRaw) => normalizeRunnerRoute(routeRaw))
3824
+ .filter((route) => route.enabled)
3825
+ .filter((route) => route.projectID === normalizedProjectID)
3826
+ .filter((route) => !normalizedProvider || route.provider === normalizedProvider)
3827
+ .filter((route) => !normalizedDestinationID || route.destinationID === normalizedDestinationID)
3828
+ .filter((route) => !normalizedDestinationLabel || String(route.destinationLabel || "").trim().toLowerCase() === normalizedDestinationLabel)
3829
+ .map((route) => {
3830
+ const diagnostics = collectRunnerRouteDiagnostics(route, runnerConfig);
3831
+ const matchedTelegramEntry = findRunnerTelegramEntryForRoute(route, telegramEntries);
3832
+ const botName = firstNonEmptyString([
3833
+ route.botName,
3834
+ matchedTelegramEntry?.serverBotName,
3835
+ ]);
3836
+ return {
3837
+ route_name: String(route.name || runnerRouteKey(route)).trim(),
3838
+ project_id: String(route.projectID || "").trim(),
3839
+ provider: String(route.provider || "").trim(),
3840
+ destination_id: String(route.destinationID || "").trim(),
3841
+ destination_label: String(route.destinationLabel || "").trim(),
3842
+ bot_id: String(route.botID || "").trim(),
3843
+ bot_name: botName,
3844
+ role: String(route.role || "").trim(),
3845
+ role_profile_name: String(diagnostics.roleProfileName || route.roleProfile || "").trim(),
3846
+ client: String(diagnostics.roleProfile?.client || "").trim(),
3847
+ model: String(diagnostics.roleProfile?.model || "").trim(),
3848
+ permission_mode: String(diagnostics.roleProfile?.permissionMode || "").trim(),
3849
+ reasoning_effort: String(diagnostics.roleProfile?.reasoningEffort || "").trim(),
3850
+ };
3851
+ })
3852
+ .sort((left, right) => String(left.bot_name || left.route_name).localeCompare(String(right.bot_name || right.route_name)));
3853
+ }
3854
+
3855
+ function buildProjectBotRolesPayload({
3856
+ projectID,
3857
+ provider = "",
3858
+ destinationID = "",
3859
+ destinationLabel = "",
3860
+ }) {
3861
+ const bindings = listProjectRunnerRoleBindings({
3862
+ projectID,
3863
+ provider,
3864
+ destinationID,
3865
+ destinationLabel,
3866
+ });
3867
+ return {
3868
+ ok: bindings.length > 0,
3869
+ project_id: String(projectID || "").trim(),
3870
+ provider: normalizeBotProvider(provider || "") || "",
3871
+ destination_id: String(destinationID || "").trim(),
3872
+ destination_label: String(destinationLabel || "").trim(),
3873
+ count: bindings.length,
3874
+ bindings,
3875
+ };
3876
+ }
3877
+
3878
+ function buildProjectBotRolesText(payload) {
3879
+ const bindings = ensureArray(safeObject(payload).bindings);
3880
+ if (!bindings.length) {
3881
+ return "현재 프로젝트의 활성 runner 설정에서 확인되는 봇 역할이 없습니다.";
3882
+ }
3883
+ return [
3884
+ "현재 프로젝트의 활성 runner 설정 기준 역할은 아래와 같습니다.",
3885
+ ...bindings.map((binding) => {
3886
+ const current = safeObject(binding);
3887
+ const botName = String(current.bot_name || current.route_name || "").trim() || "(unknown bot)";
3888
+ const routeRole = String(current.role || "").trim() || "-";
3889
+ const roleProfile = String(current.role_profile_name || "").trim() || "-";
3890
+ const client = String(current.client || "").trim() || "-";
3891
+ const model = String(current.model || "").trim() || "-";
3892
+ return `- ${botName}: role=${routeRole}, profile=${roleProfile}, client=${client}, model=${model}`;
3893
+ }),
3894
+ ].join("\n");
3895
+ }
3896
+
3897
+ async function resolveInformationalQueryReply({
3898
+ intentType,
3899
+ route,
3900
+ routeState,
3901
+ selectedRecord,
3902
+ runtime,
3903
+ executionPlan,
3904
+ }) {
3905
+ const normalizedIntentType = String(intentType || "").trim();
3906
+ const messageText = String(safeObject(selectedRecord?.parsedArchive).body || "").trim();
3907
+ if (normalizedIntentType === "small_talk") {
3908
+ return {
3909
+ handled: true,
3910
+ reply: "안녕하세요. 현재 프로젝트 기준으로 필요한 정보를 바로 확인해드릴게요.",
3911
+ source: "small_talk",
3912
+ };
3913
+ }
3914
+ if (normalizedIntentType === "workspace_query") {
3915
+ const workspace = resolveProjectWorkspaceBindingSummary(route?.projectID, executionPlan?.workspaceDir || route?.workspaceDir || "");
3916
+ const workspaceDir = String(workspace.workspace_dir || "").trim();
3917
+ const reply = workspaceDir
3918
+ ? `현재 이 프로젝트에 바인딩된 로컬 작업 폴더는 "${workspaceDir}" 입니다.`
3919
+ : "현재 이 프로젝트는 project-workspaces.json에 로컬 작업 폴더가 바인딩되어 있지 않습니다.";
3920
+ return {
3921
+ handled: true,
3922
+ reply,
3923
+ source: "project.workspace",
3924
+ lookup: workspace,
3925
+ };
3926
+ }
3927
+ if (normalizedIntentType === "bot_role_query") {
3928
+ const payload = buildProjectBotRolesPayload({
3929
+ projectID: route?.projectID,
3930
+ provider: route?.provider,
3931
+ destinationID: route?.destinationID,
3932
+ destinationLabel: route?.destinationLabel,
3933
+ });
3934
+ return {
3935
+ handled: true,
3936
+ reply: buildProjectBotRolesText(payload),
3937
+ source: "project.bot_roles",
3938
+ lookup: payload,
3939
+ };
3940
+ }
3941
+ if (normalizedIntentType === "status_query") {
3942
+ const activeExecution = resolveRunnerActiveExecutionState(routeState);
3943
+ if (activeExecution.active) {
3944
+ const startedAt = String(activeExecution.startedAt || "").trim();
3945
+ const sourceMessage = intFromRawAllowZero(activeExecution.sourceMessageID, 0);
3946
+ const stuckSuffix = activeExecution.stuck ? " 현재 실행이 오래 지속되어 확인이 필요합니다." : "";
3947
+ return {
3948
+ handled: true,
3949
+ reply: `현재 이 runner는 작업 중입니다.${sourceMessage > 0 ? ` source_message_id=${sourceMessage}` : ""}${startedAt ? ` started_at=${startedAt}` : ""}.${stuckSuffix}`.replace(/\s+\./g, "."),
3950
+ source: "runner.status",
3951
+ lookup: activeExecution,
3952
+ };
3953
+ }
3954
+ const lastAction = String(safeObject(routeState).last_action || "").trim();
3955
+ const lastReason = String(safeObject(routeState).last_reason || "").trim();
3956
+ const lastIntentType = String(safeObject(routeState).last_intent_type || "").trim();
3957
+ const reply = lastAction
3958
+ ? `현재 실행 중인 작업은 없습니다. 마지막 상태는 "${lastAction}"${lastIntentType ? ` (intent=${lastIntentType})` : ""}${lastReason ? `, detail=${lastReason}` : ""} 입니다.`
3959
+ : "현재 실행 중인 작업은 없습니다. 아직 기록된 최근 작업 상태도 없습니다.";
3960
+ return {
3961
+ handled: true,
3962
+ reply,
3963
+ source: "runner.status",
3964
+ lookup: {
3965
+ active: false,
3966
+ last_action: lastAction,
3967
+ last_reason: lastReason,
3968
+ last_intent_type: lastIntentType,
3969
+ },
3970
+ };
3971
+ }
3972
+ if (normalizedIntentType === "artifact_location_query") {
3973
+ const payload = await locateProjectFilesForQuery({
3974
+ siteBaseURL: runtime.baseURL,
3975
+ projectID: route?.projectID,
3976
+ token: runtime.token,
3977
+ timeoutSeconds: runtime.timeoutSeconds,
3978
+ query: messageText,
3979
+ });
3980
+ return {
3981
+ handled: true,
3982
+ reply: buildProjectFileLocateText(payload),
3983
+ source: "project.file.locate",
3984
+ lookup: payload,
3985
+ };
3986
+ }
3987
+ return null;
3988
+ }
3989
+
3571
3990
  function buildRunnerExecutionDeps() {
3572
3991
  return {
3573
3992
  analyzeHumanConversationIntentWithAI,
@@ -3594,8 +4013,10 @@ function buildRunnerExecutionDeps() {
3594
4013
  createWorkItemThread,
3595
4014
  linkWorkItemEvidence,
3596
4015
  listProjectContextItems,
4016
+ listProjectCtxpackFiles,
3597
4017
  createProjectContextItem,
3598
4018
  replaceProjectCtxpackFiles,
4019
+ resolveInformationalQueryReply,
3599
4020
  };
3600
4021
  }
3601
4022
 
@@ -3650,23 +4071,52 @@ function emptyRunnerActiveExecutionPatch() {
3650
4071
  active_comment_created_at: "",
3651
4072
  active_source_message_id: undefined,
3652
4073
  active_started_at: "",
4074
+ active_heartbeat_at: "",
3653
4075
  active_runner_pid: undefined,
3654
4076
  active_execution_token: "",
3655
4077
  };
3656
4078
  }
3657
4079
 
3658
4080
  function buildRunnerActiveExecutionPatch(selectedRecord) {
4081
+ const nowISO = new Date().toISOString();
3659
4082
  const parsed = safeObject(selectedRecord?.parsedArchive);
3660
4083
  return {
3661
4084
  active_comment_id: String(selectedRecord?.id || "").trim(),
3662
4085
  active_comment_created_at: firstNonEmptyString([selectedRecord?.createdAt, selectedRecord?.updatedAt]),
3663
4086
  active_source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
3664
- active_started_at: new Date().toISOString(),
4087
+ active_started_at: nowISO,
4088
+ active_heartbeat_at: nowISO,
3665
4089
  active_runner_pid: process.pid,
3666
4090
  active_execution_token: `${Date.now()}-${process.pid}-${String(selectedRecord?.id || "").trim()}`,
3667
4091
  };
3668
4092
  }
3669
4093
 
4094
+ function startRunnerExecutionHeartbeat(routeKey, selectedRecord, intervalMs = BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_INTERVAL_MS) {
4095
+ let stopped = false;
4096
+ const tick = () => {
4097
+ if (stopped) return;
4098
+ const currentState = safeObject(loadBotRunnerState().routes[routeKey]);
4099
+ if (String(currentState.active_comment_id || "").trim() !== String(selectedRecord?.id || "").trim()) {
4100
+ return;
4101
+ }
4102
+ saveRunnerRouteState(routeKey, {
4103
+ ...currentState,
4104
+ active_heartbeat_at: new Date().toISOString(),
4105
+ });
4106
+ };
4107
+ tick();
4108
+ const timer = setInterval(tick, Math.max(1000, intervalMs));
4109
+ if (typeof timer.unref === "function") {
4110
+ timer.unref();
4111
+ }
4112
+ return {
4113
+ stop() {
4114
+ stopped = true;
4115
+ clearInterval(timer);
4116
+ },
4117
+ };
4118
+ }
4119
+
3670
4120
  function resolveRunnerActiveExecutionState(routeState) {
3671
4121
  const state = safeObject(routeState);
3672
4122
  const activeCommentID = String(state.active_comment_id || "").trim();
@@ -3674,19 +4124,45 @@ function resolveRunnerActiveExecutionState(routeState) {
3674
4124
  return {
3675
4125
  active: false,
3676
4126
  stale: false,
4127
+ stuck: false,
3677
4128
  commentID: "",
3678
4129
  sourceMessageID: 0,
3679
4130
  startedAt: "",
4131
+ ageMs: 0,
4132
+ ageSeconds: 0,
4133
+ warning: "",
3680
4134
  };
3681
4135
  }
3682
4136
  const activeRunnerPID = intFromRawAllowZero(state.active_runner_pid, 0);
3683
4137
  const staleByProcess = activeRunnerPID > 0 && !isProcessAlive(activeRunnerPID);
4138
+ const startedAt = String(state.active_started_at || "").trim();
4139
+ const startedAtMs = Date.parse(startedAt);
4140
+ const ageMs = Number.isFinite(startedAtMs)
4141
+ ? Math.max(0, Date.now() - startedAtMs)
4142
+ : 0;
4143
+ const ageSeconds = ageMs > 0 ? Math.floor(ageMs / 1000) : 0;
4144
+ const heartbeatAt = String(state.active_heartbeat_at || "").trim();
4145
+ const heartbeatAtMs = Date.parse(heartbeatAt);
4146
+ const heartbeatAgeMs = Number.isFinite(heartbeatAtMs)
4147
+ ? Math.max(0, Date.now() - heartbeatAtMs)
4148
+ : ageMs;
4149
+ const staleByHeartbeat = !staleByProcess && heartbeatAgeMs >= BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_STALE_MS;
4150
+ const stale = staleByProcess || staleByHeartbeat;
4151
+ const stuck = !stale && ageMs >= BOT_RUNNER_ACTIVE_EXECUTION_STUCK_WARNING_MS;
3684
4152
  return {
3685
- active: !staleByProcess,
3686
- stale: staleByProcess,
4153
+ active: !stale,
4154
+ stale,
4155
+ stuck,
3687
4156
  commentID: activeCommentID,
3688
4157
  sourceMessageID: intFromRawAllowZero(state.active_source_message_id, 0),
3689
- startedAt: String(state.active_started_at || "").trim(),
4158
+ startedAt,
4159
+ ageMs,
4160
+ ageSeconds,
4161
+ warning: staleByHeartbeat
4162
+ ? "active execution heartbeat expired; clearing busy state"
4163
+ : stuck
4164
+ ? "active execution is taking longer than expected"
4165
+ : "",
3690
4166
  };
3691
4167
  }
3692
4168
 
@@ -4895,7 +5371,14 @@ async function runRunnerProjectUp(flags) {
4895
5371
  await runRunnerStartDetachedResolvedRoutes(matchingRoutes, startFlags, "runner project up");
4896
5372
  return;
4897
5373
  }
4898
- await runRunnerStartResolvedRoutes(matchingRoutes, startFlags);
5374
+ await runRunnerStartResolvedRoutes(matchingRoutes, startFlags, {
5375
+ sourceLabel: "runner project up",
5376
+ bootstrapEvent: {
5377
+ route_name: "runner project up",
5378
+ outcome: "prepared",
5379
+ detail: `prepared ${matchingRoutes.length} enabled route(s) for ${summaryPayload.destination_label || summaryPayload.destination_id || summaryPayload.project_id || "project"}`,
5380
+ },
5381
+ });
4899
5382
  }
4900
5383
 
4901
5384
  function canStartRunnerDespiteProjectUpApplyFailure({ applyRequested, applyResult, matchingRoutes }) {
@@ -5115,6 +5598,7 @@ function buildRunnerShowPayload(route, flags = {}) {
5115
5598
  const runnerConfig = loadBotRunnerConfig({ persistIfNeeded: true });
5116
5599
  const runnerState = loadBotRunnerState();
5117
5600
  const routeState = safeObject(runnerState.routes[runnerRouteKey(normalizedRoute)]);
5601
+ const activeExecutionState = resolveRunnerActiveExecutionState(routeState);
5118
5602
  const diagnostics = collectRunnerRouteDiagnostics(normalizedRoute, runnerConfig);
5119
5603
  const telegramState = readTelegramEnvState();
5120
5604
  const telegramEntries = ensureArray(telegramState.entries);
@@ -5177,8 +5661,21 @@ function buildRunnerShowPayload(route, flags = {}) {
5177
5661
  artifact_errors: ensureArray(routeState.last_artifact_errors).map((item) => String(item || "").trim()).filter(Boolean),
5178
5662
  boundary_violations: ensureArray(routeState.last_boundary_violations).map((item) => safeObject(item)),
5179
5663
  },
5664
+ active_execution: {
5665
+ active: activeExecutionState.active === true,
5666
+ stale: activeExecutionState.stale === true,
5667
+ stuck: activeExecutionState.stuck === true,
5668
+ comment_id: String(activeExecutionState.commentID || "").trim(),
5669
+ source_message_id: intFromRawAllowZero(activeExecutionState.sourceMessageID, 0),
5670
+ started_at: String(activeExecutionState.startedAt || "").trim(),
5671
+ age_seconds: intFromRawAllowZero(activeExecutionState.ageSeconds, 0),
5672
+ warning: String(activeExecutionState.warning || "").trim(),
5673
+ },
5180
5674
  route_selection_note: "Routes are the executable unit. Use --route-name in production. --bot-name and --bot-id are convenience selectors that resolve one enabled route only when the match is unique.",
5181
- warnings: diagnostics.warnings,
5675
+ warnings: [
5676
+ ...ensureArray(diagnostics.warnings),
5677
+ ...(activeExecutionState.stuck ? [activeExecutionState.warning] : []),
5678
+ ],
5182
5679
  errors: diagnostics.errors,
5183
5680
  };
5184
5681
  }
@@ -5234,6 +5731,15 @@ async function runRunnerShow(flags) {
5234
5731
  ` artifact_paths: ${payload.last_run.artifact_paths.length ? payload.last_run.artifact_paths.join(", ") : "-"}`,
5235
5732
  ` artifact_errors: ${payload.last_run.artifact_errors.length ? payload.last_run.artifact_errors.join(" | ") : "-"}`,
5236
5733
  ` boundary_violations: ${payload.last_run.boundary_violations.length ? payload.last_run.boundary_violations.map((item) => String(item.detail || item.path || JSON.stringify(item))).join(" | ") : "-"}`,
5734
+ " active_execution:",
5735
+ ` active: ${payload.active_execution.active ? "true" : "false"}`,
5736
+ ` stale: ${payload.active_execution.stale ? "true" : "false"}`,
5737
+ ` stuck: ${payload.active_execution.stuck ? "true" : "false"}`,
5738
+ ` comment_id: ${payload.active_execution.comment_id || "-"}`,
5739
+ ` source_message_id: ${payload.active_execution.source_message_id || "-"}`,
5740
+ ` started_at: ${payload.active_execution.started_at || "-"}`,
5741
+ ` age_seconds: ${payload.active_execution.age_seconds || 0}`,
5742
+ ` warning: ${payload.active_execution.warning || "-"}`,
5237
5743
  payload.warnings.length ? ` warnings: ${payload.warnings.join("; ")}` : " warnings: -",
5238
5744
  payload.errors.length ? ` errors: ${payload.errors.join("; ")}` : " errors: -",
5239
5745
  ].join("\n") + "\n",
@@ -5323,7 +5829,7 @@ async function waitForRunnerPIDFile(pidFilePath, timeoutMs = 8000) {
5323
5829
  throw new Error(`detached runner launch did not publish a child process id: ${pidFilePath}`);
5324
5830
  }
5325
5831
 
5326
- function buildRunnerDetachedLaunchRecord(childPID, routes, flags, sourceCommand) {
5832
+ function buildRunnerDetachedLaunchRecord(childPID, routes, flags, sourceCommand, logFilePath = "") {
5327
5833
  const normalizedRoutes = ensureArray(routes).map((route) => normalizeRunnerRoute(route));
5328
5834
  const cliPath = fileURLToPath(import.meta.url);
5329
5835
  return normalizeRunnerProcessLaunchEntry({
@@ -5339,6 +5845,7 @@ function buildRunnerDetachedLaunchRecord(childPID, routes, flags, sourceCommand)
5339
5845
  project_ids: Array.from(new Set(normalizedRoutes.map((route) => String(route.projectID || "").trim()).filter(Boolean))),
5340
5846
  provider: firstNonEmptyString(normalizedRoutes.map((route) => route.provider)),
5341
5847
  destination_labels: Array.from(new Set(normalizedRoutes.map((route) => String(route.destinationLabel || route.destinationID || "").trim()).filter(Boolean))),
5848
+ log_file: String(logFilePath || "").trim(),
5342
5849
  created_by_pid: process.pid,
5343
5850
  source_command: sourceCommand,
5344
5851
  });
@@ -5355,7 +5862,12 @@ function findExistingDetachedRunnerLaunch(registry, routes) {
5355
5862
 
5356
5863
  async function launchDetachedRunnerProcess(flags, routes, sourceCommand) {
5357
5864
  const cliPath = fileURLToPath(import.meta.url);
5358
- const startArgs = [cliPath, "--no-update", "runner", "start", ...serializeCLIFlags(flags, {
5865
+ const detachedLogFilePath = resolveRunnerLogFilePath(flags["log-file"], routes, sourceCommand);
5866
+ const startFlags = {
5867
+ ...safeObject(flags),
5868
+ ...(detachedLogFilePath ? { "log-file": detachedLogFilePath } : {}),
5869
+ };
5870
+ const startArgs = [cliPath, "--no-update", "runner", "start", ...serializeCLIFlags(startFlags, {
5359
5871
  omit: ["json", "start", "start-detached", "detached"],
5360
5872
  })];
5361
5873
  if (process.platform === "win32") {
@@ -5387,7 +5899,7 @@ async function launchDetachedRunnerProcess(flags, routes, sourceCommand) {
5387
5899
  if (childPID <= 0) {
5388
5900
  throw new Error("detached runner launch did not return a child process id");
5389
5901
  }
5390
- return buildRunnerDetachedLaunchRecord(childPID, routes, flags, sourceCommand);
5902
+ return buildRunnerDetachedLaunchRecord(childPID, routes, startFlags, sourceCommand, detachedLogFilePath);
5391
5903
  }
5392
5904
  const launchTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-runner-launch-"));
5393
5905
  const scriptPath = path.join(launchTempDir, "start-runner.sh");
@@ -5417,7 +5929,7 @@ async function launchDetachedRunnerProcess(flags, routes, sourceCommand) {
5417
5929
  throw new Error(String(launched.stderr || launched.stdout || "failed to launch detached runner in Terminal.app").trim());
5418
5930
  }
5419
5931
  const childPID = await waitForRunnerPIDFile(pidFilePath);
5420
- return buildRunnerDetachedLaunchRecord(childPID, routes, flags, sourceCommand);
5932
+ return buildRunnerDetachedLaunchRecord(childPID, routes, startFlags, sourceCommand, detachedLogFilePath);
5421
5933
  }
5422
5934
  const terminalLauncher = resolveLinuxDetachedTerminalLauncher(scriptPath);
5423
5935
  if (!terminalLauncher) {
@@ -5433,7 +5945,7 @@ async function launchDetachedRunnerProcess(flags, routes, sourceCommand) {
5433
5945
  throw new Error("detached terminal launcher did not return a process id");
5434
5946
  }
5435
5947
  const runnerPID = await waitForRunnerPIDFile(pidFilePath);
5436
- return buildRunnerDetachedLaunchRecord(runnerPID, routes, flags, sourceCommand);
5948
+ return buildRunnerDetachedLaunchRecord(runnerPID, routes, startFlags, sourceCommand, detachedLogFilePath);
5437
5949
  } finally {
5438
5950
  try {
5439
5951
  fs.rmSync(launchTempDir, { recursive: true, force: true });
@@ -5515,6 +6027,7 @@ async function runRunnerStatus(flags) {
5515
6027
  ` project_ids: ${launch.project_ids.join(", ") || "-"}`,
5516
6028
  ` route_names: ${launch.route_names.join(", ") || "-"}`,
5517
6029
  ` destination_labels: ${launch.destination_labels.join(", ") || "-"}`,
6030
+ ` log_file: ${launch.log_file || "-"}`,
5518
6031
  ` command: ${launch.command || "-"}`,
5519
6032
  ].join("\n") + "\n",
5520
6033
  );
@@ -5551,7 +6064,7 @@ async function runRunnerStartDetachedResolvedRoutes(routes, flags, sourceCommand
5551
6064
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
5552
6065
  return payload;
5553
6066
  }
5554
- process.stdout.write(`Detached runner already running: launch_id=${existing.launch_id} pid=${existing.pid}\n`);
6067
+ process.stdout.write(`Detached runner already running: launch_id=${existing.launch_id} pid=${existing.pid}${existing.log_file ? ` log_file=${existing.log_file}` : ""}\n`);
5555
6068
  return payload;
5556
6069
  }
5557
6070
  const launch = launchDetachedRunnerProcess(flags, routes, sourceCommand);
@@ -5582,6 +6095,7 @@ async function runRunnerStartDetachedResolvedRoutes(routes, flags, sourceCommand
5582
6095
  `project_ids: ${launch.project_ids.join(", ") || "-"}`,
5583
6096
  `route_names: ${launch.route_names.join(", ") || "-"}`,
5584
6097
  `destination_labels: ${launch.destination_labels.join(", ") || "-"}`,
6098
+ `log_file: ${launch.log_file || "-"}`,
5585
6099
  `command: ${launch.command}`,
5586
6100
  ].join("\n") + "\n",
5587
6101
  );
@@ -5677,8 +6191,311 @@ async function runRunnerArtifactScan(flags) {
5677
6191
  }
5678
6192
  }
5679
6193
 
5680
- async function runRunnerStartResolvedRoutes(routes, flags) {
6194
+ function truncateRunnerTUIText(rawValue, width) {
6195
+ const text = String(rawValue || "").replace(/\s+/g, " ").trim();
6196
+ const maxWidth = Math.max(4, intFromRaw(width, 0));
6197
+ if (!maxWidth || text.length <= maxWidth) {
6198
+ return text;
6199
+ }
6200
+ if (maxWidth <= 3) {
6201
+ return text.slice(0, maxWidth);
6202
+ }
6203
+ return `${text.slice(0, maxWidth - 3)}...`;
6204
+ }
6205
+
6206
+ function normalizeRunnerTUIPhase(rawValue) {
6207
+ const value = String(rawValue || "").trim().toLowerCase();
6208
+ return value || "waiting";
6209
+ }
6210
+
6211
+ function colorizeRunnerTUIPhase(phase, width, useColor) {
6212
+ const rendered = bootstrapPadRight(String(phase || "-").toUpperCase(), width);
6213
+ if (!useColor) return rendered;
6214
+ const colorCode = phase === "replied" || phase === "idle"
6215
+ ? "32"
6216
+ : phase === "ai_running" || phase === "delivering" || phase === "context_suggesting"
6217
+ ? "36"
6218
+ : phase === "polling" || phase === "busy" || phase === "waiting" || phase === "primed"
6219
+ ? "33"
6220
+ : phase === "skipped"
6221
+ ? "35"
6222
+ : "31";
6223
+ return bootstrapColorText(rendered, colorCode);
6224
+ }
6225
+
6226
+ function mapRunnerTUIPhaseFromOutcome(outcome) {
6227
+ const value = String(outcome || "").trim().toLowerCase();
6228
+ if (!value) return "waiting";
6229
+ if (value === "accepted") return "ai_running";
6230
+ if (value === "dry_run") return "replied";
6231
+ if (value === "policy_violation" || value === "execution_failed") return "error";
6232
+ return value;
6233
+ }
6234
+
6235
+ function formatRunnerTUIAge(now, startedAtMs) {
6236
+ const ageMs = Math.max(0, Number(now) - Number(startedAtMs || 0));
6237
+ if (!(ageMs > 0)) return "-";
6238
+ const ageSeconds = Math.floor(ageMs / 1000);
6239
+ if (ageSeconds < 60) return `${ageSeconds}s`;
6240
+ const minutes = Math.floor(ageSeconds / 60);
6241
+ const seconds = ageSeconds % 60;
6242
+ if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, "0")}`;
6243
+ const hours = Math.floor(minutes / 60);
6244
+ const remMinutes = minutes % 60;
6245
+ return `${hours}h${String(remMinutes).padStart(2, "0")}`;
6246
+ }
6247
+
6248
+ function summarizeRunnerTUIPhases(routes = []) {
6249
+ const counts = {
6250
+ waiting: 0,
6251
+ polling: 0,
6252
+ running: 0,
6253
+ busy: 0,
6254
+ replied: 0,
6255
+ error: 0,
6256
+ };
6257
+ let warningCount = 0;
6258
+ for (const route of ensureArray(routes)) {
6259
+ const phase = normalizeRunnerTUIPhase(route.phase);
6260
+ if (phase === "waiting") counts.waiting += 1;
6261
+ else if (phase === "polling") counts.polling += 1;
6262
+ else if (["ai_running", "delivering", "context_suggesting"].includes(phase)) counts.running += 1;
6263
+ else if (phase === "busy") counts.busy += 1;
6264
+ else if (["replied", "idle", "skipped", "dry_run"].includes(phase)) counts.replied += 1;
6265
+ else if (["error", "blocked", "locked", "execution_failed", "policy_violation"].includes(phase)) counts.error += 1;
6266
+ else counts.waiting += 1;
6267
+ if (String(route.warning || route.last_error || "").trim()) {
6268
+ warningCount += 1;
6269
+ }
6270
+ }
6271
+ return { counts, warningCount };
6272
+ }
6273
+
6274
+ function buildRunnerTUIFrame({ routes = [], recentEvents = [], concurrency = 1, now = Date.now(), stopping = false, useColor = false, sourceLabel = "runner start", logFilePath = "" }) {
6275
+ const lines = [];
6276
+ const activeCount = ensureArray(routes).filter((item) => ["polling", "ai_running", "delivering", "context_suggesting", "busy"].includes(normalizeRunnerTUIPhase(item.phase))).length;
6277
+ const phaseSummary = summarizeRunnerTUIPhases(routes);
6278
+ const header = `METHEUS RUNNER ${stopping ? "STOPPING" : "LIVE"} | source=${String(sourceLabel || "runner start").trim()} | routes=${routes.length} active=${activeCount} concurrency=${concurrency} time=${new Date(now).toLocaleTimeString("en-US", { hour12: false })}`;
6279
+ lines.push(useColor ? bootstrapColorText(header, stopping ? "33" : "36") : header);
6280
+ if (String(logFilePath || "").trim()) {
6281
+ lines.push(`Log: ${truncateRunnerTUIText(String(logFilePath || "").trim(), 112)}`);
6282
+ }
6283
+ lines.push(`Summary: waiting=${phaseSummary.counts.waiting} polling=${phaseSummary.counts.polling} running=${phaseSummary.counts.running} busy=${phaseSummary.counts.busy} replied=${phaseSummary.counts.replied} error=${phaseSummary.counts.error} warnings=${phaseSummary.warningCount}`);
6284
+ lines.push("Ctrl+C to stop. Foreground runner state is shown below.");
6285
+ lines.push("");
6286
+ lines.push(
6287
+ [
6288
+ bootstrapPadRight("Route", 24),
6289
+ bootstrapPadRight("Phase", 18),
6290
+ bootstrapPadRight("Age", 7),
6291
+ bootstrapPadRight("AI", 20),
6292
+ bootstrapPadRight("Intent", 18),
6293
+ bootstrapPadRight("Msg", 8),
6294
+ bootstrapPadRight("Next", 8),
6295
+ bootstrapPadRight("Warn", 6),
6296
+ ].join(" "),
6297
+ );
6298
+ lines.push("-".repeat(120));
6299
+ for (const route of ensureArray(routes)) {
6300
+ const phase = normalizeRunnerTUIPhase(route.phase);
6301
+ const aiLabel = truncateRunnerTUIText(
6302
+ route.client
6303
+ ? `${String(route.client || "").trim()}/${String(route.model || "").trim() || "-"}`
6304
+ : "-",
6305
+ 20,
6306
+ );
6307
+ const ageLabel = formatRunnerTUIAge(now, route.phase_started_at || route.updated_at || now);
6308
+ const nextSeconds = Number.isFinite(Number(route.next_run_at)) && Number(route.next_run_at) > now
6309
+ ? `${Math.max(0, Math.ceil((Number(route.next_run_at) - now) / 1000))}s`
6310
+ : phase === "ai_running" || phase === "delivering" || phase === "context_suggesting"
6311
+ ? "run"
6312
+ : "-";
6313
+ const warningLabel = String(route.warning || route.last_error || "").trim() ? "yes" : "-";
6314
+ lines.push(
6315
+ [
6316
+ bootstrapPadRight(truncateRunnerTUIText(route.route_name, 24), 24),
6317
+ colorizeRunnerTUIPhase(phase, 18, useColor),
6318
+ bootstrapPadRight(ageLabel, 7),
6319
+ bootstrapPadRight(aiLabel, 20),
6320
+ bootstrapPadRight(truncateRunnerTUIText(route.intent_type || "-", 18), 18),
6321
+ bootstrapPadRight(String(route.source_message_id || "-"), 8),
6322
+ bootstrapPadRight(nextSeconds, 8),
6323
+ bootstrapPadRight(warningLabel, 6),
6324
+ ].join(" "),
6325
+ );
6326
+ const detailParts = [
6327
+ String(route.detail || "").trim(),
6328
+ String(route.context_suggestion_status || "").trim()
6329
+ ? `context=${String(route.context_suggestion_status || "").trim()}`
6330
+ : "",
6331
+ String(route.warning || "").trim()
6332
+ ? `warning=${String(route.warning || "").trim()}`
6333
+ : "",
6334
+ String(route.last_error || "").trim()
6335
+ ? `error=${String(route.last_error || "").trim()}`
6336
+ : "",
6337
+ ].filter(Boolean);
6338
+ lines.push(` ${truncateRunnerTUIText(detailParts.join(" | ") || "-", 116)}`);
6339
+ }
6340
+ lines.push("");
6341
+ lines.push(useColor ? bootstrapColorText("Recent Events", "35") : "Recent Events");
6342
+ if (!ensureArray(recentEvents).length) {
6343
+ lines.push(" (none yet)");
6344
+ } else {
6345
+ for (const event of ensureArray(recentEvents).slice(0, 8)) {
6346
+ lines.push(
6347
+ ` [${String(event.time || "").trim() || "--:--:--"}] ${truncateRunnerTUIText(event.route_name, 24)} ${bootstrapPadRight(String(event.outcome || "-").trim(), 12)} ${truncateRunnerTUIText(event.detail || "-", 74)}`,
6348
+ );
6349
+ }
6350
+ }
6351
+ return lines.join("\n");
6352
+ }
6353
+
6354
+ function createRunnerStartTUI({ routes, flags, jsonMode, concurrency, sourceLabel = "runner start", bootstrapEvent = null, logFilePath = "" }) {
6355
+ const enabled = !jsonMode && boolFromRaw(flags.tui, Boolean(process.stdout?.isTTY));
6356
+ if (!enabled || !process.stdout?.isTTY) {
6357
+ return null;
6358
+ }
6359
+ const useColor = bootstrapSupportsANSIColors();
6360
+ const routeStates = new Map();
6361
+ const recentEvents = [];
6362
+ for (const route of ensureArray(routes)) {
6363
+ const normalizedRoute = normalizeRunnerRoute(route);
6364
+ const routeKey = runnerRouteKey(normalizedRoute);
6365
+ routeStates.set(routeKey, {
6366
+ route_key: routeKey,
6367
+ route_name: normalizedRoute.name,
6368
+ phase: "waiting",
6369
+ detail: "awaiting next poll",
6370
+ client: "",
6371
+ model: "",
6372
+ intent_type: "",
6373
+ source_message_id: "",
6374
+ next_run_at: 0,
6375
+ context_suggestion_status: "",
6376
+ warning: "",
6377
+ last_error: "",
6378
+ phase_started_at: Date.now(),
6379
+ updated_at: Date.now(),
6380
+ });
6381
+ }
6382
+ if (bootstrapEvent && typeof bootstrapEvent === "object") {
6383
+ recentEvents.unshift({
6384
+ time: new Date().toLocaleTimeString("en-US", { hour12: false }),
6385
+ route_name: String(bootstrapEvent.route_name || sourceLabel || "runner").trim(),
6386
+ outcome: String(bootstrapEvent.outcome || "prepared").trim(),
6387
+ detail: String(bootstrapEvent.detail || "").trim(),
6388
+ });
6389
+ }
6390
+ let intervalHandle = null;
6391
+ let disposed = false;
6392
+
6393
+ const render = (stopping = false) => {
6394
+ if (disposed) return;
6395
+ const frame = buildRunnerTUIFrame({
6396
+ routes: Array.from(routeStates.values()),
6397
+ recentEvents,
6398
+ concurrency,
6399
+ now: Date.now(),
6400
+ stopping,
6401
+ useColor,
6402
+ sourceLabel,
6403
+ logFilePath,
6404
+ });
6405
+ process.stdout.write(`\u001b[?25l\u001b[2J\u001b[H${frame}`);
6406
+ };
6407
+
6408
+ const setRouteState = (routeKey, patch = {}) => {
6409
+ const current = safeObject(routeStates.get(routeKey));
6410
+ if (!current.route_key) return;
6411
+ const nextPhase = normalizeRunnerTUIPhase(patch.phase || current.phase);
6412
+ const phaseChanged = nextPhase !== normalizeRunnerTUIPhase(current.phase);
6413
+ routeStates.set(routeKey, {
6414
+ ...current,
6415
+ ...safeObject(patch),
6416
+ phase: nextPhase,
6417
+ phase_started_at: phaseChanged ? Date.now() : Number(current.phase_started_at || Date.now()),
6418
+ updated_at: Date.now(),
6419
+ });
6420
+ render(false);
6421
+ };
6422
+
6423
+ const recordResult = (result, routeState = null) => {
6424
+ const routeKey = String(result?.route_key || "").trim();
6425
+ if (routeKey && routeStates.has(routeKey)) {
6426
+ const current = safeObject(routeStates.get(routeKey));
6427
+ setRouteState(routeKey, {
6428
+ phase: mapRunnerTUIPhaseFromOutcome(result?.outcome),
6429
+ detail: String(result?.detail || "").trim(),
6430
+ intent_type: String(routeState?.last_intent_type || current.intent_type || "").trim(),
6431
+ source_message_id: intFromRawAllowZero(routeState?.last_source_message_id, intFromRawAllowZero(current.source_message_id, 0)) || current.source_message_id,
6432
+ context_suggestion_status: String(result?.context_suggestion_status || routeState?.last_context_suggestion_status || current.context_suggestion_status || "").trim(),
6433
+ warning: String(safeObject(routeState).active_execution_warning || result?.warning || "").trim(),
6434
+ last_error: String(routeState?.last_error || "").trim(),
6435
+ });
6436
+ }
6437
+ if (!result || ["idle", "busy"].includes(String(result.outcome || "").trim().toLowerCase())) {
6438
+ return;
6439
+ }
6440
+ recentEvents.unshift({
6441
+ time: new Date().toLocaleTimeString("en-US", { hour12: false }),
6442
+ route_name: String(result.route_name || result.route_key || "").trim(),
6443
+ outcome: String(result.outcome || "").trim(),
6444
+ detail: String(result.detail || "").trim(),
6445
+ });
6446
+ if (recentEvents.length > 8) {
6447
+ recentEvents.length = 8;
6448
+ }
6449
+ render(false);
6450
+ };
6451
+
6452
+ const reportExecutionStage = ({ routeKey, executionPlan, selectedRecord, phase, detail, warning = "", contextSuggestionStatus = "", intentType = "" }) => {
6453
+ const normalizedRouteKey = String(routeKey || "").trim();
6454
+ if (!normalizedRouteKey || !routeStates.has(normalizedRouteKey)) return;
6455
+ setRouteState(normalizedRouteKey, {
6456
+ phase: normalizeRunnerTUIPhase(phase),
6457
+ detail: String(detail || "").trim(),
6458
+ client: String(safeObject(executionPlan).roleProfile?.client || "").trim(),
6459
+ model: String(safeObject(executionPlan).roleProfile?.model || "").trim(),
6460
+ source_message_id: intFromRawAllowZero(safeObject(selectedRecord).parsedArchive?.messageID, 0),
6461
+ intent_type: String(intentType || safeObject(executionPlan).intentType || "").trim(),
6462
+ warning: String(warning || "").trim(),
6463
+ context_suggestion_status: String(contextSuggestionStatus || "").trim(),
6464
+ });
6465
+ };
6466
+
6467
+ const updateNextRunAt = (routeKey, nextRunAt) => {
6468
+ const normalizedRouteKey = String(routeKey || "").trim();
6469
+ if (!normalizedRouteKey || !routeStates.has(normalizedRouteKey)) return;
6470
+ setRouteState(normalizedRouteKey, { next_run_at: Number(nextRunAt) || 0 });
6471
+ };
6472
+
6473
+ return {
6474
+ start() {
6475
+ render(false);
6476
+ intervalHandle = setInterval(() => render(false), 1000);
6477
+ },
6478
+ stop() {
6479
+ if (intervalHandle) clearInterval(intervalHandle);
6480
+ render(true);
6481
+ disposed = true;
6482
+ process.stdout.write("\u001b[?25h\n");
6483
+ },
6484
+ setRouteState,
6485
+ recordResult,
6486
+ reportExecutionStage,
6487
+ updateNextRunAt,
6488
+ };
6489
+ }
6490
+
6491
+ async function runRunnerStartResolvedRoutes(routes, flags, options = {}) {
5681
6492
  const jsonMode = boolFromRaw(flags.json, false);
6493
+ const sourceLabel = String(safeObject(options).sourceLabel || "runner start").trim() || "runner start";
6494
+ const runnerLogger = createRunnerStartLogger({ routes, flags, sourceLabel, jsonMode });
6495
+ const tui = createRunnerStartTUI({ routes, flags, jsonMode, concurrency: intFromRaw(
6496
+ flags.concurrency ?? process.env.METHEUS_RUNNER_CONCURRENCY,
6497
+ routes.length > 1 ? Math.min(BOT_RUNNER_DEFAULT_CONCURRENCY, routes.length) : 1,
6498
+ ), sourceLabel, bootstrapEvent: safeObject(options).bootstrapEvent || null, logFilePath: String(runnerLogger?.filePath || "").trim() });
5682
6499
  const schedules = new Map();
5683
6500
  const inFlightExecutions = new Map();
5684
6501
  const requestedConcurrency = intFromRaw(
@@ -5695,6 +6512,10 @@ async function runRunnerStartResolvedRoutes(routes, flags) {
5695
6512
  process.on("SIGTERM", stop);
5696
6513
 
5697
6514
  try {
6515
+ if (!tui && runnerLogger?.filePath && !jsonMode) {
6516
+ process.stdout.write(`Runner log file: ${runnerLogger.filePath}\n`);
6517
+ }
6518
+ tui?.start();
5698
6519
  while (!stopRequested) {
5699
6520
  const now = Date.now();
5700
6521
  const dueRoutes = [];
@@ -5715,10 +6536,38 @@ async function runRunnerStartResolvedRoutes(routes, flags) {
5715
6536
  await runTasksWithConcurrencyLimit(dueRouteGroups, concurrency, async (routeGroup) => {
5716
6537
  for (const normalizedRoute of ensureArray(routeGroup)) {
5717
6538
  const routeKey = runnerRouteKey(normalizedRoute);
6539
+ tui?.setRouteState(routeKey, {
6540
+ phase: "polling",
6541
+ detail: "checking inbound messages and archive thread",
6542
+ });
6543
+ runnerLogger?.append("route_poll", {
6544
+ route_key: routeKey,
6545
+ route_name: normalizedRoute.name,
6546
+ logical_signature: runnerRouteLogicalSignature(normalizedRoute),
6547
+ });
5718
6548
  try {
5719
6549
  const result = await processRunnerRouteOnce(normalizedRoute, runtime, "start", { deferExecution: true });
5720
6550
  const deferredExecution = safeObject(result.deferred_execution);
5721
6551
  if (deferredExecution.routeKey) {
6552
+ tui?.reportExecutionStage({
6553
+ routeKey: deferredExecution.routeKey,
6554
+ executionPlan: deferredExecution.executionPlan,
6555
+ selectedRecord: deferredExecution.selectedRecord,
6556
+ phase: "ai_running",
6557
+ detail: `running local AI client for comment ${String(deferredExecution.selectedRecord?.id || "").trim() || "-"}`,
6558
+ });
6559
+ runnerLogger?.append("execution_accepted", {
6560
+ route_key: deferredExecution.routeKey,
6561
+ route_name: deferredExecution.normalizedRoute?.name,
6562
+ comment_id: String(deferredExecution.selectedRecord?.id || "").trim(),
6563
+ source_message_id: intFromRawAllowZero(deferredExecution.selectedRecord?.parsedArchive?.messageID, 0),
6564
+ execution_mode: String(deferredExecution.executionPlan?.mode || "").trim(),
6565
+ role_profile: String(deferredExecution.executionPlan?.roleProfileName || "").trim(),
6566
+ });
6567
+ const executionHeartbeat = startRunnerExecutionHeartbeat(
6568
+ deferredExecution.routeKey,
6569
+ deferredExecution.selectedRecord,
6570
+ );
5722
6571
  const executionPromise = (async () => {
5723
6572
  try {
5724
6573
  const processed = await processRunnerSelectedRecord({
@@ -5743,6 +6592,24 @@ async function runRunnerStartResolvedRoutes(routes, flags) {
5743
6592
  buildRunnerDeliveryDeps,
5744
6593
  buildRunnerRuntimeDeps,
5745
6594
  resolveConversationPeerBots: resolveRunnerConversationPeers,
6595
+ reportRunnerStage: (stage) => {
6596
+ runnerLogger?.append("execution_stage", {
6597
+ route_key: deferredExecution.routeKey,
6598
+ route_name: deferredExecution.normalizedRoute?.name,
6599
+ comment_id: String(deferredExecution.selectedRecord?.id || "").trim(),
6600
+ source_message_id: intFromRawAllowZero(deferredExecution.selectedRecord?.parsedArchive?.messageID, 0),
6601
+ phase: String(safeObject(stage).phase || "").trim(),
6602
+ detail: String(safeObject(stage).detail || "").trim(),
6603
+ intent_type: String(safeObject(stage).intentType || "").trim(),
6604
+ context_suggestion_status: String(safeObject(stage).contextSuggestionStatus || "").trim(),
6605
+ });
6606
+ tui?.reportExecutionStage({
6607
+ routeKey: deferredExecution.routeKey,
6608
+ executionPlan: deferredExecution.executionPlan,
6609
+ selectedRecord: deferredExecution.selectedRecord,
6610
+ ...safeObject(stage),
6611
+ });
6612
+ },
5746
6613
  },
5747
6614
  });
5748
6615
  if (processed.kind === "skipped") {
@@ -5786,20 +6653,68 @@ async function runRunnerStartResolvedRoutes(routes, flags) {
5786
6653
  comment_id: deferredExecution.selectedRecord.id,
5787
6654
  };
5788
6655
  } finally {
6656
+ executionHeartbeat.stop();
5789
6657
  inFlightExecutions.delete(deferredExecution.routeKey);
5790
6658
  schedules.set(deferredExecution.routeKey, Date.now() + 250);
6659
+ tui?.updateNextRunAt(deferredExecution.routeKey, Date.now() + 250);
5791
6660
  }
5792
6661
  })();
5793
6662
  inFlightExecutions.set(routeKey, executionPromise);
5794
6663
  executionPromise.then((finalResult) => {
5795
- printRunnerResult("start", finalResult, jsonMode);
6664
+ const latestRouteState = safeObject(loadBotRunnerState().routes[deferredExecution.routeKey]);
6665
+ runnerLogger?.append("execution_result", {
6666
+ route_key: String(finalResult?.route_key || deferredExecution.routeKey).trim(),
6667
+ route_name: String(finalResult?.route_name || deferredExecution.normalizedRoute?.name || "").trim(),
6668
+ outcome: String(finalResult?.outcome || "").trim(),
6669
+ detail: String(finalResult?.detail || "").trim(),
6670
+ comment_id: String(finalResult?.comment_id || deferredExecution.selectedRecord?.id || "").trim(),
6671
+ source_message_id: intFromRawAllowZero(latestRouteState?.last_source_message_id, intFromRawAllowZero(deferredExecution.selectedRecord?.parsedArchive?.messageID, 0)),
6672
+ intent_type: String(latestRouteState?.last_intent_type || "").trim(),
6673
+ context_suggestion_status: String(finalResult?.context_suggestion_status || latestRouteState?.last_context_suggestion_status || "").trim(),
6674
+ });
6675
+ if (tui) {
6676
+ tui.recordResult(finalResult, latestRouteState);
6677
+ } else {
6678
+ printRunnerResult("start", finalResult, jsonMode);
6679
+ }
5796
6680
  });
5797
- printRunnerResult("start", {
6681
+ const acceptedResult = {
5798
6682
  ...result,
5799
6683
  deferred_execution: undefined,
5800
- }, jsonMode);
6684
+ };
6685
+ runnerLogger?.append("route_result", {
6686
+ route_key: String(acceptedResult?.route_key || routeKey).trim(),
6687
+ route_name: String(acceptedResult?.route_name || normalizedRoute.name || "").trim(),
6688
+ outcome: String(acceptedResult?.outcome || "").trim(),
6689
+ detail: String(acceptedResult?.detail || "").trim(),
6690
+ });
6691
+ if (tui) {
6692
+ tui.recordResult(acceptedResult);
6693
+ } else {
6694
+ printRunnerResult("start", acceptedResult, jsonMode);
6695
+ }
5801
6696
  } else if (result.outcome !== "busy") {
5802
- printRunnerResult("start", result, jsonMode);
6697
+ runnerLogger?.append("route_result", {
6698
+ route_key: String(result?.route_key || routeKey).trim(),
6699
+ route_name: String(result?.route_name || normalizedRoute.name || "").trim(),
6700
+ outcome: String(result?.outcome || "").trim(),
6701
+ detail: String(result?.detail || "").trim(),
6702
+ comment_id: String(result?.comment_id || "").trim(),
6703
+ });
6704
+ if (tui) {
6705
+ tui.recordResult(result);
6706
+ } else {
6707
+ printRunnerResult("start", result, jsonMode);
6708
+ }
6709
+ } else {
6710
+ runnerLogger?.append("route_result", {
6711
+ route_key: String(result?.route_key || routeKey).trim(),
6712
+ route_name: String(result?.route_name || normalizedRoute.name || "").trim(),
6713
+ outcome: "busy",
6714
+ detail: String(result?.detail || "").trim(),
6715
+ comment_id: String(result?.comment_id || "").trim(),
6716
+ });
6717
+ tui?.recordResult(result);
5803
6718
  }
5804
6719
  } catch (err) {
5805
6720
  const errorText = String(err?.message || err);
@@ -5816,9 +6731,26 @@ async function runRunnerStartResolvedRoutes(routes, flags) {
5816
6731
  outcome: fatalArchiveBootstrapError ? "blocked" : "error",
5817
6732
  detail: errorText,
5818
6733
  };
5819
- printRunnerResult("start", result, jsonMode);
6734
+ runnerLogger?.append("route_result", {
6735
+ route_key: routeKey,
6736
+ route_name: normalizedRoute.name,
6737
+ outcome: String(result.outcome || "error").trim(),
6738
+ detail: errorText,
6739
+ });
6740
+ if (tui) {
6741
+ tui.recordResult(result);
6742
+ } else {
6743
+ printRunnerResult("start", result, jsonMode);
6744
+ }
5820
6745
  } finally {
5821
6746
  const routeState = safeObject(loadBotRunnerState().routes[routeKey]);
6747
+ const activeExecutionState = resolveRunnerActiveExecutionState(routeState);
6748
+ tui?.setRouteState(routeKey, {
6749
+ intent_type: String(routeState.last_intent_type || "").trim(),
6750
+ source_message_id: intFromRawAllowZero(routeState.last_source_message_id, 0),
6751
+ warning: String(activeExecutionState.warning || "").trim(),
6752
+ last_error: String(routeState.last_error || "").trim(),
6753
+ });
5822
6754
  const lastErrorText = String(routeState.last_error || "").trim();
5823
6755
  const isFatalArchiveBootstrapError = lastErrorText.includes("Archive thread is missing")
5824
6756
  && lastErrorText.includes("write access is denied");
@@ -5826,6 +6758,7 @@ async function runRunnerStartResolvedRoutes(routes, flags) {
5826
6758
  ? 60000
5827
6759
  : Math.max(1000, normalizedRoute.pollIntervalMs));
5828
6760
  schedules.set(routeKey, nextRunAt);
6761
+ tui?.updateNextRunAt(routeKey, nextRunAt);
5829
6762
  }
5830
6763
  }
5831
6764
  });
@@ -5845,12 +6778,14 @@ async function runRunnerStartResolvedRoutes(routes, flags) {
5845
6778
  } finally {
5846
6779
  process.off("SIGINT", stop);
5847
6780
  process.off("SIGTERM", stop);
6781
+ tui?.stop();
6782
+ runnerLogger?.close({ stop_requested: stopRequested === true });
5848
6783
  }
5849
6784
  }
5850
6785
 
5851
6786
  async function runRunnerStart(flags) {
5852
6787
  const routes = resolveRunnerRoutes(flags, "start");
5853
- await runRunnerStartResolvedRoutes(routes, flags);
6788
+ await runRunnerStartResolvedRoutes(routes, flags, { sourceLabel: "runner start" });
5854
6789
  }
5855
6790
 
5856
6791
  async function runRunner(argv) {
@@ -7493,6 +8428,10 @@ function buildLocalProjectDispatchDeps() {
7493
8428
  startDetachedRunnerWithFlags: (flags, sourceCommand) => startDetachedRunnerWithFlags(flags, sourceCommand),
7494
8429
  buildRunnerDetachedStatusPayload: (flags) => buildRunnerDetachedStatusPayload(flags),
7495
8430
  stopDetachedRunnerWithFlags: (flags) => stopDetachedRunnerWithFlags(flags),
8431
+ buildProjectBotRolesPayload: (params) => buildProjectBotRolesPayload(params),
8432
+ buildProjectBotRolesText,
8433
+ locateProjectFilesForQuery: (params) => locateProjectFilesForQuery(params),
8434
+ buildProjectFileLocateText,
7496
8435
  loadBotRunnerWorkspaceRegistry: (options) => loadBotRunnerWorkspaceRegistry(options),
7497
8436
  botRunnerWorkspaceRegistryFilePath,
7498
8437
  sanitizeWorkspaceCandidate,
@@ -7527,6 +8466,7 @@ function buildProxyToolHelperDeps() {
7527
8466
  parseToolEnvelopeFromRPCResult: (resultObj) =>
7528
8467
  parseToolEnvelopeFromRPCResult(resultObj, buildGatewayTransportDeps()),
7529
8468
  ctxpackUpdateToolNames: CTXPACK_UPDATE_TOOL_NAMES,
8469
+ workitemMutationToolNames: WORKITEM_MUTATION_TOOL_NAMES,
7530
8470
  localProjectToolNames: LOCAL_PROJECT_TOOL_NAMES,
7531
8471
  localCtxpackMergeToolNames: LOCAL_CTXPACK_MERGE_TOOL_NAMES,
7532
8472
  autoCtxpackSyncTracker,
@@ -7552,6 +8492,7 @@ function buildProxyResponsePipelineDeps() {
7552
8492
  function buildProxyGatewayRequestDeps() {
7553
8493
  return {
7554
8494
  injectCtxpackUpdateDefaults,
8495
+ injectWorkitemBotActorDefaults,
7555
8496
  injectCtxpackPreflightToken,
7556
8497
  stripLocalOnlyToolArgs,
7557
8498
  postJSON,
@@ -8027,6 +8968,8 @@ const LOCAL_PROJECT_TOOL_NAMES = [
8027
8968
  "project.get",
8028
8969
  "project.workspace",
8029
8970
  "project.workspace.bind",
8971
+ "project.bot_roles",
8972
+ "project.file.locate",
8030
8973
  "project.context.list",
8031
8974
  "project.context.get",
8032
8975
  "project.context.active",
@@ -8104,6 +9047,56 @@ function buildProjectWorkspaceBindInputSchema() {
8104
9047
  };
8105
9048
  }
8106
9049
 
9050
+ function buildProjectBotRolesInputSchema() {
9051
+ return {
9052
+ type: "object",
9053
+ properties: {
9054
+ project_id: {
9055
+ type: "string",
9056
+ description: "Project UUID.",
9057
+ },
9058
+ provider: {
9059
+ type: "string",
9060
+ enum: ["telegram", "slack", "kakaotalk"],
9061
+ description: "Optional provider filter.",
9062
+ },
9063
+ destination_id: {
9064
+ type: "string",
9065
+ description: "Optional destination UUID filter.",
9066
+ },
9067
+ destination_label: {
9068
+ type: "string",
9069
+ description: "Optional destination label filter.",
9070
+ },
9071
+ },
9072
+ additionalProperties: false,
9073
+ };
9074
+ }
9075
+
9076
+ function buildProjectFileLocateInputSchema() {
9077
+ return {
9078
+ type: "object",
9079
+ properties: {
9080
+ project_id: {
9081
+ type: "string",
9082
+ description: "Project UUID.",
9083
+ },
9084
+ query: {
9085
+ type: "string",
9086
+ description: "Filename, path fragment, or natural-language file location query.",
9087
+ },
9088
+ limit: {
9089
+ type: "integer",
9090
+ minimum: 1,
9091
+ maximum: 20,
9092
+ description: "Maximum matching file paths to return. Default: 5.",
9093
+ },
9094
+ },
9095
+ required: ["project_id", "query"],
9096
+ additionalProperties: false,
9097
+ };
9098
+ }
9099
+
8107
9100
  function buildProjectContextListInputSchema() {
8108
9101
  return {
8109
9102
  type: "object",
@@ -8417,6 +9410,18 @@ function buildLocalToolSpecs() {
8417
9410
  "Bind a project_id to a local workspace_dir in project-workspaces.json so local runner and AI execution use the correct project folder.",
8418
9411
  inputSchema: buildProjectWorkspaceBindInputSchema(),
8419
9412
  },
9413
+ {
9414
+ name: "project.bot_roles",
9415
+ description:
9416
+ "List the currently enabled local runner bot roles for a project and destination. Use this to answer role/ownership questions from current route config, not memory.",
9417
+ inputSchema: buildProjectBotRolesInputSchema(),
9418
+ },
9419
+ {
9420
+ name: "project.file.locate",
9421
+ description:
9422
+ "Locate project ctxpack file paths that match a filename or natural-language query. Use this for file/path questions.",
9423
+ inputSchema: buildProjectFileLocateInputSchema(),
9424
+ },
8420
9425
  {
8421
9426
  name: "project.context.list",
8422
9427
  description:
@@ -9116,6 +10121,16 @@ function injectCtxpackUpdateDefaults(requestObj, toolName, workspaceDir) {
9116
10121
  );
9117
10122
  }
9118
10123
 
10124
+ function injectWorkitemBotActorDefaults(requestObj, toolName) {
10125
+ return injectWorkitemBotActorDefaultsImpl(
10126
+ {
10127
+ requestObj,
10128
+ toolName,
10129
+ },
10130
+ buildProxyToolHelperDeps(),
10131
+ );
10132
+ }
10133
+
9119
10134
  async function injectCtxpackPreflightToken(requestObj, toolName, toolArgs, args, token, workspaceDir) {
9120
10135
  return injectCtxpackPreflightTokenImpl(
9121
10136
  {
@@ -10135,6 +11150,42 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
10135
11150
  push("local_project_context_suggest_tool_dispatches", false, String(err?.message || err));
10136
11151
  }
10137
11152
 
11153
+ const previousRunnerBotID = process.env.METHEUS_RUNNER_BOT_ID;
11154
+ const previousRunnerBotName = process.env.METHEUS_RUNNER_BOT_NAME;
11155
+ try {
11156
+ process.env.METHEUS_RUNNER_BOT_ID = "bot-selftest-123";
11157
+ process.env.METHEUS_RUNNER_BOT_NAME = "RyoAI_bot";
11158
+ const injected = injectWorkitemBotActorDefaults(
11159
+ {
11160
+ jsonrpc: "2.0",
11161
+ id: 51,
11162
+ method: "tools/call",
11163
+ params: {
11164
+ name: "workitem.transition",
11165
+ arguments: {
11166
+ work_item_id: "w1",
11167
+ status: "doing",
11168
+ },
11169
+ },
11170
+ },
11171
+ "workitem.transition",
11172
+ );
11173
+ const injectedArgs = safeObject(safeObject(injected).params).arguments || {};
11174
+ push(
11175
+ "workitem_bot_actor_defaults_injected",
11176
+ String(injectedArgs.actor_bot_id || "").trim() === "bot-selftest-123"
11177
+ && String(injectedArgs.actor_bot_name || "").trim() === "RyoAI_bot",
11178
+ `actor_bot_id=${String(injectedArgs.actor_bot_id || "").trim() || "(none)"} actor_bot_name=${String(injectedArgs.actor_bot_name || "").trim() || "(none)"}`,
11179
+ );
11180
+ } catch (err) {
11181
+ push("workitem_bot_actor_defaults_injected", false, String(err?.message || err));
11182
+ } finally {
11183
+ if (previousRunnerBotID === undefined) delete process.env.METHEUS_RUNNER_BOT_ID;
11184
+ else process.env.METHEUS_RUNNER_BOT_ID = previousRunnerBotID;
11185
+ if (previousRunnerBotName === undefined) delete process.env.METHEUS_RUNNER_BOT_NAME;
11186
+ else process.env.METHEUS_RUNNER_BOT_NAME = previousRunnerBotName;
11187
+ }
11188
+
10138
11189
  try {
10139
11190
  const activeResponse = await handleLocalProjectToolDispatchImpl(
10140
11191
  {
@@ -10268,6 +11319,118 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
10268
11319
  push("local_project_context_activate_tool_dispatches", false, String(err?.message || err));
10269
11320
  }
10270
11321
 
11322
+ try {
11323
+ const botRolesResponse = await handleLocalProjectToolDispatchImpl(
11324
+ {
11325
+ requestObj: {
11326
+ jsonrpc: "2.0",
11327
+ id: 7.1,
11328
+ method: "tools/call",
11329
+ params: {
11330
+ name: "project.bot_roles",
11331
+ arguments: {
11332
+ project_id: selftestProjectID,
11333
+ destination_label: "Selftest Room",
11334
+ },
11335
+ },
11336
+ },
11337
+ toolName: "project.bot_roles",
11338
+ toolArgs: {
11339
+ project_id: selftestProjectID,
11340
+ destination_label: "Selftest Room",
11341
+ },
11342
+ args: {
11343
+ baseURL: DEFAULT_SITE_URL,
11344
+ timeoutSeconds: 30,
11345
+ },
11346
+ token: "selftest-token",
11347
+ workspaceDir: "",
11348
+ workspaceSignalTrusted: true,
11349
+ },
11350
+ {
11351
+ ...buildLocalProjectDispatchDeps(),
11352
+ buildProjectBotRolesPayload: () => ({
11353
+ project_id: selftestProjectID,
11354
+ provider: "telegram",
11355
+ destination_label: "Selftest Room",
11356
+ count: 2,
11357
+ routes: [
11358
+ { route_name: "telegram-monitor-ryoai-bot", bot_name: "RyoAI_bot", role: "monitor" },
11359
+ { route_name: "telegram-monitor-ryoai2-bot", bot_name: "RyoAI2_bot", role: "review" },
11360
+ ],
11361
+ }),
11362
+ buildProjectBotRolesText: () => "RyoAI_bot=monitor, RyoAI2_bot=review",
11363
+ },
11364
+ );
11365
+ const botRolesStructured = safeObject(safeObject(botRolesResponse).result)?.structuredContent || {};
11366
+ push(
11367
+ "local_project_bot_roles_tool_dispatches",
11368
+ String(botRolesStructured.project_id || "").trim() === selftestProjectID
11369
+ && Number(botRolesStructured.count || 0) === 2
11370
+ && ensureArray(botRolesStructured.routes).length === 2,
11371
+ `count=${String(botRolesStructured.count || 0)} routes=${ensureArray(botRolesStructured.routes).map((item) => String(safeObject(item).route_name || "")).join(",") || "(none)"}`,
11372
+ );
11373
+ } catch (err) {
11374
+ push("local_project_bot_roles_tool_dispatches", false, String(err?.message || err));
11375
+ }
11376
+
11377
+ try {
11378
+ const fileLocateResponse = await handleLocalProjectToolDispatchImpl(
11379
+ {
11380
+ requestObj: {
11381
+ jsonrpc: "2.0",
11382
+ id: 7.2,
11383
+ method: "tools/call",
11384
+ params: {
11385
+ name: "project.file.locate",
11386
+ arguments: {
11387
+ project_id: selftestProjectID,
11388
+ query: "project-operating-guide",
11389
+ },
11390
+ },
11391
+ },
11392
+ toolName: "project.file.locate",
11393
+ toolArgs: {
11394
+ project_id: selftestProjectID,
11395
+ query: "project-operating-guide",
11396
+ },
11397
+ args: {
11398
+ baseURL: DEFAULT_SITE_URL,
11399
+ timeoutSeconds: 30,
11400
+ },
11401
+ token: "selftest-token",
11402
+ workspaceDir: "",
11403
+ workspaceSignalTrusted: true,
11404
+ },
11405
+ {
11406
+ ...buildLocalProjectDispatchDeps(),
11407
+ locateProjectFilesForQuery: async () => ({
11408
+ query: "project-operating-guide",
11409
+ count: 1,
11410
+ matches: [
11411
+ {
11412
+ path: ".metheus/project-guides/project-operating-guide.md",
11413
+ doc_type: "Guide",
11414
+ generated: true,
11415
+ },
11416
+ ],
11417
+ }),
11418
+ buildProjectFileLocateText: () => ".metheus/project-guides/project-operating-guide.md",
11419
+ },
11420
+ );
11421
+ const fileLocateStructured = safeObject(safeObject(fileLocateResponse).result)?.structuredContent || {};
11422
+ push(
11423
+ "local_project_file_locate_tool_dispatches",
11424
+ fileLocateStructured.ok === true
11425
+ && String(fileLocateStructured.project_id || "").trim() === selftestProjectID
11426
+ && Number(fileLocateStructured.count || 0) === 1
11427
+ && String(safeObject(ensureArray(fileLocateStructured.matches)[0]).path || "").trim() === ".metheus/project-guides/project-operating-guide.md",
11428
+ `count=${String(fileLocateStructured.count || 0)} path=${String(safeObject(ensureArray(fileLocateStructured.matches)[0]).path || "").trim() || "(none)"}`,
11429
+ );
11430
+ } catch (err) {
11431
+ push("local_project_file_locate_tool_dispatches", false, String(err?.message || err));
11432
+ }
11433
+
10271
11434
  let detachedRunnerRegistryTempHome = "";
10272
11435
  const previousDetachedRunnerUserProfile = process.env.USERPROFILE;
10273
11436
  const previousDetachedRunnerHome = process.env.HOME;
@@ -10336,6 +11499,41 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
10336
11499
  push("detached_runner_cli_args_strip_control_flags", false, String(err?.message || err));
10337
11500
  }
10338
11501
 
11502
+ try {
11503
+ const defaultLogFilePath = buildDefaultRunnerLogFilePath(
11504
+ [{ name: "telegram-monitor-selftest" }],
11505
+ "runner start",
11506
+ new Date("2026-03-19T07:45:00Z"),
11507
+ );
11508
+ push(
11509
+ "runner_default_log_file_path_uses_runner_logs_directory",
11510
+ defaultLogFilePath.includes(path.join(".metheus", "runner-logs"))
11511
+ && defaultLogFilePath.endsWith(".jsonl")
11512
+ && defaultLogFilePath.includes("runner-start")
11513
+ && defaultLogFilePath.includes("telegram-monitor-selftest"),
11514
+ defaultLogFilePath,
11515
+ );
11516
+ } catch (err) {
11517
+ push("runner_default_log_file_path_uses_runner_logs_directory", false, String(err?.message || err));
11518
+ }
11519
+
11520
+ try {
11521
+ const detachedLaunch = buildRunnerDetachedLaunchRecord(
11522
+ 4321,
11523
+ [{ name: "telegram-monitor-selftest", projectID: selftestProjectID, provider: "telegram", destinationLabel: "Selftest Room" }],
11524
+ {},
11525
+ "runner start-detached",
11526
+ "C:\\logs\\runner-selftest.jsonl",
11527
+ );
11528
+ push(
11529
+ "detached_runner_launch_record_persists_log_file",
11530
+ String(detachedLaunch.log_file || "").trim() === "C:\\logs\\runner-selftest.jsonl",
11531
+ `log_file=${String(detachedLaunch.log_file || "").trim() || "(none)"}`,
11532
+ );
11533
+ } catch (err) {
11534
+ push("detached_runner_launch_record_persists_log_file", false, String(err?.message || err));
11535
+ }
11536
+
10339
11537
  let detachedRunnerPosixTempDir = "";
10340
11538
  try {
10341
11539
  detachedRunnerPosixTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-detached-script-selftest-"));
@@ -10503,6 +11701,7 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
10503
11701
  resolved_destination: { destination_label: "Selftest Room" },
10504
11702
  workspace_mapping: { workspace_dir: "C:\\selftest-workspace", workspace_source: "project-workspaces.json:manual_override" },
10505
11703
  execution_profile: { permission_mode: "read_only" },
11704
+ active_execution: { active: true, stuck: true, source_message_id: 128, warning: "active execution is taking longer than expected" },
10506
11705
  last_run: { intent_type: "status_query" },
10507
11706
  warnings: [],
10508
11707
  errors: [],
@@ -10514,13 +11713,53 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
10514
11713
  "local_runner_show_tool_dispatches",
10515
11714
  showStructured.ok === true
10516
11715
  && String(showStructured.route_name || "").trim() === "telegram-monitor-selftest"
10517
- && String(safeObject(showStructured.last_run).intent_type || "").trim() === "status_query",
10518
- `route=${String(showStructured.route_name || "").trim() || "(none)"} workspace=${String(safeObject(showStructured.workspace_mapping).workspace_dir || "").trim() || "-"} intent=${String(safeObject(showStructured.last_run).intent_type || "").trim() || "-"}`,
11716
+ && String(safeObject(showStructured.last_run).intent_type || "").trim() === "status_query"
11717
+ && safeObject(showStructured.active_execution).stuck === true,
11718
+ `route=${String(showStructured.route_name || "").trim() || "(none)"} workspace=${String(safeObject(showStructured.workspace_mapping).workspace_dir || "").trim() || "-"} intent=${String(safeObject(showStructured.last_run).intent_type || "").trim() || "-"} stuck=${String(safeObject(showStructured.active_execution).stuck || false)}`,
10519
11719
  );
10520
11720
  } catch (err) {
10521
11721
  push("local_runner_show_tool_dispatches", false, String(err?.message || err));
10522
11722
  }
10523
11723
 
11724
+ try {
11725
+ const heartbeatFreshState = resolveRunnerActiveExecutionState({
11726
+ active_comment_id: "comment-live",
11727
+ active_source_message_id: 128,
11728
+ active_started_at: new Date(Date.now() - 15_000).toISOString(),
11729
+ active_heartbeat_at: new Date(Date.now() - 2_000).toISOString(),
11730
+ active_runner_pid: process.pid,
11731
+ });
11732
+ push(
11733
+ "runner_active_execution_heartbeat_keeps_fresh_live_execution_active",
11734
+ heartbeatFreshState.active === true
11735
+ && heartbeatFreshState.stale === false
11736
+ && heartbeatFreshState.stuck === false
11737
+ && String(heartbeatFreshState.warning || "").trim() === "",
11738
+ `active=${String(heartbeatFreshState.active)} stale=${String(heartbeatFreshState.stale)} stuck=${String(heartbeatFreshState.stuck)} warning=${String(heartbeatFreshState.warning || "").trim() || "-"}`,
11739
+ );
11740
+ } catch (err) {
11741
+ push("runner_active_execution_heartbeat_keeps_fresh_live_execution_active", false, String(err?.message || err));
11742
+ }
11743
+
11744
+ try {
11745
+ const heartbeatExpiredState = resolveRunnerActiveExecutionState({
11746
+ active_comment_id: "comment-stale",
11747
+ active_source_message_id: 129,
11748
+ active_started_at: new Date(Date.now() - (BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_STALE_MS + 20_000)).toISOString(),
11749
+ active_heartbeat_at: new Date(Date.now() - (BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_STALE_MS + 5_000)).toISOString(),
11750
+ active_runner_pid: process.pid,
11751
+ });
11752
+ push(
11753
+ "runner_active_execution_heartbeat_marks_live_execution_stale_when_expired",
11754
+ heartbeatExpiredState.active === false
11755
+ && heartbeatExpiredState.stale === true
11756
+ && String(heartbeatExpiredState.warning || "").trim() === "active execution heartbeat expired; clearing busy state",
11757
+ `active=${String(heartbeatExpiredState.active)} stale=${String(heartbeatExpiredState.stale)} warning=${String(heartbeatExpiredState.warning || "").trim() || "-"}`,
11758
+ );
11759
+ } catch (err) {
11760
+ push("runner_active_execution_heartbeat_marks_live_execution_stale_when_expired", false, String(err?.message || err));
11761
+ }
11762
+
10524
11763
  try {
10525
11764
  const startResponse = await handleLocalProjectToolDispatchImpl(
10526
11765
  {
@@ -10678,6 +11917,51 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
10678
11917
  push("local_runner_stop_tool_returns_stopped_entries", false, String(err?.message || err));
10679
11918
  }
10680
11919
 
11920
+ try {
11921
+ const frame = buildRunnerTUIFrame({
11922
+ routes: [
11923
+ {
11924
+ route_name: "telegram-monitor-selftest",
11925
+ phase: "ai_running",
11926
+ client: "gpt",
11927
+ model: "gpt-5.4",
11928
+ intent_type: "status_query",
11929
+ source_message_id: 128,
11930
+ next_run_at: Date.now() + 5000,
11931
+ detail: "running local AI execution",
11932
+ warning: "active execution is taking longer than expected",
11933
+ phase_started_at: Date.now() - 15000,
11934
+ },
11935
+ ],
11936
+ recentEvents: [
11937
+ {
11938
+ time: "12:34:56",
11939
+ route_name: "telegram-monitor-selftest",
11940
+ outcome: "accepted",
11941
+ detail: "accepted comment 128 for background execution",
11942
+ },
11943
+ ],
11944
+ concurrency: 1,
11945
+ now: Date.now(),
11946
+ useColor: false,
11947
+ logFilePath: "C:\\logs\\runner-selftest.jsonl",
11948
+ });
11949
+ push(
11950
+ "runner_tui_frame_renders_route_statuses",
11951
+ frame.includes("METHEUS RUNNER LIVE")
11952
+ && frame.includes("Log: C:\\logs\\runner-selftest.jsonl")
11953
+ && frame.includes("Summary: waiting=0 polling=0 running=1 busy=0 replied=0 error=0 warnings=1")
11954
+ && frame.includes("telegram-monitor-self...")
11955
+ && frame.includes("AI_RUNNING")
11956
+ && frame.includes("gpt/gpt-5.4")
11957
+ && frame.includes("status_query")
11958
+ && frame.includes("warning=active execution is taking longer than expected"),
11959
+ frame.replace(/\r?\n/g, " | "),
11960
+ );
11961
+ } catch (err) {
11962
+ push("runner_tui_frame_renders_route_statuses", false, String(err?.message || err));
11963
+ }
11964
+
10681
11965
  await runSelftestBotCommands(push, {
10682
11966
  cliPath: fileURLToPath(import.meta.url),
10683
11967
  parseSimpleEnvText,