@bojackduy/opencode-loopd 1.10.1 → 1.10.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-loopd",
4
- "version": "1.10.1",
4
+ "version": "1.10.4",
5
5
  "description": "Codex-inspired background goal engine for OpenCode — autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-or-later",
@@ -87,5 +87,9 @@
87
87
  "@types/node": "^26.2.0",
88
88
  "solid-js": "^1.9.13",
89
89
  "typescript": "^5.7.0"
90
+ },
91
+ "dependencies": {
92
+ "@xterm/headless": "^6.0.0",
93
+ "bun-pty": "^0.4.10"
90
94
  }
91
95
  }
@@ -1,30 +1,44 @@
1
1
  ---
2
2
  name: loopd
3
- description: "Agent skill for loopd background goals. Teaches when to create goals, how to inspect/steer/pause them, and how to send instructions to workers."
3
+ description: "Agent skill for loopd: background AI goals (multi-turn autonomous workers with checks) AND standalone command sessions (raw interactive processes with a fullscreen terminal UI). Teaches which one to use, how to create/inspect/steer them, and the dashboard that shows both."
4
4
  metadata:
5
- version: "1.2.0"
5
+ version: "1.3.0"
6
6
  status: active
7
- tags: [opencode, loop, goal, background, automation]
7
+ tags: [opencode, loop, goal, background, automation, command, terminal, pty]
8
8
  ---
9
9
 
10
- # Loopd Background Goals
10
+ # Loopd: Background Goals & Command Sessions
11
11
 
12
- Loopd runs long-running or autonomous tasks as background goals, each with a dedicated worker session. The parent chat stays interactive while work happens behind the scenes.
12
+ Loopd gives OpenCode two independent background primitives that share one dashboard:
13
13
 
14
- ## When to Use Loopd
14
+ | | **Goal** | **Command Session** |
15
+ |---|---|---|
16
+ | What it is | An autonomous AI worker looping turns | A raw OS process (no AI) |
17
+ | Use for | Multi-step AI work: implement, fix, research, write | Run/watch/type into one process: dev server, `test --watch`, REPL, log tail, build, script |
18
+ | Created with | `loopd_create_goal` | `loopd_command_start` |
19
+ | Finishes via | `complete_goal` after deterministic `checks` pass | Process exits, or `loopd_command_terminate` |
20
+ | Has | objective, checks, agent, model, budgets, turns | argv, cwd, stdin/stdout, exit code |
21
+ | Monitor in | `/loop` dashboard, **Goals** tab | `/loop` or `/commands`, **Commands** tab → `o` for fullscreen terminal |
22
+ | Link between them | — | optional `goal_id` (display only) + `loopd_command_await` (explicit wake) |
15
23
 
16
- Use loopd when:
24
+ **Pick a Goal** when the user wants something *figured out or built* across many turns with a definition of done.
25
+ **Pick a Command Session** when the user wants to *run and watch/interact with a process* — no AI reasoning loop needed, just stdin/stdout.
26
+
27
+ ## When to Use Loopd (Goals)
28
+
29
+ Use a goal when:
17
30
 
18
31
  - A task takes many turns and would block the main chat
19
- - You want autonomous work that continues while the user does other things
32
+ - You want autonomous AI work that continues while the user does other things
20
33
  - A task needs progress tracking, completion checks, or blocking
21
34
  - You want a dashboard to monitor and steer background work
22
35
 
23
- Do not use loopd when:
36
+ Do not use a goal when:
24
37
 
25
38
  - The task is trivial (1-3 turns)
26
39
  - The user needs to answer questions at every step
27
40
  - The task modifies production systems without review gates
41
+ - The user just wants to run/watch/type into a process — use a **Command Session** (below) instead; it has no agent overhead
28
42
 
29
43
  ## Goal Lifecycle
30
44
 
@@ -188,17 +202,143 @@ Abort the worker *session only* (`A` / `:abort` in TUI). Keeps goal+transcript+s
188
202
 
189
203
  Owner-side equivalents of TUI `:force` / `:block`. Bypass checks — use only when you have verified manually that the artifact is correct or that the blocker is real.
190
204
 
205
+ ## Command Sessions (Standalone Interactive Processes)
206
+
207
+ A **command session** is a raw OS process (spawned via `bun-pty`, falling back to a plain pipe) — no agent, no turns, no checks. It is completely independent from goals: starting/stopping one never pauses, blocks, or completes any goal, and vice versa. Use it whenever the request is "run X and let me watch/type into it" rather than "figure out/build X".
208
+
209
+ Typical uses: dev servers (`npm run dev`), watch mode (`npm test -- --watch`), REPLs (`python3`, `node`), log tails (`tail -f`), one-off scripts, interactive shells.
210
+
211
+ ### Agent Tools
212
+
213
+ | Tool | Purpose |
214
+ |------|---------|
215
+ | `loopd_command_start` | Spawn `{title, command, args, cwd?, goal_id?, cols?, rows?, env?, timeout_seconds?, shell?, watch_filter?, watch_until?, watch_ignore_case?, watch_until_action?}`. Requests OpenCode's `bash` permission first. Returns `command_id` + host `capabilities` (`spawn`/`write`/`interruptSignal`/`terminate`/`resize`/`terminalEmulation`). `env` passes values to the child but persists names only (`envKeys`); `timeout_seconds` (positive integer) kills via SIGTERM→SIGKILL with `endReason: timeout` + owner notify; `shell:true` spawns via `/bin/sh -c` (POSIX-quoted join; prefer argv form for untrusted input). `watch_filter`/`watch_until` arm line-level notifications at spawn (see Watching below); invalid `watch_until_action` (anything but `"stop"`/`"keep"`) or bad regexes fail the start before spawning. |
216
+ | `loopd_command_list` | List commands owned by the calling session. Summaries include the watch spec (`watchFilter`/`watchUntil`/`watchIgnoreCase`/`watchUntilAction`) plus the live `watchState` snapshot (`state`/`matches`/`pushes`/`droppedLines`). |
217
+ | `loopd_command_watch` | Set, replace, or clear a watch on an ALREADY-RUNNING command (`{command_id, watch_filter?, watch_until?, watch_ignore_case?, watch_until_action?, clear?}`). Replacing resets counters/state to `active`. Fails closed on bad regexes (previous watch untouched) and returns `ok:false` for terminal commands (watch is meaningless after exit) or another session's commands. |
218
+ | `loopd_command_get` | Read status + a bounded output snapshot (`offset_bytes` to page, default 64KB/cap 256KB) — for progress checks on plain one-shot commands. For never-exiting processes prefer a watch + `loopd_command_await` (pushes come to you; no repeated reads). With `pattern` (+`ignore_case`), only regex-matching lines return (ANSI-stripped for matching, originals kept; dangerous nested-quantifier patterns are rejected) and `offset_bytes`/`limit_bytes` page over matches. |
219
+ | `loopd_command_write` | Send raw stdin bytes (include your own trailing `\n` for line-buffered programs). |
220
+ | `loopd_command_interrupt` | Deliver SIGINT (Ctrl+C as a signal). A process that traps it may keep running — that's correct, not a failure. |
221
+ | `loopd_command_terminate` | SIGTERM, escalating to SIGKILL. The only way a command actually stops (closing a view never does). With `remove:true`, deletes the record+log in the same call (works even when already terminal). |
222
+ | `loopd_command_remove` | Delete a finished command's record + log. Refuses while `running` (terminate first). |
223
+ | `loopd_command_resize` | Set terminal size. Applied live to the real PTY winsize when the host supports it; stored-only (never applied) on the pipe fallback — check `capabilities.resize`. |
224
+ | `loopd_command_await` | **The only command→goal edge.** Explicit opt-in: makes `goal_id` wake up exactly once when `command_id` reaches a terminal state (`exited`/`terminated`/`missing`), with exit code/signal/last-4KB output as evidence. With `until` (+`ignore_case`), the goal instead wakes ONCE on the first output line matching the regex (matched lines + bounded tail as evidence) and the command keeps running — for "wait until the dev server prints ready" without repeated reads. Invalid/dangerous `until` patterns are rejected; an `until` already present in the log fires immediately. Merely passing `goal_id` to `loopd_command_start` links for *display only* — it never wakes anything by itself. |
225
+
226
+ ### Lifecycle
227
+
228
+ ```
229
+ running → exited (process ended on its own; exit code kept; endReason: exit)
230
+ running → terminated (explicit terminate/kill; signal kept; endReason: terminate)
231
+ running → terminated (timeout_seconds deadline; endReason: timeout — always notifies the owner)
232
+ running → missing (plugin restart found no live execution — log retained; endReason: missing)
233
+ ```
234
+
235
+ - **Detach ≠ terminate.** Closing the fullscreen terminal page, switching tabs, or the TUI restarting never stops the process. Only `loopd_command_terminate` (or the process exiting on its own) does.
236
+ - **`terminate ≠ remove`.** `remove` deletes the record + log and refuses while `running`.
237
+ - Every response carries the active backend's `capabilities` so you never have to guess (`terminalEmulation` is always `false` — the host captures a byte stream; the TUI's terminal page emulates a screen client-side from it).
238
+
239
+ ### Monitoring in the Dashboard
240
+
241
+ `/loop` and `/commands` open the **same** shared dashboard — `/loop` focuses the Goals tab, `/commands` focuses Commands. Switch with `Tab` (toggle) or directionally `h` (Goals) / `l` (Commands); `j`/`k`/`g`/`G` select within the active tab. The Commands tab is owner-scoped (only the current session's commands).
242
+
243
+ On the Commands tab, `o` **never** opens a chat session or a dialog — it closes the popup and navigates to a dedicated fullscreen terminal page inside OpenCode (route `opencode.loopd.terminal`). There:
244
+
245
+ - Keystrokes forward immediately as raw input — no line-submit box.
246
+ - `Ctrl+C` is interrupt input to the *process* (never closes OpenCode).
247
+ - `Ctrl+]` detaches back to where you came from — the process keeps running.
248
+ - Paste forwards raw bytes immediately.
249
+ - The viewport is measured and resizes both the emulator and the real PTY.
250
+
251
+ Colon commands available directly on the Commands tab (before opening fullscreen): `:new <command> [args...]` (quoted args preserved, e.g. `:new bash -c "echo hi"`), `:interrupt`, `:terminate`, `:remove`, and bare text + Enter writes stdin to the selected command. Goal controls (`p`/`r`/`R`/`x`/`A`/`N`) never fire on the Commands tab.
252
+
253
+ ### Example: Watching a Dev Server
254
+
255
+ ```
256
+ User: Start the dev server and let me see the logs.
257
+
258
+ Agent: loopd_command_start({ title: "dev-server", command: "npm", args: ["run", "dev"] })
259
+ # → {ok:true, command:{id:"...", status:"running", ...}, capabilities:{...}}
260
+ Agent: "Started — open /loop, Tab to Commands, press o to watch live, or I can set a watch so it pings you on interesting lines."
261
+
262
+ # Later, to check on it without opening the TUI:
263
+ loopd_command_get({ command_id: "..." })
264
+ # → status + latest output
265
+ ```
266
+
267
+ ### Watching Long-Running Commands
268
+
269
+ For never-exiting processes (dev servers, `test --watch`, log tails), **watch instead of re-reading**: the owner gets pushed on match/exit with no repeated `loopd_command_get` calls.
270
+
271
+ ```
272
+ # Arm at spawn…
273
+ loopd_command_start({ title: "dev", command: "npm", args: ["run", "dev"],
274
+ watch_filter: "ERROR|WARN", watch_until: "ready in",
275
+ watch_until_action: "keep" })
276
+ # …or attach to an already-running command:
277
+ loopd_command_watch({ command_id: "...", watch_filter: "ERROR|WARN" })
278
+ loopd_command_watch({ command_id: "...", watch_until: "ready in", watch_until_action: "keep" })
279
+ loopd_command_watch({ command_id: "...", clear: true }) # drop the watch
280
+ ```
281
+
282
+ - **`watch_filter`** (regex on ANSI-stripped lines): only matching lines buffer toward an owner push. No filter = every non-blank line counts.
283
+ - **`watch_until`** (regex on the filter-surviving stream — filter first, then until): first match notifies the owner immediately with the matched lines.
284
+ - **`watch_until_action`**: `"stop"` (default) terminates the command on first until-match (`endReason: until`, exactly one owner message total — the later exit ping is suppressed); `"keep"` notifies once but keeps running (until fires once, the filter stream continues, a later exit still notifies normally).
285
+ - **Replacing** a watch (calling `loopd_command_watch` again) resets counters/state to `active`. **Clearing** drops spec + counters + the in-memory watcher. Watching a terminal command returns `ok:false`. Bad regexes fail closed — the previous watch is left untouched.
286
+ - **Notifications are bounded** (so chatty processes can't spam you): matched lines coalesce into one push per ~2s window (≤20 lines / ≤4KB each); at most 30 pushes per command (`budget-exhausted`, one notice, process keeps running); over 100 lines/sec suspends the watch (`flood-suspended`, one notice with dropped count, process keeps running). Every push is prefixed `[watch "<title>"]`.
287
+ - **Dashboard**: the Commands tab shows a badge per command — `watch:filter`, `until:stop-armed` / `until:keep-armed`, `until-matched`, `flood-suspended`, `budget-exhausted` — plus the `endReason` (`timeout`/`until`/…) on terminal rows; the detail panel shows the watch spec with `matches`/`pushes`/`dropped` counters.
288
+
289
+ ### Migrating from PTY Sessions (`pty_*` tools)
290
+
291
+ | Before (`pty_*`) | After (`loopd_command_*`) | Notes |
292
+ |---|---|---|
293
+ | `pty_spawn({cmd, args, cwd})` | `loopd_command_start({title, command, args, cwd, env?, timeout_seconds?, shell?, notify_on_exit?, watch_filter?, watch_until?, watch_ignore_case?, watch_until_action?})` | `env` passes values but persists names only (`envKeys`); `timeout_seconds` kills via SIGTERM→SIGKILL with `endReason: timeout` and always notifies the owner (in-memory timer, never resurrected across restarts); `shell:true` joins via `/bin/sh -c`; `notify_on_exit` overrides the auto policy (auto = notify on non-zero exit, `missing`, `timeout`, or runs ≥ ~2min; quick successes stay silent) |
294
+ | `pty_write({id, data})` | `loopd_command_write({command_id, input})` | Same raw-stdin bytes; include your own trailing `\n` |
295
+ | `pty_read({id, pattern?})` | `loopd_command_get({command_id, pattern?, ignore_case?, offset_bytes?, limit_bytes?})` | `pattern` matches ANSI-stripped text, originals returned; pages over matches; dangerous patterns rejected. For continuous monitoring prefer a watch over repeated reads |
296
+ | `pty_list()` | `loopd_command_list()` | Owner-scoped (your session's commands only — same set as the TUI Commands tab); summaries carry `watchState` + `endReason` |
297
+ | `pty_kill({id, cleanup})` | `loopd_command_terminate({command_id, remove?})` | `remove:true` deletes record+log atomically (even when already terminal); plain `terminate` keeps the record for `loopd_command_remove` later |
298
+ | `notifyOnExit` / `timeoutSeconds` spawn opts | `notify_on_exit` / `timeout_seconds` args | Same shape, two loopd differences: the auto-notify policy above (not plain true/false), and timeout stamps `endReason: timeout` instead of a generic kill |
299
+
300
+ ### Migrating from Sentinel Watches (`sentinel_monitor`)
301
+
302
+ | Sentinel | Loopd | Notes |
303
+ |---|---|---|
304
+ | `filter` / `until` patterns | `watch_filter` / `watch_until` (+`watch_ignore_case`) | Same filter-first-then-until pipeline; same dangerous-regex guard; attach at start or later via `loopd_command_watch` |
305
+ | Steer delivery (message routed back into the agent turn) | Owner prompt push (`[watch "<title>"] …`) | Loopd notifies the owning session directly; a goal that must *act* on output should use `loopd_command_await` (`until` variant) instead |
306
+ | Flood/batch limits | Coalesce (~2s) + per-push bounds + 30-push budget + 100-lines/sec flood suspend | All bounded, all one-notice, process always keeps running (sentinel SIGTERMs on flood — loopd deliberately doesn't) |
307
+ | Until auto-stop | `watch_until_action: "stop"` (default) → `endReason: until` | `"keep"` is the explicit opt-out; `until-matched` shows as a dashboard badge either way |
308
+ | Shell-string spawn | `shell:true` (`command`+`args` joined POSIX-quoted via `/bin/sh -c`) | Prefer argv form for untrusted input |
309
+
310
+ ### Example: A Goal That Waits On a Background Build
311
+
312
+ ```
313
+ # Inside a goal worker:
314
+ loopd_command_start({ title: "full-build", command: "npm", args: ["run", "build"] })
315
+ # → command_id: "abc123"
316
+ loopd_command_await({ command_id: "abc123", goal_id: <this goal's id> })
317
+ # The worker can now idle; the engine wakes it exactly once when the build
318
+ # finishes, with exit code + last 4KB of output as evidence — no polling.
319
+ ```
320
+
191
321
  ## Dashboard Commands
192
322
 
193
- Open the dashboard with `/loop` or <leader>o.
323
+ Open the shared dashboard with `/loop` (or `<leader>o`) — focuses the **Goals** tab — or `/commands` — focuses the **Commands** tab. Same popup, same component, two entry points.
324
+
325
+ ### Switching Tabs
326
+
327
+ | Key | Action |
328
+ |-----|--------|
329
+ | `Tab` | Toggle Goals ↔ Commands |
330
+ | `h` | Select Goals directionally |
331
+ | `l` | Select Commands directionally |
194
332
 
195
- ### Keyboard Shortcuts (Normal Mode)
333
+ `j`/`k`/`g`/`G` select within whichever tab is active. Goal controls (`p`/`r`/`R`/`x`/`A`/`N`) only fire on the Goals tab and are refused with a hint on Commands.
334
+
335
+ ### Goals Tab — Keyboard Shortcuts (Normal Mode)
196
336
 
197
337
  | Key | Action | Agent equivalent |
198
338
  |-----|--------|-----------------|
199
339
  | `j` / `k` | Move selection down / up | — (UI only) |
200
340
  | `g` / `G` | Jump to top / bottom | — |
201
- | `o` | Open child (view details) | `read_goal_transcript` / `inspect_background_goal` |
341
+ | `o` | Open the selected goal's native worker session | `read_goal_transcript` / `inspect_background_goal` |
202
342
  | `L` | Toggle log view | — |
203
343
  | `?` | Toggle help | — |
204
344
  | `c` | Toggle done (show/hide completed) | — |
@@ -208,7 +348,7 @@ Open the dashboard with `/loop` or <leader>o.
208
348
  | `Ctrl+N` | Switch to normal mode | — |
209
349
  | `:` | Enter command mode | — |
210
350
 
211
- ### Command Mode (`:` prefix)
351
+ ### Goals Tab — Command Mode (`:` prefix)
212
352
 
213
353
  | Command | Description | Agent equivalent |
214
354
  |---------|-------------|-----------------|
@@ -223,7 +363,23 @@ Open the dashboard with `/loop` or <leader>o.
223
363
 
224
364
  > Goal creation (`:goal start`) was removed from the dashboard — create goals via `/goal` in the parent chat so the agent can clarify the objective first.
225
365
 
226
- **Scrolling:** the goal list is a `<scrollbox>` capped at 10 rows — short lists sit compact with no gap, long lists scroll and follow `j`/`k`/`g`/`G` via `scrollChildIntoView`. Detail + input stay pinned below in both cases. Empty list shows the fallback tip.
366
+ ### Commands Tab — Keyboard & Command Mode
367
+
368
+ | Key / Command | Action | Agent equivalent |
369
+ |-----|--------|-----------------|
370
+ | `j` / `k` / `g` / `G` | Select a command session | — |
371
+ | `o` | Close the popup, open the selected command in the **fullscreen terminal page** | — |
372
+ | `Ctrl+C` | Interrupt (SIGINT) the selected command | `loopd_command_interrupt` |
373
+ | `:new <command> [args...]` | Start a new command (quoted args preserved) | `loopd_command_start` |
374
+ | `:interrupt` | SIGINT the selected command | `loopd_command_interrupt` |
375
+ | `:terminate` | SIGTERM→SIGKILL the selected command | `loopd_command_terminate` |
376
+ | `:remove` | Delete a finished command's record + log | `loopd_command_remove` |
377
+ | bare text + Enter | Write that line as stdin to the selected command | `loopd_command_write` |
378
+ | `q` | Detach (view closes; command keeps running) | — |
379
+
380
+ Inside the **fullscreen terminal page** (reached via `o`): every keystroke forwards immediately as raw input (no line-submit box), `Ctrl+C` is interrupt input to the process, `Ctrl+]` detaches back to where you came from without stopping the process.
381
+
382
+ **Scrolling:** both lists are a `<scrollbox>` capped at 10 rows — short lists sit compact with no gap, long lists scroll and follow `j`/`k`/`g`/`G` via `scrollChildIntoView`. Detail + input stay pinned below in both cases. Empty list shows the fallback tip.
227
383
 
228
384
  ## Safety Patterns
229
385
 
@@ -238,6 +394,7 @@ Open the dashboard with `/loop` or <leader>o.
238
394
  9. **Recover stuck workers explicitly** — If `inspect` shows `phase=running` with `unknownStatusCount ≥3` or `lastActivityAt` far in the past, use `nudge_goal` (full steering) for a healthy-but-stuck worker or `abort_goal_worker` for a spinning/burning one. `send_goal_input` alone is now immediate for active goals, but it is still bare words, not steering.
239
395
  10. **One writer, one check suite** — Don’t run concurrent `start` calls that touch the same files from parallel chats; chain them sequentially or mark the second as `workspaceWrite:false`.
240
396
  11. **Registry is the source of truth** — `src/domain/interaction-registry.ts` lists every `LoopCommand` with its TUI and agent bindings. `test/domain/parity.test.ts` fails CI if they drift. When adding an interaction: add one row to `INTERACTIONS`, one handler in `control-worker.ts`, and both surfaces light up.
397
+ 12. **Goal vs Command Session — pick by shape of the task, not by habit** — "Run/watch/type into a process" (dev server, test watch, REPL, log tail, one-off script) is a `loopd_command_start` job with zero agent overhead, even if it will run a long time. Reach for `loopd_create_goal` only when the task genuinely needs multi-turn AI reasoning against a checkable definition of done. Don't spin up a goal just to babysit a process — use `loopd_command_await` if a goal needs to *wait* on one.
241
398
 
242
399
  ## Example: Creating and Monitoring a Goal
243
400