@bojackduy/opencode-loopd 1.10.4 → 1.10.5

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/commands/goal.md CHANGED
@@ -16,8 +16,8 @@ When you have enough to write a concrete contract:
16
16
  1. Call `loopd_create_goal` with:
17
17
  - `name` — a short slug (e.g. "pdf-notes")
18
18
  - `objective` — a precise, self-contained statement including verification criteria
19
- - `agent` — any OpenCode agent name: built-in (`general`, `explore`), `~/.config/opencode/agents/*.md`, or `opencode.jsonc` `agent.*`. Discover with `opencode agent list`. Prefer `subagent`-mode agents for workers; `primary`-mode agents work but may expect user interaction. Optional unless no `defaultAgent` is configured.
20
- - `model` — any model as `"providerID/modelID"` (e.g. `openai/gpt-5.6-sol`, `ollama/qwen3.8:27b`). Discover with `opencode models [provider]`. Sent on every worker prompt; omit to use the agent/session default, or configure `defaultModel` in `opencode.jsonc`.
19
+ - `agent` — any OpenCode agent name: built-in (`general`, `explore`), `~/.config/opencode/agents/*.md`, or `opencode.jsonc` `agent.*`. Discover with `opencode agent list`. Prefer `subagent`-mode agents for workers; `primary`-mode agents work but may expect user interaction. **Omit by default — inherits calling session's live agent, then `defaultAgent`.** Only pass when explicitly requested.
20
+ - `model` — any model as `"providerID/modelID"` (e.g. `openai/gpt-5.6-sol`, `ollama/qwen3.8:27b`). Discover with `opencode models [provider]`. Sent on every worker prompt. **Omit by default — inherits calling session's live model, then `defaultModel`.** Only pass when explicitly requested.
21
21
  - `checks` — shell commands that must pass before `complete_goal` is accepted; **mandatory when `workspaceWrite:true`** (the default) — or configure `defaultChecks` in `opencode.jsonc`
22
22
  - `workspaceWrite` — default `true` (safe — may touch the shared repo; only one active writer allowed — second `start`/`resume` fails with `already active`); set `false` explicitly for artifact-only/read-only work to allow concurrency
23
23
  - `checkCwd` — optional directory where `checks` run; writers default to project root, artifact-only jobs default to their `artifactDir`
package/dist/server.js CHANGED
@@ -576,10 +576,16 @@ function tailLastBytes(text, maxBytes = MAX_AWAIT_TAIL_BYTES) {
576
576
  return text;
577
577
  return buf.subarray(buf.length - maxBytes).toString("utf8");
578
578
  }
579
+ function safeCommandText(text) {
580
+ return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][\s\S]*?\x1b\\|\x1b[\x20-\x2f]*[\x30-\x7e]/g, "").replace(/\r\n?/g, `
581
+ `).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "").replace(/\n{3,}/g, `
582
+
583
+ `);
584
+ }
579
585
  function formatAwaitEvidence(input) {
580
- const short = input.commandID.slice(0, 8);
581
- const header = `[command "${input.title}" (${input.argv.join(" ") || input.title}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${input.signal ?? "none"}]`;
582
- const tail = tailLastBytes(input.tail);
586
+ const short = safeCommandText(input.commandID.slice(0, 8));
587
+ const header = `[command "${safeCommandText(input.title)}" (${safeCommandText(input.argv.join(" ") || input.title)}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${safeCommandText(input.signal ?? "none")}]`;
588
+ const tail = tailLastBytes(safeCommandText(input.tail));
583
589
  return tail ? `${header}
584
590
  ${tail}` : header;
585
591
  }
@@ -1020,13 +1026,13 @@ async function fireUntilAwaits(directory, commandID, lines) {
1020
1026
  continue;
1021
1027
  }
1022
1028
  const short = commandID.slice(0, 8);
1023
- const header = `[command "${cmd.title}" (${[cmd.command, ...cmd.args].join(" ") || cmd.title}) ${cmd.status}` + ` ${short}... watch-until "${pattern}" matched]`;
1024
- const matchedText = tailLastBytes(matched.slice(0, 20).join(`
1025
- `), MAX_AWAIT_TAIL_BYTES);
1029
+ const header = `[command "${safeCommandText(cmd.title)}" (${safeCommandText([cmd.command, ...cmd.args].join(" ") || cmd.title)}) ${cmd.status}` + ` ${short}... watch-until "${safeCommandText(pattern)}" matched]`;
1030
+ const matchedText = tailLastBytes(safeCommandText(matched.slice(0, 20).join(`
1031
+ `)), MAX_AWAIT_TAIL_BYTES);
1026
1032
  const evidence = tail ? `${header}
1027
1033
  ${matchedText}
1028
1034
  --- tail ---
1029
- ${tail}` : `${header}
1035
+ ${tailLastBytes(safeCommandText(tail))}` : `${header}
1030
1036
  ${matchedText}`;
1031
1037
  await appendGoalInbox(directory, a.goalID, "worker", evidence);
1032
1038
  await appendEvent(directory, ledgerEvent({
@@ -1687,6 +1693,8 @@ function createControlWorker(options) {
1687
1693
  case "cmd_write":
1688
1694
  case "cmd_interrupt":
1689
1695
  case "cmd_terminate":
1696
+ case "cmd_kill":
1697
+ case "cmd_restart":
1690
1698
  case "cmd_remove":
1691
1699
  case "cmd_resize":
1692
1700
  case "cmd_watch": {
@@ -1739,6 +1747,14 @@ function createControlWorker(options) {
1739
1747
  const r = await cmdSvc.terminate(directory, id, ownerSessionID);
1740
1748
  const state = await readState(directory);
1741
1749
  response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1750
+ } else if (request.command === "cmd_kill") {
1751
+ const r = await cmdSvc.kill(directory, id, ownerSessionID);
1752
+ const state = await readState(directory);
1753
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1754
+ } else if (request.command === "cmd_restart") {
1755
+ const r = await cmdSvc.restart(directory, id, ownerSessionID);
1756
+ const state = await readState(directory);
1757
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1742
1758
  } else if (request.command === "cmd_remove") {
1743
1759
  const r = await cmdSvc.remove(directory, id, ownerSessionID);
1744
1760
  const state = await readState(directory);
@@ -2901,7 +2917,6 @@ function shouldDedupParentNotify(ownerSessionID, message) {
2901
2917
  const last = recentParentNotifies.get(key);
2902
2918
  if (last !== undefined && now - last < 60000)
2903
2919
  return true;
2904
- recentParentNotifies.set(key, now);
2905
2920
  if (recentParentNotifies.size > 200) {
2906
2921
  for (const [k, t] of recentParentNotifies.entries())
2907
2922
  if (now - t > 60000)
@@ -2909,6 +2924,9 @@ function shouldDedupParentNotify(ownerSessionID, message) {
2909
2924
  }
2910
2925
  return false;
2911
2926
  }
2927
+ function markParentNotified2(ownerSessionID, message) {
2928
+ recentParentNotifies.set(`${ownerSessionID}:${message.slice(0, 200)}`, Date.now());
2929
+ }
2912
2930
  function createRealHost(client, directory) {
2913
2931
  return {
2914
2932
  async createWorker({ parentID, title, agent, model }) {
@@ -3054,7 +3072,7 @@ function createRealHost(client, directory) {
3054
3072
  async notifyOwner(ownerSessionID, message, agent) {
3055
3073
  if (shouldDedupParentNotify(ownerSessionID, message)) {
3056
3074
  await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
3057
- return;
3075
+ return true;
3058
3076
  }
3059
3077
  let resolvedAgent = agent?.trim() || undefined;
3060
3078
  if (!resolvedAgent) {
@@ -3075,11 +3093,15 @@ function createRealHost(client, directory) {
3075
3093
  }), 1e4, "OpenCode parent notify");
3076
3094
  if (result?.error) {
3077
3095
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
3096
+ return false;
3078
3097
  } else {
3098
+ markParentNotified2(ownerSessionID, message);
3079
3099
  await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
3100
+ return true;
3080
3101
  }
3081
3102
  } catch (error) {
3082
3103
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
3104
+ return false;
3083
3105
  }
3084
3106
  }
3085
3107
  };
@@ -3253,8 +3275,10 @@ function createV2Host(context, statuses, options = {}) {
3253
3275
  try {
3254
3276
  await context.session.prompt({ sessionID: ownerSessionID, text: message });
3255
3277
  await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
3278
+ return true;
3256
3279
  } catch (error) {
3257
3280
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
3281
+ return false;
3258
3282
  }
3259
3283
  }
3260
3284
  };
@@ -4808,6 +4832,7 @@ function createCommandSession(input) {
4808
4832
  ownerSessionID: input.ownerSessionID,
4809
4833
  goalID: input.goalID,
4810
4834
  notifyOnExit: input.notifyOnExit,
4835
+ notifySuccessfulExit: input.notifySuccessfulExit,
4811
4836
  status: "running",
4812
4837
  pid: input.pid,
4813
4838
  cols: input.cols,
@@ -4839,6 +4864,8 @@ function shouldNotifyOwnerOnExit(session) {
4839
4864
  return false;
4840
4865
  if (session.exitCode !== undefined && session.exitCode !== 0)
4841
4866
  return true;
4867
+ if (session.notifySuccessfulExit === true)
4868
+ return true;
4842
4869
  const started = Date.parse(session.createdAt);
4843
4870
  const ended = session.endedAt ? Date.parse(session.endedAt) : Date.now();
4844
4871
  if (!Number.isFinite(started) || !Number.isFinite(ended))
@@ -5066,6 +5093,28 @@ function createCommandService(host, opts) {
5066
5093
  const commandDirs = new Map;
5067
5094
  const timeouts = new Map;
5068
5095
  const timeoutOwners = new Map;
5096
+ const notifyInFlight = new Set;
5097
+ const notifyRetries = new Map;
5098
+ const notifyAttempts = new Map;
5099
+ function clearNotifyRetry(id) {
5100
+ const timer = notifyRetries.get(id);
5101
+ if (timer)
5102
+ clearTimeout(timer);
5103
+ notifyRetries.delete(id);
5104
+ notifyAttempts.delete(id);
5105
+ }
5106
+ function retryOwnerNotification(directory, id) {
5107
+ const attempt = (notifyAttempts.get(id) ?? 0) + 1;
5108
+ notifyAttempts.set(id, attempt);
5109
+ if (attempt > 3 || notifyRetries.has(id))
5110
+ return;
5111
+ const timer = setTimeout(() => {
5112
+ notifyRetries.delete(id);
5113
+ notifyOwnerIfNeeded(directory, id);
5114
+ }, 1000 * 2 ** (attempt - 1));
5115
+ timer.unref?.();
5116
+ notifyRetries.set(id, timer);
5117
+ }
5069
5118
  const watchers = new Map;
5070
5119
  const watchTimers = new Map;
5071
5120
  const awaitAsm = new Map;
@@ -5214,23 +5263,12 @@ function createCommandService(host, opts) {
5214
5263
  } catch {}
5215
5264
  }
5216
5265
  async function notifyOwnerIfNeeded(directory, id) {
5266
+ if (!opts?.onOwnerNotify || notifyInFlight.has(id))
5267
+ return;
5268
+ notifyInFlight.add(id);
5217
5269
  try {
5218
- let target;
5219
- await mutateState(directory, `cmd.notify-claim:${id}`, async (s) => {
5220
- const c = (s.commands ?? []).find((x) => x.id === id);
5221
- if (!c)
5222
- return s;
5223
- if (c.ownerNotifiedAt)
5224
- return s;
5225
- if (!isTerminalCommandStatus(c.status))
5226
- return s;
5227
- if (!shouldNotifyOwnerOnExit(c))
5228
- return s;
5229
- c.ownerNotifiedAt = new Date().toISOString();
5230
- target = { ...c };
5231
- return s;
5232
- });
5233
- if (!target)
5270
+ const target = (await readState(directory)).commands?.find((c) => c.id === id);
5271
+ if (!target || target.ownerNotifiedAt || !isTerminalCommandStatus(target.status) || !shouldNotifyOwnerOnExit(target))
5234
5272
  return;
5235
5273
  const tail = await readBoundedTail(directory, id);
5236
5274
  const message = formatAwaitEvidence({
@@ -5242,8 +5280,25 @@ function createCommandService(host, opts) {
5242
5280
  signal: target.signal,
5243
5281
  tail
5244
5282
  });
5245
- await opts?.onOwnerNotify?.(directory, target.ownerSessionID, message);
5246
- } catch {}
5283
+ const accepted = await opts.onOwnerNotify(directory, target.ownerSessionID, message);
5284
+ if (accepted === false)
5285
+ throw new Error("host did not accept owner prompt");
5286
+ await mutateState(directory, `cmd.notify-delivered:${id}`, async (s) => {
5287
+ const c = (s.commands ?? []).find((x) => x.id === id);
5288
+ if (c && !c.ownerNotifiedAt)
5289
+ c.ownerNotifiedAt = new Date().toISOString();
5290
+ return s;
5291
+ });
5292
+ clearNotifyRetry(id);
5293
+ } catch (error) {
5294
+ await logServerEvent(directory, "command.owner-notify-failed", {
5295
+ commandID: id,
5296
+ detail: error instanceof Error ? error.message : String(error)
5297
+ }).catch(() => {});
5298
+ retryOwnerNotification(directory, id);
5299
+ } finally {
5300
+ notifyInFlight.delete(id);
5301
+ }
5247
5302
  }
5248
5303
  async function flushWatch(directory, id) {
5249
5304
  const watcher = watchers.get(id);
@@ -5650,6 +5705,7 @@ function createCommandService(host, opts) {
5650
5705
  ownerSessionID: input.ownerSessionID,
5651
5706
  goalID: input.goalID,
5652
5707
  notifyOnExit: input.notifyOnExit,
5708
+ notifySuccessfulExit: true,
5653
5709
  pid: handle.pid,
5654
5710
  cols: input.cols,
5655
5711
  rows: input.rows,
@@ -5931,6 +5987,104 @@ function createCommandService(host, opts) {
5931
5987
  }
5932
5988
  return { ok: true, message: `Command "${session.title}" ${status}.` };
5933
5989
  },
5990
+ async kill(directory, id, ownerSessionID) {
5991
+ rememberDir(id, directory);
5992
+ const session = await service.get(directory, id, ownerSessionID);
5993
+ if (!session)
5994
+ return { ok: false, message: "Command not found." };
5995
+ if (session.status !== "running") {
5996
+ return { ok: false, message: `Command is ${session.status}; nothing to kill.` };
5997
+ }
5998
+ await mutateState(directory, `cmd.kill-claim:${id}`, async (s) => {
5999
+ const c = (s.commands ?? []).find((x) => x.id === id);
6000
+ if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
6001
+ c.signal = "SIGKILL";
6002
+ c.updatedAt = new Date().toISOString();
6003
+ }
6004
+ return s;
6005
+ }).catch(() => {});
6006
+ try {
6007
+ const claimed = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6008
+ if (claimed)
6009
+ emitBroker(id, { type: "status", command: claimed });
6010
+ } catch {}
6011
+ const entry = live.get(id);
6012
+ if (entry) {
6013
+ if (entry.handle.isAlive())
6014
+ entry.handle.kill();
6015
+ try {
6016
+ await entry.handle.exited();
6017
+ } catch {}
6018
+ live.delete(id);
6019
+ }
6020
+ clearCommandTimeout(id);
6021
+ clearWatchTimer(id);
6022
+ await mutateState(directory, `cmd.kill:${id}`, async (s) => {
6023
+ const c = (s.commands ?? []).find((x) => x.id === id);
6024
+ if (!c || c.ownerSessionID !== ownerSessionID)
6025
+ return s;
6026
+ if (c.status === "running") {
6027
+ c.status = "terminated";
6028
+ if (c.endReason !== "timeout")
6029
+ c.endReason = "terminate";
6030
+ c.exitCode = c.exitCode ?? 137;
6031
+ c.signal = c.signal ?? "SIGKILL";
6032
+ c.endedAt = new Date().toISOString();
6033
+ c.updatedAt = c.endedAt;
6034
+ } else if (c.status === "terminated" && !c.endedAt) {
6035
+ if (c.endReason !== "timeout" && !c.endReason)
6036
+ c.endReason = "terminate";
6037
+ c.exitCode = c.exitCode ?? 137;
6038
+ c.endedAt = new Date().toISOString();
6039
+ c.updatedAt = c.endedAt;
6040
+ }
6041
+ return s;
6042
+ });
6043
+ try {
6044
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6045
+ if (fresh)
6046
+ emitBroker(id, { type: "status", command: fresh });
6047
+ } catch {}
6048
+ await fireAwaits(directory, id);
6049
+ await notifyOwnerIfNeeded(directory, id);
6050
+ return { ok: true, message: `Command "${session.title}" killed (SIGKILL).` };
6051
+ },
6052
+ async restart(directory, id, ownerSessionID) {
6053
+ rememberDir(id, directory);
6054
+ const session = await service.get(directory, id, ownerSessionID);
6055
+ if (!session)
6056
+ return { ok: false, message: "Command not found." };
6057
+ const spec = {
6058
+ title: session.title,
6059
+ command: session.command,
6060
+ args: [...session.args],
6061
+ cwd: session.cwd,
6062
+ ownerSessionID,
6063
+ ...session.goalID !== undefined ? { goalID: session.goalID } : {},
6064
+ ...session.notifyOnExit !== undefined ? { notifyOnExit: session.notifyOnExit } : {},
6065
+ ...session.cols !== undefined ? { cols: session.cols } : {},
6066
+ ...session.rows !== undefined ? { rows: session.rows } : {},
6067
+ ...session.shell !== undefined ? { shell: session.shell } : {},
6068
+ ...session.timeoutSeconds !== undefined ? { timeoutSeconds: session.timeoutSeconds } : {},
6069
+ ...session.watchFilter !== undefined ? { watchFilter: session.watchFilter } : {},
6070
+ ...session.watchUntil !== undefined ? { watchUntil: session.watchUntil } : {},
6071
+ ...session.watchIgnoreCase !== undefined ? { watchIgnoreCase: session.watchIgnoreCase } : {},
6072
+ ...session.watchUntilAction !== undefined ? { watchUntilAction: session.watchUntilAction } : {}
6073
+ };
6074
+ if (session.status === "running") {
6075
+ const stopped = await service.terminate(directory, id, ownerSessionID);
6076
+ if (!stopped.ok) {
6077
+ return { ok: false, message: `Restart aborted: could not stop "${session.title}": ${stopped.message}` };
6078
+ }
6079
+ }
6080
+ const next = await service.start(directory, spec);
6081
+ const envNote = (session.envKeys?.length ?? 0) > 0 ? ` (env ${session.envKeys.join(",")} NOT restored \u2014 values are never persisted)` : "";
6082
+ return {
6083
+ ok: true,
6084
+ message: `Command "${session.title}" restarted as ${next.id.slice(0, 8)}...${envNote}`,
6085
+ command: next
6086
+ };
6087
+ },
5934
6088
  async remove(directory, id, ownerSessionID) {
5935
6089
  rememberDir(id, directory);
5936
6090
  const session = await service.get(directory, id, ownerSessionID);
@@ -5939,6 +6093,7 @@ function createCommandService(host, opts) {
5939
6093
  if (session.status === "running") {
5940
6094
  return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
5941
6095
  }
6096
+ clearNotifyRetry(id);
5942
6097
  deleteWatch(id);
5943
6098
  const lastKnown = { ...session };
5944
6099
  await mutateState(directory, `cmd.remove:${id}`, async (s) => {
@@ -6037,8 +6192,11 @@ function createCommandService(host, opts) {
6037
6192
  rememberDir(c.id, directory);
6038
6193
  let markedMissing = 0;
6039
6194
  for (const c of cmds) {
6040
- if (c.status !== "running")
6195
+ if (c.status !== "running") {
6196
+ if ((c.notifySuccessfulExit === true || c.notifyOnExit === true) && !c.ownerNotifiedAt)
6197
+ await notifyOwnerIfNeeded(directory, c.id);
6041
6198
  continue;
6199
+ }
6042
6200
  const entry = live.get(c.id);
6043
6201
  if (entry) {
6044
6202
  if (!entry.handle.isAlive()) {
@@ -6091,6 +6249,8 @@ function createCommandService(host, opts) {
6091
6249
  return { markedMissing };
6092
6250
  },
6093
6251
  async dispose(directory) {
6252
+ for (const id of [...notifyRetries.keys()])
6253
+ clearNotifyRetry(id);
6094
6254
  for (const id of [...watchTimers.keys()])
6095
6255
  clearWatchTimer(id);
6096
6256
  watchers.clear();
@@ -6114,6 +6274,8 @@ function createCommandService(host, opts) {
6114
6274
  }));
6115
6275
  for (const id of [...timeouts.keys()])
6116
6276
  clearCommandTimeout(id);
6277
+ for (const id of [...notifyRetries.keys()])
6278
+ clearNotifyRetry(id);
6117
6279
  }
6118
6280
  };
6119
6281
  return service;
@@ -6625,12 +6787,12 @@ var execAsync = promisify(execChild);
6625
6787
  function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6626
6788
  return {
6627
6789
  loopd_create_goal: tool({
6628
- description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). When omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
6790
+ description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
6629
6791
  args: {
6630
6792
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
6631
6793
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
6632
- agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Optional \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent.`),
6633
- model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Optional \u2014 inherits the calling session's live model if omitted, else plugin defaultModel.`),
6794
+ agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Prefer omit \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent. Only pass when explicitly requested.`),
6795
+ model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Prefer omit \u2014 inherits the calling session's live model if omitted, else plugin defaultModel. Only pass when explicitly requested.`),
6634
6796
  costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
6635
6797
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
6636
6798
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
@@ -7918,14 +8080,14 @@ function commandTools(options) {
7918
8080
  const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
7919
8081
  return {
7920
8082
  loopd_command_start: tool3({
7921
- description: "Start a standalone interactive OS process (arbitrary shell command) in the background \u2014 a dev server, `npm test --watch`, a REPL, a log tail, a build, or a one-off script. This is a raw process, NOT an AI worker: no agent, no checks, no turn loop. For multi-turn autonomous AI work with completion criteria, use loopd_create_goal instead. " + "Returns a command_id for loopd_command_get (read output)/loopd_command_write (send stdin)/loopd_command_interrupt (Ctrl+C)/loopd_command_terminate (kill)/loopd_command_remove (delete). " + "The user can also open it live: /loop or /commands \u2192 Tab/l to the Commands tab \u2192 select it \u2192 `o` opens a fullscreen interactive terminal page (type directly, Ctrl+C interrupts, Ctrl+] detaches without stopping it). " + "Independent from goals: an optional goal_id is display-only metadata and never couples lifecycles \u2014 pausing/clearing a goal never touches the command, and terminating a command never touches the goal. " + "To make a specific goal wake up when this command finishes, call loopd_command_await separately after starting it (linking alone does not wake anything). " + "The OWNER session (you) gets pushed a real message when the command reaches a terminal status \u2014 no polling required to find out: by default (auto) that fires on a non-zero exit, on 'missing' (host restarted mid-run), on 'timeout' (timeout_seconds deadline reached \u2014 always notifies), or once total runtime crosses ~2 minutes (the long-running/monitor case); quick successful commands stay silent. Override with notify_on_exit.",
8083
+ description: "PREFER THIS over the built-in shell/bash tool whenever a command might take a while or never return: installs (npm/pip/brew/cargo...), builds, test suites, downloads, migrations, dev servers, watchers, log tails, REPLs, or anything interactive. The built-in shell BLOCKS your whole turn until the process exits \u2014 a long install freezes you (and can time out); this returns immediately with a command_id and pushes you a message on exit or pattern match, so you stay responsive and can do other work meanwhile. Only use the built-in shell for quick one-shots (ls, git status, a single fast test) that finish in seconds. " + "Start a standalone interactive OS process (arbitrary shell command) in the background \u2014 a dev server, `npm test --watch`, a REPL, a log tail, a build, or a one-off script. This is a raw process, NOT an AI worker: no agent, no checks, no turn loop. For multi-turn autonomous AI work with completion criteria, use loopd_create_goal instead. " + "Returns a command_id for loopd_command_get (read output)/loopd_command_write (send stdin)/loopd_command_interrupt (Ctrl+C)/loopd_command_terminate (kill)/loopd_command_remove (delete). " + "The user can also open it live: /loop or /commands \u2192 Tab/l to the Commands tab \u2192 select it \u2192 `o` opens a fullscreen interactive terminal page (type directly, Ctrl+C interrupts, Ctrl+] detaches without stopping it). " + "Independent from goals: an optional goal_id is display-only metadata and never couples lifecycles \u2014 pausing/clearing a goal never touches the command, and terminating a command never touches the goal. " + "To make a specific goal wake up when this command finishes, call loopd_command_await separately after starting it (linking alone does not wake anything). " + "The OWNER session gets a notification on natural exit (including code 0), missing process, or timeout \u2014 no polling required for completion. Manual terminate stays silent by default. Raw PTY output stays in the log; any short tail included in the notification is sanitized for chat. Set notify_on_exit:false to suppress owner exit notifications.",
7922
8084
  args: {
7923
8085
  title: tool3.schema.string().describe("Short human label for the session."),
7924
8086
  command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
7925
8087
  args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
7926
8088
  cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
7927
8089
  goal_id: tool3.schema.string().optional().describe("Optional goal linkage (display only \u2014 no lifecycle coupling)."),
7928
- notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override. true = always push a message to you when this command finishes. false = never (dashboard/loopd_command_get only). Omit for auto (failure, lost-host, timeout, or long-running success)."),
8090
+ notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override. Omit to notify on natural exits (including quick success), missing or timeout; manual terminate stays silent. true also notifies on manual terminate; false disables owner exit notifications."),
7929
8091
  cols: tool3.schema.number().optional().describe(`Requested terminal width (${sizeNote}).`),
7930
8092
  rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`),
7931
8093
  env: tool3.schema.record(tool3.schema.string(), tool3.schema.string()).optional().describe("Extra environment variables for the child. Values are passed to the host but never persisted \u2014 only names appear as envKeys in summaries."),
@@ -8179,7 +8341,7 @@ function commandTools(options) {
8179
8341
  // src/server/plugin.ts
8180
8342
  init_state_repository();
8181
8343
  // package.json
8182
- var version = "1.10.4";
8344
+ var version = "1.10.5";
8183
8345
 
8184
8346
  // src/server/plugin.ts
8185
8347
  var PLUGIN_ID = "opencode-loopd.server";
@@ -8208,10 +8370,15 @@ function createServerHooks(directory, host, defaults) {
8208
8370
  }
8209
8371
  },
8210
8372
  onOwnerNotify: async (dir, ownerSessionID, message) => {
8211
- await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.owner-notify-failed", {
8212
- ownerSessionID,
8213
- detail: describeError(error)
8214
- }));
8373
+ try {
8374
+ return await host.notifyOwner(ownerSessionID, message) !== false;
8375
+ } catch (error) {
8376
+ await logServerEvent(dir, "command.owner-notify-failed", {
8377
+ ownerSessionID,
8378
+ detail: describeError(error)
8379
+ });
8380
+ return false;
8381
+ }
8215
8382
  },
8216
8383
  onWatchNotify: async (dir, ownerSessionID, message) => {
8217
8384
  await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.watch-notify-failed", {