@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 +2 -2
- package/dist/server.js +205 -38
- package/dist/tui.js +1007 -811
- package/package.json +1 -1
- package/skills/loopd/SKILL.md +36 -10
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.
|
|
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
|
|
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
|
-
|
|
5219
|
-
|
|
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
|
|
5246
|
-
|
|
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]`).
|
|
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").
|
|
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").
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
8212
|
-
ownerSessionID,
|
|
8213
|
-
|
|
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", {
|