opencode-waitfor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zjc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # opencode-waitfor
2
+
3
+ A zero-dependency opencode plugin that adds a `wait_for` tool to poll HTTP, TCP, or shell command targets until they are ready or a timeout elapses.
4
+
5
+ Replaces the manual `for i in $(seq 1 40); do sleep 3; curl ...; done` polling loops that agents write by hand.
6
+
7
+ ## Install
8
+
9
+ Add `"opencode-waitfor"` to the `plugin` array in your `opencode.json`:
10
+
11
+ ```json
12
+ {
13
+ "plugin": ["opencode-waitfor"]
14
+ }
15
+ ```
16
+
17
+ Then restart opencode.
18
+
19
+ ## Tool: `wait_for`
20
+
21
+ Poll a target until it meets a readiness condition or the timeout expires. The condition type is **inferred from `target`**:
22
+
23
+ | `target` pattern | Condition type |
24
+ |---|---|
25
+ | Contains `://` (e.g. `http://localhost:3000`) | HTTP |
26
+ | Bare `host:port` (e.g. `localhost:5432`) | TCP |
27
+ | Anything else | Shell command |
28
+
29
+ ### Args
30
+
31
+ | Arg | Type | Default | Description |
32
+ |---|---|---|---|
33
+ | `target` | string | *(required)* | URL (with scheme), host:port, or shell command |
34
+ | `timeout` | number | `60` | Total seconds to wait |
35
+ | `interval` | number | `2` | Seconds between attempts |
36
+ | `expect.status` | number \| number[] | any 2xx | HTTP only — acceptable status code(s) |
37
+ | `expect.json_match` | `{path: value}` | — | HTTP only — dot-path → expected value (compared as strings) |
38
+ | `expect.exit_code` | number | `0` | Command only — required exit code |
39
+
40
+ ### Result
41
+
42
+ Returns `{ title, output, metadata }` with `metadata.{ success, type, target, elapsed_seconds, attempts, last, reason? }`.
43
+
44
+ On timeout, `metadata.success` is `false`, `metadata.reason` is `"timeout"`, and `metadata.last` contains the final probe's state (last HTTP status + body, last command exit + output, etc.) — enough to diagnose the failure without re-probing.
45
+
46
+ Invalid inputs (e.g. `interval > timeout`, `expect.exit_code` on an HTTP target) throw an error immediately.
47
+
48
+ ## Examples
49
+
50
+ ### Wait for a dev server
51
+
52
+ ```
53
+ wait_for http://localhost:3000
54
+ ```
55
+
56
+ ### Deploy verify — health endpoint version check
57
+
58
+ After deploying commit `abc123`:
59
+
60
+ ```
61
+ wait_for http://host/api/health
62
+ timeout 30 interval 3
63
+ expect { json_match: { status: ok, version: abc123 } }
64
+ ```
65
+
66
+ ### Wait for a database port
67
+
68
+ ```
69
+ wait_for localhost:5432
70
+ timeout 10
71
+ ```
72
+
73
+ ### Wait for Docker container health
74
+
75
+ ```
76
+ wait_for 'docker inspect -f "{{.State.Health.Status}}" pg | grep -q healthy'
77
+ timeout 60 interval 5
78
+ ```
79
+
80
+ ## Sharp edges
81
+
82
+ - **HTTP targets MUST include the scheme** (`http://` or `https://`). A schemeless URL like `localhost:3000/health` is treated as a shell command (and will fail to run).
83
+ - **`json_match` compares stringified values only** — no deep equality, numeric comparison, or regex. Exact string match.
84
+ - **`command` runs through the shell** and inherits the agent's environment/cwd. The caller is responsible for command safety.
85
+
86
+ ## License
87
+
88
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "opencode-waitfor",
3
+ "version": "0.1.0",
4
+ "description": "opencode plugin: a wait_for tool that polls HTTP/TCP/command targets until ready or timeout.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": { ".": "./src/index.ts" },
8
+ "files": ["src", "README.md", "LICENSE"],
9
+ "keywords": ["opencode", "opencode-plugin", "wait", "healthcheck", "poll"],
10
+ "license": "MIT",
11
+ "peerDependencies": { "@opencode-ai/plugin": "*", "zod": "*" },
12
+ "devDependencies": {
13
+ "@opencode-ai/plugin": "^1.17.18",
14
+ "@types/node": "^22.0.0",
15
+ "typescript": "^5.6.0",
16
+ "zod": "^3.23.0"
17
+ },
18
+ "scripts": {
19
+ "test": "bun test",
20
+ "typecheck": "tsc --noEmit"
21
+ }
22
+ }
package/src/dotpath.ts ADDED
@@ -0,0 +1,16 @@
1
+ export function resolve(obj: unknown, path: string): unknown {
2
+ const segments = path.split(".")
3
+ let current: unknown = obj
4
+ for (const seg of segments) {
5
+ if (current === null || current === undefined) return undefined
6
+ if (Array.isArray(current)) {
7
+ if (!/^\d+$/.test(seg)) return undefined
8
+ current = current[Number(seg)]
9
+ } else if (typeof current === "object") {
10
+ current = (current as Record<string, unknown>)[seg]
11
+ } else {
12
+ return undefined
13
+ }
14
+ }
15
+ return current
16
+ }
package/src/format.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { WaitType } from "./infer"
2
+ import type { PollResult } from "./poll"
3
+
4
+ export function formatResult(input: {
5
+ type: WaitType
6
+ target: string
7
+ poll: PollResult
8
+ }): { title: string; output: string; metadata: Record<string, unknown> } {
9
+ const { type, target, poll } = input
10
+ const elapsed_seconds = Math.round((poll.elapsedMs / 1000) * 100) / 100
11
+ const metadata: Record<string, unknown> = {
12
+ success: poll.success,
13
+ type,
14
+ target,
15
+ elapsed_seconds,
16
+ attempts: poll.attempts,
17
+ last: poll.last,
18
+ }
19
+ if (!poll.success) metadata.reason = "timeout"
20
+
21
+ const title = poll.success
22
+ ? `${type} target ready after ${elapsed_seconds}s`
23
+ : `${type} target NOT ready (timed out after ${elapsed_seconds}s)`
24
+ const output = poll.success
25
+ ? `Target "${target}" became ready after ${poll.attempts} attempt(s), ${elapsed_seconds}s.\nLast observation: ${JSON.stringify(poll.last)}`
26
+ : `Timed out waiting for "${target}" after ${poll.attempts} attempt(s), ${elapsed_seconds}s.\nLast observation: ${JSON.stringify(poll.last)}`
27
+
28
+ return { title, output, metadata }
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { type Plugin, tool } from "@opencode-ai/plugin"
2
+ import { formatResult } from "./format"
3
+ import { poll, type Probe } from "./poll"
4
+ import { commandProbe } from "./probes/command"
5
+ import { httpProbe } from "./probes/http"
6
+ import { tcpProbe } from "./probes/tcp"
7
+ import { validate, type WaitArgs } from "./validate"
8
+
9
+ const MAX_BYTES = 2048
10
+
11
+ const WaitForPlugin: Plugin = async ({ directory }) => {
12
+ return {
13
+ tool: {
14
+ wait_for: tool({
15
+ description:
16
+ "Poll a target until it is ready or a timeout elapses. " +
17
+ "The condition type is inferred from `target`: a URL with scheme (http:// or https://) is polled over HTTP; " +
18
+ "a bare host:port is polled as a TCP connection; anything else is run as a shell command. " +
19
+ "Use this instead of writing manual sleep/curl loops. Returns success or a timeout result with the last observed state. " +
20
+ "Examples: wait_for a dev server (http://localhost:3000), a deployed /health version (target http://host/health, expect.json_match {status: ok, version: <sha>}), " +
21
+ "a database port (localhost:5432), or docker health (target: docker inspect -f '{{.State.Health.Status}}' pg | grep -q healthy).",
22
+ args: {
23
+ target: tool.schema
24
+ .string()
25
+ .describe("URL (with scheme), host:port, or a shell command."),
26
+ timeout: tool.schema
27
+ .number()
28
+ .positive()
29
+ .optional()
30
+ .describe("Total seconds to wait. Default 60."),
31
+ interval: tool.schema
32
+ .number()
33
+ .positive()
34
+ .optional()
35
+ .describe("Seconds between attempts. Default 2."),
36
+ expect: tool.schema
37
+ .object({
38
+ status: tool.schema
39
+ .union([tool.schema.number(), tool.schema.array(tool.schema.number())])
40
+ .optional()
41
+ .describe("HTTP only: acceptable status code(s). Default any 2xx."),
42
+ json_match: tool.schema
43
+ .record(tool.schema.string(), tool.schema.string())
44
+ .optional()
45
+ .describe("HTTP only: dot-path -> expected value (compared as strings)."),
46
+ exit_code: tool.schema
47
+ .number()
48
+ .optional()
49
+ .describe("Command only: required exit code. Default 0."),
50
+ })
51
+ .optional(),
52
+ },
53
+ async execute(args, context) {
54
+ const { type, timeoutMs, intervalMs } = validate(args as WaitArgs)
55
+ const perAttemptMs = Math.min(intervalMs, 10000)
56
+
57
+ let probe: Probe
58
+ if (type === "http") {
59
+ probe = httpProbe(args.target, args.expect, { perAttemptMs, maxBytes: MAX_BYTES })
60
+ } else if (type === "tcp") {
61
+ const m = args.target.match(/^([\w.-]+):(\d+)$/)!
62
+ probe = tcpProbe(m[1], Number(m[2]), { perAttemptMs })
63
+ } else {
64
+ probe = commandProbe(args.target, args.expect, {
65
+ cwd: context.directory ?? directory,
66
+ maxBytes: MAX_BYTES,
67
+ timeoutMs,
68
+ })
69
+ }
70
+
71
+ const result = await poll({ probe, timeoutMs, intervalMs, signal: context.abort })
72
+ return formatResult({ type, target: args.target, poll: result })
73
+ },
74
+ }),
75
+ },
76
+ }
77
+ }
78
+
79
+ export default WaitForPlugin
package/src/infer.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type WaitType = "http" | "tcp" | "command"
2
+
3
+ export function inferType(target: string): WaitType {
4
+ if (target.includes("://")) return "http"
5
+ if (/^[\w.-]+:\d+$/.test(target)) return "tcp"
6
+ return "command"
7
+ }
package/src/poll.ts ADDED
@@ -0,0 +1,66 @@
1
+ export interface ProbeResult {
2
+ ok: boolean
3
+ snapshot: Record<string, unknown>
4
+ }
5
+ export type Probe = (signal: AbortSignal) => Promise<ProbeResult>
6
+ export interface PollResult {
7
+ success: boolean
8
+ attempts: number
9
+ elapsedMs: number
10
+ last: Record<string, unknown>
11
+ }
12
+
13
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
14
+ return new Promise((res) => {
15
+ if (signal.aborted) return res()
16
+ const onAbort = () => {
17
+ clearTimeout(t)
18
+ signal.removeEventListener("abort", onAbort)
19
+ res()
20
+ }
21
+ const t = setTimeout(() => {
22
+ signal.removeEventListener("abort", onAbort)
23
+ res()
24
+ }, ms)
25
+ signal.addEventListener("abort", onAbort)
26
+ })
27
+ }
28
+
29
+ export async function poll(opts: {
30
+ probe: Probe
31
+ timeoutMs: number
32
+ intervalMs: number
33
+ signal?: AbortSignal
34
+ }): Promise<PollResult> {
35
+ const { probe, timeoutMs, intervalMs, signal: external } = opts
36
+ const controller = new AbortController()
37
+ const onExternalAbort = () => controller.abort()
38
+ if (external) {
39
+ if (external.aborted) controller.abort()
40
+ else external.addEventListener("abort", onExternalAbort)
41
+ }
42
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
43
+ const start = Date.now()
44
+ let attempts = 0
45
+ let last: Record<string, unknown> = {}
46
+
47
+ try {
48
+ while (true) {
49
+ if (controller.signal.aborted) break
50
+ attempts++
51
+ const result = await probe(controller.signal)
52
+ last = result.snapshot
53
+ if (result.ok) {
54
+ return { success: true, attempts, elapsedMs: Date.now() - start, last }
55
+ }
56
+ if (controller.signal.aborted) break
57
+ const elapsed = Date.now() - start
58
+ if (elapsed + intervalMs > timeoutMs) break
59
+ await sleep(intervalMs, controller.signal)
60
+ }
61
+ return { success: false, attempts, elapsedMs: Date.now() - start, last }
62
+ } finally {
63
+ clearTimeout(timer)
64
+ if (external) external.removeEventListener("abort", onExternalAbort)
65
+ }
66
+ }
@@ -0,0 +1,40 @@
1
+ import { exec } from "node:child_process"
2
+ import type { Probe, ProbeResult } from "../poll"
3
+
4
+ export interface CommandExpect {
5
+ exit_code?: number
6
+ }
7
+
8
+ export function commandProbe(
9
+ command: string,
10
+ expect: CommandExpect | undefined,
11
+ opts: { cwd: string; maxBytes: number; timeoutMs: number },
12
+ ): Probe {
13
+ const wanted = expect?.exit_code ?? 0
14
+ return (signal: AbortSignal): Promise<ProbeResult> => {
15
+ return new Promise<ProbeResult>((res) => {
16
+ const child = exec(
17
+ command,
18
+ { cwd: opts.cwd, timeout: opts.timeoutMs, maxBuffer: 1024 * 1024 },
19
+ (err, stdout, stderr) => {
20
+ signal.removeEventListener("abort", onAbort)
21
+ const code =
22
+ err && typeof (err as { code?: unknown }).code === "number"
23
+ ? (err as { code: number }).code
24
+ : err
25
+ ? 1
26
+ : 0
27
+ const snapshot = {
28
+ exitCode: code,
29
+ stdoutPreview: String(stdout).slice(0, opts.maxBytes),
30
+ stderrPreview: String(stderr).slice(0, opts.maxBytes),
31
+ }
32
+ res({ ok: code === wanted, snapshot })
33
+ },
34
+ )
35
+ const onAbort = () => child.kill()
36
+ if (signal.aborted) child.kill()
37
+ else signal.addEventListener("abort", onAbort)
38
+ })
39
+ }
40
+ }
@@ -0,0 +1,53 @@
1
+ import type { Probe, ProbeResult } from "../poll"
2
+ import { resolve } from "../dotpath"
3
+
4
+ export interface HttpExpect {
5
+ status?: number | number[]
6
+ json_match?: Record<string, string>
7
+ }
8
+
9
+ function statusOk(status: number, expect: HttpExpect | undefined): boolean {
10
+ if (!expect || expect.status === undefined) return status >= 200 && status < 300
11
+ const wanted = Array.isArray(expect.status) ? expect.status : [expect.status]
12
+ return wanted.includes(status)
13
+ }
14
+
15
+ export function httpProbe(
16
+ target: string,
17
+ expect: HttpExpect | undefined,
18
+ opts: { perAttemptMs: number; maxBytes: number },
19
+ ): Probe {
20
+ return async (signal: AbortSignal): Promise<ProbeResult> => {
21
+ const ac = new AbortController()
22
+ const onAbort = () => ac.abort()
23
+ if (signal.aborted) ac.abort()
24
+ else signal.addEventListener("abort", onAbort)
25
+ const timer = setTimeout(() => ac.abort(), opts.perAttemptMs)
26
+ try {
27
+ const res = await fetch(target, { signal: ac.signal })
28
+ const text = await res.text()
29
+ const bodyPreview = text.slice(0, opts.maxBytes)
30
+ const snapshot: Record<string, unknown> = { status: res.status, bodyPreview }
31
+ if (!statusOk(res.status, expect)) return { ok: false, snapshot }
32
+ if (expect?.json_match) {
33
+ let parsed: unknown
34
+ try {
35
+ parsed = JSON.parse(text)
36
+ } catch {
37
+ return { ok: false, snapshot: { ...snapshot, error: "response not JSON" } }
38
+ }
39
+ for (const [path, want] of Object.entries(expect.json_match)) {
40
+ if (String(resolve(parsed, path)) !== String(want)) {
41
+ return { ok: false, snapshot }
42
+ }
43
+ }
44
+ }
45
+ return { ok: true, snapshot }
46
+ } catch (e) {
47
+ return { ok: false, snapshot: { error: (e as Error).message } }
48
+ } finally {
49
+ clearTimeout(timer)
50
+ signal.removeEventListener("abort", onAbort)
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,35 @@
1
+ import { Socket } from "node:net"
2
+ import type { Probe, ProbeResult } from "../poll"
3
+
4
+ export function tcpProbe(
5
+ host: string,
6
+ port: number,
7
+ opts: { perAttemptMs: number },
8
+ ): Probe {
9
+ return (signal: AbortSignal): Promise<ProbeResult> => {
10
+ return new Promise<ProbeResult>((res) => {
11
+ const socket = new Socket()
12
+ let settled = false
13
+ const done = (result: ProbeResult) => {
14
+ if (settled) return
15
+ settled = true
16
+ clearTimeout(timer)
17
+ signal.removeEventListener("abort", onAbort)
18
+ socket.destroy()
19
+ res(result)
20
+ }
21
+ const onAbort = () => done({ ok: false, snapshot: { connected: false, error: "aborted" } })
22
+ const timer = setTimeout(
23
+ () => done({ ok: false, snapshot: { connected: false, error: "timeout" } }),
24
+ opts.perAttemptMs,
25
+ )
26
+ if (signal.aborted) return onAbort()
27
+ signal.addEventListener("abort", onAbort)
28
+ socket.once("connect", () => done({ ok: true, snapshot: { connected: true } }))
29
+ socket.once("error", (e) =>
30
+ done({ ok: false, snapshot: { connected: false, error: (e as Error).message } }),
31
+ )
32
+ socket.connect(port, host)
33
+ })
34
+ }
35
+ }
@@ -0,0 +1,36 @@
1
+ import { inferType, type WaitType } from "./infer"
2
+
3
+ export interface WaitArgs {
4
+ target: string
5
+ timeout?: number
6
+ interval?: number
7
+ expect?: {
8
+ status?: number | number[]
9
+ json_match?: Record<string, string>
10
+ exit_code?: number
11
+ }
12
+ }
13
+
14
+ export function validate(args: WaitArgs): {
15
+ type: WaitType
16
+ timeoutMs: number
17
+ intervalMs: number
18
+ } {
19
+ const timeout = args.timeout ?? 60
20
+ const interval = args.interval ?? 2
21
+ if (timeout <= 0) throw new Error("wait_for: timeout must be > 0 seconds")
22
+ if (interval <= 0) throw new Error("wait_for: interval must be > 0 seconds")
23
+ if (interval > timeout) throw new Error("wait_for: interval must be <= timeout")
24
+
25
+ const type = inferType(args.target)
26
+ const e = args.expect
27
+ if (e) {
28
+ if (e.exit_code !== undefined && type !== "command") {
29
+ throw new Error(`wait_for: expect.exit_code is only valid for command targets (inferred: ${type})`)
30
+ }
31
+ if ((e.status !== undefined || e.json_match !== undefined) && type !== "http") {
32
+ throw new Error(`wait_for: expect.status/json_match are only valid for http targets (inferred: ${type})`)
33
+ }
34
+ }
35
+ return { type, timeoutMs: timeout * 1000, intervalMs: interval * 1000 }
36
+ }