opencode-workflow-guard 1.1.5 → 1.3.0

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.
@@ -0,0 +1,133 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, resolve } from "node:path";
3
+ import { getWorkspaceRoot } from "../lib/state.ts";
4
+
5
+ export const SAFE_ENV_FIXTURE_RE =
6
+ /\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
7
+
8
+ export interface SecretPattern {
9
+ re: RegExp;
10
+ what: string;
11
+ }
12
+
13
+ export const SECRET_PATTERNS: SecretPattern[] = [
14
+ { re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, what: "private key material" },
15
+ { re: /\bAKIA[0-9A-Z]{16}\b/, what: "AWS access key ID" },
16
+ { re: /\bASIA[0-9A-Z]{16}\b/, what: "AWS temporary session credential" },
17
+ { re: /\bghp_[A-Za-z0-9]{36}\b/, what: "GitHub personal access token" },
18
+ { re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/, what: "GitHub fine-grained PAT" },
19
+ { re: /\bgho_[A-Za-z0-9]{36}\b/, what: "GitHub OAuth token" },
20
+ { re: /\bghu_[A-Za-z0-9]{36}\b/, what: "GitHub user-to-server token" },
21
+ { re: /\bghs_[A-Za-z0-9]{36}\b/, what: "GitHub server-to-server token" },
22
+ { re: /\bghr_[A-Za-z0-9]{36}\b/, what: "GitHub refresh token" },
23
+ { re: /\bsk-[A-Za-z0-9]{20,}\b/, what: "OpenAI-style API key" },
24
+ { re: /\bog-[A-Za-z0-9]{20,}\b/, what: "OpenCode/legacy API key" },
25
+ { re: /\bAIza[0-9A-Za-z_-]{35}\b/, what: "Google API key" },
26
+ { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, what: "Slack token (xox family)" },
27
+ { re: /(?:^|\s)(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)\s*=\s*\S+/, what: "AWS credential assignment" },
28
+ { re: /(?:^|\s)(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENAI_KEY)\s*=\s*\S+/, what: "LLM API key assignment" },
29
+ ];
30
+
31
+ export function secretIn(content: string): string | undefined {
32
+ for (const { re, what } of SECRET_PATTERNS) {
33
+ if (re.test(content)) return what;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ export function isSecretPath(targetPath: string): boolean {
39
+ if (!targetPath) return false;
40
+ const root = getWorkspaceRoot();
41
+ const resolved = resolve(root, targetPath);
42
+ const matches = (path: string): boolean => {
43
+ const base = basename(path).toLowerCase();
44
+ const full = path.toLowerCase();
45
+
46
+ // Safe fixtures
47
+ if (SAFE_ENV_FIXTURE_RE.test(base)) {
48
+ return false;
49
+ }
50
+
51
+ // .env files (e.g. .env, .env.local, .env.prod, .env.secret)
52
+ if (/^\.env(?:\.|$)/i.test(base)) {
53
+ return true;
54
+ }
55
+
56
+ // SSH & TLS key material
57
+ if (
58
+ /^(id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.|$)/i.test(base) ||
59
+ /\.(pem|key|pkcs12|pfx|p12)$/i.test(base)
60
+ ) {
61
+ return true;
62
+ }
63
+
64
+ // Cloud / cluster / service account credentials
65
+ if (
66
+ /kubeconfig/i.test(base) ||
67
+ full.includes("/.kube/config") ||
68
+ /^(service[-_]?account|client[-_]?secret).*\.json$/i.test(base) ||
69
+ /credentials\.json$/i.test(base) ||
70
+ full.includes("/.aws/credentials") ||
71
+ full.includes("/.docker/config.json") ||
72
+ base === ".netrc" ||
73
+ base === ".git-credentials"
74
+ ) {
75
+ return true;
76
+ }
77
+
78
+ return false;
79
+ };
80
+
81
+ if (matches(resolved)) return true;
82
+ try {
83
+ return matches(realpathSync(resolved));
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Returns true specifically for environment variable files (.env, .env.local,
91
+ * etc.) that can safely present their schema (masked keys) to agents without
92
+ * exposing secret values.
93
+ */
94
+ export function isEnvFilePath(targetPath: string): boolean {
95
+ if (!targetPath) return false;
96
+ const base = basename(targetPath).toLowerCase();
97
+ if (SAFE_ENV_FIXTURE_RE.test(base)) return false;
98
+ return /^\.env(?:\.|$)/i.test(base);
99
+ }
100
+
101
+ /**
102
+ * Parses a raw .env file and returns a sanitized schema mask where variable
103
+ * names and comment structures are preserved, but sensitive values are
104
+ * redacted to '********'.
105
+ */
106
+ export function generateMaskedEnvSchema(content: string): string {
107
+ const lines = content.split("\n");
108
+ return lines
109
+ .map((line) => {
110
+ const trimmed = line.trim();
111
+ if (!trimmed || trimmed.startsWith("#")) return line;
112
+ const eqIdx = line.indexOf("=");
113
+ if (eqIdx === -1) return line;
114
+ const key = line.slice(0, eqIdx);
115
+ return `${key}=********`;
116
+ })
117
+ .join("\n");
118
+ }
119
+
120
+ export const SECRET_READ_COMMAND_RE =
121
+ /(?:^|\s)(?:cat|head|tail|less|more|grep|awk|sed|od|hexdump|strings|base64|xxd|nl|sort|uniq|view|nano|vim?)\s+[^|;&]*?(?:["']?)([\w\/.~-]*\.(?:pem|key|pfx|p12)|[\w\/.~-]*\.env(?:\.[\w-]+)*|[\w\/.~-]*id_(?:rsa|dsa|ecdsa|ed25519)[\w.-]*|[\w\/.~-]*kubeconfig[\w.-]*|[\w\/.~-]*(?:service[-_]?account|credentials|client[-_]?secret)[\w.-]*\.json)(?:["']?)/i;
122
+ export const SIMPLE_FILE_READ_COMMAND_RE =
123
+ /(?:^|\s)(?:cat|head|tail|less|more|od|hexdump|strings|base64|xxd|nl|view|nano|vim?)\s+(?:-\S+\s+)*["']?([^\s|;&"']+)/i;
124
+
125
+ export function secretFileReadIn(segment: string): string | undefined {
126
+ const match = segment.match(SECRET_READ_COMMAND_RE);
127
+ if (match?.[1] && isSecretPath(match[1])) {
128
+ return match[1];
129
+ }
130
+ const simpleMatch = segment.match(SIMPLE_FILE_READ_COMMAND_RE);
131
+ if (simpleMatch?.[1] && isSecretPath(simpleMatch[1])) return simpleMatch[1];
132
+ return undefined;
133
+ }
@@ -0,0 +1,128 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ // AI agents frequently run dangerous package manager flags (e.g. npm audit fix --force,
4
+ // npm install -g, pip install --force-reinstall, agent-driven publish) that introduce
5
+ // breaking changes, downgrade packages insecurely, or execute arbitrary code.
6
+
7
+ const PACKAGE_HYGIENE_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
8
+ {
9
+ regex: /\bnpm\s+audit\s+fix\s+--force\b/i,
10
+ name: "destructive npm audit fix --force",
11
+ advice: "Run 'npm audit' or update dependencies individually in package.json. '--force' introduces breaking major version downgrades.",
12
+ },
13
+ {
14
+ regex: /\b(?:npm|pnpm|yarn|bun)\s+(?:install|i|add)\s+-[a-zA-Z]*g\b/i,
15
+ name: "global package installation",
16
+ advice: "Install packages locally as devDependencies (--save-dev) or execute with npx/bunx rather than polluting global machine state.",
17
+ },
18
+ {
19
+ regex: /\bpip\s+install\s+(?:--upgrade\s+)?--force-reinstall\b/i,
20
+ name: "destructive pip force reinstall",
21
+ advice: "Specify exact versions in requirements.txt or pyproject.toml instead of force reinstalling.",
22
+ },
23
+ {
24
+ regex: /\b(?:npm|yarn|pnpm|bun)\s+publish\b/i,
25
+ name: "direct package publish from agent subshell",
26
+ advice: "Package publishing must be managed by automated release workflows or explicit user release commands.",
27
+ },
28
+ ];
29
+
30
+ export function checkPackageHygiene(command: string): { isViolating: boolean; name?: string; advice?: string } {
31
+ for (const { regex, name, advice } of PACKAGE_HYGIENE_PATTERNS) {
32
+ if (regex.test(command)) {
33
+ return { isViolating: true, name, advice };
34
+ }
35
+ }
36
+ return { isViolating: false };
37
+ }
38
+
39
+ // AI agents running in non-interactive terminal subshells hang indefinitely when an
40
+ // interactive prompt (vim, nano, less, top, sudo, npm init without -y, apt without -y,
41
+ // git rebase -i) waits for TTY input.
42
+
43
+ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
44
+ {
45
+ regex: /\b(?:nano|vim?|emacs|pico|joe|micro)\b/i,
46
+ name: "interactive text editor",
47
+ advice: "Use the edit, write, or apply_patch tools instead of interactive terminal editors.",
48
+ },
49
+ {
50
+ regex: /\b(?:less|more|most)\b/i,
51
+ name: "terminal pager",
52
+ advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
53
+ },
54
+ {
55
+ regex: /\b(?:top|htop|btop|atop|glances)\b/i,
56
+ name: "interactive process monitor",
57
+ advice: "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.",
58
+ },
59
+ {
60
+ regex: /\bsudo\b/i,
61
+ name: "sudo with password prompt",
62
+ advice: "Avoid sudo in agent sessions; run commands directly or request user execution.",
63
+ },
64
+ {
65
+ regex: /\bgit\s+rebase\s+-[a-zA-Z]*i/i,
66
+ name: "git interactive rebase",
67
+ advice: "Use non-interactive git commands or explicit cherry-pick/merge sequences.",
68
+ },
69
+ {
70
+ regex: /\bgit\s+add\s+-[a-zA-Z]*p/i,
71
+ name: "git interactive patch",
72
+ advice: "Use git add <file> or git apply rather than interactive patch selection.",
73
+ },
74
+ {
75
+ regex: /\bnpm\s+init\b(?!\s+(?:-y|--yes|--force))\b/i,
76
+ name: "npm init without non-interactive flag",
77
+ advice: "Add -y or --yes flag: npm init -y",
78
+ },
79
+ {
80
+ regex: /\b(?:apt|apt-get)\s+(?:install|remove|purge|upgrade|dist-upgrade)\b(?!\s+(?:-y|--yes|--assume-yes))\b/i,
81
+ name: "apt command without non-interactive flag",
82
+ advice: "Add -y flag to avoid interactive confirmation prompts (e.g. apt-get install -y <pkg>).",
83
+ },
84
+ {
85
+ regex: /\b(?:yum|dnf)\s+(?:install|remove|upgrade)\b(?!\s+-y)\b/i,
86
+ name: "yum/dnf command without non-interactive flag",
87
+ advice: "Add -y flag to avoid interactive confirmation prompts (e.g. dnf install -y <pkg>).",
88
+ },
89
+ ];
90
+
91
+ export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
92
+ for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
93
+ if (regex.test(command)) {
94
+ return { isInteractive: true, name, advice };
95
+ }
96
+ }
97
+ return { isInteractive: false };
98
+ }
99
+
100
+ /**
101
+ * Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
102
+ * on important guard events (policy blocks, verification completion).
103
+ * Non-blocking, fails gracefully.
104
+ */
105
+ export function sendDesktopNotification(title: string, message: string): void {
106
+ if (process.env.WORKFLOW_GUARD_NOTIFY === "0") return;
107
+ const safeTitle = title.replace(/"/g, '\\"');
108
+ const safeMsg = message.slice(0, 150).replace(/"/g, '\\"');
109
+
110
+ try {
111
+ if (process.platform === "darwin") {
112
+ const child = spawn(
113
+ "osascript",
114
+ ["-e", `display notification "${safeMsg}" with title "${safeTitle}"`],
115
+ { stdio: "ignore", detached: true },
116
+ );
117
+ child.on("error", () => {});
118
+ child.unref();
119
+ } else if (process.platform === "linux") {
120
+ const child = spawn("notify-send", [safeTitle, safeMsg], {
121
+ stdio: "ignore",
122
+ detached: true,
123
+ });
124
+ child.on("error", () => {});
125
+ child.unref();
126
+ }
127
+ } catch {}
128
+ }
@@ -0,0 +1,86 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { basename, relative, resolve } from "node:path";
3
+ import { getWorkspaceRoot } from "../lib/state.ts";
4
+
5
+ export const PROTECTED_PATH_REASON =
6
+ "Blocked: modifying Open" +
7
+ "Code settings, permissions, auth, or the guard " +
8
+ "plugin itself is not allowed from the agent. The user must change " +
9
+ "these manually in configuration or the UI.";
10
+
11
+ const GT = String.fromCharCode(62);
12
+ const V_LIST = ["sed\\s+-i", "tee", "mv", "cp", "rm", "chmod", "chown", "ln", "install", "truncate", "dd"].join("|");
13
+ const C_LIST = ["open" + "code\\.jsonc?", "\\.config\\/open" + "code", "\\.open" + "code\\/"].join("|");
14
+ const U_LIST = ["\\.config\\/open" + "code\\/(?:plugins|ui)\\/"].join("|");
15
+
16
+ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
17
+ new RegExp(`(?:^|\\s)(?:${V_LIST})\\s+[^|;&]*?(?:[\\w\\/.~-]*(?:${C_LIST}))`, "i"),
18
+ new RegExp(`${GT}\\s*["']?[\\w\\/.~-]*(?:${C_LIST})`, "i"),
19
+ new RegExp(`(?:^|\\s)(?:${V_LIST})\\s+[^|;&]*?[\\w\\/.~-]*(?:${U_LIST})`, "i"),
20
+ new RegExp(`${GT}\\s*["']?[\\w\\/.~-]*(?:${U_LIST})`, "i"),
21
+ new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:auth|config|permission)\\b", "i"),
22
+ new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:run\\s+)?--auto\\b", "i"),
23
+ ];
24
+
25
+ export function normalizeShellEvasion(text: string): string {
26
+ return text
27
+ .replace(/'([^']*)'/g, "$1")
28
+ .replace(/"([^"]*)"/g, "$1")
29
+ .replace(/\\(.)/g, "$1");
30
+ }
31
+
32
+ export function normalizeGlobPathEvasion(text: string): string {
33
+ const ocJson = "op" + "encode.json";
34
+ return text
35
+ .replace(/opencode\.jso[?]/gi, ocJson)
36
+ .replace(/opencode\.[?*]/gi, ocJson);
37
+ }
38
+
39
+ export function isSettingsTamper(command: string): boolean {
40
+ const segments = command.split(/[\n|;&]+/).map((s) =>
41
+ normalizeGlobPathEvasion(normalizeShellEvasion(s)),
42
+ );
43
+ return segments.some((segment) =>
44
+ SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
45
+ );
46
+ }
47
+
48
+ export function isProtectedPath(targetPath: string): boolean {
49
+ if (!targetPath) return false;
50
+ const root = getWorkspaceRoot();
51
+ const resolved = resolve(root, targetPath);
52
+ const matches = (path: string): boolean => {
53
+ const base = basename(path);
54
+ const lower = path.toLowerCase();
55
+ const dotOc = "." + "opencode/";
56
+ const cfgOc = "/.config/" + "opencode/";
57
+ const cfgOcJson = "/.config/" + "opencode.json";
58
+ return (
59
+ /^opencode\.jsonc?$/i.test(base) ||
60
+ /^workflow-guard\.jsonc?$/i.test(base) ||
61
+ // the .opencode directory itself and anything under it (project
62
+ // plugins, agents) - including the exact directory, not just paths
63
+ // nested inside it
64
+ lower === ".opencode" ||
65
+ lower.endsWith("/.opencode") ||
66
+ lower.includes("/" + dotOc) ||
67
+ lower.includes(cfgOc) ||
68
+ lower.includes(cfgOcJson)
69
+ );
70
+ };
71
+ if (matches(resolved)) return true;
72
+ try {
73
+ return matches(realpathSync(resolved));
74
+ } catch {
75
+ let ancestor = resolve(resolved, "..");
76
+ while (ancestor !== resolve(ancestor, "..")) {
77
+ try {
78
+ const realAncestor = realpathSync(ancestor);
79
+ return matches(resolve(realAncestor, relative(ancestor, resolved)));
80
+ } catch {
81
+ ancestor = resolve(ancestor, "..");
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+ }
@@ -0,0 +1,106 @@
1
+ import type { TodoItem } from "../lib/types.ts";
2
+ import { getSdkClient } from "../lib/state.ts";
3
+
4
+ export const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
5
+ export const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
6
+
7
+ export async function fetchSessionTodos(sessionID: string): Promise<TodoItem[] | undefined> {
8
+ const client = getSdkClient();
9
+ const session = client?.session;
10
+ const todo = session?.todo;
11
+ if (typeof todo !== "function") return undefined;
12
+ try {
13
+ const result = await todo.call(session, { path: { id: sessionID } });
14
+ const data = (result as { data?: unknown } | undefined)?.data;
15
+ return Array.isArray(data) ? (data as TodoItem[]) : undefined;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ export async function fetchParentSessionID(sessionID: string): Promise<string | undefined> {
22
+ const client = getSdkClient();
23
+ const session = client?.session;
24
+ const get = session?.get;
25
+ if (typeof get !== "function") return undefined;
26
+ try {
27
+ const result = await get.call(session, { path: { id: sessionID } });
28
+ const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
29
+ return typeof parent === "string" && parent ? parent : undefined;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ export async function effectiveTodos(
36
+ sessionID: string | undefined,
37
+ ): Promise<TodoItem[] | undefined> {
38
+ if (!sessionID) return undefined;
39
+ const seen = new Set<string>();
40
+ let current: string | undefined = sessionID;
41
+ while (current && !seen.has(current)) {
42
+ seen.add(current);
43
+ const todos = await fetchSessionTodos(current);
44
+ if (todos === undefined) return undefined;
45
+ if (todos.length > 0) return todos;
46
+ current = await fetchParentSessionID(current);
47
+ }
48
+ return [];
49
+ }
50
+
51
+ export async function effectiveTodoOwnerSessionID(
52
+ sessionID: string | undefined,
53
+ ): Promise<string | undefined> {
54
+ if (!sessionID) return undefined;
55
+ const seen = new Set<string>();
56
+ let current: string | undefined = sessionID;
57
+ while (current && !seen.has(current)) {
58
+ seen.add(current);
59
+ const todos = await fetchSessionTodos(current);
60
+ if (todos === undefined) return sessionID;
61
+ if (todos.length > 0) return current;
62
+ current = await fetchParentSessionID(current);
63
+ }
64
+ return sessionID;
65
+ }
66
+
67
+ export function hasActiveTodo(todos: TodoItem[]): boolean {
68
+ return todos.some((todo) => ACTIVE_TODO_STATUSES.has(String(todo.status ?? "")));
69
+ }
70
+
71
+ export function validateTodoLifecycle(
72
+ newTodos: TodoItem[],
73
+ existingTodos: TodoItem[] | undefined,
74
+ ): string | undefined {
75
+ // Rule: No silent task deletion while active work remains
76
+ // (single-focus rule removed: multiple tasks may be in_progress to allow
77
+ // concurrent subagent work on different tasks)
78
+ if (existingTodos && existingTodos.length > 0) {
79
+ const activeExisting = existingTodos.filter((t) => {
80
+ const s = String(t.status ?? "");
81
+ return s === "pending" || s === "in_progress";
82
+ });
83
+ if (activeExisting.length > 0) {
84
+ const newContentCounts = new Map<string, number>();
85
+ for (const todo of newTodos) {
86
+ const content = String(todo.content ?? "").trim();
87
+ newContentCounts.set(content, (newContentCounts.get(content) ?? 0) + 1);
88
+ }
89
+ const missing = activeExisting.find((todo) => {
90
+ const content = String(todo.content ?? "").trim();
91
+ const remaining = newContentCounts.get(content) ?? 0;
92
+ if (remaining === 0) return true;
93
+ newContentCounts.set(content, remaining - 1);
94
+ return false;
95
+ });
96
+ if (missing) {
97
+ return (
98
+ `Blocked todowrite: active task '${String(missing.content ?? "")}' was removed ` +
99
+ "without being marked completed or cancelled. Tasks cannot silently disappear."
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ return undefined;
106
+ }
@@ -24,19 +24,75 @@ function text(props: Record<string, unknown>, children: Child[]) {
24
24
  return element("text", props, children);
25
25
  }
26
26
 
27
- const BADGE = "🛡️ [Workflow Guard: Active]";
27
+ const BADGE_ACTIVE = "🛡️ [Workflow Guard: Active]";
28
+ let lastBlockedReason: string | undefined;
29
+ const sessionBlockedReasons = new Map<string, string>();
30
+ const badgeNodes = new Map<string, unknown>();
31
+
32
+ export function setLastBlockedReasonForTesting(reason: string | undefined): void {
33
+ lastBlockedReason = reason;
34
+ if (reason === undefined) sessionBlockedReasons.clear();
35
+ }
36
+
37
+ export function getLastBlockedReason(): string | undefined {
38
+ return lastBlockedReason;
39
+ }
40
+
41
+ export function formatBadge(sessionID?: string): { text: string; isBlocked: boolean } {
42
+ const reason = sessionID ? sessionBlockedReasons.get(sessionID) : lastBlockedReason;
43
+ if (!reason) {
44
+ return { text: BADGE_ACTIVE, isBlocked: false };
45
+ }
46
+ const cleanReason = reason.replace(/^\[workflow-guard\]\s*/, "").replace(/^Blocked:\s*/, "");
47
+ const shortReason = cleanReason.length > 30 ? cleanReason.slice(0, 27) + "..." : cleanReason;
48
+ return { text: `🛡️ [Workflow Guard: Blocked: ${shortReason}]`, isBlocked: true };
49
+ }
28
50
 
29
51
  export const WorkflowGuardTui: TuiPlugin = async (api) => {
52
+ try {
53
+ api.event?.on?.("tui.toast.show" as any, (e: any) => {
54
+ const title = e?.properties?.title ?? e?.title;
55
+ const msg = e?.properties?.message ?? e?.message;
56
+ if (title === "Workflow Guard Blocked" && typeof msg === "string") {
57
+ lastBlockedReason = msg;
58
+ const route = api.route.current;
59
+ const sessionID =
60
+ route.name === "session" && typeof route.params?.sessionID === "string"
61
+ ? route.params.sessionID
62
+ : undefined;
63
+ if (sessionID) sessionBlockedReasons.set(sessionID, msg);
64
+ const key = sessionID ?? "home";
65
+ const node = badgeNodes.get(key);
66
+ if (node) {
67
+ const badge = formatBadge(sessionID);
68
+ setProp(node as any, "content", badge.text);
69
+ setProp(node as any, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
70
+ }
71
+ }
72
+ });
73
+ } catch {}
74
+
30
75
  api.slots.register({
31
76
  order: 1,
32
77
  slots: {
33
78
  home_prompt_right() {
34
79
  const theme = api.theme.current;
35
- return text({ fg: theme.success }, [BADGE]);
80
+ const badge = formatBadge();
81
+ const node = text({ fg: badge.isBlocked ? theme.warning : theme.success }, [badge.text]);
82
+ badgeNodes.set("home", node);
83
+ return node;
36
84
  },
37
85
  session_prompt_right() {
38
86
  const theme = api.theme.current;
39
- return text({ fg: theme.success }, [BADGE]);
87
+ const route = api.route.current;
88
+ const sessionID =
89
+ route.name === "session" && typeof route.params?.sessionID === "string"
90
+ ? route.params.sessionID
91
+ : undefined;
92
+ const badge = formatBadge(sessionID);
93
+ const node = text({ fg: badge.isBlocked ? theme.warning : theme.success }, [badge.text]);
94
+ if (sessionID) badgeNodes.set(sessionID, node);
95
+ return node;
40
96
  },
41
97
  },
42
98
  });