Files
moeka-project/services/computer-use-mcp/src/state.ts
T
刘梓恒 7021572cb8 feat(computer-use-mcp): add tool lane hygiene advisory (#1919)
## Summary

Adds advisory-only tool lane hygiene inside `services/computer-use-mcp`.

This tracks the most recent non-exempt tool lane and appends a
non-blocking advisory when a registered tool is called from a different
active lane. The change is intentionally scoped to computer-use MCP
server registration and run state.

## Boundary

- Only touches `services/computer-use-mcp`.
- Does not touch app, renderer, overlay, extension, or root project
governance files.
- Does not include local AI config, local worktree artifacts,
screenshots, tokens, account identifiers, or raw environment dumps.

## Validation

Sanitized evidence is committed in
`services/computer-use-mcp/validation/tool-lane-hygiene.md`.

Ran:

- `pnpm install --ignore-scripts --frozen-lockfile`
- `pnpm -F @proj-airi/computer-use-mcp exec vitest run
src/server/tool-lane-hygiene.test.ts --config ./vitest.config.ts`
- `pnpm -F @proj-airi/computer-use-mcp exec vitest run
src/server/tool-lane-hygiene.test.ts
src/server/register-tools-coordinate-contract.test.ts
src/server/register-tools-pty-approval.test.ts --config
./vitest.config.ts`
- `pnpm exec moeru-lint --fix
services/computer-use-mcp/validation/tool-lane-hygiene.md
services/computer-use-mcp/src/server/tool-lane-hygiene.ts
services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts
services/computer-use-mcp/src/server/register-tools.ts
services/computer-use-mcp/src/state.ts`
- `git diff --check`

`pnpm -F @proj-airi/computer-use-mcp typecheck` is currently blocked by
existing baseline errors in `chrome-session-manager` and
`desktop-grounding`, outside this patch. The validation note lists the
error classes without local paths or raw logs.
2026-06-01 17:15:58 +08:00

752 lines
23 KiB
TypeScript

/**
* Run-level state manager.
*
* Maintains a unified, continuously updated picture of the current
* execution environment so that downstream strategy / workflow layers
* can make informed decisions without re-querying every subsystem.
*
* State is **ephemeral** — it lives for the duration of the MCP server
* process. Persistent audit lives in session trace / JSONL.
*/
import type { DesktopSession } from './desktop-session'
import type { ToolLane } from './server/tool-descriptors'
import type { TaskMemory } from './task-memory/types'
import type {
BrowserSurfaceAvailability,
ChromeSessionInfo,
DisplayInfo,
ExecutionTarget,
ForegroundContext,
LastScreenshotInfo,
PolicyDecision,
PtyApprovalGrant,
PtyAuditEntry,
SurfaceDecision,
TerminalCommandResult,
TerminalState,
VscodeControllerState,
VscodeProblem,
WindowObservation,
WorkflowStepTerminalBinding,
} from './types'
import { appNamesMatch } from './app-aliases'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TaskPhase
= | 'idle'
| 'planning'
| 'executing'
| 'awaiting_approval'
| 'recovering'
| 'reroute_required'
| 'completed'
| 'failed'
/** Lightweight snapshot of a PTY session stored in RunState. */
export interface PtySessionState {
/** Session id (e.g. "pty_1"). */
id: string
/** Whether the underlying process is still alive. */
alive: boolean
/** Terminal dimensions. */
rows: number
cols: number
/** Process PID. */
pid: number
/** Working directory at creation time. */
cwd?: string
/** Last cwd observed from terminal prompt heuristics. */
observedCwd?: string
/** Stable workflow step id that created this session (if any). */
boundStepId?: string
/**
* @deprecated Use `boundStepId`. Kept for backward-compat logging only.
*/
boundWorkflowStepLabel?: string
/** ISO timestamp when the session was created. */
createdAt: string
/** ISO timestamp of last interaction (write/read). */
lastInteractionAt?: string
}
export interface TaskStep {
/** Sequential 1-based index within the current task. */
index: number
/** Stable unique id for binding/recovery (e.g. "step_<uuid>"). */
stepId: string
/** Human-readable label, e.g. "Open Terminal" */
label: string
/** MCP tool invoked, e.g. "desktop_open_app" */
toolName?: string
/** Outcome after execution. */
outcome?: 'success' | 'failure' | 'skipped' | 'pending_approval' | 'rejected' | 'reroute_required'
/** Short explanation of the outcome. */
outcomeReason?: string
/** ISO timestamp when started. */
startedAt?: string
/** ISO timestamp when finished. */
finishedAt?: string
}
export interface ActiveTask {
/** Unique identifier. */
id: string
/** Human-readable goal description. */
goal: string
/** Workflow template id (if driven by a workflow). */
workflowId?: string
phase: TaskPhase
/** Ordered list of steps planned / executed so far. */
steps: TaskStep[]
/** Index of the currently executing step (0-based into `steps`). */
currentStepIndex: number
/** ISO timestamp when the task started. */
startedAt: string
/** ISO timestamp when the task finished (completed / failed). */
finishedAt?: string
/** Accumulated failure count within this task. */
failureCount: number
/** Maximum tolerable consecutive failures before aborting. */
maxConsecutiveFailures: number
}
export interface RunState {
// --- Desktop context --------------------------------------------------
/** Most recently observed foreground app name. */
activeApp?: string
/** Most recently observed window title. */
activeWindowTitle?: string
/** Full foreground context from last probe. */
foregroundContext?: ForegroundContext
/** Most recent window observation. */
lastWindowObservation?: WindowObservation
/** Last known execution target. */
executionTarget?: ExecutionTarget
/** Last known display info. */
displayInfo?: DisplayInfo
/** Browser DOM/CDP surface availability for browser rerouting. */
browserSurfaceAvailability?: BrowserSurfaceAvailability
// --- VS Code controller context --------------------------------------
/** Sticky VS Code engineering-controller state. */
vscode?: VscodeControllerState
// --- Terminal context -------------------------------------------------
/** Sticky terminal state (cwd, last exit code, etc.). */
terminalState?: TerminalState
/** Full result of the most recent terminal command. */
lastTerminalResult?: TerminalCommandResult
// --- Screenshot context -----------------------------------------------
/** Metadata for the most recent screenshot. */
lastScreenshot?: LastScreenshotInfo
/** One-line human summary of the most recent screenshot content. */
lastScreenshotSummary?: string
// --- Approval context -------------------------------------------------
/** Number of pending approval actions. */
pendingApprovalCount: number
/** Whether the last approval was rejected. */
lastApprovalRejected: boolean
/** Reason for the last rejection (if any). */
lastRejectionReason?: string
/** The most recent policy decision. */
lastPolicyDecision?: PolicyDecision
// --- PTY context -------------------------------------------------------
/** Registry of active PTY sessions tracked by the state manager. */
ptySessions: PtySessionState[]
/** The session id most recently written to or read from. */
activePtySessionId?: string
// --- Terminal lane context ---------------------------------------------
/** Most recent surface routing decision. */
recentSurfaceDecision?: SurfaceDecision
/** Active workflow-step → terminal bindings. */
workflowStepTerminalBindings: WorkflowStepTerminalBinding[]
/** Active PTY Open Grant records. */
ptyApprovalGrants: PtyApprovalGrant[]
/** PTY audit log (kept in memory for current session). */
ptyAuditLog: PtyAuditEntry[]
// --- Task context -----------------------------------------------------
/** Currently active task (if any). */
activeTask?: ActiveTask
// --- Task memory ------------------------------------------------------
/** High-level task execution state (goal, facts, blockers, next step). */
taskMemory?: TaskMemory
// --- Desktop grounding -------------------------------------------------
/** Latest unified desktop grounding snapshot captured by `desktop_observe`. */
lastGroundingSnapshot?: import('./desktop-grounding-types').DesktopGroundingSnapshot
/** Most recent pointer snap intent for overlay rendering. */
lastPointerIntent?: import('./desktop-grounding-types').PointerIntent
/** Candidate id of the last `desktop_click_target` call for duplicate protection. */
lastClickedCandidateId?: string
// --- Chrome Session ----------------------------------------------------
/** Agent's dedicated Chrome session (managed by ChromeSessionManager). */
chromeSession?: ChromeSessionInfo
/** The user's foreground app before the agent took over. */
previousUserForegroundApp?: string
// --- Desktop Session ---------------------------------------------------
/** Agent's active desktop execution session. */
desktopSession?: DesktopSession
// --- Tool lane hygiene ------------------------------------------------
/** Inferred active lane from the most recent non-exempt tool invocation. */
inferredActiveLane?: ToolLane
// --- Meta -------------------------------------------------------------
/** ISO timestamp of the last state update. */
updatedAt: string
}
// ---------------------------------------------------------------------------
// State Manager
// ---------------------------------------------------------------------------
export class RunStateManager {
private state: RunState
constructor() {
this.state = {
pendingApprovalCount: 0,
lastApprovalRejected: false,
ptySessions: [],
workflowStepTerminalBindings: [],
ptyApprovalGrants: [],
ptyAuditLog: [],
updatedAt: new Date().toISOString(),
}
}
/** Return a readonly snapshot of the current run state. */
getState(): Readonly<RunState> {
return { ...this.state }
}
// -- Desktop context updates -------------------------------------------
updateForegroundContext(ctx: ForegroundContext) {
this.state.foregroundContext = ctx
this.state.activeApp = ctx.appName
this.state.activeWindowTitle = ctx.windowTitle
this.touch()
}
updateWindowObservation(obs: WindowObservation) {
this.state.lastWindowObservation = obs
if (obs.frontmostAppName) {
this.state.activeApp = obs.frontmostAppName
}
if (obs.frontmostWindowTitle) {
this.state.activeWindowTitle = obs.frontmostWindowTitle
}
this.touch()
}
updateExecutionTarget(target: ExecutionTarget) {
this.state.executionTarget = target
this.touch()
}
updateDisplayInfo(info: DisplayInfo) {
this.state.displayInfo = info
this.touch()
}
updateBrowserSurfaceAvailability(availability: BrowserSurfaceAvailability) {
this.state.browserSurfaceAvailability = availability
this.touch()
}
updateVscodeCli(cli: { cli: string, path: string }) {
this.state.vscode = {
...(this.state.vscode ?? { updatedAt: new Date().toISOString() }),
codeCli: cli,
updatedAt: new Date().toISOString(),
}
this.touch()
}
updateVscodeWorkspace(workspacePath: string) {
this.state.vscode = {
...(this.state.vscode ?? { updatedAt: new Date().toISOString() }),
workspacePath,
updatedAt: new Date().toISOString(),
}
this.touch()
}
updateVscodeCurrentFile(file: { filePath: string, line?: number, column?: number }) {
this.state.vscode = {
...(this.state.vscode ?? { updatedAt: new Date().toISOString() }),
currentFile: file,
updatedAt: new Date().toISOString(),
}
this.touch()
}
updateVscodeTaskResult(task: { command: string, cwd: string, exitCode: number }) {
this.state.vscode = {
...(this.state.vscode ?? { updatedAt: new Date().toISOString() }),
lastTask: task,
updatedAt: new Date().toISOString(),
}
this.touch()
}
updateVscodeProblems(problems: {
command: string
cwd: string
problemCount: number
problems: VscodeProblem[]
}) {
this.state.vscode = {
...(this.state.vscode ?? { updatedAt: new Date().toISOString() }),
lastProblems: problems,
updatedAt: new Date().toISOString(),
}
this.touch()
}
// -- Terminal context updates ------------------------------------------
updateTerminalState(ts: TerminalState) {
this.state.terminalState = ts
this.touch()
}
updateTerminalResult(result: TerminalCommandResult) {
this.state.lastTerminalResult = result
this.state.terminalState = {
effectiveCwd: result.effectiveCwd,
lastExitCode: result.exitCode,
lastCommandSummary: result.command.length > 160
? `${result.command.slice(0, 157)}...`
: result.command,
}
this.touch()
}
// -- Screenshot context updates ----------------------------------------
updateLastScreenshot(info: LastScreenshotInfo, summary?: string) {
this.state.lastScreenshot = info
if (summary !== undefined) {
this.state.lastScreenshotSummary = summary
}
this.touch()
}
setScreenshotSummary(summary: string) {
this.state.lastScreenshotSummary = summary
this.touch()
}
// -- Approval context updates ------------------------------------------
setPendingApprovalCount(count: number) {
this.state.pendingApprovalCount = count
this.touch()
}
recordApprovalOutcome(rejected: boolean, reason?: string) {
this.state.lastApprovalRejected = rejected
this.state.lastRejectionReason = rejected ? reason : undefined
this.touch()
}
updatePolicyDecision(decision: PolicyDecision) {
this.state.lastPolicyDecision = decision
this.touch()
}
// -- Task context updates ----------------------------------------------
startTask(task: ActiveTask) {
this.state.activeTask = task
this.touch()
}
updateTaskPhase(phase: TaskPhase) {
if (this.state.activeTask) {
this.state.activeTask.phase = phase
this.touch()
}
}
advanceTaskStep(step: TaskStep) {
if (this.state.activeTask) {
this.state.activeTask.steps.push(step)
this.state.activeTask.currentStepIndex = this.state.activeTask.steps.length - 1
this.touch()
}
}
completeCurrentStep(outcome: TaskStep['outcome'], reason?: string) {
if (!this.state.activeTask)
return
const step = this.state.activeTask.steps[this.state.activeTask.currentStepIndex]
if (step) {
step.outcome = outcome
step.outcomeReason = reason
step.finishedAt = new Date().toISOString()
if (outcome === 'failure') {
this.state.activeTask.failureCount += 1
}
}
this.touch()
}
finishTask(phase: 'completed' | 'failed' | 'reroute_required') {
if (this.state.activeTask) {
this.state.activeTask.phase = phase
this.state.activeTask.finishedAt = new Date().toISOString()
}
this.touch()
}
// -- Desktop grounding updates -----------------------------------------
/**
* Store a fresh desktop grounding snapshot from `desktop_observe`.
* A new observe invalidates the duplicate-click guard.
*/
updateGroundingSnapshot(snapshot: import('./desktop-grounding-types').DesktopGroundingSnapshot): void {
this.state.lastGroundingSnapshot = snapshot
this.state.lastClickedCandidateId = undefined
this.touch()
}
/**
* Store the last pointer snap intent and, optionally, the clicked candidate id
* once an execution path has actually succeeded.
*/
updatePointerIntent(intent: import('./desktop-grounding-types').PointerIntent, candidateId?: string): void {
this.state.lastPointerIntent = intent
if (candidateId !== undefined) {
this.state.lastClickedCandidateId = candidateId
}
this.touch()
}
/**
* Clear desktop grounding state when the snapshot becomes invalid.
*/
clearGroundingState(): void {
this.state.lastGroundingSnapshot = undefined
this.state.lastPointerIntent = undefined
this.state.lastClickedCandidateId = undefined
this.touch()
}
// -- Chrome Session updates ---------------------------------------------
/**
* Store the agent's Chrome session info.
* Called after ChromeSessionManager.ensureAgentWindow() succeeds.
*/
updateChromeSession(info: ChromeSessionInfo): void {
this.state.chromeSession = info
this.touch()
}
/**
* Clear the Chrome session (e.g. on session end or Chrome crash).
*/
clearChromeSession(): void {
this.state.chromeSession = undefined
this.state.previousUserForegroundApp = undefined
this.touch()
}
/**
* Remember the user's foreground app before agent takes over.
*/
savePreviousUserForeground(appName: string): void {
this.state.previousUserForegroundApp = appName
this.touch()
}
// -- Desktop Session updates --------------------------------------------
/**
* Update the agent's desktop session.
*/
updateDesktopSession(session: DesktopSession): void {
this.state.desktopSession = session
this.touch()
}
/**
* Clear the desktop session.
*/
clearDesktopSession(): void {
this.state.desktopSession = undefined
this.touch()
}
updateInferredLane(lane: ToolLane): void {
this.state.inferredActiveLane = lane
this.touch()
}
clearTask() {
this.state.activeTask = undefined
this.touch()
}
// -- Task memory updates ------------------------------------------------
updateTaskMemory(tm: TaskMemory) {
this.state.taskMemory = tm
this.touch()
}
clearTaskMemory() {
this.state.taskMemory = undefined
this.touch()
}
// -- PTY session lifecycle ---------------------------------------------
/** Register a newly created PTY session in state. */
registerPtySession(session: Omit<PtySessionState, 'createdAt'>): void {
// Remove stale entry with same id (shouldn't happen, but defensive)
this.state.ptySessions = this.state.ptySessions.filter(s => s.id !== session.id)
this.state.ptySessions.push({
...session,
createdAt: new Date().toISOString(),
})
this.state.activePtySessionId = session.id
this.touch()
}
/** Update the alive status of a PTY session (e.g. after process exit). */
updatePtySessionAlive(sessionId: string, alive: boolean): void {
const entry = this.state.ptySessions.find(s => s.id === sessionId)
if (entry) {
entry.alive = alive
this.touch()
}
}
/** Record an interaction timestamp on a PTY session. */
touchPtySession(sessionId: string): void {
const entry = this.state.ptySessions.find(s => s.id === sessionId)
if (entry) {
entry.lastInteractionAt = new Date().toISOString()
this.state.activePtySessionId = sessionId
this.touch()
}
}
/** Update the last observed cwd of a PTY session from terminal prompt heuristics. */
updatePtySessionObservedCwd(sessionId: string, cwd: string): void {
const entry = this.state.ptySessions.find(s => s.id === sessionId)
if (entry && entry.observedCwd !== cwd) {
entry.observedCwd = cwd
this.touch()
}
}
/** Bind a PTY session to a workflow step by stable stepId. */
bindPtySessionToStepId(sessionId: string, stepId: string): void {
const entry = this.state.ptySessions.find(s => s.id === sessionId)
if (entry) {
entry.boundStepId = stepId
this.touch()
}
}
/** Bind a PTY session to a workflow step label (legacy compat). */
bindPtySessionToStep(sessionId: string, stepLabel: string): void {
const entry = this.state.ptySessions.find(s => s.id === sessionId)
if (entry) {
entry.boundWorkflowStepLabel = stepLabel
this.touch()
}
}
/** Remove a PTY session from the registry (after destroy). */
unregisterPtySession(sessionId: string): void {
this.state.ptySessions = this.state.ptySessions.filter(s => s.id !== sessionId)
if (this.state.activePtySessionId === sessionId) {
this.state.activePtySessionId = this.state.ptySessions[0]?.id
}
this.touch()
}
/** Get the active PTY session id. */
getActivePtySessionId(): string | undefined {
return this.state.activePtySessionId
}
/** Get all PTY sessions. */
getPtySessions(): readonly PtySessionState[] {
return this.state.ptySessions
}
// -- Terminal lane: surface decision ------------------------------------
/** Record the most recent surface routing decision. */
recordSurfaceDecision(decision: Omit<SurfaceDecision, 'at'>): void {
this.state.recentSurfaceDecision = {
...decision,
at: new Date().toISOString(),
}
this.touch()
}
/** Get the most recent surface decision. */
getRecentSurfaceDecision(): SurfaceDecision | undefined {
return this.state.recentSurfaceDecision
}
// -- Terminal lane: step bindings --------------------------------------
/** Bind a workflow step to a terminal surface/session. */
addStepTerminalBinding(binding: WorkflowStepTerminalBinding): void {
// Replace existing binding for same taskId+stepId
this.state.workflowStepTerminalBindings = this.state.workflowStepTerminalBindings.filter(
b => b.taskId !== binding.taskId || b.stepId !== binding.stepId,
)
this.state.workflowStepTerminalBindings.push(binding)
this.touch()
}
/** Look up the terminal binding for a task+step. */
getStepTerminalBinding(taskId: string, stepId: string): WorkflowStepTerminalBinding | undefined {
return this.state.workflowStepTerminalBindings.find(
b => b.taskId === taskId && b.stepId === stepId,
)
}
/** Clear all bindings for a given task. */
clearTaskTerminalBindings(taskId: string): void {
this.state.workflowStepTerminalBindings = this.state.workflowStepTerminalBindings.filter(
b => b.taskId !== taskId,
)
this.touch()
}
// -- Terminal lane: PTY Open Grant -------------------------------------
/** Grant approval for a PTY session (Open Grant model). */
grantPtyApproval(approvalSessionId: string, ptySessionId: string): void {
// Deduplicate
const existing = this.state.ptyApprovalGrants.find(
g => g.approvalSessionId === approvalSessionId && g.ptySessionId === ptySessionId,
)
if (existing) {
existing.active = true
existing.grantedAt = new Date().toISOString()
}
else {
this.state.ptyApprovalGrants.push({
approvalSessionId,
ptySessionId,
grantedAt: new Date().toISOString(),
active: true,
})
}
this.touch()
}
/** Check if a PTY session has an active grant in the given approval session. */
hasPtyApprovalGrant(approvalSessionId: string, ptySessionId: string): boolean {
return this.state.ptyApprovalGrants.some(
g => g.approvalSessionId === approvalSessionId
&& g.ptySessionId === ptySessionId
&& g.active,
)
}
/** Revoke the grant for a PTY session (called on pty_destroy). */
revokePtyApproval(ptySessionId: string): void {
for (const g of this.state.ptyApprovalGrants) {
if (g.ptySessionId === ptySessionId) {
g.active = false
}
}
this.touch()
}
/** Revoke all grants for an approval session (session end). */
revokeApprovalSession(approvalSessionId: string): void {
for (const g of this.state.ptyApprovalGrants) {
if (g.approvalSessionId === approvalSessionId) {
g.active = false
}
}
this.touch()
}
/** Get all active PTY grants. */
getActivePtyGrants(): readonly PtyApprovalGrant[] {
return this.state.ptyApprovalGrants.filter(g => g.active)
}
// -- Terminal lane: PTY audit ------------------------------------------
/** Append a PTY audit entry. */
appendPtyAudit(entry: Omit<PtyAuditEntry, 'at'>): void {
this.state.ptyAuditLog.push({
...entry,
at: new Date().toISOString(),
})
this.touch()
}
/** Get all PTY audit entries. */
getPtyAuditLog(): readonly PtyAuditEntry[] {
return this.state.ptyAuditLog
}
/** Get audit entries for a specific PTY session. */
getPtyAuditForSession(ptySessionId: string): PtyAuditEntry[] {
return this.state.ptyAuditLog.filter(e => e.ptySessionId === ptySessionId)
}
// -- Helpers -----------------------------------------------------------
/** Whether the system believes the correct app is in front. */
isAppInForeground(appName: string): boolean {
if (!this.state.activeApp)
return false
return appNamesMatch(this.state.activeApp, appName)
}
/** Whether the last terminal command succeeded (exit 0). */
lastTerminalSucceeded(): boolean {
return this.state.lastTerminalResult?.exitCode === 0
}
/** Whether the runner is in a healthy state for mutations. */
isReadyForMutations(): boolean {
if (!this.state.executionTarget)
return false
return !this.state.executionTarget.tainted
}
/** Whether there is a task currently in progress. */
hasActiveTask(): boolean {
return !!this.state.activeTask
&& this.state.activeTask.phase !== 'completed'
&& this.state.activeTask.phase !== 'failed'
}
private touch() {
this.state.updatedAt = new Date().toISOString()
}
}