@mystilleef/pi-subagent 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lateef Alabi-Oki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # Subagent
2
+
3
+ This `pi` extension provides a `subagent` tool designed for the
4
+ [SPAE Framework](https://github.com/mystilleef/spae-framework).
5
+
6
+ ## Installation
7
+
8
+ ```sh
9
+ pi install npm:@mystilleef/pi-subagent
10
+ ```
11
+
12
+ ## Features
13
+
14
+ - **Asynchronous:** Agents always run in background.
15
+ - **Parallel:** Run more than one agents simultaneously.
16
+ - **Simplicity:** No advanced orchestration workflows.
17
+ - **Bloat-free:** No pre-installed agents.
18
+
19
+ ## Usage
20
+
21
+ Invoke an agent with:
22
+
23
+ ```text
24
+ /run agent [optional task]
25
+ ```
26
+
27
+ Stop running agents with:
28
+
29
+ ```text
30
+ /cancel-subagent
31
+ ```
32
+
33
+ ## Workflow
34
+
35
+ The [SPAE Framework](https://github.com/mystilleef/spae-framework) emphasizes a structured workflow.
36
+
37
+ | Phase | Agent | Purpose |
38
+ | ----- | ------------------------- | --------------------------------------------- |
39
+ | 1 | `/run spec <requirement>` | Distill requirements into `SPEC.md` |
40
+ | 2 | `/run plan` | Decompose `SPEC.md` into an atomic task graph |
41
+ | 3 | `/run inspect` | Perform gap analysis and optimize `PLAN.md` |
42
+ | 4 | `/run build` | Carry out tasks from `PLAN.md` |
43
+ | 5 | `/run verify` | Verify implementation against `SPEC.md` |
44
+
45
+ Visit the [SPAE Framework](https://github.com/mystilleef/spae-framework) for pre-packaged agents and their associated skills.
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@mystilleef/pi-subagent",
3
+ "version": "0.3.0",
4
+ "description": "Pi subagent for the SPAE Framework",
5
+ "author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/mystilleef/pi-subagent.git"
10
+ },
11
+ "keywords": [
12
+ "pi",
13
+ "agent",
14
+ "subagent",
15
+ "orchestration",
16
+ "ai",
17
+ "llm"
18
+ ],
19
+ "module": "src/index.ts",
20
+ "main": "src/index.ts",
21
+ "exports": {
22
+ ".": "./src/index.ts"
23
+ },
24
+ "type": "module",
25
+ "engines": {
26
+ "bun": ">=1.3.13"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "tsconfig.json"
32
+ ],
33
+ "pi": {
34
+ "extensions": [
35
+ "./src/index.ts"
36
+ ]
37
+ },
38
+ "scripts": {
39
+ "verify": "bun check && bun test",
40
+ "coverage": "bun check && bun test --coverage",
41
+ "check": "biome check --fix --unsafe . && tsc --noEmit",
42
+ "migrate": "biome migrate --write",
43
+ "release": "sh -c 'npm version \"$1\" && git push --follow-tags && bun publish --access public' --"
44
+ },
45
+ "dependencies": {
46
+ "@earendil-works/pi-agent-core": "^0.74.0",
47
+ "@earendil-works/pi-ai": "^0.74.0",
48
+ "@earendil-works/pi-coding-agent": "^0.74.0",
49
+ "@earendil-works/pi-tui": "^0.74.0",
50
+ "typebox": "^1.1.38"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2.4.15",
54
+ "@types/bun": "^1.3.14",
55
+ "@types/node": "^25.8.0",
56
+ "typescript": "^6.0.3"
57
+ }
58
+ }
@@ -0,0 +1,41 @@
1
+ import path from "node:path";
2
+ import {
3
+ type AgentDiscoveryResult,
4
+ type AgentScope,
5
+ discoverAgents,
6
+ } from "./agents.js";
7
+
8
+ export type AgentDiscoveryCacheEntry = AgentDiscoveryResult & { ts: number };
9
+ export type AgentDiscoveryCache = Map<string, AgentDiscoveryCacheEntry>;
10
+ export const AGENT_DISCOVERY_CACHE_TTL_MS = 3_000;
11
+ const sharedAgentDiscoveryCache: AgentDiscoveryCache = new Map();
12
+
13
+ export function resetAgentDiscoveryCache(): void {
14
+ sharedAgentDiscoveryCache.clear();
15
+ }
16
+
17
+ export function getCachedAgentDiscovery(
18
+ cwd: string,
19
+ scope: AgentScope,
20
+ cache: AgentDiscoveryCache = sharedAgentDiscoveryCache,
21
+ cacheTtlMs = AGENT_DISCOVERY_CACHE_TTL_MS,
22
+ ): AgentDiscoveryCacheEntry {
23
+ const key = `${path.resolve(cwd)}\0${scope}`;
24
+ const now = Date.now();
25
+ const entry = cache.get(key);
26
+ if (entry && now - entry.ts <= cacheTtlMs) return entry;
27
+ const nextEntry = { ...discoverAgents(cwd, scope), ts: now };
28
+ cache.set(key, nextEntry);
29
+ return nextEntry;
30
+ }
31
+
32
+ export function getCachedAgentCompletions(
33
+ prefix: string,
34
+ cwd = process.cwd(),
35
+ cache: AgentDiscoveryCache = sharedAgentDiscoveryCache,
36
+ cacheTtlMs = AGENT_DISCOVERY_CACHE_TTL_MS,
37
+ ): { value: string; label: string }[] {
38
+ return getCachedAgentDiscovery(cwd, "both", cache, cacheTtlMs)
39
+ .agents.filter((agent) => agent.name.startsWith(prefix))
40
+ .map((agent) => ({ value: agent.name, label: agent.name }));
41
+ }
package/src/agents.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Agent discovery and configuration
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
+
9
+ export type AgentScope = "user" | "project" | "both";
10
+
11
+ export type ThinkingLevel =
12
+ | "off"
13
+ | "minimal"
14
+ | "low"
15
+ | "medium"
16
+ | "high"
17
+ | "xhigh";
18
+
19
+ const THINKING_LEVELS = [
20
+ "off",
21
+ "minimal",
22
+ "low",
23
+ "medium",
24
+ "high",
25
+ "xhigh",
26
+ ] as const;
27
+
28
+ export interface AgentConfig {
29
+ name: string;
30
+ description: string;
31
+ tools?: string[];
32
+ skills?: string[];
33
+ thinking?: ThinkingLevel;
34
+ systemPrompt: string;
35
+ source: "user" | "project";
36
+ filePath: string;
37
+ }
38
+
39
+ export interface AgentDiscoveryResult {
40
+ agents: AgentConfig[];
41
+ projectAgentsDir: string | null;
42
+ }
43
+
44
+ function parseCommaList(raw: unknown): string[] | undefined {
45
+ if (typeof raw !== "string") return undefined;
46
+ const items = raw
47
+ .split(",")
48
+ .map((s) => s.trim())
49
+ .filter(Boolean);
50
+ return items.length > 0 ? items : undefined;
51
+ }
52
+
53
+ function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
54
+ if (typeof raw !== "string") return undefined;
55
+ const normalized = raw.trim().toLowerCase();
56
+ return (THINKING_LEVELS as readonly string[]).includes(normalized)
57
+ ? (normalized as ThinkingLevel)
58
+ : undefined;
59
+ }
60
+
61
+ function loadAgentsFromDir(
62
+ dir: string,
63
+ source: "user" | "project",
64
+ ): AgentConfig[] {
65
+ const agents: AgentConfig[] = [];
66
+ if (!fs.existsSync(dir)) {
67
+ return agents;
68
+ }
69
+ let entries: fs.Dirent[];
70
+ try {
71
+ entries = fs.readdirSync(dir, { withFileTypes: true });
72
+ } catch {
73
+ return agents;
74
+ }
75
+ for (const entry of entries) {
76
+ if (!entry.name.endsWith(".md")) continue;
77
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
78
+ const filePath = path.join(dir, entry.name);
79
+ let content: string;
80
+ try {
81
+ content = fs.readFileSync(filePath, "utf-8");
82
+ } catch {
83
+ continue;
84
+ }
85
+ let parsed: ReturnType<typeof parseFrontmatter<Record<string, unknown>>>;
86
+ try {
87
+ parsed = parseFrontmatter<Record<string, unknown>>(content);
88
+ } catch {
89
+ continue;
90
+ }
91
+ const { frontmatter, body } = parsed;
92
+ if (
93
+ typeof frontmatter !== "object" ||
94
+ frontmatter === null ||
95
+ Array.isArray(frontmatter)
96
+ )
97
+ continue;
98
+ const {
99
+ name,
100
+ description,
101
+ tools: rawTools,
102
+ skills: rawSkills,
103
+ thinking: rawThinking,
104
+ } = frontmatter;
105
+ if (typeof name !== "string" || typeof description !== "string") continue;
106
+ if (rawTools != null && typeof rawTools !== "string") continue;
107
+ if (rawSkills != null && typeof rawSkills !== "string") continue;
108
+ if (rawThinking != null && typeof rawThinking !== "string") continue;
109
+ const tools = parseCommaList(rawTools);
110
+ const skills = Object.hasOwn(frontmatter, "skills")
111
+ ? (parseCommaList(rawSkills) ?? [])
112
+ : undefined;
113
+ const thinking = parseThinkingLevel(rawThinking);
114
+ agents.push({
115
+ name,
116
+ description,
117
+ tools,
118
+ skills,
119
+ thinking,
120
+ systemPrompt: body,
121
+ source,
122
+ filePath,
123
+ });
124
+ }
125
+ return agents;
126
+ }
127
+
128
+ function isDirectory(p: string): boolean {
129
+ try {
130
+ return fs.statSync(p).isDirectory();
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ function findNearestProjectAgentsDir(cwd: string): string | null {
137
+ let currentDir = cwd;
138
+ while (true) {
139
+ const candidate = path.join(currentDir, ".pi", "agents");
140
+ if (isDirectory(candidate)) return candidate;
141
+ const parentDir = path.dirname(currentDir);
142
+ if (parentDir === currentDir) return null;
143
+ currentDir = parentDir;
144
+ }
145
+ }
146
+
147
+ export function discoverAgents(
148
+ cwd: string,
149
+ scope: AgentScope,
150
+ ): AgentDiscoveryResult {
151
+ const userDir = path.join(getAgentDir(), "agents");
152
+ const projectAgentsDir = findNearestProjectAgentsDir(cwd);
153
+ const userAgents =
154
+ scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
155
+ const projectAgents =
156
+ scope === "user" || !projectAgentsDir
157
+ ? []
158
+ : loadAgentsFromDir(projectAgentsDir, "project");
159
+ const agentMap = new Map<string, AgentConfig>();
160
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
161
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
162
+ return { agents: Array.from(agentMap.values()), projectAgentsDir };
163
+ }
164
+
165
+ export function formatAgentList(
166
+ agents: AgentConfig[],
167
+ maxItems: number,
168
+ ): { text: string; remaining: number } {
169
+ if (agents.length === 0) return { text: "none", remaining: 0 };
170
+ const listed = agents.slice(0, maxItems);
171
+ const remaining = agents.length - listed.length;
172
+ return {
173
+ text: listed
174
+ .map((a) => `${a.name} (${a.source}): ${a.description}`)
175
+ .join("; "),
176
+ remaining,
177
+ };
178
+ }
@@ -0,0 +1,49 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { cancelAllRunJobs, cancelRunJob, listRunJobs } from "./run-registry.js";
3
+
4
+ const CANCEL_REASON = "Cancelled by /cancel-subagent";
5
+
6
+ export async function cancelSubagentCommandHandler(
7
+ ctx: ExtensionCommandContext,
8
+ args: string,
9
+ ): Promise<void> {
10
+ const target = args.trim();
11
+ if (!target) {
12
+ const jobs = listRunJobs();
13
+ if (jobs.length === 0) {
14
+ ctx.ui.notify("No active /run jobs.");
15
+ return;
16
+ }
17
+ const options = [
18
+ ...jobs.map((job) => `${job.agentName} (${job.requestId})`),
19
+ "All running subagents",
20
+ ];
21
+ const selection = await ctx.ui.select("Cancel subagent", options);
22
+ if (selection === undefined) return;
23
+ if (selection === "All running subagents") {
24
+ const count = cancelAllRunJobs(CANCEL_REASON);
25
+ ctx.ui.notify(`Cancelled ${count} /run job${count === 1 ? "" : "s"}.`);
26
+ return;
27
+ }
28
+ const requestId = selection.match(/\((.*)\)$/)?.[1];
29
+ if (requestId) {
30
+ cancelRunJob(requestId, CANCEL_REASON);
31
+ ctx.ui.notify(`Cancelled /run job ${requestId}.`);
32
+ }
33
+ return;
34
+ }
35
+ if (target === "all") {
36
+ const count = cancelAllRunJobs(CANCEL_REASON);
37
+ ctx.ui.notify(
38
+ count === 0
39
+ ? "No active /run jobs."
40
+ : `Cancelled ${count} /run job${count === 1 ? "" : "s"}.`,
41
+ );
42
+ return;
43
+ }
44
+ if (cancelRunJob(target, CANCEL_REASON)) {
45
+ ctx.ui.notify(`Cancelled /run job ${target}.`);
46
+ return;
47
+ }
48
+ ctx.ui.notify(`No active /run job ${target}.`);
49
+ }
@@ -0,0 +1,32 @@
1
+ type ChildKnownEvent =
2
+ | { type: "message_end"; message: unknown }
3
+ | { type: "tool_result_end"; message: unknown }
4
+ | { type: "agent_end"; messages?: unknown; stopReason?: string };
5
+
6
+ export type ChildEventParseResult =
7
+ | { kind: "known"; event: ChildKnownEvent }
8
+ | { kind: "unknown"; event: unknown }
9
+ | { kind: "invalid"; line: string };
10
+
11
+ const KNOWN_TYPES = new Set(["message_end", "tool_result_end", "agent_end"]);
12
+
13
+ export function parseChildEventLine(line: string): ChildEventParseResult {
14
+ if (typeof line !== "string" || !line.trim())
15
+ return { kind: "invalid", line };
16
+ let event: unknown;
17
+ try {
18
+ event = JSON.parse(line);
19
+ } catch {
20
+ return { kind: "invalid", line };
21
+ }
22
+ if (
23
+ typeof event === "object" &&
24
+ event !== null &&
25
+ "type" in event &&
26
+ typeof (event as Record<string, unknown>).type === "string" &&
27
+ KNOWN_TYPES.has((event as Record<string, unknown>).type as string)
28
+ ) {
29
+ return { kind: "known", event: event as ChildKnownEvent };
30
+ }
31
+ return { kind: "unknown", event };
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ getCachedAgentCompletions,
7
+ resetAgentDiscoveryCache,
8
+ } from "./agent-cache.js";
9
+ import { cancelSubagentCommandHandler } from "./cancel-command.js";
10
+ import { renderSubagentProgress } from "./progress.js";
11
+ import { renderSubagentResultMessage } from "./run.js";
12
+ import { runCommandHandler } from "./run-command.js";
13
+ import {
14
+ formatStartJobStatus,
15
+ SubagentParams,
16
+ startSubagentJob,
17
+ } from "./subagent-orchestrator.js";
18
+ import { renderSubagentCall, renderSubagentResult } from "./ui.js";
19
+
20
+ export { SubagentParams };
21
+
22
+ export function resetAgentCache() {
23
+ resetAgentDiscoveryCache();
24
+ }
25
+
26
+ export default function registerSubagentExtension(pi: ExtensionAPI) {
27
+ pi.registerMessageRenderer("subagent-progress", renderSubagentProgress);
28
+ pi.registerMessageRenderer("subagent-result", renderSubagentResultMessage);
29
+ pi.registerCommand("run", {
30
+ description: "Run a subagent directly: /run <agent> [task]",
31
+ getArgumentCompletions: async (prefix: string) =>
32
+ getCachedAgentCompletions(prefix),
33
+ handler: async (args, ctx) =>
34
+ runCommandHandler(pi, ctx as ExtensionContext, args),
35
+ });
36
+ pi.registerCommand("cancel-subagent", {
37
+ description:
38
+ "Cancel active /run subagents: /cancel-subagent [requestId|all]",
39
+ handler: async (args, ctx) => cancelSubagentCommandHandler(ctx, args),
40
+ });
41
+ pi.registerTool({
42
+ name: "subagent",
43
+ label: "Subagent",
44
+ description: "Delegate a task to a subagent with isolated context.",
45
+ parameters: SubagentParams,
46
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
47
+ const result = await startSubagentJob(
48
+ pi,
49
+ ctx,
50
+ params,
51
+ signal ?? undefined,
52
+ );
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text" as const,
57
+ text: formatStartJobStatus(params.agent, result),
58
+ },
59
+ ],
60
+ details: result.makeDetails([]),
61
+ };
62
+ },
63
+ renderCall(args, theme, _context) {
64
+ return renderSubagentCall(args, theme);
65
+ },
66
+ renderResult(result, display, theme, _context) {
67
+ return renderSubagentResult(result, theme, display);
68
+ },
69
+ });
70
+ }
@@ -0,0 +1,109 @@
1
+ export const TERMINAL_SENTENCE_MAX_CHARS = 100;
2
+
3
+ export function normalizeSummaryValue(value: string): string {
4
+ const normalized = value.trim().replace(/\s+/g, " ");
5
+ const wrapper = normalized.match(/^(?:`([^`]+)`|\*\*([^*]+)\*\*)$/);
6
+ if (wrapper) return (wrapper[1] ?? wrapper[2] ?? "").trim();
7
+ return normalized;
8
+ }
9
+
10
+ export function extractSemanticToolTarget(
11
+ toolName: string,
12
+ args: Record<string, unknown>,
13
+ forceJson = false,
14
+ ): string {
15
+ if (forceJson) return JSON.stringify(args);
16
+ if (toolName === "bash" && typeof args.command === "string")
17
+ return args.command;
18
+ if (
19
+ ["read", "write", "edit", "file_search"].includes(toolName) &&
20
+ typeof args.path === "string"
21
+ )
22
+ return args.path;
23
+ if (toolName === "subagent") {
24
+ const parts = [];
25
+ if (typeof args.agent === "string") parts.push(args.agent);
26
+ if (typeof args.task === "string")
27
+ parts.push(normalizeSummaryValue(args.task));
28
+ if (typeof args.agentScope === "string") parts.push(`[${args.agentScope}]`);
29
+ if (parts.length) return parts.join(" ");
30
+ return JSON.stringify(args);
31
+ }
32
+ return "";
33
+ }
34
+
35
+ export function isTranscriptNoiseLine(line: string): boolean {
36
+ return /^(?:(?:hello|hi|hey)(?:[!,.?:;]+|\s|$)|reasoning:|raw log:|apolog(?:y|ies)|sorry\b)/i.test(
37
+ line,
38
+ );
39
+ }
40
+
41
+ export function isFailureDiagnosticLine(line: string): boolean {
42
+ return /^(?:at\s+|error:|failed:|failure:|exception:|traceback\b|caused by:)/i.test(
43
+ line,
44
+ );
45
+ }
46
+
47
+ export function filterOutputLines(output: string): string[] {
48
+ return output
49
+ .split(/\r?\n/)
50
+ .map((l) => l.trim())
51
+ .filter(
52
+ (l) => l && !isTranscriptNoiseLine(l) && !isFailureDiagnosticLine(l),
53
+ );
54
+ }
55
+
56
+ export function stripTerminalStatusPrefixes(value: string): string {
57
+ return value.replace(/^(?:(?:success|failure):\s*)+/i, "");
58
+ }
59
+
60
+ export function truncateText(text: string, limit: number): string {
61
+ if (text.length <= limit) return text;
62
+ return `${text.slice(0, limit - 1)}…`;
63
+ }
64
+
65
+ export function normalizeTerminalSentence(
66
+ value: string,
67
+ limit = TERMINAL_SENTENCE_MAX_CHARS,
68
+ ): string {
69
+ const unwrapped = value
70
+ .replace(/^\s*(?:[-*>]\s*)+/, "")
71
+ .replace(/^\s*#{1,6}\s+/, "")
72
+ .replace(/^\s*`{1,3}([^`]+)`{1,3}\s*$/, "$1")
73
+ .replace(/^\s*\*\*([^*]+)\*\*\s*$/, "$1")
74
+ .replace(/^\s*__([^_]+)__\s*$/, "$1");
75
+ const withoutStatusPrefix = stripTerminalStatusPrefixes(unwrapped);
76
+ const withoutLabel = withoutStatusPrefix.replace(
77
+ /^\s*(?:status|summary|result|output|message|error|check|outcome|project summary):\s+/i,
78
+ "",
79
+ );
80
+ const normalizedOnce = normalizeSummaryValue(withoutLabel);
81
+ const stripped = normalizedOnce.replace(/[\s.!,;:—–-]+$/g, "");
82
+ const collapsed = normalizeSummaryValue(stripped);
83
+ return truncateText(collapsed, limit);
84
+ }
85
+
86
+ export const TOOL_PREVIEW_MAX_CHARS = 120;
87
+
88
+ export function makeToolPreview(
89
+ toolName: string,
90
+ args: Record<string, unknown> | undefined,
91
+ ): string {
92
+ if (!args || Object.keys(args).length === 0) return toolName;
93
+ const target = normalizeSummaryValue(
94
+ extractSemanticToolTarget(toolName, args),
95
+ );
96
+ if (!target) return toolName;
97
+ return truncateText(
98
+ normalizeSummaryValue(`${toolName}: ${target}`),
99
+ TOOL_PREVIEW_MAX_CHARS,
100
+ );
101
+ }
102
+
103
+ export function isStatusOnlySuccess(value: string): boolean {
104
+ return /^(?:success|done)$/i.test(value.trim());
105
+ }
106
+
107
+ export function isStatusOnlyFailure(value: string): boolean {
108
+ return /^(?:failure|failed|error)$/i.test(value.trim());
109
+ }