@capagents/forgeqe 0.0.0-stage → 1.0.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/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable, Selection } from "@opentui/core"
|
|
2
|
+
import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { useEffect, useRef, useState } from "react"
|
|
5
|
+
|
|
6
|
+
import { NO_MODEL_SET_UP } from "../llm.ts"
|
|
7
|
+
import type { CodeEvent, ReplayMode } from "../types.ts"
|
|
8
|
+
import { brainUnavailable, carryOver, openBrain, sessionSetup, type Brain, type BrainOptions } from "../brain/index.ts"
|
|
9
|
+
import { INSTALL_HINT } from "../code/runtime.ts"
|
|
10
|
+
import { briefText, detailFrom, shows, showsAgentTool, stepIntent, type Detail } from "../detail.ts"
|
|
11
|
+
import { stepVerdictLines } from "../cases/types.ts"
|
|
12
|
+
import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
|
|
13
|
+
import { authStatus, openInBrowser, signIn, signOut, type AuthIO } from "../auth/signin.ts"
|
|
14
|
+
import { HELP, parseCommand } from "../commands.ts"
|
|
15
|
+
import { loadConfig, selectProfile, type AppConfig } from "../config.ts"
|
|
16
|
+
import { TOOLS, type AskQuestion, type ToolEvent } from "../mcp/tools.ts"
|
|
17
|
+
import type { executeRun } from "../run.ts"
|
|
18
|
+
import { bundleLogs } from "../sessionLog.ts"
|
|
19
|
+
import { findSkill, loadSkills } from "../skills/registry.ts"
|
|
20
|
+
import { skillDetail, skillsOverview } from "../skills/view.ts"
|
|
21
|
+
import { counts } from "../suite/report.ts"
|
|
22
|
+
import { buildSummary, shortPath } from "../summary.ts"
|
|
23
|
+
import { meterSnapshot, requestLine } from "../usage.ts"
|
|
24
|
+
import { cleanSelection, copyText } from "./clipboard.ts"
|
|
25
|
+
import { ChoicePanel, Footer, Hints, Logo, Prompt, Spinner, type Hint } from "./components.tsx"
|
|
26
|
+
import { EXAMPLES, TIPS, formatDuration, formatTokens, packageVersion, pick, shortCwd, theme } from "./theme.ts"
|
|
27
|
+
import { emptyProduced, Line, Produced, producedCard, TestsSetting, TranscriptLine } from "./transcript.tsx"
|
|
28
|
+
import { Dialog, dialogSpec } from "./dialogs.ts"
|
|
29
|
+
import { activityFromLog, clip, hostOf, modelLabel } from "./text.ts"
|
|
30
|
+
import { featuresText } from "../capabilities/render.ts"
|
|
31
|
+
|
|
32
|
+
export type TuiOptions = {
|
|
33
|
+
configPath?: string
|
|
34
|
+
profile?: string
|
|
35
|
+
/** Start URL pinned for browser runs. */
|
|
36
|
+
url?: string
|
|
37
|
+
/** First message, sent as if typed. */
|
|
38
|
+
goal?: string
|
|
39
|
+
headed?: boolean
|
|
40
|
+
replay?: ReplayMode
|
|
41
|
+
memory?: boolean
|
|
42
|
+
/** Repo the agent works in and writes tests for; null: no tests unless asked; undefined: code.repo. */
|
|
43
|
+
repo?: string | null
|
|
44
|
+
/** Replaces executeRun; tests use it to hold a run open. */
|
|
45
|
+
runner?: typeof executeRun
|
|
46
|
+
/** Replaces openBrain in tests. */
|
|
47
|
+
brain?: (o: BrainOptions) => Promise<Brain>
|
|
48
|
+
/** Replaces the clipboard write in tests. */
|
|
49
|
+
copy?: (text: string) => boolean
|
|
50
|
+
/** Show everything the agents do (their reasoning, every file read, engine logs); /details toggles it. */
|
|
51
|
+
verbose?: boolean
|
|
52
|
+
/** Replaces opening a page in the browser (sign-in) in tests. */
|
|
53
|
+
openUrl?: (url: string) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const VERSION = packageVersion()
|
|
57
|
+
|
|
58
|
+
export function App({ options }: { options: TuiOptions }) {
|
|
59
|
+
const renderer = useRenderer()
|
|
60
|
+
const { width, height } = useTerminalDimensions()
|
|
61
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
62
|
+
const submitRef = useRef<(value: string) => void>(() => {})
|
|
63
|
+
const configRef = useRef<AppConfig | null>(null)
|
|
64
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
65
|
+
const brainRef = useRef<Brain | null>(null)
|
|
66
|
+
/** The conversation so far, carried into a new agent session when the profile changes. */
|
|
67
|
+
const convoRef = useRef<{ user: string; reply: string }[]>([])
|
|
68
|
+
const idRef = useRef(1)
|
|
69
|
+
const booted = useRef(false)
|
|
70
|
+
const closeAfterRun = useRef(false)
|
|
71
|
+
const markRef = useRef(0)
|
|
72
|
+
const stepRef = useRef(0)
|
|
73
|
+
const replayingRef = useRef(false)
|
|
74
|
+
const usedRef = useRef(new Map<string, number>())
|
|
75
|
+
const directNoted = useRef(false)
|
|
76
|
+
const thinkingRef = useRef(false)
|
|
77
|
+
const runIndex = useRef(0)
|
|
78
|
+
const producedRef = useRef<Produced>(emptyProduced())
|
|
79
|
+
const testsRef = useRef<TestsSetting>({ on: false, why: "" })
|
|
80
|
+
const codingRef = useRef(false)
|
|
81
|
+
const codeRunDir = useRef("")
|
|
82
|
+
|
|
83
|
+
const [lines, setLines] = useState<Line[]>([])
|
|
84
|
+
const [headed, setHeaded] = useState(Boolean(options.headed))
|
|
85
|
+
const [maxSteps, setMaxSteps] = useState(40)
|
|
86
|
+
const [profile, setProfile] = useState(options.profile || "")
|
|
87
|
+
const [model, setModel] = useState("")
|
|
88
|
+
const [status, setStatus] = useState("idle")
|
|
89
|
+
const [step, setStep] = useState(0)
|
|
90
|
+
const [tokens, setTokens] = useState(0)
|
|
91
|
+
const [tick, setTick] = useState(0)
|
|
92
|
+
const [inputKey, setInputKey] = useState(0)
|
|
93
|
+
const [activity, setActivity] = useState("")
|
|
94
|
+
const [lastStats, setLastStats] = useState("")
|
|
95
|
+
const [dialog, setDialog] = useState<Dialog | null>(null)
|
|
96
|
+
const [choice, setChoice] = useState(0)
|
|
97
|
+
const [thinking, setThinking] = useState(false)
|
|
98
|
+
const [browsing, setBrowsing] = useState(false)
|
|
99
|
+
const [coding, setCoding] = useState(false)
|
|
100
|
+
const [suiteProgress, setSuiteProgress] = useState<{ done: number; total: number } | null>(null)
|
|
101
|
+
const [copied, setCopied] = useState("")
|
|
102
|
+
const [example] = useState(() => pick(EXAMPLES))
|
|
103
|
+
const [tip] = useState(() => pick(TIPS))
|
|
104
|
+
const started = useRef<number | null>(null)
|
|
105
|
+
|
|
106
|
+
const detailRef = useRef<Detail>(detailFrom(options.verbose))
|
|
107
|
+
const headedRef = useRef(headed)
|
|
108
|
+
const stepsRef = useRef(maxSteps)
|
|
109
|
+
const dialogRef = useRef(dialog)
|
|
110
|
+
const choiceRef = useRef(choice)
|
|
111
|
+
const waitRef = useRef<{ text: string; resolve: (ok: boolean) => void } | null>(null)
|
|
112
|
+
const askRef = useRef<{ q: AskQuestion; resolve: (id: string | null) => void } | null>(null)
|
|
113
|
+
/** A line the sign-in is waiting for (a URL, an email, a token); the next Enter answers it instead of the agent. */
|
|
114
|
+
const priceNotesRef = useRef<string[]>([])
|
|
115
|
+
const entryRef = useRef<{ label: string; secret: boolean; initial?: string; resolve: (v: string | null) => void } | null>(null)
|
|
116
|
+
const [entry, setEntry] = useState<{ label: string; secret: boolean; initial?: string } | null>(null)
|
|
117
|
+
/** What has been typed or pasted into a secret entry; only its length is ever rendered. */
|
|
118
|
+
const secretRef = useRef("")
|
|
119
|
+
const [secretLength, setSecretLength] = useState(0)
|
|
120
|
+
headedRef.current = headed
|
|
121
|
+
stepsRef.current = maxSteps
|
|
122
|
+
dialogRef.current = dialog
|
|
123
|
+
choiceRef.current = choice
|
|
124
|
+
thinkingRef.current = thinking
|
|
125
|
+
|
|
126
|
+
const push = (kind: Line["kind"], text: string) => {
|
|
127
|
+
const parts = kind === "user" || kind === "reply" ? [text] : text.split("\n")
|
|
128
|
+
setLines((prev) => {
|
|
129
|
+
const next = [...prev]
|
|
130
|
+
for (const part of parts) next.push({ id: idRef.current++, kind, text: part })
|
|
131
|
+
return next.slice(-300)
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
const pushLine = (line: Omit<Line, "id">) => setLines((prev) => [...prev, { ...line, id: idRef.current++ }].slice(-300))
|
|
135
|
+
/** An agent's tool call; files it writes or edits (the test script) stand out from reads and searches. */
|
|
136
|
+
const pushTool = (text: string, ok: boolean) => push(!ok ? "err" : /^(Writing|Editing) /.test(text) ? "file" : "tool", text)
|
|
137
|
+
const pushProduced = () => {
|
|
138
|
+
const card = producedCard(producedRef.current, testsRef.current)
|
|
139
|
+
if (card) pushLine({ kind: "card", text: card.title, card })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const useTokens = (key: string, n: number) => {
|
|
143
|
+
usedRef.current.set(key, n)
|
|
144
|
+
setTokens([...usedRef.current.values()].reduce((a, b) => a + b, 0))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (status !== "running") return
|
|
149
|
+
const timer = setInterval(() => setTick((n) => n + 1), 1000)
|
|
150
|
+
return () => clearInterval(timer)
|
|
151
|
+
}, [status])
|
|
152
|
+
|
|
153
|
+
// The terminal hands mouse drags to the TUI, so text selected with the mouse is copied here when the drag ends.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
156
|
+
const onSelection = (selection: Selection | null) => {
|
|
157
|
+
const text = cleanSelection(selection?.getSelectedText() || "")
|
|
158
|
+
if (!text.trim()) return
|
|
159
|
+
const ok = (options.copy || ((t: string) => copyText(t, (x) => renderer.copyToClipboardOSC52(x))))(text)
|
|
160
|
+
setCopied(ok ? `copied ${text.length} ${text.length === 1 ? "character" : "characters"}` : "could not reach the clipboard")
|
|
161
|
+
clearTimeout(timer)
|
|
162
|
+
timer = setTimeout(() => setCopied(""), 2500)
|
|
163
|
+
}
|
|
164
|
+
renderer.on("selection", onSelection)
|
|
165
|
+
return () => {
|
|
166
|
+
clearTimeout(timer)
|
|
167
|
+
renderer.off("selection", onSelection)
|
|
168
|
+
}
|
|
169
|
+
}, [renderer])
|
|
170
|
+
|
|
171
|
+
const closeBrain = () => {
|
|
172
|
+
const brain = brainRef.current
|
|
173
|
+
brainRef.current = null
|
|
174
|
+
void brain?.close().catch(() => {})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const quit = () => {
|
|
178
|
+
abortRef.current?.abort()
|
|
179
|
+
closeBrain()
|
|
180
|
+
renderer.destroy()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const outDirLabel = () => path.relative(process.cwd(), configRef.current?.export.outDir || "out") || "."
|
|
184
|
+
|
|
185
|
+
const settleWait = (ok: boolean) => {
|
|
186
|
+
const w = waitRef.current
|
|
187
|
+
waitRef.current = null
|
|
188
|
+
w?.resolve(ok)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const settleAsk = (id: string | null) => {
|
|
192
|
+
const a = askRef.current
|
|
193
|
+
askRef.current = null
|
|
194
|
+
a?.resolve(id)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const openDialog = (next: Dialog) => {
|
|
198
|
+
if (next !== "wait") settleWait(false)
|
|
199
|
+
if (next !== "ask") settleAsk(null)
|
|
200
|
+
setChoice(0)
|
|
201
|
+
setDialog(next)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const closeDialog = () => {
|
|
205
|
+
if (dialogRef.current === "wait") settleWait(false)
|
|
206
|
+
if (dialogRef.current === "ask") settleAsk(null)
|
|
207
|
+
setDialog(null)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A tool needs the user to do something by hand (sign in in the browser); resolves when they continue or cancel. */
|
|
211
|
+
const waitForUser = (prompt: string) =>
|
|
212
|
+
new Promise<boolean>((resolve) => {
|
|
213
|
+
settleWait(false)
|
|
214
|
+
waitRef.current = { text: prompt, resolve }
|
|
215
|
+
push("sys", prompt)
|
|
216
|
+
openDialog("wait")
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/** A tool asks the user to pick an option (a sprint, whether to run tests); resolves with its id, or null on cancel. */
|
|
220
|
+
const ask = (q: AskQuestion) =>
|
|
221
|
+
new Promise<string | null>((resolve) => {
|
|
222
|
+
settleAsk(null)
|
|
223
|
+
push("sys", [q.title, ...(q.lines || [])].join("\n"))
|
|
224
|
+
openDialog("ask")
|
|
225
|
+
askRef.current = { q, resolve }
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const settleEntry = (value: string | null) => {
|
|
229
|
+
const e = entryRef.current
|
|
230
|
+
entryRef.current = null
|
|
231
|
+
secretRef.current = ""
|
|
232
|
+
setSecretLength(0)
|
|
233
|
+
setEntry(null)
|
|
234
|
+
e?.resolve(value)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const setSecret = (value: string) => {
|
|
238
|
+
secretRef.current = value
|
|
239
|
+
setSecretLength(value.length)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The sign-in asks for a line of text in the prompt box; a secret is typed unseen and never sent to the agent. */
|
|
243
|
+
const input: AuthIO["input"] = (q) =>
|
|
244
|
+
new Promise((resolve) => {
|
|
245
|
+
settleEntry(null)
|
|
246
|
+
const e = { label: q.label, secret: Boolean(q.secret), initial: q.initial }
|
|
247
|
+
entryRef.current = { ...e, resolve }
|
|
248
|
+
setEntry(e)
|
|
249
|
+
setInputKey((k) => k + 1)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const runAuth = async (action: "status" | "login" | "logout", system?: string) => {
|
|
253
|
+
const cfg = configRef.current || undefined
|
|
254
|
+
if (action === "status") {
|
|
255
|
+
push("sys", authStatus(cfg).join("\n"))
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (action === "logout") {
|
|
259
|
+
push("sys", await signOut(system!))
|
|
260
|
+
reloadAfterAuth()
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const controller = begin("Signing in…")
|
|
264
|
+
controller.signal.addEventListener("abort", () => settleEntry(null), { once: true })
|
|
265
|
+
let finalStatus = "fail"
|
|
266
|
+
try {
|
|
267
|
+
const res = await signIn(
|
|
268
|
+
{
|
|
269
|
+
say: (text, kind) => push(kind === "ok" ? "ok" : kind === "err" ? "err" : "sys", text),
|
|
270
|
+
input,
|
|
271
|
+
choose: ask,
|
|
272
|
+
openUrl: (url) => {
|
|
273
|
+
push("sys", `Opening ${url}`)
|
|
274
|
+
;(options.openUrl || openInBrowser)(url)
|
|
275
|
+
},
|
|
276
|
+
signal: controller.signal,
|
|
277
|
+
},
|
|
278
|
+
system,
|
|
279
|
+
cfg,
|
|
280
|
+
)
|
|
281
|
+
finalStatus = res.ok ? "done" : controller.signal.aborted ? "stopped" : res.message === "Cancelled." ? "idle" : "fail"
|
|
282
|
+
if (res.ok) reloadAfterAuth()
|
|
283
|
+
} finally {
|
|
284
|
+
settleEntry(null)
|
|
285
|
+
finish(finalStatus)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** New credentials: read the config again (a system may be set up now) and restart the agent so its MCP servers use them. */
|
|
290
|
+
const reloadAfterAuth = () => {
|
|
291
|
+
try {
|
|
292
|
+
const cfg = loadConfig({ configPath: options.configPath })
|
|
293
|
+
configRef.current = configRef.current ? selectProfile(cfg, configRef.current.activeProfile) : cfg
|
|
294
|
+
} catch (err) {
|
|
295
|
+
push("err", (err as Error).message)
|
|
296
|
+
}
|
|
297
|
+
if (brainRef.current) {
|
|
298
|
+
closeBrain()
|
|
299
|
+
push("sys", "The agent reconnects with the new sign-in on your next message and keeps the conversation.")
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const requestStop = () => {
|
|
304
|
+
const controller = abortRef.current
|
|
305
|
+
if (!controller || controller.signal.aborted) return
|
|
306
|
+
controller.abort()
|
|
307
|
+
push("sys", "Stopping…")
|
|
308
|
+
setActivity("Stopping: closing the browser and saving files…")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const choose = (id: string) => {
|
|
312
|
+
setDialog(null)
|
|
313
|
+
if (id === "wait-done" || id === "wait-cancel") settleWait(id === "wait-done")
|
|
314
|
+
if (id.startsWith("ask:")) settleAsk(id.slice(4))
|
|
315
|
+
if (id === "stop") requestStop()
|
|
316
|
+
if (id === "close") quit()
|
|
317
|
+
if (id === "stop-close") {
|
|
318
|
+
if (!abortRef.current) {
|
|
319
|
+
quit()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
closeAfterRun.current = true
|
|
323
|
+
requestStop()
|
|
324
|
+
push("sys", "ForgeQE closes once the browser has shut down.")
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const applyProfile = (name: string) => {
|
|
329
|
+
if (!configRef.current) throw new Error("Config is not loaded.")
|
|
330
|
+
configRef.current = selectProfile(configRef.current, name)
|
|
331
|
+
setProfile(name)
|
|
332
|
+
setModel(modelLabel(configRef.current))
|
|
333
|
+
if (brainRef.current) {
|
|
334
|
+
closeBrain()
|
|
335
|
+
push("sys", "The agent switches to the new profile on your next message and keeps the conversation.")
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const cycleProfile = () => {
|
|
340
|
+
const cfg = configRef.current
|
|
341
|
+
if (!cfg || abortRef.current) return
|
|
342
|
+
const names = Object.keys(cfg.profiles)
|
|
343
|
+
if (names.length < 2) return
|
|
344
|
+
const next = names[(names.indexOf(cfg.activeProfile) + 1) % names.length]
|
|
345
|
+
applyProfile(next)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
useKeyboard((key) => {
|
|
349
|
+
const open = dialogRef.current
|
|
350
|
+
if (key.name === "c" && key.ctrl) {
|
|
351
|
+
if (open === "quit") quit()
|
|
352
|
+
else openDialog("quit")
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (open) {
|
|
356
|
+
const opts = dialogSpec(open, Boolean(abortRef.current), outDirLabel(), thinkingRef.current, waitRef.current?.text, askRef.current?.q).options
|
|
357
|
+
const n = opts.length
|
|
358
|
+
if (key.name === "left" || key.name === "up" || (key.name === "tab" && key.shift)) setChoice((c) => (c - 1 + n) % n)
|
|
359
|
+
else if (key.name === "right" || key.name === "down" || key.name === "tab") setChoice((c) => (c + 1) % n)
|
|
360
|
+
else if (key.name === "return" || key.name === "enter") choose(opts[Math.min(choiceRef.current, n - 1)].id)
|
|
361
|
+
else if (key.name === "escape") closeDialog()
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (key.name === "escape" && entryRef.current) {
|
|
365
|
+
push("sys", "Cancelled.")
|
|
366
|
+
settleEntry(null)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
if (entryRef.current?.secret) {
|
|
370
|
+
if (key.name === "return" || key.name === "enter" || key.name === "kpenter") submitRef.current(secretRef.current)
|
|
371
|
+
else if (key.name === "backspace") setSecret(secretRef.current.slice(0, -1))
|
|
372
|
+
else if (key.name === "u" && key.ctrl) setSecret("")
|
|
373
|
+
else if (!key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence >= " ") setSecret(secretRef.current + key.sequence)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
if (key.name === "tab" && !key.shift) cycleProfile()
|
|
377
|
+
if (key.name === "p" && key.ctrl) push("sys", HELP)
|
|
378
|
+
if (key.name === "escape" && abortRef.current) openDialog("stop")
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
usePaste((event) => {
|
|
382
|
+
if (!entryRef.current?.secret) return
|
|
383
|
+
event.preventDefault()
|
|
384
|
+
setSecret(secretRef.current + new TextDecoder().decode(event.bytes).replace(/[\r\n]+/g, "").trim())
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
/** Browser runs and suites report here, whether the agent started them or the text went straight to the browser. */
|
|
388
|
+
const onToolEvent = (e: ToolEvent, agent: boolean) => {
|
|
389
|
+
const aborted = () => Boolean(abortRef.current?.signal.aborted)
|
|
390
|
+
const cfg = configRef.current
|
|
391
|
+
switch (e.type) {
|
|
392
|
+
case "run-start":
|
|
393
|
+
runIndex.current++
|
|
394
|
+
stepRef.current = 0
|
|
395
|
+
markRef.current = Date.now()
|
|
396
|
+
replayingRef.current = false
|
|
397
|
+
setStep(0)
|
|
398
|
+
setBrowsing(true)
|
|
399
|
+
if (agent) push("code", `Browser: ${clip(e.goal, 110)}`)
|
|
400
|
+
if (!aborted()) setActivity(cfg?.llm.provider === "mock" ? "Running the offline demo…" : "Starting the browser…")
|
|
401
|
+
return
|
|
402
|
+
case "run-event": {
|
|
403
|
+
const event = e.event
|
|
404
|
+
if (event.type === "step") {
|
|
405
|
+
stepRef.current = event.step
|
|
406
|
+
setStep(event.step)
|
|
407
|
+
replayingRef.current = event.source === "memory"
|
|
408
|
+
if (!replayingRef.current && detailRef.current === "full") push("timing", `Thought: ${formatDuration(Date.now() - markRef.current, true)}`)
|
|
409
|
+
else if (!aborted()) setActivity(`Replaying step ${event.step} from memory…`)
|
|
410
|
+
}
|
|
411
|
+
if (event.type === "memory") {
|
|
412
|
+
if (shows(event, detailRef.current)) push("memory", event.text)
|
|
413
|
+
markRef.current = Date.now()
|
|
414
|
+
}
|
|
415
|
+
if (event.type === "usage") useTokens(`run-${runIndex.current}`, event.usage.input + event.usage.output)
|
|
416
|
+
if (event.type === "log") {
|
|
417
|
+
if (shows(event, detailRef.current)) push(event.level === "error" ? "err" : "sys", event.text)
|
|
418
|
+
if (!aborted()) setActivity(activityFromLog(event.text))
|
|
419
|
+
}
|
|
420
|
+
if (event.type === "thought" && !replayingRef.current) {
|
|
421
|
+
if (shows(event, detailRef.current)) push("thought", stepIntent(event.step, event.text, detailRef.current))
|
|
422
|
+
if (!aborted()) setActivity(`Acting in the browser (step ${stepRef.current})…`)
|
|
423
|
+
}
|
|
424
|
+
if (event.type === "outcome") {
|
|
425
|
+
markRef.current = Date.now()
|
|
426
|
+
if (!aborted()) setActivity(replayingRef.current ? "Replaying from memory…" : `Thinking about step ${stepRef.current + 1}…`)
|
|
427
|
+
}
|
|
428
|
+
if (event.type === "outcome") push(event.ok ? "ok" : "err", briefText(event.text, detailRef.current))
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
case "run-done": {
|
|
432
|
+
const r = e.result
|
|
433
|
+
const summary = buildSummary({
|
|
434
|
+
status: r?.status || "fail",
|
|
435
|
+
text: r?.summary || e.error || "",
|
|
436
|
+
finalUrl: r?.finalUrl || "",
|
|
437
|
+
files: e.files,
|
|
438
|
+
memory: r?.memory,
|
|
439
|
+
usage: r?.usage,
|
|
440
|
+
cost: r?.cost,
|
|
441
|
+
outcome: r?.outcome,
|
|
442
|
+
failedChecks: r?.failedChecks,
|
|
443
|
+
quality: r?.quality,
|
|
444
|
+
budgetExceeded: r?.budgetExceeded,
|
|
445
|
+
ms: e.ms,
|
|
446
|
+
tokens: r ? r.usage.input + r.usage.output : 0,
|
|
447
|
+
steps: stepRef.current,
|
|
448
|
+
})
|
|
449
|
+
pushLine({ kind: "summary", text: summary.text, summary })
|
|
450
|
+
producedRef.current.runs.push({ runDir: e.runDir, status: r?.status || "fail", files: e.files })
|
|
451
|
+
setLastStats(summary.stats)
|
|
452
|
+
setBrowsing(false)
|
|
453
|
+
if (agent && !aborted()) setActivity("The agent is reading the result…")
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
case "suite-start":
|
|
457
|
+
push("sys", `${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
|
|
458
|
+
setSuiteProgress({ done: 0, total: e.cases.length })
|
|
459
|
+
return
|
|
460
|
+
case "suite-event": {
|
|
461
|
+
const s = e.event
|
|
462
|
+
const tag = (id: string) => ((configRef.current?.suite.parallel || 1) > 1 ? `[${id}] ` : "")
|
|
463
|
+
if (s.type === "case-start") {
|
|
464
|
+
push("code", `${s.index + 1}/${s.total} ${s.case.id} ${s.case.title}`)
|
|
465
|
+
// What was read from the test management system: where the case lives and the steps the browser will follow.
|
|
466
|
+
const steps = s.case.steps
|
|
467
|
+
const shown = detailRef.current === "full" ? steps : steps.slice(0, 15)
|
|
468
|
+
const caseLines = [
|
|
469
|
+
...(s.case.url ? [`Source ${s.case.url}`] : []),
|
|
470
|
+
...shown.map((st, i) => `${String(i + 1).padStart(2)}. ${clip(st.action.replace(/\s+/g, " "), 110)}${st.expected ? ` → ${clip(st.expected.replace(/\s+/g, " "), 80)}` : ""}`),
|
|
471
|
+
...(steps.length > shown.length ? [` and ${steps.length - shown.length} more steps`] : []),
|
|
472
|
+
...(!steps.length && s.case.description ? [clip(s.case.description.replace(/\s+/g, " "), 200)] : []),
|
|
473
|
+
]
|
|
474
|
+
if (caseLines.length) push("sys", caseLines.map((l) => ` ${tag(s.case.id)}${l}`).join("\n"))
|
|
475
|
+
if (!aborted()) setActivity(`Running ${s.case.id}…`)
|
|
476
|
+
} else if (s.type === "case-done") {
|
|
477
|
+
setSuiteProgress((p) => (p ? { ...p, done: p.done + 1 } : p))
|
|
478
|
+
const r = s.result
|
|
479
|
+
push(r.status === "passed" ? "ok" : "err", `${r.case.id} ${r.status} in ${formatDuration(r.ms)}${r.failure ? ` · ${FAILURE_LABEL[r.failure.type]}` : ""}`)
|
|
480
|
+
if (r.failure) push("sys", ` ${tag(r.case.id)}${clip(r.failure.reason, 200)} Next: ${FAILURE_ACTION[r.failure.type]}.`)
|
|
481
|
+
// Each step of the case with its verdict, and what the page showed for a step that did not pass.
|
|
482
|
+
for (const line of stepVerdictLines(r)) push(line.startsWith("✓") ? "ok" : line.startsWith("✗") ? "err" : "sys", ` ${tag(r.case.id)}${clip(line, 200)}`)
|
|
483
|
+
} else if (s.type === "writeback" && !aborted()) setActivity("Posting results…")
|
|
484
|
+
else if (s.type === "case-event") {
|
|
485
|
+
const ev = s.event
|
|
486
|
+
const p = tag(s.case.id)
|
|
487
|
+
if (ev.type === "usage") useTokens(`case-${s.index}`, ev.usage.input + ev.usage.output)
|
|
488
|
+
else if (!shows(ev, detailRef.current)) {
|
|
489
|
+
if (ev.type === "log" && !aborted()) setActivity(`${p}${activityFromLog(ev.text)}`)
|
|
490
|
+
} else if (ev.type === "log") push(ev.level === "error" ? "err" : "sys", p + ev.text)
|
|
491
|
+
else if (ev.type === "memory") push("memory", p + ev.text)
|
|
492
|
+
else if (ev.type === "thought") push("thought", p + stepIntent(ev.step, ev.text, detailRef.current))
|
|
493
|
+
else if (ev.type === "outcome") push(ev.ok ? "ok" : "err", p + briefText(ev.text, detailRef.current))
|
|
494
|
+
}
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
case "suite-done": {
|
|
498
|
+
pushLine({ kind: "suitesummary", text: e.suite.name, suite: e.suite })
|
|
499
|
+
producedRef.current.suites.push({ html: e.suite.files.html, junit: e.suite.files.junit })
|
|
500
|
+
const c = counts(e.suite.results)
|
|
501
|
+
setLastStats(`${c.passed}/${c.total} passed · ${formatDuration(e.suite.ms)}`)
|
|
502
|
+
setSuiteProgress(null)
|
|
503
|
+
if (agent && !aborted()) setActivity("The agent is reading the results…")
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
case "info":
|
|
507
|
+
push(e.ok === undefined ? "code" : e.ok ? "ok" : "err", e.text)
|
|
508
|
+
return
|
|
509
|
+
case "agent": {
|
|
510
|
+
const ev = e.event
|
|
511
|
+
if (ev.kind === "usage") useTokens(`agent-tool-${runIndex.current}`, ev.usage.input + ev.usage.output)
|
|
512
|
+
// Hidden lines (reads, searches, the agent's narration) still show on the activity line below.
|
|
513
|
+
else if (shows(ev, detailRef.current)) {
|
|
514
|
+
if (ev.kind === "info") push("code", ev.text)
|
|
515
|
+
else if (ev.kind === "say") push("thought", ev.text)
|
|
516
|
+
else if (ev.kind === "tool") pushTool(ev.text, ev.ok)
|
|
517
|
+
else if (ev.kind === "test") push(ev.ok ? "ok" : "err", ev.text)
|
|
518
|
+
}
|
|
519
|
+
if (aborted()) return
|
|
520
|
+
const who = codingRef.current ? "Coding agent" : "Agent"
|
|
521
|
+
if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
|
|
522
|
+
else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
|
|
523
|
+
else if (ev.kind === "say") setActivity(`${who} is working…`)
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
case "code-start": {
|
|
527
|
+
codingRef.current = true
|
|
528
|
+
codeRunDir.current = e.runDir
|
|
529
|
+
setCoding(true)
|
|
530
|
+
const show = shortPath
|
|
531
|
+
pushLine({
|
|
532
|
+
kind: "card",
|
|
533
|
+
text: "Handing over to the coding agent",
|
|
534
|
+
card: {
|
|
535
|
+
status: "info",
|
|
536
|
+
title: "Handing over to the coding agent",
|
|
537
|
+
stats: "browser agent → coding agent",
|
|
538
|
+
body: [
|
|
539
|
+
...(e.goal ? [`Flow ${clip(e.goal, 100)}`] : []),
|
|
540
|
+
`Run ${show(e.runDir)}/`,
|
|
541
|
+
`Repo ${show(e.repo)}`,
|
|
542
|
+
"It reads the run's steps, locators and page memory, studies the repo's tests, and writes a test.",
|
|
543
|
+
"ForgeQE then runs that test itself until it passes.",
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
})
|
|
547
|
+
if (!aborted()) setActivity("Coding agent: reading the handoff…")
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
case "code-done":
|
|
551
|
+
codingRef.current = false
|
|
552
|
+
setCoding(false)
|
|
553
|
+
producedRef.current.tests.push({ runDir: codeRunDir.current, result: e.result, error: e.error })
|
|
554
|
+
if (e.error) push("err", `Coding agent: ${e.error}`)
|
|
555
|
+
if (agent && !aborted()) setActivity("The agent is reading the result…")
|
|
556
|
+
return
|
|
557
|
+
case "card":
|
|
558
|
+
pushLine({ kind: "card", text: e.card.title, card: e.card })
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const onBrainEvent = (e: CodeEvent) => {
|
|
564
|
+
if (e.kind === "usage") {
|
|
565
|
+
useTokens("agent", e.usage.input + e.usage.output)
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
// ForgeQE's own agent: its tool calls (Azure DevOps and other MCP servers, commands) and what it says between them,
|
|
569
|
+
// one line each in brief; only its file reads and searches are left to /details.
|
|
570
|
+
if (e.kind === "tool" && showsAgentTool(e.text, e.ok, detailRef.current)) pushTool(e.text, e.ok)
|
|
571
|
+
else if (e.kind === "say") push("thought", briefText(e.text, detailRef.current, 200))
|
|
572
|
+
else if (shows(e, detailRef.current)) {
|
|
573
|
+
if (e.kind === "info") push("code", e.text)
|
|
574
|
+
if (e.kind === "tool") pushTool(e.text, e.ok)
|
|
575
|
+
if (e.kind === "test") push(e.ok ? "ok" : "err", e.text)
|
|
576
|
+
}
|
|
577
|
+
if (abortRef.current?.signal.aborted) return
|
|
578
|
+
if (e.kind === "tool") setActivity(`Agent: ${clip(e.text, 60)}`)
|
|
579
|
+
else if (e.kind === "say") setActivity("The agent is working…")
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const toolContext = (agent: boolean, signal?: AbortSignal) => ({
|
|
583
|
+
headed: headedRef.current,
|
|
584
|
+
maxSteps: stepsRef.current,
|
|
585
|
+
replay: options.replay,
|
|
586
|
+
memory: options.memory,
|
|
587
|
+
url: options.url,
|
|
588
|
+
signal,
|
|
589
|
+
execute: options.runner,
|
|
590
|
+
emit: (e: ToolEvent) => onToolEvent(e, agent),
|
|
591
|
+
session: "shell" as const,
|
|
592
|
+
writeTests: testsRef.current.on,
|
|
593
|
+
waitForUser,
|
|
594
|
+
ask,
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
const begin = (activityText: string) => {
|
|
598
|
+
const controller = new AbortController()
|
|
599
|
+
abortRef.current = controller
|
|
600
|
+
started.current = Date.now()
|
|
601
|
+
usedRef.current = new Map()
|
|
602
|
+
producedRef.current = emptyProduced()
|
|
603
|
+
setTokens(0)
|
|
604
|
+
setStatus("running")
|
|
605
|
+
setActivity(activityText)
|
|
606
|
+
return controller
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const finish = (finalStatus: string) => {
|
|
610
|
+
setStatus(finalStatus)
|
|
611
|
+
setActivity("")
|
|
612
|
+
setThinking(false)
|
|
613
|
+
setBrowsing(false)
|
|
614
|
+
codingRef.current = false
|
|
615
|
+
setCoding(false)
|
|
616
|
+
setSuiteProgress(null)
|
|
617
|
+
abortRef.current = null
|
|
618
|
+
started.current = null
|
|
619
|
+
setChoice(0)
|
|
620
|
+
setDialog((d) => (d === "stop" ? null : d))
|
|
621
|
+
if (closeAfterRun.current) quit()
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** No agent available: the text is the browser goal. */
|
|
625
|
+
const runDirect = async (goal: string, cfg: AppConfig, why: string) => {
|
|
626
|
+
if (!directNoted.current) {
|
|
627
|
+
directNoted.current = true
|
|
628
|
+
push("sys", `ForgeQE's agent is off (${why}), so the text runs as a browser goal.${why.includes("agent runtime") ? ` ${INSTALL_HINT}` : " Press Tab for a live profile."} /features lists what ForgeQE can do.`)
|
|
629
|
+
}
|
|
630
|
+
const controller = begin(cfg.llm.provider === "mock" ? "Running the offline demo…" : "Starting…")
|
|
631
|
+
testsRef.current = { on: false, why: why.includes("agent runtime") ? "the agent runtime Scribe needs is not installed" : "ForgeQE's agent is off in the offline profile" }
|
|
632
|
+
let finalStatus = "fail"
|
|
633
|
+
try {
|
|
634
|
+
const out = (await TOOLS[0].run({ goal }, { ...toolContext(false, controller.signal), config: cfg, cwd: process.cwd() })) as { status: string }
|
|
635
|
+
finalStatus = out.status === "passed" ? "done" : out.status === "stopped" ? "stopped" : "fail"
|
|
636
|
+
} catch {
|
|
637
|
+
finalStatus = controller.signal.aborted ? "stopped" : "fail"
|
|
638
|
+
} finally {
|
|
639
|
+
pushProduced()
|
|
640
|
+
finish(finalStatus)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const askAgent = async (text: string, cfg: AppConfig) => {
|
|
645
|
+
const controller = begin("Thinking…")
|
|
646
|
+
const meterStart = meterSnapshot()
|
|
647
|
+
setThinking(true)
|
|
648
|
+
const setup = sessionSetup(cfg, options.repo)
|
|
649
|
+
testsRef.current = { on: setup.writeTests, why: "the session was started with --no-code" }
|
|
650
|
+
let finalStatus = "fail"
|
|
651
|
+
let fresh = false
|
|
652
|
+
try {
|
|
653
|
+
if (!brainRef.current) {
|
|
654
|
+
fresh = true
|
|
655
|
+
setActivity("Starting ForgeQE's agent…")
|
|
656
|
+
const { cwd, notes } = setup
|
|
657
|
+
brainRef.current = await (options.brain || openBrain)({
|
|
658
|
+
config: cfg,
|
|
659
|
+
cwd,
|
|
660
|
+
notes,
|
|
661
|
+
context: () => toolContext(true, abortRef.current?.signal),
|
|
662
|
+
onEvent: onBrainEvent,
|
|
663
|
+
})
|
|
664
|
+
setModel(`${modelLabel(cfg)} · agent ${brainRef.current.model}`)
|
|
665
|
+
}
|
|
666
|
+
const brain = brainRef.current
|
|
667
|
+
const onAbort = () => void brain.abort()
|
|
668
|
+
controller.signal.addEventListener("abort", onAbort, { once: true })
|
|
669
|
+
if (!controller.signal.aborted) setActivity("Thinking…")
|
|
670
|
+
const answer = await brain.ask(fresh ? carryOver(convoRef.current, text) : text)
|
|
671
|
+
controller.signal.removeEventListener("abort", onAbort)
|
|
672
|
+
if (answer.outcome !== "stopped") convoRef.current.push({ user: text, reply: answer.reply })
|
|
673
|
+
if (answer.outcome === "stopped") {
|
|
674
|
+
push("sys", "Stopped.")
|
|
675
|
+
finalStatus = "stopped"
|
|
676
|
+
} else {
|
|
677
|
+
if (answer.reply) pushLine({ kind: "reply", text: answer.reply, outcome: answer.outcome })
|
|
678
|
+
if (answer.error && !answer.reply) push("err", answer.error)
|
|
679
|
+
finalStatus = answer.outcome === "done" ? "done" : answer.outcome === "needs_input" ? "idle" : "fail"
|
|
680
|
+
}
|
|
681
|
+
} catch (err) {
|
|
682
|
+
push("err", (err as Error).message)
|
|
683
|
+
closeBrain()
|
|
684
|
+
} finally {
|
|
685
|
+
pushProduced()
|
|
686
|
+
// What this request used and cost in total: ForgeQE's agent, browser runs, Scribe, and the reviewer.
|
|
687
|
+
const spent = requestLine(meterStart)
|
|
688
|
+
if (spent) push("sys", spent)
|
|
689
|
+
finish(finalStatus)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const handleText = (text: string) => {
|
|
694
|
+
const cfg = configRef.current
|
|
695
|
+
if (!cfg) {
|
|
696
|
+
push("err", "Config failed to load.")
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
if (abortRef.current) {
|
|
700
|
+
push("err", "ForgeQE is busy. Press esc to stop it.")
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
if (cfg.llm.provider === "none" && !options.brain) {
|
|
704
|
+
push("err", NO_MODEL_SET_UP)
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
const why = options.brain ? null : brainUnavailable(cfg)
|
|
708
|
+
if (why) void runDirect(text, cfg, why)
|
|
709
|
+
else void askAgent(text, cfg)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
useEffect(() => {
|
|
713
|
+
if (booted.current) return
|
|
714
|
+
booted.current = true
|
|
715
|
+
try {
|
|
716
|
+
let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
|
|
717
|
+
if (options.profile) cfg = selectProfile(cfg, options.profile)
|
|
718
|
+
configRef.current = cfg
|
|
719
|
+
// A price in llms.json that differs from the model's list price (probably a typo or an old price), said with the
|
|
720
|
+
// first message so the home screen stays as it is.
|
|
721
|
+
const reviewer = cfg.code.reviewProfile ? cfg.profiles[cfg.code.reviewProfile] : undefined
|
|
722
|
+
priceNotesRef.current = [...new Set([cfg.llm.priceWarning, reviewer?.priceWarning].filter((w): w is string => Boolean(w)))]
|
|
723
|
+
setProfile(cfg.activeProfile)
|
|
724
|
+
setModel(modelLabel(cfg))
|
|
725
|
+
setMaxSteps(cfg.browser.maxSteps)
|
|
726
|
+
stepsRef.current = cfg.browser.maxSteps
|
|
727
|
+
if (options.goal) {
|
|
728
|
+
push("user", options.goal)
|
|
729
|
+
handleText(options.goal)
|
|
730
|
+
}
|
|
731
|
+
} catch (err) {
|
|
732
|
+
push("err", (err as Error).message)
|
|
733
|
+
}
|
|
734
|
+
return () => closeBrain()
|
|
735
|
+
// Boot once.
|
|
736
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
737
|
+
}, [])
|
|
738
|
+
|
|
739
|
+
const onSubmit = (value: string) => {
|
|
740
|
+
const text = value.replace(/\t/g, "").trim()
|
|
741
|
+
setInputKey((k) => k + 1)
|
|
742
|
+
const pending = entryRef.current
|
|
743
|
+
if (pending) {
|
|
744
|
+
const answer = text || pending.initial || ""
|
|
745
|
+
if (!answer) return
|
|
746
|
+
push("user", pending.secret ? "•".repeat(Math.min(12, answer.length)) : answer)
|
|
747
|
+
settleEntry(answer)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
if (!text) return
|
|
751
|
+
push("user", text)
|
|
752
|
+
for (const w of priceNotesRef.current.splice(0)) push("sys", `Price check: ${w}`)
|
|
753
|
+
const cmd = parseCommand(text)
|
|
754
|
+
if (cmd.type === "help") {
|
|
755
|
+
push("sys", HELP)
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
if (cmd.type === "exit") {
|
|
759
|
+
if (abortRef.current) openDialog("quit")
|
|
760
|
+
else quit()
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
if (cmd.type === "clear") {
|
|
764
|
+
setLines([])
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
if (cmd.type === "stop") {
|
|
768
|
+
if (!abortRef.current) push("sys", "Nothing is running.")
|
|
769
|
+
else requestStop()
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
if (cmd.type === "headed") {
|
|
773
|
+
setHeaded(cmd.value)
|
|
774
|
+
headedRef.current = cmd.value
|
|
775
|
+
push("sys", `headed ${cmd.value ? "on" : "off"}`)
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
if (cmd.type === "details") {
|
|
779
|
+
const full = cmd.value ?? detailRef.current === "brief"
|
|
780
|
+
detailRef.current = full ? "full" : "brief"
|
|
781
|
+
push("sys", full ? "details on: the agents' reasoning, every file they read, and engine logs" : "details off: progress, results, files written, and problems")
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
if (cmd.type === "profile") {
|
|
785
|
+
try {
|
|
786
|
+
applyProfile(cmd.name)
|
|
787
|
+
push("sys", `profile ${cmd.name}`)
|
|
788
|
+
} catch (err) {
|
|
789
|
+
push("err", (err as Error).message)
|
|
790
|
+
}
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
if (cmd.type === "features") {
|
|
794
|
+
if (configRef.current) push("sys", featuresText(configRef.current))
|
|
795
|
+
else push("err", "Config is not loaded.")
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
if (cmd.type === "skills") {
|
|
799
|
+
try {
|
|
800
|
+
const cfg = configRef.current
|
|
801
|
+
if (!cfg) throw new Error("Config is not loaded.")
|
|
802
|
+
const reg = loadSkills(cfg.skills, process.cwd())
|
|
803
|
+
if (!cmd.name) push("sys", skillsOverview(reg, cfg.skills, process.cwd()))
|
|
804
|
+
else {
|
|
805
|
+
const skill = findSkill(reg, cmd.name)
|
|
806
|
+
if (!skill) throw new Error(`No skill named "${cmd.name}". /skills lists them.`)
|
|
807
|
+
push("sys", skillDetail(skill, reg.disabled.includes(skill)))
|
|
808
|
+
}
|
|
809
|
+
} catch (err) {
|
|
810
|
+
push("err", (err as Error).message)
|
|
811
|
+
}
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
if (cmd.type === "logs") {
|
|
815
|
+
try {
|
|
816
|
+
if (!configRef.current) throw new Error("Config is not loaded.")
|
|
817
|
+
const out = bundleLogs(configRef.current, { shots: cmd.shots })
|
|
818
|
+
push("sys", [`log ${out.log}`, `runs ${out.runs}`, `archive ${out.archive}`, "Secrets are replaced in every file. Share the archive to get help with this session."].join("\n"))
|
|
819
|
+
} catch (err) {
|
|
820
|
+
push("err", (err as Error).message)
|
|
821
|
+
}
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
if (cmd.type === "auth") {
|
|
825
|
+
if (abortRef.current) {
|
|
826
|
+
push("err", "ForgeQE is busy. Press esc to stop it.")
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
void runAuth(cmd.action, cmd.system).catch((err) => push("err", (err as Error).message))
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
if (cmd.type === "unknown") {
|
|
833
|
+
push("err", "Usage: /headed on|off, /details on|off, /profile <name>, /auth [system], /auth logout <system>. /help lists the commands.")
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
handleText(cmd.text)
|
|
837
|
+
}
|
|
838
|
+
submitRef.current = onSubmit
|
|
839
|
+
|
|
840
|
+
void tick
|
|
841
|
+
const running = status === "running"
|
|
842
|
+
const elapsed = started.current != null ? formatDuration(Date.now() - started.current) : ""
|
|
843
|
+
const modeColor = running ? theme.warn : status === "fail" ? theme.err : theme.accent
|
|
844
|
+
const right = running
|
|
845
|
+
? suiteProgress
|
|
846
|
+
? `case ${Math.min(suiteProgress.done + 1, suiteProgress.total)}/${suiteProgress.total}`
|
|
847
|
+
: browsing
|
|
848
|
+
? step > maxSteps
|
|
849
|
+
? `step ${step} (past ${maxSteps}, still progressing)`
|
|
850
|
+
: `step ${step}/${maxSteps}`
|
|
851
|
+
: coding
|
|
852
|
+
? "coding agent"
|
|
853
|
+
: "agent"
|
|
854
|
+
: status === "done" || status === "fail" || status === "stopped"
|
|
855
|
+
? status
|
|
856
|
+
: options.url
|
|
857
|
+
? hostOf(options.url)
|
|
858
|
+
: headed
|
|
859
|
+
? "headed"
|
|
860
|
+
: ""
|
|
861
|
+
const rightColor = running ? theme.warn : status === "done" ? theme.ok : status === "fail" ? theme.err : theme.muted
|
|
862
|
+
const footerRight = VERSION ? `ForgeQE ${VERSION}` : "ForgeQE"
|
|
863
|
+
const home = lines.length === 0 && !running
|
|
864
|
+
|
|
865
|
+
const closeHint: Hint = { key: "ctrl+c", label: "close", onPress: () => openDialog("quit") }
|
|
866
|
+
const copiedHint: Hint[] = copied ? [{ key: `✓ ${copied}`, label: "" }] : []
|
|
867
|
+
const idleHints: Hint[] = [
|
|
868
|
+
...copiedHint,
|
|
869
|
+
...(lastStats ? [{ key: `last run ${lastStats}`, label: "" }] : []),
|
|
870
|
+
{ key: "tab", label: "profiles" },
|
|
871
|
+
{ key: "ctrl+p", label: "commands" },
|
|
872
|
+
closeHint,
|
|
873
|
+
]
|
|
874
|
+
const runningHints: Hint[] = [
|
|
875
|
+
...copiedHint,
|
|
876
|
+
{ key: `${formatTokens(tokens)} tokens · ${elapsed}`, label: "" },
|
|
877
|
+
{ key: "ctrl+p", label: "commands" },
|
|
878
|
+
closeHint,
|
|
879
|
+
]
|
|
880
|
+
const runningLeft = (
|
|
881
|
+
<>
|
|
882
|
+
<Spinner kind="bar" />
|
|
883
|
+
<text onMouseDown={() => openDialog("stop")}>
|
|
884
|
+
<span fg={theme.text}>{" esc"}</span>
|
|
885
|
+
<span fg={theme.muted}>{" stop"}</span>
|
|
886
|
+
</text>
|
|
887
|
+
</>
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
const renderControls = (promptWidth: number, placeholder: string, rows: [number, number]) => {
|
|
891
|
+
if (dialog) {
|
|
892
|
+
const spec = dialogSpec(dialog, running, outDirLabel(), thinking, waitRef.current?.text, askRef.current?.q)
|
|
893
|
+
const wide = spec.options.reduce((n, o) => n + o.label.length + 3, 0) > promptWidth - 36
|
|
894
|
+
return (
|
|
895
|
+
<ChoicePanel
|
|
896
|
+
vertical={spec.options.length > 3 || wide}
|
|
897
|
+
width={promptWidth}
|
|
898
|
+
title={spec.title}
|
|
899
|
+
detail={spec.detail}
|
|
900
|
+
color={spec.color}
|
|
901
|
+
options={spec.options}
|
|
902
|
+
selected={Math.min(choice, spec.options.length - 1)}
|
|
903
|
+
onChoose={choose}
|
|
904
|
+
onHover={setChoice}
|
|
905
|
+
/>
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
return (
|
|
909
|
+
<>
|
|
910
|
+
<Prompt
|
|
911
|
+
width={promptWidth}
|
|
912
|
+
inputKey={inputKey}
|
|
913
|
+
onSubmit={(value) => submitRef.current(value)}
|
|
914
|
+
minRows={entry ? 1 : rows[0]}
|
|
915
|
+
maxRows={entry ? 3 : rows[1]}
|
|
916
|
+
placeholder={entry ? `${entry.label}${entry.secret ? " (hidden as you type)" : ""}${entry.initial ? ` · Enter keeps ${entry.initial}` : ""} · esc cancels` : placeholder}
|
|
917
|
+
masked={entry?.secret ? secretLength : undefined}
|
|
918
|
+
modeColor={modeColor}
|
|
919
|
+
profile={profile}
|
|
920
|
+
model={model}
|
|
921
|
+
right={right}
|
|
922
|
+
rightColor={rightColor}
|
|
923
|
+
/>
|
|
924
|
+
<Hints width={promptWidth} items={running ? runningHints : idleHints} left={running ? runningLeft : undefined} />
|
|
925
|
+
</>
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (home) {
|
|
930
|
+
const promptWidth = Math.max(40, Math.min(width - 8, 120))
|
|
931
|
+
return (
|
|
932
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
933
|
+
<box style={{ flexDirection: "column", flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
|
|
934
|
+
<Logo />
|
|
935
|
+
<box style={{ height: 2 }} />
|
|
936
|
+
{renderControls(promptWidth, `Describe a goal… "${example}"`, [4, Math.max(4, Math.min(12, height - 22))])}
|
|
937
|
+
<box style={{ height: 3 }} />
|
|
938
|
+
<text>
|
|
939
|
+
<span fg={theme.warn}>{"● "}</span>
|
|
940
|
+
<span fg={theme.text}>{"Tip "}</span>
|
|
941
|
+
<span fg={theme.muted}>{tip}</span>
|
|
942
|
+
</text>
|
|
943
|
+
</box>
|
|
944
|
+
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
945
|
+
</box>
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const promptWidth = Math.max(40, width - 4)
|
|
950
|
+
return (
|
|
951
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
952
|
+
<scrollbox
|
|
953
|
+
ref={scrollRef}
|
|
954
|
+
stickyScroll
|
|
955
|
+
stickyStart="bottom"
|
|
956
|
+
style={{ flexGrow: 1, paddingLeft: 2, paddingRight: 2, paddingTop: 1 }}
|
|
957
|
+
focused={false}
|
|
958
|
+
>
|
|
959
|
+
{lines.map((line) => (
|
|
960
|
+
<TranscriptLine key={line.id} line={line} />
|
|
961
|
+
))}
|
|
962
|
+
{running ? (
|
|
963
|
+
<box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2, flexShrink: 0 }}>
|
|
964
|
+
<Spinner kind="dots" />
|
|
965
|
+
<text fg={theme.text}>{` ${activity || "Working…"}`}</text>
|
|
966
|
+
</box>
|
|
967
|
+
) : null}
|
|
968
|
+
</scrollbox>
|
|
969
|
+
<box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>
|
|
970
|
+
{renderControls(
|
|
971
|
+
promptWidth,
|
|
972
|
+
running
|
|
973
|
+
? coding
|
|
974
|
+
? "The coding agent is writing a test · esc to stop"
|
|
975
|
+
: `${thinking ? "ForgeQE's agent" : "The browser agent"} is working · esc to stop`
|
|
976
|
+
: "Ask ForgeQE: a goal, test cases, a test, a pipeline…",
|
|
977
|
+
[3, Math.max(3, Math.min(10, Math.floor(height / 3)))],
|
|
978
|
+
)}
|
|
979
|
+
</box>
|
|
980
|
+
<Footer left={shortCwd()} right={footerRight} width={width} />
|
|
981
|
+
</box>
|
|
982
|
+
)
|
|
983
|
+
}
|