Wendy mods for Claude Code: verifies Wendy deploys and shows the connected device above the prompt.

A Claude Code mod (a plugin of function hooks) for people building on WendyOS.
run tool reports a started deploy, the mod checks the app and adds the verdict to the run result, so the model cannot report success on a detached deploy alone. A follow-up check reports apps that crash, restart or never become ready. It runs 20 s later, or when the app's readiness window ends.The mod calls only wendy_status, app_inspect and container_list, all read-only. It never connects, deploys, changes tool groups, or starts a turn by itself. App log lines it quotes to the model are marked as untrusted app output.
wendy mcp setup, or the wendy plugin). Older CLIs get "not verified" with the reason.wendy_tools(groups=["observability"])).app_inspect is not listed, and Claude Code lets a mod call only listed tools.container_list: crash loops, stops and restarts are still caught, and readiness is reported as unknown.claude --plugin-dir plugins/wendy-mods
If your Wendy MCP server has another name, set it under /config → wendy-mods → "Wendy MCP server".
cd plugins/wendy-mods
claude plugin validate .
claude plugin test .
npx -y -p typescript@5.9 tsc -p . # after one load has written .claude-plugin/types
All decisions live in plain modules under hooks/ that take no engine object. hooks/register.tsx only wires engine events to them. tests/live-fixtures.ts holds output recorded from a real device; re-record it rather than editing it.
CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json).wendy run typed in a terminal is not verified; it streams logs itself.wendy_status reports no device name).wendy-agentic-coding mirror, until the mod API settles.hooks/register.tsx 296 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Band, BandVerdict, WendyTarget } from '../types'
5import { bandParts } from './band'
6import type { Part } from './band'
7import { leadingJson, str } from './json'
8import { appIdFrom, readinessWindowFrom, wendyJsonPath } from './project'
9import { callWendy, discover, MIN_CLI, missingTool, noticeIn, serverFromTool } from './server'
10import type { McpCall } from './server'
11import { asTarget, connectedTarget, connectionOf, sameTarget } from './target'
12import { contextText, followUpText, followUpToast, inspectionFromContainers, isWorse, NOT_VERIFIED, notVerifiedText, readInspection, toastText, verdictOf } from './verify'
13import type { Inspection } from './verify'
14
15const band = atom({ plugin: 'wendy-mods', key: 'band' } as const, null as Band)
16
17const POLL_MS = 30_000
18const MAX_POLL_FAILURES = 3
19const SETTLE_MS = 3_000
20const SETTLE_MIN_BUDGET_MS = 6_000
21const FOLLOW_UP_MS = 20_000
22const INSPECT_ARGS = { timeout_seconds: 5, max_logs: 5 } as const
23
24// The engine lets `$` reach only functions declared at the top of this file, so
25// the helpers live here and share this state. `register` resets it; a hot reload
26// starts it over, which the spec accepts.
27let override = ''
28let server: string | undefined
29let pollFailures = 0
30const followUps = new Map<string, Timer>()
31// Notices the server sent to the mod's own calls, held for the model's next Wendy call.
32let pendingNotices: string[] = []
33
34function textProps(part: Part): { color?: string; bold?: true; dimColor?: true } {
35 return {
36 ...(part.color === undefined ? {} : { color: part.color }),
37 ...(part.bold === true ? { bold: true as const } : {}),
38 ...(part.dim === true ? { dimColor: true as const } : {}),
39 }
40}
41
42function keepNotice(notice: string): void {
43 if (notice !== '' && !pendingNotices.includes(notice)) pendingNotices.push(notice)
44}
45
46/** The held notices, worded for the model, and forgotten. */
47function relayNotices(): string[] {
48 const relayed = pendingNotices.map(notice => `wendy-mods: the Wendy MCP server sent this notice to a wendy-mods background check, so it is relayed here: ${notice}`)
49 pendingNotices = []
50 return relayed
51}
52
53// The mod's calls share the model's MCP session, so they can receive the
54// server's one-time CLI update notice; keep it for the model.
55const mcpOf =
56 ($: EngineInterface): McpCall =>
57 async (name, tool, args) => {
58 const result = await $.mcp.call(name, tool, args)
59 keepNotice(noticeIn(result))
60 return result
61 }
62
63function useServer(name: string): void {
64 if (name !== server) {
65 server = name
66 pollFailures = 0
67 }
68}
69
70/** Re-reads the connection for the band; finds a server first when none is known. */
71async function refresh($: EngineInterface): Promise<void> {
72 if (server === undefined) {
73 server = await discover(mcpOf($), override)
74 if (server === undefined) {
75 await update($, band, () => null)
76 return
77 }
78 }
79 const status = await callWendy(mcpOf($), server, 'wendy_status')
80 if (!status.ok) {
81 pollFailures += 1
82 if (pollFailures >= MAX_POLL_FAILURES) {
83 server = undefined
84 pollFailures = 0
85 await update($, band, () => null)
86 }
87 return
88 }
89 pollFailures = 0
90 const connection = connectionOf(status.value)
91 await update($, band, previous => ({ connection, verdict: previous?.verdict ?? null }))
92}
93
94/** Refreshes in a dispatch of its own, so the caller never waits on it. */
95function refreshSoon($: EngineInterface): void {
96 $.clock.after(0, () => {
97 refresh($).catch(() => undefined)
98 })
99}
100
101/** How a target is named to the model and in toasts. */
102function nameOf(target: WendyTarget): string {
103 return target.selector ?? target.device
104}
105
106async function setVerdict($: EngineInterface, verdict: BandVerdict): Promise<void> {
107 await update($, band, previous => ({ connection: previous?.connection ?? null, verdict }))
108}
109
110type Inspected = { readonly ok: true; readonly inspection: Inspection } | { readonly ok: false; readonly reason: string }
111
112/**
113 * The app's state from app_inspect, or from container_list when the server does
114 * not list app_inspect (its observability group is off): `$.mcp.call` only
115 * reaches tools the server lists.
116 */
117async function inspectApp($: EngineInterface, runServer: string, appId: string, projectPath: string): Promise<Inspected> {
118 const inspected = await callWendy(mcpOf($), runServer, 'app_inspect', { app_name: appId, project_path: projectPath, ...INSPECT_ARGS })
119 if (inspected.ok) {
120 const inspection = readInspection(inspected.value)
121 return inspection === undefined ? { ok: false, reason: 'app_inspect returned no app state' } : { ok: true, inspection }
122 }
123 if (!missingTool(inspected.error, 'app_inspect')) return { ok: false, reason: `app_inspect failed: ${inspected.error}` }
124 const listed = await callWendy(mcpOf($), runServer, 'container_list')
125 if (!listed.ok) {
126 return {
127 ok: false,
128 reason: missingTool(listed.error, 'container_list') ? `app_inspect is unavailable (needs Wendy CLI ≥ ${MIN_CLI})` : `container_list failed: ${listed.error}`,
129 }
130 }
131 const inspection = inspectionFromContainers(listed.value, appId)
132 return inspection === undefined ? { ok: false, reason: `${appId} is not in the device's container list` } : { ok: true, inspection }
133}
134
135/** Checks the app again later; speaks only when things got worse. */
136async function followUp(
137 $: EngineInterface,
138 runServer: string,
139 appId: string,
140 projectPath: string,
141 deployed: WendyTarget,
142 first: Inspection,
143 afterMs: number,
144): Promise<void> {
145 followUps.delete(appId)
146 const status = await callWendy(mcpOf($), runServer, 'wendy_status')
147 if (!status.ok || !sameTarget(deployed, connectedTarget(status.value))) return
148 const again = await inspectApp($, runServer, appId, projectPath)
149 if (!again.ok) return
150 const later = again.inspection
151 await setVerdict($, { app: appId, target: deployed, ...verdictOf(later) })
152 if (!isWorse(first, later)) return
153 $.ui.toast(followUpToast(appId, nameOf(deployed), first, later))
154 await $.session.append({
155 message: { type: 'user', content: [{ type: 'text', text: followUpText(appId, nameOf(deployed), first, later, afterMs) }] },
156 })
157}
158
159function scheduleFollowUp(
160 $: EngineInterface,
161 runServer: string,
162 appId: string,
163 projectPath: string,
164 deployed: WendyTarget,
165 first: Inspection,
166 delayMs: number,
167 afterMs: number,
168): void {
169 followUps.get(appId)?.cancel()
170 followUps.set(
171 appId,
172 $.clock.after(delayMs, () => {
173 followUp($, runServer, appId, projectPath, deployed, first, afterMs).catch(() => undefined)
174 }),
175 )
176}
177
178/** The note for the model about a `run` that reported `started`; undefined for any other run. */
179async function verifyRun(
180 $: EngineInterface,
181 tool: string,
182 projectPath: string,
183 runText: string,
184 signal: AbortSignal,
185 remainingMs: () => number,
186): Promise<string | undefined> {
187 const run = leadingJson(runText)?.value
188 const runServer = serverFromTool(tool)
189 if (run === undefined || run.status !== 'started' || runServer === undefined) return undefined
190 useServer(runServer)
191 refreshSoon($)
192
193 const deployed = asTarget(run.target)
194 if (deployed === undefined) return notVerifiedText(`the run result names no target (needs Wendy CLI ≥ ${MIN_CLI})`)
195 const unverified = async (appId: string, reason: string): Promise<string> => {
196 await setVerdict($, { app: appId === '' ? 'deploy' : appId, target: deployed, ...NOT_VERIFIED })
197 return notVerifiedText(reason)
198 }
199
200 const status = await callWendy(mcpOf($), runServer, 'wendy_status')
201 const connected = status.ok ? connectedTarget(status.value) : undefined
202 if (connected === undefined) return unverified('', 'this session is not connected to a device')
203 if (!sameTarget(deployed, connected)) {
204 return unverified('', `this session is connected to ${nameOf(connected)}, not to the deployed ${nameOf(deployed)}`)
205 }
206
207 const file = wendyJsonPath(await $.session.cwd(), projectPath)
208 let project: string
209 try {
210 project = String(await $.fs.read(file))
211 } catch {
212 return unverified('', `no wendy.json at ${file}`)
213 }
214 const appId = appIdFrom(project)
215 if (appId === '') return unverified('', `no appId in ${file}`)
216 const readinessWindowS = readinessWindowFrom(project)
217
218 let settledMs = 0
219 if (remainingMs() > SETTLE_MIN_BUDGET_MS) {
220 await $.clock.sleep(SETTLE_MS, { signal })
221 settledMs = SETTLE_MS
222 }
223
224 const inspected = await inspectApp($, runServer, appId, projectPath)
225 if (!inspected.ok) return unverified(appId, inspected.reason)
226 const inspection = inspected.inspection
227
228 const verdict = verdictOf(inspection, 'initial')
229 await setVerdict($, { app: appId, target: deployed, ...verdict })
230 if (verdict.kind === 'failing') $.ui.toast(toastText(appId, nameOf(deployed), verdict))
231 if (verdict.kind === 'healthy' || verdict.kind === 'readiness-unknown') {
232 // A probe that has not passed yet is rechecked once Wendy's readiness window is over.
233 const delayMs = inspection.readiness === 'failed' ? Math.max(FOLLOW_UP_MS, readinessWindowS * 1000) : FOLLOW_UP_MS
234 scheduleFollowUp($, runServer, appId, projectPath, deployed, inspection, delayMs, settledMs + delayMs)
235 }
236 return contextText(appId, nameOf(deployed), inspection, { afterMs: settledMs, readinessWindowS })
237}
238
239export const register: Register = (on, options) => {
240 override = typeof options.server === 'string' ? options.server.trim() : ''
241 server = undefined
242 pollFailures = 0
243 followUps.clear()
244 pendingNotices = []
245
246 on('session.start', async ($, e, next) => {
247 const started = await next(e)
248 refreshSoon($)
249 $.clock.every(POLL_MS, () => {
250 refresh($).catch(() => undefined)
251 })
252 return started
253 })
254
255 // The model's own Wendy calls name the server whose connection it relies on.
256 on(
257 'tool.call',
258 { tool: /^mcp__(.+)__(wendy_status|wendy_tools|device_list|device_connect|device_disconnect|device_info|container_list|telemetry_logs|app_inspect)$/ },
259 async ($, e, next) => {
260 const ran = await next(e)
261 const seen = serverFromTool(e.tool)
262 if (seen !== undefined) useServer(seen)
263 refreshSoon($)
264 if (ran.deny !== undefined || ran.isError === true || pendingNotices.length === 0) return ran
265 return { ...ran, context: [...(ran.context ?? []), ...relayNotices()] }
266 },
267 )
268
269 on('tool.call', { tool: /^mcp__(.+)__run$/ }, async ($, e, next) => {
270 const ran = await next(e)
271 if (ran.deny !== undefined || ran.isError === true) return ran
272 try {
273 const projectPath = str((e as Readonly<Record<string, unknown>>).project_path)
274 const note = await verifyRun($, e.tool, projectPath, ran.text ?? '', next.signal, () => next.budget.remainingMs)
275 const added = [...(note === undefined ? [] : [note]), ...relayNotices()]
276 return added.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...added] }
277 } catch {
278 // An interrupt during the settle, or any bug here, must never cost the model the run result.
279 return ran
280 }
281 })
282
283 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
284 const value = await read($, band)
285 if (value === null || e.props.hasSurvey) return next(e)
286 const { Box, Text } = $.ui.resolve(e)
287 return (
288 <Box>
289 {bandParts(value, e.props.bodyColumns).map(part => (
290 <Text {...textProps(part)}>{part.text}</Text>
291 ))}
292 </Box>
293 )
294 })
295}
296hooks/band.ts 50 lines1import type { Band, VerdictKind } from '../types'
2import { sameTarget } from './target'
3
4export type Part = { readonly text: string; readonly color?: string; readonly bold?: true; readonly dim?: true }
5
6/** The CLI's palette (go/internal/cli/tui/theme.go). */
7export const COLORS = {
8 label: '#34d399', // Emerald400, ColorPrimary
9 device: '#6ee7b7', // Emerald300, deviceStyleTUI
10 healthy: '#10b981', // Emerald500
11 caution: '#f59e0b', // Amber500
12 failing: '#ef4444', // Red500
13} as const
14
15const MARKS: Readonly<Record<VerdictKind, string>> = { healthy: '✓', 'readiness-unknown': '?', failing: '✗', 'not-verified': '–' }
16
17const TINTS: Readonly<Record<VerdictKind, string>> = {
18 healthy: COLORS.healthy,
19 'readiness-unknown': COLORS.caution,
20 failing: COLORS.failing,
21 'not-verified': COLORS.caution,
22}
23
24/** Below this many columns the band drops the transport and narrows its gaps. */
25export const NARROW_COLUMNS = 60
26
27export function shortApp(appId: string): string {
28 const segments = appId.split('.').filter(segment => segment !== '')
29 return segments[segments.length - 1] ?? appId
30}
31
32/** The band's row as styled parts; none when no Wendy server is known. */
33export function bandParts(band: Band, columns: number): Part[] {
34 if (band === null) return []
35 const parts: Part[] = [{ text: '▍wendy', color: COLORS.label, bold: true }, { text: ' ' }]
36 const { connection, verdict } = band
37 if (connection === null) {
38 parts.push({ text: 'not connected', dim: true })
39 return parts
40 }
41 const narrow = columns < NARROW_COLUMNS
42 parts.push({ text: connection.device, color: COLORS.device, bold: true })
43 if (!narrow && connection.transport !== '') parts.push({ text: ` · ${connection.transport}`, dim: true })
44 if (verdict !== null && sameTarget(verdict.target, connection.target)) {
45 parts.push({ text: narrow ? ' ' : ' ' })
46 parts.push({ text: `${MARKS[verdict.kind]} ${shortApp(verdict.app)} ${verdict.label}`, color: TINTS[verdict.kind] })
47 }
48 return parts
49}
50hooks/json.ts 64 lines1export type Obj = Readonly<Record<string, unknown>>
2
3export function isObj(value: unknown): value is Obj {
4 return typeof value === 'object' && value !== null && !Array.isArray(value)
5}
6
7export function obj(value: unknown): Obj | undefined {
8 return isObj(value) ? value : undefined
9}
10
11export function str(value: unknown): string {
12 return typeof value === 'string' ? value : ''
13}
14
15export function num(value: unknown): number | undefined {
16 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
17}
18
19export function arr(value: unknown): readonly unknown[] {
20 return Array.isArray(value) ? value : []
21}
22
23/** A JSON object from text; a leading UTF-8 BOM is ignored. */
24export function parseJson(text: string): Obj | undefined {
25 try {
26 return obj(JSON.parse(text.replace(/^\uFEFF/, '')))
27 } catch {
28 return undefined
29 }
30}
31
32/**
33 * The JSON object a text starts with, and the text after it; undefined when the
34 * text starts with anything else. Wendy MCP results can carry more text after
35 * their JSON, such as the CLI's one-time update notice.
36 */
37export function leadingJson(text: string): { readonly value: Obj; readonly rest: string } | undefined {
38 const source = text.replace(/^\uFEFF/, '')
39 const start = source.search(/\S/)
40 if (start === -1 || source[start] !== '{') return undefined
41 let depth = 0
42 let inString = false
43 let escaped = false
44 for (let i = start; i < source.length; i++) {
45 const c = source[i]
46 if (inString) {
47 if (escaped) escaped = false
48 else if (c === '\\') escaped = true
49 else if (c === '"') inString = false
50 } else if (c === '"') {
51 inString = true
52 } else if (c === '{' || c === '[') {
53 depth += 1
54 } else if (c === '}' || c === ']') {
55 depth -= 1
56 if (depth === 0) {
57 const value = parseJson(source.slice(start, i + 1))
58 return value === undefined ? undefined : { value, rest: source.slice(i + 1) }
59 }
60 }
61 }
62 return undefined
63}
64hooks/project.ts 27 lines1import { num, obj, parseJson, str } from './json'
2
3const ABSOLUTE = /^(\/|[A-Za-z]:[\\/]|\\\\)/
4
5/** `<project>/wendy.json`; a relative project path resolves against the session's directory. */
6export function wendyJsonPath(cwd: string, projectPath: string): string {
7 const joined = ABSOLUTE.test(projectPath) ? projectPath : `${cwd.replace(/[\\/]+$/, '')}/${projectPath}`
8 const tidy = joined.replace(/\/\.(?=\/|$)/g, '').replace(/[\\/]+$/, '')
9 return `${tidy}/wendy.json`
10}
11
12/** `wendy run`'s readiness timeout when it waits attached (commands/run.go:2833). */
13const DEFAULT_READINESS_SECONDS = 30
14
15/** Seconds Wendy allows a declared TCP readiness probe to pass; 0 when none is declared. */
16export function readinessWindowFrom(text: string): number {
17 const readiness = obj(parseJson(text)?.readiness)
18 if (obj(readiness?.tcpSocket) === undefined) return 0
19 const seconds = num(readiness?.timeoutSeconds) ?? 0
20 return seconds > 0 ? seconds : DEFAULT_READINESS_SECONDS
21}
22
23/** The `appId` of a wendy.json text; '' when absent or unreadable. */
24export function appIdFrom(text: string): string {
25 return str(parseJson(text)?.appId)
26}
27hooks/server.ts 75 lines1import type { McpToolResult } from 'claude-code'
2
3import { isObj, leadingJson, str } from './json'
4import type { Obj } from './json'
5
6/** `$.mcp.call`, handed in so this module needs no engine to test. */
7export type McpCall = (server: string, tool: string, args: Record<string, unknown>) => Promise<McpToolResult>
8
9export type CallResult = { readonly ok: true; readonly value: Obj } | { readonly ok: false; readonly error: string }
10
11/** Tried when no server is known: `wendy mcp setup`'s name, then the plugin's in tool-name spelling. */
12export const CANDIDATES: readonly string[] = ['wendy', 'plugin_wendy_wendy']
13
14/** The first CLI release whose `run` reports its target and which has `app_inspect`. */
15export const MIN_CLI = '2026.09.30'
16
17// Keep in step with the inline matchers in register.tsx, which the engine reads off the source.
18const WENDY_TOOL = /^mcp__(.+)__(wendy_status|wendy_tools|device_list|device_connect|device_disconnect|device_info|container_list|telemetry_logs|app_inspect)$/
19const RUN_TOOL = /^mcp__(.+)__run$/
20
21export function serverFromTool(tool: string): string | undefined {
22 return WENDY_TOOL.exec(tool)?.[1] ?? RUN_TOOL.exec(tool)?.[1]
23}
24
25/**
26 * A tool result as a JSON object. Success results carry it in
27 * `structuredContent` and as text; error results carry `{ error_code, message }`
28 * and the text `[CODE] message` (go/internal/cli/mcp/errors.go:29).
29 */
30function textOf(result: McpToolResult): string {
31 return result.content.map(block => (block.type === 'text' ? str(block.text) : '')).join('\n')
32}
33
34export function parseResult(result: McpToolResult): CallResult {
35 const text = textOf(result)
36 const structured = isObj(result.structuredContent) ? result.structuredContent : undefined
37 if (result.isError) {
38 const message = str(structured?.message)
39 const code = str(structured?.error_code)
40 if (message !== '') return { ok: false, error: code === '' ? message : `${code}: ${message}` }
41 return { ok: false, error: text.trim() || 'the tool reported an error' }
42 }
43 const value = structured ?? leadingJson(text)?.value
44 return value === undefined ? { ok: false, error: 'the tool returned no JSON object' } : { ok: true, value }
45}
46
47/** Text a successful result carries after its JSON: the CLI's one-time update notice (go/internal/cli/mcp/cli_update.go). */
48export function noticeIn(result: McpToolResult): string {
49 if (result.isError) return ''
50 return leadingJson(textOf(result))?.rest.trim() ?? ''
51}
52
53/** Calls a Wendy tool; never rejects. */
54export async function callWendy(call: McpCall, server: string, tool: string, args: Obj = {}): Promise<CallResult> {
55 try {
56 return parseResult(await call(server, tool, { ...args }))
57 } catch (err) {
58 return { ok: false, error: err instanceof Error ? err.message : String(err) }
59 }
60}
61
62/** True when the server has no such tool, as with a Wendy CLI older than MIN_CLI. */
63export function missingTool(error: string, tool: string): boolean {
64 return error.includes(`no connected MCP tool "${tool}"`)
65}
66
67/** The first server that answers `wendy_status`: the override, then the candidates. */
68export async function discover(call: McpCall, override: string): Promise<string | undefined> {
69 const names = override === '' ? CANDIDATES : [override, ...CANDIDATES.filter(name => name !== override)]
70 for (const name of names) {
71 if ((await callWendy(call, name, 'wendy_status')).ok) return name
72 }
73 return undefined
74}
75hooks/target.ts 71 lines1import type { BandConnection, WendyTarget } from '../types'
2import { obj, str } from './json'
3import type { Obj } from './json'
4
5/** The CLI's plaintext agent port (go/internal/cli/mcp/run_target.go:28). */
6const DEFAULT_AGENT_PORT = 50051
7
8/** A device as host:port, as `withDefaultAgentPort` spells a bare host. */
9function withDefaultPort(device: string): string {
10 if (device.startsWith('[')) {
11 return device.includes(']:') ? device : `${device}:${DEFAULT_AGENT_PORT}`
12 }
13 const colons = device.split(':').length - 1
14 if (colons === 1) return device
15 if (colons > 1) return `[${device}]:${DEFAULT_AGENT_PORT}`
16 return `${device}:${DEFAULT_AGENT_PORT}`
17}
18
19/**
20 * One identity per target, a cautious port of `runTargetKeys`
21 * (go/internal/cli/mcp/run_target.go:82). Equal keys always mean the same
22 * device; unequal keys may still be one device, which the verifier then
23 * reports as not verified rather than guessing.
24 */
25export function targetKey(target: WendyTarget): string | undefined {
26 if (target.selector) return target.selector
27 const device = target.device
28 if (device === '') return undefined
29 if (device.toLowerCase().startsWith('cloud:') || device.startsWith('vm:')) return device
30 if (target.transport === 'cloud') return `name:${device}@${target.cloud_grpc ?? ''}`
31 return `addr:${withDefaultPort(device).toLowerCase()}`
32}
33
34export function sameTarget(a: WendyTarget | undefined, b: WendyTarget | undefined): boolean {
35 if (a === undefined || b === undefined) return false
36 const key = targetKey(a)
37 return key !== undefined && key === targetKey(b)
38}
39
40/** A `commandTarget` from MCP JSON, or undefined when it names no device. */
41export function asTarget(value: unknown): WendyTarget | undefined {
42 const o = obj(value)
43 if (o === undefined) return undefined
44 const cloudGrpc = str(o.cloud_grpc)
45 const selector = str(o.selector)
46 const target: WendyTarget = {
47 device: str(o.device),
48 transport: str(o.transport),
49 ...(cloudGrpc === '' ? {} : { cloud_grpc: cloudGrpc }),
50 ...(selector === '' ? {} : { selector }),
51 }
52 return targetKey(target) === undefined ? undefined : target
53}
54
55/** The target a `wendy_status` result is connected to (go/internal/cli/mcp/tools_status.go). */
56export function connectedTarget(status: Obj): WendyTarget | undefined {
57 if (status.connected !== true) return undefined
58 return asTarget(status.command_target) ?? asTarget({ device: status.device, transport: status.connection_type })
59}
60
61/** What the band shows for a `wendy_status` result; null when not connected. */
62export function connectionOf(status: Obj): BandConnection | null {
63 const target = connectedTarget(status)
64 if (target === undefined) return null
65 return {
66 device: str(status.device) || target.device,
67 transport: str(status.connection_type) || target.transport,
68 target,
69 }
70}
71hooks/verify.ts 198 lines1import type { VerdictKind } from '../types'
2import { arr, num, obj, str } from './json'
3import type { Obj } from './json'
4
5/** What the verdict reads from an `app_inspect` result (go/internal/cli/mcp/tools_app_inspect.go). */
6export type Inspection = {
7 readonly runningState: string
8 readonly failureCount: number
9 readonly exit: { readonly code: number; readonly reason: string } | null
10 readonly servicesDown: readonly string[]
11 readonly readiness: 'passed' | 'failed' | 'unknown'
12 readonly readinessReason: string
13 readonly passedChecks: number
14 readonly errors: readonly string[]
15}
16
17export type Verdict = { readonly kind: VerdictKind; readonly label: string }
18
19/**
20 * `initial`: the check right after a detached deploy, inside Wendy's readiness
21 * window, so a readiness probe that has not passed yet is pending, not failed.
22 * `final`: a later check, after that window.
23 */
24export type Phase = 'initial' | 'final'
25
26/** When the inline check ran, and the readiness window Wendy allows (0: no probe). */
27export type InlineCheck = { readonly afterMs: number; readonly readinessWindowS: number }
28
29export const NOT_VERIFIED: Verdict = { kind: 'not-verified', label: 'not verified' }
30
31const LOG_LINE_MAX = 200
32const LOG_LINES = 3
33
34function present<T>(value: T | undefined): value is T {
35 return value !== undefined
36}
37
38/** One log record's body as a single line of at most LOG_LINE_MAX characters. */
39export function logLine(record: Obj | undefined): string {
40 const body = record?.body
41 const text = typeof body === 'string' ? body : body === undefined ? '' : JSON.stringify(body)
42 const line = text.replace(/\s+/g, ' ').trim()
43 return line.length > LOG_LINE_MAX ? `${line.slice(0, LOG_LINE_MAX - 1)}…` : line
44}
45
46export function readInspection(value: Obj): Inspection | undefined {
47 const state = obj(value.state)
48 const runningState = str(state?.running_state)
49 if (state === undefined || runningState === '') return undefined
50 const lastExit = obj(state.last_exit)
51 const code = num(lastExit?.code)
52 const readiness = obj(value.readiness)
53 const status = str(readiness?.status)
54 const checks = arr(readiness?.checks).map(check => obj(check)).filter(present)
55 const failedCheck = checks.find(check => check.status === 'failed')
56 return {
57 runningState,
58 failureCount: num(state.failure_count) ?? 0,
59 exit: lastExit?.status === 'recorded' && code !== undefined ? { code, reason: str(lastExit.reason) } : null,
60 servicesDown: arr(state.services)
61 .map(service => obj(service))
62 .filter(present)
63 .filter(service => str(service.running_state) !== 'RUNNING')
64 .map(service => str(service.name) || str(service.container_name)),
65 readiness: status === 'passed' || status === 'failed' ? status : 'unknown',
66 readinessReason: str(readiness?.reason) || str(failedCheck?.reason),
67 passedChecks: checks.filter(check => check.status === 'passed').length,
68 errors: arr(obj(value.recent_logs)?.records)
69 .map(record => logLine(obj(record)))
70 .filter(line => line !== ''),
71 }
72}
73
74/** Why readiness is unknown when the server does not list app_inspect (its observability group is off). */
75export const NO_INSPECT_REASON = 'app_inspect is not listed; add the "observability" tools group with wendy_tools so wendy-mods can check readiness'
76
77/**
78 * The verdict's inputs from container_list (go/internal/cli/mcp/tools_container.go:116), for when
79 * app_inspect is not listed: state, failures and last exit, with readiness unknown.
80 */
81export function inspectionFromContainers(value: Obj, appId: string): Inspection | undefined {
82 const entry = arr(value.containers)
83 .map(container => obj(container))
84 .filter(present)
85 .find(container => str(container.app_name) === appId)
86 const runningState = str(entry?.running_state)
87 if (entry === undefined || runningState === '') return undefined
88 const code = num(entry.exit_code)
89 const reason = str(entry.termination_reason)
90 return {
91 runningState,
92 failureCount: num(entry.failure_count) ?? 0,
93 exit: reason !== '' && code !== undefined ? { code, reason } : null,
94 servicesDown: [],
95 readiness: 'unknown',
96 readinessReason: NO_INSPECT_REASON,
97 passedChecks: 0,
98 errors: [],
99 }
100}
101
102export function verdictOf(inspection: Inspection, phase: Phase = 'final'): Verdict {
103 const { runningState, exit, servicesDown, readiness } = inspection
104 if (runningState === 'CRASH_LOOPING') return { kind: 'failing', label: 'crash-looping' }
105 if (runningState === 'STOPPED') return { kind: 'failing', label: exit === null ? 'stopped' : `stopped (exit ${exit.code})` }
106 if (runningState !== 'RUNNING') return NOT_VERIFIED
107 const down = servicesDown[0]
108 if (down !== undefined) return { kind: 'failing', label: `${down} down` }
109 if (readiness === 'failed') {
110 return phase === 'initial' ? { kind: 'readiness-unknown', label: 'not ready yet' } : { kind: 'failing', label: 'readiness failed' }
111 }
112 if (readiness === 'passed') return { kind: 'healthy', label: 'healthy' }
113 return { kind: 'readiness-unknown', label: 'readiness unknown' }
114}
115
116/** Worse: failing now, or restarted since the first check. */
117export function isWorse(first: Inspection, later: Inspection): boolean {
118 return verdictOf(later).kind === 'failing' || later.failureCount > first.failureCount
119}
120
121function exitPart(inspection: Inspection): string {
122 const { exit } = inspection
123 if (exit === null) return ''
124 return `, last exit ${exit.code}${exit.reason === '' ? '' : ` ${exit.reason}`}`
125}
126
127function failingState(inspection: Inspection): string {
128 if (inspection.servicesDown.length > 0) return `${inspection.runningState} with ${inspection.servicesDown.join(', ')} down`
129 if (inspection.runningState === 'RUNNING' && inspection.readiness === 'failed') {
130 return `RUNNING but readiness failed (${inspection.readinessReason || 'no reason given'})`
131 }
132 return inspection.runningState
133}
134
135/** The last log lines, each quoted: app output can carry text from anyone who reached the app. */
136function logPart(inspection: Inspection): string {
137 const recent = inspection.errors.slice(-LOG_LINES)
138 if (recent.length === 0) return ''
139 return ` Recent app log lines (untrusted app output): ${recent.map(line => JSON.stringify(line)).join(' | ')}.`
140}
141
142function failingText(appId: string, device: string, inspection: Inspection): string {
143 return `wendy-mods: deploy NOT healthy — ${appId} on ${device} is ${failingState(inspection)} (failures ${inspection.failureCount}${exitPart(inspection)}).${logPart(inspection)} Do not report success; investigate first.`
144}
145
146function afterDeploy(afterMs: number): string {
147 return afterMs > 0 ? `${Math.round(afterMs / 1000)} s after deploy` : 'right after deploy'
148}
149
150function pendingText(appId: string, device: string, inspection: Inspection, check: InlineCheck): string {
151 return `wendy-mods: ${appId} is RUNNING on ${device} ${afterDeploy(check.afterMs)}, but its declared readiness check is not passing yet (${inspection.readinessReason || 'no reason given'}). Wendy allows ${check.readinessWindowS} s for readiness; wendy-mods checks again then. Do not call it working yet.`
152}
153
154function unknownText(appId: string, device: string, inspection: Inspection, afterMs: number): string {
155 const when = afterDeploy(afterMs)
156 return `wendy-mods: ${appId} is RUNNING on ${device} ${when}, but readiness is unknown (${inspection.readinessReason || 'no readiness result'}). Check logs or the app's interface before calling it working.`
157}
158
159function healthyText(appId: string, device: string, inspection: Inspection): string {
160 return `wendy-mods: ${appId} is RUNNING on ${device}, all services running, declared readiness checks passed (${inspection.passedChecks} TCP). This covers TCP connectivity only.`
161}
162
163export function notVerifiedText(reason: string): string {
164 return `wendy-mods: could not verify this deploy — ${reason}. Treat readiness as unknown.`
165}
166
167/** The note added to `run`'s result by the inline check. */
168export function contextText(appId: string, device: string, inspection: Inspection, check: InlineCheck): string {
169 const verdict = verdictOf(inspection, 'initial')
170 if (verdict.kind === 'failing') return failingText(appId, device, inspection)
171 if (verdict.kind === 'healthy') return healthyText(appId, device, inspection)
172 if (verdict.kind === 'readiness-unknown') {
173 return inspection.readiness === 'failed' ? pendingText(appId, device, inspection, check) : unknownText(appId, device, inspection, check.afterMs)
174 }
175 return notVerifiedText(`${appId} reports the unexpected state ${inspection.runningState}`)
176}
177
178/** The note a worse follow-up appends; `afterMs` is how long after the deploy it ran. */
179export function followUpText(appId: string, device: string, first: Inspection, later: Inspection, afterMs: number): string {
180 const head = `wendy-mods follow-up (${afterDeploy(afterMs)}): ${appId} on ${device}`
181 if (verdictOf(later).kind === 'failing') {
182 return `${head} is now ${failingState(later)} (failures ${first.failureCount} → ${later.failureCount}${exitPart(later)}).${logPart(later)} The earlier check is out of date. Do not report success; investigate first.`
183 }
184 const restarts = later.failureCount - first.failureCount
185 const exit = later.exit === null ? '' : ` (last exit ${later.exit.code}${later.exit.reason === '' ? '' : ` ${later.exit.reason}`})`
186 return `${head} restarted ${restarts} time${restarts === 1 ? '' : 's'} since the first check${exit} and is ${later.runningState} again. The earlier check is out of date; check its logs before calling it working.`
187}
188
189export function toastText(appId: string, device: string, verdict: Verdict): string {
190 return `wendy: ${appId} ${verdict.label} on ${device}`
191}
192
193export function followUpToast(appId: string, device: string, first: Inspection, later: Inspection): string {
194 const verdict = verdictOf(later)
195 if (verdict.kind === 'failing') return toastText(appId, device, verdict)
196 return `wendy: ${appId} restarted since deploy (failures ${first.failureCount} → ${later.failureCount}) on ${device}`
197}
198types/index.d.ts 35 lines1/** A deploy target as the Wendy MCP server reports it (`commandTarget`, go/internal/cli/mcp/server.go:31). */
2export type WendyTarget = {
3 device: string
4 transport: string
5 cloud_grpc?: string
6 selector?: string
7}
8
9export type VerdictKind = 'healthy' | 'readiness-unknown' | 'failing' | 'not-verified'
10
11export type BandVerdict = {
12 app: string
13 target: WendyTarget
14 kind: VerdictKind
15 label: string
16}
17
18export type BandConnection = {
19 device: string
20 transport: string
21 target: WendyTarget
22}
23
24/** null: no Wendy server found, so the band draws nothing. */
25export type Band = {
26 connection: BandConnection | null
27 verdict: BandVerdict | null
28} | null
29
30declare module 'claude-code' {
31 interface PluginState {
32 'wendy-mods': { band: Band }
33 }
34}
35