@bybrawe/opencode-loop 0.5.4 → 0.5.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.6
4
+
5
+ - Added clearer README examples for prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, first-run timing, and idle-safe behavior.
6
+ - Stabilized `/loop-command 200m /compact` by routing `/compact` and `/summarize` through OpenCode TUI compact handling instead of treating them as custom prompt commands.
7
+ - Clarified that command loops wait for idle and should be used for slash commands such as `/compact`.
8
+ - Kept fallback compatibility with older SDK-style calls where possible.
9
+
10
+ ## 0.5.5
11
+
12
+ - Added explicit loop action types for prompt loops, scheduled question/check loops, OpenCode slash-command loops, and shell-command loops.
13
+ - Added `/loop-command` and `/loop-cmd` for scheduled OpenCode commands such as `/compact`. These wait for the first interval by default and run only when OpenCode is idle.
14
+ - Added `/loop-ask` for recurring check prompts such as “did you run tests, tsc --noEmit, and build?” It waits for the first interval by default.
15
+ - Added `/loop-prompt` to force prompt mode.
16
+ - Added `/loop-shell` for recurring shell commands.
17
+ - Added `/loop` type flags: `--prompt`, `--ask`, `--command`, `--cmd`, `--slash`, `--shell`, and `--compact`.
18
+ - Clarified README examples for scheduled `/compact`, hourly quality checks, and idle-safe timing.
19
+ - Added `publishConfig.access=public` and normalized npm bin paths.
20
+
3
21
  ## 0.5.4
4
22
 
5
23
  - Fixed compatibility with recent OpenCode SDK/TUI call shapes.
package/README.md CHANGED
@@ -11,7 +11,7 @@ NPM package name: **@bybrawe/opencode-loop**
11
11
 
12
12
  ## Current status
13
13
 
14
- **v0.5.4 fixes the recent OpenCode update compatibility issue.** OpenCode Loop has been updated for the current OpenCode SDK/TUI call shape and idle/status behavior. The TUI loop now waits for the session to become idle, debounces idle events, and avoids starting a new run while OpenCode is still busy or queued.
14
+ **v0.5.6 fixes the recent OpenCode update compatibility issue and adds clear loop action types.** OpenCode Loop has been updated for the current OpenCode SDK/TUI call shape and idle/status behavior. The TUI loop waits for the session to become idle, debounces idle events, and avoids starting a new run while OpenCode is still busy or queued. It now separates prompt loops, scheduled question loops, OpenCode slash-command loops, shell loops, and compact loops so commands like `/compact` are not sent as normal chat text.
15
15
 
16
16
  The known update-related symptoms from older builds are fixed:
17
17
 
@@ -19,6 +19,7 @@ The known update-related symptoms from older builds are fixed:
19
19
  - a loop job is replaced but does not fire after OpenCode becomes idle
20
20
  - repeated `/loop` commands create confusing queued turns
21
21
  - prompt, shell, or toast calls silently fail after OpenCode SDK changes
22
+ - `/compact` accidentally being treated as a normal agent prompt
22
23
  - intermittent `Tool execution aborted` behavior caused by triggering a new turn too early
23
24
 
24
25
  The TUI loop is still intentionally session-bound: it runs while OpenCode is open and the current session emits status/idle events. For long-running background work after closing the terminal or OpenCode, use `opencode-loopd`.
@@ -78,7 +79,7 @@ OpenCode Loop is designed for developers searching for:
78
79
  - **Failure control** with `--max-failures` and `--pause-on-verify-fail`.
79
80
  - **Runtime control** with `--max-runtime 6h`.
80
81
  - **Run limit support** with `--max-runs`.
81
- - **Sleep-before-first-run support** with `--sleep-first`.
82
+ - **Wait-before-first-run support** with `--no-now` for TUI loops and `--sleep-first` for daemon mode.
82
83
  - **Checkpoints** with `--checkpoint-only` or `--git-checkpoint`.
83
84
  - **Safe mode** with `--safe` and prompt-level destructive command warnings.
84
85
  - **Branch setup** with `--branch ai-loop`.
@@ -263,34 +264,92 @@ If the commands do not appear:
263
264
 
264
265
  ## Quick start
265
266
 
267
+ ### The 5 examples most people need
268
+
269
+ Auto-continue every time OpenCode finishes a turn:
270
+
266
271
  ```text
267
272
  /loop 0s continue from progress.md and implement the next unfinished TODO
268
273
  ```
269
274
 
270
- This is the closest behavior to a Claude Code CLI style loop: every time OpenCode becomes idle, the loop can send the next continuation prompt.
275
+ Run now, then continue every 1 minute when OpenCode is idle:
276
+
277
+ ```text
278
+ /loop 1m continue the project
279
+ ```
271
280
 
272
- A safer development loop:
281
+ Wait 1 minute before the first run, then continue every 1 minute when idle:
273
282
 
274
283
  ```text
275
- /loop 0s --name dev --ask-never --safe --no-overlap --batch 5 --compact-every 200m --checkpoint-only --progress-file progress.md Treat progress.md as the main project state file. Continue with the next unfinished TODO, implement it, mark completed items with [x], add useful follow-up TODOs when you discover them, run tests/lint/build when available, and keep going while work remains.
284
+ /loop 1m --no-now continue the project
276
285
  ```
277
286
 
278
- Periodic compact:
287
+ Run OpenCode compact every 200 minutes, but only when OpenCode is idle:
279
288
 
280
289
  ```text
281
- /loop 200m --name compact --no-now /compact
290
+ /loop-command 200m /compact
282
291
  ```
283
292
 
284
- Test-fix loop:
293
+ Ask a recurring check question every hour:
285
294
 
286
295
  ```text
287
- /loop 0s --name testfix --ask-never --safe --verify "npm test" Continue from progress.md. If tests fail, analyze the failure, fix it, and run the tests again.
296
+ /loop-ask 1h did you run tests, tsc --noEmit, and build? If not, run them and fix any error.
297
+ ```
298
+
299
+ Run a real shell command every 10 minutes when idle:
300
+
301
+ ```text
302
+ /loop-shell 10m npm test
303
+ ```
304
+
305
+ ### What “when idle” means
306
+
307
+ OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy.
308
+
309
+ Example:
310
+
311
+ ```text
312
+ /loop 5m continue the project
313
+ ```
314
+
315
+ If 5 minutes pass while OpenCode is still working, the job becomes due but waits. As soon as OpenCode becomes idle, the loop sends the prompt. This avoids queued turns, aborted tools, and overlapping responses.
316
+
317
+ ### Prompt loops vs command loops
318
+
319
+ Use a prompt loop when you want to ask or instruct the agent:
320
+
321
+ ```text
322
+ /loop 1h check if tests, tsc --noEmit, and build pass. If not, fix them.
323
+ ```
324
+
325
+ Use a command loop when the action starts with `/` and should be executed as an OpenCode command:
326
+
327
+ ```text
328
+ /loop-command 200m /compact
329
+ ```
330
+
331
+ Do not use this for compacting:
332
+
333
+ ```text
334
+ /loop 200m /compact
335
+ ```
336
+
337
+ That can look like a chat prompt in older versions or unclear setups. Prefer `/loop-command 200m /compact` or the shortcut below:
338
+
339
+ ```text
340
+ /loop-compact 200m
341
+ ```
342
+
343
+ ### Safer development loop
344
+
345
+ ```text
346
+ /loop 0s --name dev --ask-never --safe --no-overlap --batch 5 --compact-every 200m --checkpoint-only --progress-file progress.md Treat progress.md as the main project state file. Continue with the next unfinished TODO, implement it, mark completed items with [x], add useful follow-up TODOs when you discover them, run tests/lint/build when available, and keep going while work remains.
288
347
  ```
289
348
 
290
- Shell command loop:
349
+ ### Test-fix loop
291
350
 
292
351
  ```text
293
- /loop 10m --name tests --safe !npm test
352
+ /loop 0s --name testfix --ask-never --safe --verify "npm test" Continue from progress.md. If tests fail, analyze the failure, fix it, and run the tests again.
294
353
  ```
295
354
 
296
355
  ## Background daemon
@@ -388,7 +447,12 @@ Use `--name` to manage separate named loops, or `--multi` when you intentionally
388
447
 
389
448
  | Command | Purpose |
390
449
  |---|---|
391
- | `/loop <interval> <action>` | Add an idle/interval loop job |
450
+ | `/loop <interval> <prompt>` | Add an idle/interval prompt loop |
451
+ | `/loop-command <interval> <slash-command>` | Schedule an OpenCode slash command such as `/compact` |
452
+ | `/loop-cmd <interval> <slash-command>` | Alias for `/loop-command` |
453
+ | `/loop-ask <interval> <prompt/question>` | Schedule a recurring question/check prompt; first run waits for the interval by default |
454
+ | `/loop-prompt <interval> <prompt>` | Force prompt mode, even when you want explicit prompt-loop naming |
455
+ | `/loop-shell <interval> <shell-command>` | Schedule a shell command loop |
392
456
  | `/loop-help` | Show usage help inside OpenCode |
393
457
  | `/loop-status` | Show active loop jobs |
394
458
  | `/loop-logs` | Show recent loop log entries |
@@ -423,6 +487,27 @@ Use `--name` to manage separate named loops, or `--multi` when you intentionally
423
487
 
424
488
  The plugin is **idle-driven**. It checks jobs when OpenCode becomes idle, so it avoids intentionally starting a second agent turn on top of an active one.
425
489
 
490
+ If the interval expires while OpenCode is busy, the loop becomes due and waits. It runs on the next idle event.
491
+
492
+ ## Loop types
493
+
494
+ OpenCode Loop has separate types for prompts, OpenCode slash commands, and shell commands. This matters because `/compact` should be executed as an OpenCode command, not sent to the agent as text.
495
+
496
+ | Type | Use | Example | First run |
497
+ |---|---|---|---|
498
+ | Prompt loop | Ask or instruct the agent repeatedly | `/loop 1h check whether tests, tsc --noEmit, and build pass` | Now by default |
499
+ | Scheduled question | Ask/check on a timer | `/loop-ask 1h did you run tests and build?` | After the interval by default |
500
+ | Slash-command loop | Run OpenCode commands like `/compact` | `/loop-command 200m /compact` | After the interval by default |
501
+ | Shell loop | Run real terminal commands | `/loop-shell 10m npm test` | After the interval by default |
502
+
503
+ You can still force the type on `/loop`:
504
+
505
+ ```text
506
+ /loop 200m --command /compact
507
+ /loop 10m --shell npm test
508
+ /loop 1h --prompt did you run tests, tsc --noEmit, and build?
509
+ ```
510
+
426
511
  ## Actions
427
512
 
428
513
  ### Prompt action
@@ -459,18 +544,40 @@ Do not run destructive commands, force pushes, production deploys, or database r
459
544
 
460
545
  ### Slash command action
461
546
 
547
+ Use `/loop-command` when the action should be executed as an OpenCode command instead of sent to the agent as text.
548
+
462
549
  ```text
463
- /loop 200m /compact
464
- /loop 15m /review current changes
550
+ /loop-command 200m /compact
551
+ /loop-command 15m /review current changes
552
+ ```
553
+
554
+ `/compact` and `/summarize` are treated specially and routed to OpenCode's TUI compact action. Custom command templates such as `/review` are routed through OpenCode's session command API. If OpenCode is busy when the interval expires, the command waits and runs when the session becomes idle.
555
+
556
+ Equivalent forced type on `/loop`:
557
+
558
+ ```text
559
+ /loop 200m --command /compact
465
560
  ```
466
561
 
467
- `/compact` and `/summarize` map to OpenCode session summarization.
562
+ For most users, the clearer form is preferred:
563
+
564
+ ```text
565
+ /loop-command 200m /compact
566
+ ```
468
567
 
469
568
  ### Shell action
470
569
 
570
+ Use `/loop-shell` for real terminal commands.
571
+
572
+ ```text
573
+ /loop-shell 10m npm test
574
+ /loop-shell 30m pnpm lint
575
+ ```
576
+
577
+ Equivalent forced type on `/loop`:
578
+
471
579
  ```text
472
- /loop 10m !npm test
473
- /loop 30m $pnpm lint
580
+ /loop 10m --shell npm test
474
581
  ```
475
582
 
476
583
  Shell actions starting with `!` or `$` run through the OpenCode shell tool.
@@ -692,10 +799,17 @@ Parse and preview a loop without saving it.
692
799
 
693
800
  ### `--now` and `--no-now`
694
801
 
695
- By default a new loop is due immediately. Use `--no-now` to wait for the first interval.
802
+ By default `/loop` prompt jobs are due immediately. Use `--no-now` to wait for the first interval.
803
+
804
+ ```text
805
+ /loop 1h --no-now did you run tests, tsc --noEmit, and build?
806
+ ```
807
+
808
+ Command-style shortcuts such as `/loop-command`, `/loop-ask`, and `/loop-shell` wait for the first interval by default. Use `--now` if you want them to run immediately too.
696
809
 
697
810
  ```text
698
- /loop 200m --no-now /compact
811
+ /loop-command 200m /compact
812
+ /loop-command 200m --now /compact
699
813
  ```
700
814
 
701
815
  ## Recommended recipes
@@ -727,9 +841,19 @@ opencode-loopd --project . --every 5m --prompt-file loop-prompt.md
727
841
  ### Compact loop
728
842
 
729
843
  ```text
730
- /loop 200m --name compact --no-now /compact
844
+ /loop-command 200m /compact
731
845
  ```
732
846
 
847
+ This compacts every 200 minutes when OpenCode is idle. It does not interrupt an active agent turn.
848
+
849
+ ### Scheduled quality-check question
850
+
851
+ ```text
852
+ /loop-ask 1h did you run tests, tsc --noEmit, and build? If not, run them now and fix errors.
853
+ ```
854
+
855
+ This asks the agent every hour when idle. If the hour passes while the agent is working, the question waits and is sent after the current turn finishes.
856
+
733
857
  ### Prompt file loop
734
858
 
735
859
  ```text
@@ -833,6 +957,21 @@ Improve the application in small safe steps.
833
957
 
834
958
  ## Changelog highlights
835
959
 
960
+ ### v0.5.6
961
+
962
+ - Added clearer README examples for prompt loops, scheduled question loops, command loops, shell loops, first-run timing, and idle-safe behavior.
963
+ - Stabilized `/loop-command 200m /compact` by routing compact/summarize through OpenCode TUI compact handling instead of treating it as a custom prompt command.
964
+ - Clarified that command loops wait for idle and should be used for slash commands such as `/compact`.
965
+
966
+ ## v0.5.5
967
+
968
+ - Added explicit loop action types: prompt, scheduled question, OpenCode slash command, and shell command.
969
+ - Added `/loop-command` / `/loop-cmd` for commands such as `/compact`; these wait for the first interval by default.
970
+ - Added `/loop-ask` for recurring checks such as “did you run tests, tsc --noEmit, and build?”; it waits for the first interval by default.
971
+ - Added `/loop-shell` for recurring shell commands.
972
+ - Added `--prompt`, `--ask`, `--command`, `--cmd`, `--slash`, `--shell`, and `--compact` type flags for `/loop`.
973
+ - Clarified idle behavior: if a job becomes due while OpenCode is busy, it waits and runs on the next idle event.
974
+
836
975
  ### v0.5.4
837
976
 
838
977
  - Fixed compatibility with recent OpenCode SDK/TUI call shapes.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Schedule a recurring prompt/question. Usage: /loop-ask 1h did you run tests?
3
+ ---
4
+
5
+ Loop ask scheduler received. The OpenCode Loop plugin handles this recurring prompt locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Alias for /loop-command. Usage: /loop-cmd 200m /compact
3
+ ---
4
+
5
+ Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Schedule an OpenCode slash command loop. Usage: /loop-command 200m /compact
3
+ ---
4
+
5
+ Loop command scheduler received. The OpenCode Loop plugin handles this slash-command schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Schedule a recurring prompt loop. Usage: /loop-prompt 1h continue the project
3
+ ---
4
+
5
+ Loop prompt scheduler received. The OpenCode Loop plugin handles this recurring prompt locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Schedule a recurring shell command. Usage: /loop-shell 10m npm test
3
+ ---
4
+
5
+ Loop shell scheduler received. The OpenCode Loop plugin handles this shell schedule locally. Do not explain this command, do not search the web, do not create scheduler files, and do not run extra shell commands because of this command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Claude Code style /loop command and background daemon for OpenCode: auto-continue, progress.md TODO automation, compact scheduling, verification, checkpoints, and safe autonomous coding loops.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -55,7 +55,10 @@
55
55
  "@opencode-ai/plugin": ">=1.4.0"
56
56
  },
57
57
  "bin": {
58
- "opencode-loop": "scripts/install-node.mjs",
59
- "opencode-loopd": "scripts/loopd.mjs"
58
+ "opencode-loop": "./scripts/install-node.mjs",
59
+ "opencode-loopd": "./scripts/loopd.mjs"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
60
63
  }
61
64
  }
package/src/index.js CHANGED
@@ -149,7 +149,7 @@ function parseLoopArgs(raw, defaults = {}) {
149
149
  } else if (first) {
150
150
  const parsedDuration = parseDuration(first)
151
151
  if (parsedDuration !== null) intervalMs = parsedDuration
152
- else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop 200m /compact | /loop 10m !npm test | /loop --watch progress.md <prompt>" }
152
+ else if (intervalMs === null) return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" }
153
153
  else rest = input
154
154
  }
155
155
  }
@@ -160,6 +160,7 @@ function parseLoopArgs(raw, defaults = {}) {
160
160
  id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
161
161
  name: defaults.name,
162
162
  action: defaults.action || "",
163
+ kind: defaults.kind || undefined,
163
164
  intervalMs,
164
165
  immediate: defaults.immediate ?? true,
165
166
  maxRuns: defaults.maxRuns ?? 0,
@@ -218,6 +219,13 @@ function parseLoopArgs(raw, defaults = {}) {
218
219
  ;[found, rest] = takeFlag(rest, "--dry-run"); if (found) job.dryRun = true
219
220
  ;[found, rest] = takeFlag(rest, "--multi"); if (found) job.multi = true
220
221
  ;[found, rest] = takeFlag(rest, "--replace"); if (found) job.multi = false
222
+ ;[found, rest] = takeFlag(rest, "--prompt"); if (found) job.kind = "prompt"
223
+ ;[found, rest] = takeFlag(rest, "--ask"); if (found) job.kind = "prompt"
224
+ ;[found, rest] = takeFlag(rest, "--command"); if (found) job.kind = "command"
225
+ ;[found, rest] = takeFlag(rest, "--cmd"); if (found) job.kind = "command"
226
+ ;[found, rest] = takeFlag(rest, "--slash"); if (found) job.kind = "command"
227
+ ;[found, rest] = takeFlag(rest, "--shell"); if (found) job.kind = "shell"
228
+ ;[found, rest] = takeFlag(rest, "--compact"); if (found) job.kind = "compact"
221
229
 
222
230
  ;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
223
231
  ;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
@@ -327,6 +335,38 @@ function fireSdk(client, label, method, modernArgs, legacyArgs) {
327
335
  .catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
328
336
  }
329
337
 
338
+ async function executeTuiCommand(client, command) {
339
+ if (!client?.tui?.executeCommand) throw new Error("client.tui.executeCommand is not available")
340
+ return await sdkCall(
341
+ client.tui.executeCommand.bind(client.tui),
342
+ { command },
343
+ { body: { command } },
344
+ )
345
+ }
346
+
347
+ function compactTuiCommandName(command = "compact") {
348
+ const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
349
+ if (normalized === "compact" || normalized === "summarize") return "session.compact"
350
+ return undefined
351
+ }
352
+
353
+ async function compactSession(client, sessionID) {
354
+ try {
355
+ await executeTuiCommand(client, "session.compact")
356
+ return true
357
+ } catch (error) {
358
+ await log(client, "warn", "tui session.compact failed", { error: sdkErrorMessage(error) })
359
+ }
360
+ try {
361
+ await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
362
+ return true
363
+ } catch (error) {
364
+ await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
365
+ }
366
+ await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error")
367
+ return false
368
+ }
369
+
330
370
  async function log(client, level, message, extra) {
331
371
  try {
332
372
  await sdkCall(
@@ -362,13 +402,17 @@ function wasHandled(sessionID, name, args) {
362
402
  }
363
403
 
364
404
  function commandName(name) { return String(name || "") }
365
- function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev"].includes(name) }
405
+ function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
366
406
  function presetDefaults(name, args) {
367
407
  const [maybeDuration, rest] = splitFirst(args)
368
408
  const parsed = parseDuration(maybeDuration)
369
409
  const intervalMs = parsed === null ? 0 : parsed
370
410
  const extra = parsed === null ? String(args || "").trim() : rest
371
- if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", name: "compact", immediate: false }
411
+ if (name === "loop-compact") return { intervalMs: intervalMs || parseDuration("200m"), action: extra || "/compact", kind: "compact", name: "compact", immediate: false }
412
+ if (name === "loop-command" || name === "loop-cmd") return { intervalMs, action: extra, kind: "command", name: "command", immediate: false }
413
+ if (name === "loop-prompt") return { intervalMs, action: extra, kind: "prompt", name: "prompt", immediate: true }
414
+ if (name === "loop-ask") return { intervalMs, action: extra, kind: "prompt", name: "ask", immediate: false }
415
+ if (name === "loop-shell") return { intervalMs, action: extra, kind: "shell", name: "shell", immediate: false }
372
416
  if (name === "loop-testfix") return { intervalMs, name: "testfix", safe: true, askNever: true, verifyCommand: extra || "npm test", action: `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${extra || "npm test"}` }
373
417
  if (name === "loop-progress") return { intervalMs, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: extra || "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
374
418
  if (name === "loop-safe-dev") return { intervalMs, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: extra || "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
@@ -377,6 +421,7 @@ function presetDefaults(name, args) {
377
421
 
378
422
  function jobLabel(job) {
379
423
  const title = job.name ? `${job.name}: ` : ""
424
+ const kind = job.kind ? ` [${job.kind}]` : ""
380
425
  const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
381
426
  const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
382
427
  const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
@@ -387,7 +432,7 @@ function jobLabel(job) {
387
432
  const stopFile = job.stopFile ? ", stop-file" : ""
388
433
  const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
389
434
  const paused = job.paused ? ", paused" : ""
390
- return `${title}${durationToText(job.intervalMs)} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
435
+ return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
391
436
  }
392
437
 
393
438
  function matchJob(job, target, index) {
@@ -448,9 +493,14 @@ function dangerousShell(command) {
448
493
  return [/\brm\s+-rf\b/, /\bgit\s+reset\b/, /\bgit\s+clean\b/, /\bgit\s+push\b/, /\bdel\s+\/s\b/, /\brmdir\s+\/s\b/, /\bformat\b/, /\bterraform\s+destroy\b/, /\bkubectl\s+delete\b/, /\bdeploy\b.*\bproduction\b/].some((pattern) => pattern.test(text))
449
494
  }
450
495
 
451
- function actionKind(action) {
496
+ function actionKind(action, job = {}) {
452
497
  const text = String(action || "").trim()
498
+ const forced = String(job.kind || "").trim().toLowerCase()
499
+ if (forced === "compact") return "compact"
453
500
  if (text === "/compact" || text === "/summarize") return "compact"
501
+ if (forced === "prompt" || forced === "ask") return "prompt"
502
+ if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
503
+ if (forced === "shell") return "shell"
454
504
  if (text.startsWith("/")) return "command"
455
505
  if (text.startsWith("!") || text.startsWith("$")) return "shell"
456
506
  return "prompt"
@@ -502,11 +552,10 @@ async function maybeCompact(client, sessionID, job) {
502
552
  const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
503
553
  const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
504
554
  if (!dueRuns && !dueTime) return job
505
- try {
506
- await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
555
+ if (await compactSession(client, sessionID)) {
507
556
  job.lastCompactAt = now()
508
557
  job.lastCompactRunCount = job.runCount || 0
509
- } catch {}
558
+ }
510
559
  return job
511
560
  }
512
561
 
@@ -708,18 +757,28 @@ async function finalizeActiveRun(directory, client, sessionID) {
708
757
 
709
758
  async function fireAction(directory, client, sessionID, job) {
710
759
  const action = String(job.action || "").trim()
711
- const kind = actionKind(action)
760
+ const kind = actionKind(action, job)
712
761
  if (kind === "compact") {
713
- await sdkCall(client.session.summarize.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} })
714
- return { startsAssistantTurn: false }
762
+ const ok = await compactSession(client, sessionID)
763
+ return { startsAssistantTurn: ok }
715
764
  }
716
765
  if (kind === "command") {
717
- const [command, argumentsText] = splitFirst(action.slice(1))
718
- fireSdk(client, "session.command", client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
766
+ const normalized = action.startsWith("/") ? action.slice(1) : action
767
+ const [command, argumentsText] = splitFirst(normalized)
768
+ if (!command) {
769
+ await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
770
+ return { startsAssistantTurn: false }
771
+ }
772
+ const tuiCommand = compactTuiCommandName(command)
773
+ if (tuiCommand) {
774
+ await executeTuiCommand(client, tuiCommand)
775
+ return { startsAssistantTurn: true }
776
+ }
777
+ await sdkCall(client.session.command.bind(client.session), { sessionID, command, arguments: argumentsText }, { path: { id: sessionID }, body: { command, arguments: argumentsText } })
719
778
  return { startsAssistantTurn: true }
720
779
  }
721
780
  if (kind === "shell") {
722
- const command = action.slice(1).trim()
781
+ const command = action.replace(/^[!$]\s*/, "").trim()
723
782
  if (job.safe && dangerousShell(command)) {
724
783
  await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
725
784
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
@@ -835,6 +894,7 @@ function sameLoopDefinition(a, b) {
835
894
  return (a.name || "") === (b.name || "") &&
836
895
  Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
837
896
  normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
897
+ normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
838
898
  normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
839
899
  }
840
900
 
@@ -911,9 +971,11 @@ async function helpLoop(client, sessionID) {
911
971
  await say(client, sessionID, [
912
972
  "OpenCode Loop help:",
913
973
  "/loop 0s <prompt> Claude Code style auto-continue",
914
- "/loop 5m --ask-never --safe <prompt> interval autonomous loop",
915
- "/loop 200m --no-now /compact compact/summarize loop",
916
- "/loop 10m !npm test shell loop",
974
+ "/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
975
+ "/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
976
+ "/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
977
+ "/loop-shell 10m npm test shell loop, waits for idle",
978
+ "/loop 200m --command /compact same as command loop",
917
979
  "/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
918
980
  "/loop 0s --prompt-file loop-prompt.md load prompt from a file",
919
981
  "/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",