SLOPSHOPPER

wendy-mods

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

newbandguardtoasttimer
★ 91v0.1.0Apache-2.0updated 2026-10-09wendylabsinc/WendyOS/plugins/wendy-mods
A shopper browsing a rack in a slop shop
README

wendy-mods

A Claude Code mod (a plugin of function hooks) for people building on WendyOS.

  • Deploy verifier. When the Wendy MCP 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.
  • Device band. A row above the prompt shows the connected device, its transport, and the last deploy's verdict in the CLI's colors.

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.

Requirements

  • Claude Code 2.1.287 or newer. The function-hook API is early access and can change.
  • Wendy CLI 2026.09.30 or newer, with its MCP server connected (wendy mcp setup, or the wendy plugin). Older CLIs get "not verified" with the reason.
  • For readiness checks, enable the observability tools group (wendy_tools(groups=["observability"])).
  • Without it, app_inspect is not listed, and Claude Code lets a mod call only listed tools.
  • The mod then checks app state with container_list: crash loops, stops and restarts are still caught, and readiness is reported as unknown.

Try it

claude --plugin-dir plugins/wendy-mods

If your Wendy MCP server has another name, set it under /config → wendy-mods → "Wendy MCP server".

Develop

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.

Limits

  • Claude Code terminal and desktop sessions only. VS Code, Codex and ChatGPT do not run mods.
  • The band draws only in the session that loaded the mod. The desktop app shows a terminal-started session through the session handoff without attaching to it, so the band does not appear there. A session the desktop app starts needs the mod loaded in that session (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.
  • A deploy to a device other than the connected one is reported as not verified.
  • The band names the device by its address (wendy_status reports no device name).
  • Not published anywhere yet. It is in no marketplace, and is excluded from the public wendy-agentic-coding mirror, until the mod API settles.
Source 8 files
hooks/register.tsx 296 lines
1import { 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}
296
hooks/band.ts 50 lines
1import 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}
50
hooks/json.ts 64 lines
1export 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}
64
hooks/project.ts 27 lines
1import { 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}
27
hooks/server.ts 75 lines
1import 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}
75
hooks/target.ts 71 lines
1import 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}
71
hooks/verify.ts 198 lines
1import 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}
198
types/index.d.ts 35 lines
1/** 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