Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type { Plugin, Hooks, PluginModule } from "@opencode-ai/plugin"
import type { Part } from "@opencode-ai/sdk"
import { LoopStore } from "./store.js"
import { InstanceLock } from "./instance-lock.js"
import { Scheduler } from "./scheduler.js"
import { Scheduler, stripOuterQuotes } from "./scheduler.js"
import { CronParser } from "./cron-parser.js"
import { Jitter } from "./jitter.js"
import { buildLoopTools } from "./tools/loop-tools.js"
Expand Down Expand Up @@ -211,10 +211,14 @@ export const LoopPlugin: Plugin = async (ctx) => {
// deterministic parser. Parts already consumed by
// command.execute.before are synthetic/ignored and skipped, so the
// TUI path is never handled twice.
//
// Note: opencode run re-quotes argv elements that contain spaces, so
// the stored text is often `"/loop 5m"` (with literal quotes) — strip
// outer quotes before matching, mirroring handleUserCommand.
for (const part of output?.parts ?? []) {
if (part.type !== "text" || part.synthetic || part.ignored) continue
const match = /^\/loop(?:\s+([\s\S]*))?$/.exec(part.text.trim())
if (!match) return
const match = /^\/loop(?:\s+([\s\S]*))?$/.exec(stripOuterQuotes(part.text))
if (!match) continue
await runLoopCommand(match[1] ?? "", input.sessionID, output.parts)
return
}
Expand Down
2 changes: 1 addition & 1 deletion src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function parseFlagPrefix(text: string): {
}

/** Strip one layer of matching surrounding quotes (B10). */
function stripOuterQuotes(text: string): string {
export function stripOuterQuotes(text: string): string {
const t = text.trim()
if (t.length >= 2) {
const first = t[0]
Expand Down
83 changes: 83 additions & 0 deletions tests/run-mode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,86 @@ test("chat.message without output argument still tracks the active session", asy
rmSync(dir, { recursive: true, force: true })
}
})

test("run mode: opencode run quotes argv with spaces — quoted text is still intercepted", async () => {
// Real-world part.text from `opencode run "/loop 5m"` is "\"/loop 5m\""
// (opencode re-quotes argv elements containing spaces). Regression test
// for the quote-stripping fix: without it the regex never matches and
// every guard is bypassed end-to-end despite unit tests passing.
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
try {
const hooks = await makeHooks(dir)

const bare = textMessage('"/loop 5m"')
await hooks["chat.message"]({ sessionID: "sRun" }, bare)
assert.ok(
bare.parts[0].text.includes('Missing prompt after interval "5m"'),
`expected missing-prompt failure for quoted input, got: ${bare.parts[0].text.slice(0, 120)}`
)
assert.equal(taskCount(dir), 0, "no task created from quoted bare interval")

const cron = textMessage('"/loop */5 * * * * check something"')
await hooks["chat.message"]({ sessionID: "sRun" }, cron)
assert.ok(
cron.parts[0].text.includes("Cron expressions are not supported"),
`expected cron rejection for quoted input, got: ${cron.parts[0].text.slice(0, 120)}`
)
assert.equal(taskCount(dir), 0, "no task created from quoted cron")

const valid = textMessage('"/loop 1m ping the server"')
await hooks["chat.message"]({ sessionID: "sRun" }, valid)
assert.equal(taskCount(dir), 1, "quoted valid command creates the task")
const task = JSON.parse(readFileSync(tasksFile(dir), "utf-8")).tasks[0]
assert.equal(task.mode, "fixed")
assert.equal(task.prompt, "ping the server")

await hooks.dispose()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})

test("run mode: single-quoted command text is also intercepted", async () => {
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
try {
const hooks = await makeHooks(dir)
const out = textMessage("'/loop 5m'")
await hooks["chat.message"]({ sessionID: "sRun" }, out)
assert.ok(
out.parts[0].text.includes('Missing prompt after interval "5m"'),
`expected missing-prompt failure for single-quoted input, got: ${out.parts[0].text.slice(0, 120)}`
)
assert.equal(taskCount(dir), 0)
await hooks.dispose()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})

test("run mode: /loop in a later text part is still found (continue, not return)", async () => {
const dir = mkdtempSync(join(tmpdir(), "loop-run-"))
const hooks = await makeHooks(dir)
try {
const out = {
message: { id: "m9", sessionID: "sRun", role: "user", time: { created: Date.now() } },
parts: [
{ id: "p1", sessionID: "sRun", messageID: "m9", type: "text", text: "preface" },
{ id: "p2", sessionID: "sRun", messageID: "m9", type: "text", text: "/loop 5m" },
],
}
await hooks["chat.message"]({ sessionID: "sRun" }, out)
// consumeLoopCommand replaces the FIRST text part with the result and
// marks the rest ignored — interception happened if the error text
// landed in parts[0] and the command part was consumed.
assert.ok(
out.parts[0].text.includes('Missing prompt after interval "5m"'),
`expected interception of later part, got: ${out.parts[0].text.slice(0, 120)}`
)
assert.equal(out.parts[0].synthetic, true)
assert.equal(out.parts[1].ignored, true)
assert.equal(taskCount(dir), 0)
} finally {
await hooks.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
Loading