@bojackduy/opencode-loopd 1.9.2 → 1.9.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/commands/goal.md +2 -1
- package/dist/server.js +174 -11
- package/dist/tui.js +307 -225
- package/package.json +4 -4
- package/skills/loopd/SKILL.md +88 -53
package/commands/goal.md
CHANGED
|
@@ -20,7 +20,8 @@ When you have enough to write a concrete contract:
|
|
|
20
20
|
- `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
|
|
21
21
|
- `checkCwd` — optional directory where `checks` run; writers default to project root, artifact-only jobs default to their `artifactDir`
|
|
22
22
|
- `progressFile` — optional path to a markdown progress file (defaults to `<artifactDir>/progress.md`)
|
|
23
|
-
-
|
|
23
|
+
- `costBudget` — max dollars before `budget_limited` (e.g. `0.5`); enforced continuously, worker aborted mid-turn when exceeded
|
|
24
|
+
- limits — optional `maxTurns` (default 50), `maxNoProgress`, `maxFailures`, `compactEvery`, `timeoutMs` (also `costBudget` / `tokenBudget` for hard caps)
|
|
24
25
|
2. After it returns (`ok:true` with `goalID`/`artifactDir`/`defaultsApplied`, or `ok:false` with `errorCode: "missing_agent"|"missing_checks"|"already active"`), tell the user the goal is running in the background and they can monitor it with `/loop` (or <leader>o). On `missing_*`, explain the contract violation and ask for the missing piece; on `already active`, tell them to `pause`/`clear` the current writer or use `workspaceWrite:false`.
|
|
25
26
|
|
|
26
27
|
Important: the worker session runs autonomously — do not try to do the goal's work in this chat. This chat only creates the goal. Completion is host-judged: if `checks` fail, the worker will see `HOST VERDICT: COMPLETION REJECTED` with exact `stderr` and must fix the behavior (not just rewrite evidence) before retrying.
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
var __esm = (fn, res, err) => () => {
|
|
3
|
+
if (fn)
|
|
4
|
+
try {
|
|
5
|
+
res = fn(fn = 0);
|
|
6
|
+
} catch (e) {
|
|
7
|
+
err = [e];
|
|
8
|
+
}
|
|
9
|
+
if (err)
|
|
10
|
+
throw err[0];
|
|
11
|
+
return res;
|
|
12
|
+
};
|
|
13
|
+
|
|
2
14
|
// src/domain/runtime.ts
|
|
3
15
|
function createRuntimeState(goalID) {
|
|
4
16
|
const now = new Date().toISOString();
|
|
@@ -113,14 +125,10 @@ function removeToolCall(rt, callID) {
|
|
|
113
125
|
}
|
|
114
126
|
var PARENT_NOTIFY_DEDUPE_MS = 60000;
|
|
115
127
|
|
|
116
|
-
// src/application/control-worker.ts
|
|
117
|
-
import { randomUUID } from "crypto";
|
|
118
|
-
|
|
119
128
|
// src/infrastructure/state-repository.ts
|
|
120
129
|
import { promises as fs } from "fs";
|
|
121
130
|
import path from "path";
|
|
122
131
|
import os from "os";
|
|
123
|
-
var CURRENT_VERSION = 6;
|
|
124
132
|
function emptyState() {
|
|
125
133
|
return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
|
|
126
134
|
}
|
|
@@ -140,7 +148,6 @@ function lockDir(directory) {
|
|
|
140
148
|
function lockFile(directory, key) {
|
|
141
149
|
return path.join(lockDir(directory), `${key}.lock`);
|
|
142
150
|
}
|
|
143
|
-
var LOCK_STALE_MS = 1e4;
|
|
144
151
|
async function acquireLock(directory, key, operation) {
|
|
145
152
|
const dir = lockDir(directory);
|
|
146
153
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -476,6 +483,15 @@ async function peekGoalInbox(directory, goalID) {
|
|
|
476
483
|
function delay(ms) {
|
|
477
484
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
478
485
|
}
|
|
486
|
+
var CURRENT_VERSION = 6, LOCK_STALE_MS = 1e4;
|
|
487
|
+
var init_state_repository = () => {};
|
|
488
|
+
|
|
489
|
+
// src/server/plugin.ts
|
|
490
|
+
import { define } from "@opencode-ai/plugin/v2/promise";
|
|
491
|
+
|
|
492
|
+
// src/application/control-worker.ts
|
|
493
|
+
init_state_repository();
|
|
494
|
+
import { randomUUID } from "crypto";
|
|
479
495
|
|
|
480
496
|
// src/application/goal-policy.ts
|
|
481
497
|
function resolveGoalCreationConfig(input) {
|
|
@@ -932,6 +948,7 @@ function createControlWorker(options) {
|
|
|
932
948
|
}
|
|
933
949
|
|
|
934
950
|
// src/application/loop-engine.ts
|
|
951
|
+
init_state_repository();
|
|
935
952
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
936
953
|
|
|
937
954
|
// src/domain/goal.ts
|
|
@@ -1871,6 +1888,7 @@ function createLoopEngine(options) {
|
|
|
1871
1888
|
|
|
1872
1889
|
// src/application/goal-service.ts
|
|
1873
1890
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
1891
|
+
init_state_repository();
|
|
1874
1892
|
import * as path2 from "path";
|
|
1875
1893
|
import { promises as fs2 } from "fs";
|
|
1876
1894
|
|
|
@@ -2975,6 +2993,7 @@ function createGoalService(host) {
|
|
|
2975
2993
|
}
|
|
2976
2994
|
|
|
2977
2995
|
// src/application/schedule-worker.ts
|
|
2996
|
+
init_state_repository();
|
|
2978
2997
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
2979
2998
|
function createScheduleWorker(options) {
|
|
2980
2999
|
const { directory, goalService } = options;
|
|
@@ -3089,6 +3108,7 @@ function createScheduleWorker(options) {
|
|
|
3089
3108
|
}
|
|
3090
3109
|
|
|
3091
3110
|
// src/server/goal-tools.ts
|
|
3111
|
+
init_state_repository();
|
|
3092
3112
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
3093
3113
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
3094
3114
|
// src/domain/verification.ts
|
|
@@ -3666,6 +3686,7 @@ async function runCompletionChecks(checks, cwd) {
|
|
|
3666
3686
|
}
|
|
3667
3687
|
|
|
3668
3688
|
// src/server/owner-tools.ts
|
|
3689
|
+
init_state_repository();
|
|
3669
3690
|
import { tool as tool2 } from "@opencode-ai/plugin/tool";
|
|
3670
3691
|
import { promises as fs3 } from "fs";
|
|
3671
3692
|
function withTimeout2(promise, ms) {
|
|
@@ -3893,7 +3914,7 @@ function ownerTools(options) {
|
|
|
3893
3914
|
}
|
|
3894
3915
|
}),
|
|
3895
3916
|
send_goal_input: tool2({
|
|
3896
|
-
description: "Send
|
|
3917
|
+
description: "Send bare words to the worker as their own turn (no steering wrapper). Delivers immediately if the goal is active, otherwise queues for the next active turn. Use to answer `question`, redirect, or nudge with a short message.",
|
|
3897
3918
|
args: {
|
|
3898
3919
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to target the first active goal."),
|
|
3899
3920
|
message: tool2.schema.string().describe("Message to send to the worker.")
|
|
@@ -3908,14 +3929,14 @@ function ownerTools(options) {
|
|
|
3908
3929
|
output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
|
|
3909
3930
|
};
|
|
3910
3931
|
}
|
|
3911
|
-
await
|
|
3932
|
+
const result = await goalService.sendUserMessage(directory, goal.id, args.message);
|
|
3912
3933
|
return {
|
|
3913
|
-
title: "Message sent",
|
|
3934
|
+
title: result.ok ? "Message sent" : "Send failed",
|
|
3914
3935
|
output: JSON.stringify({
|
|
3915
|
-
ok:
|
|
3936
|
+
ok: result.ok,
|
|
3916
3937
|
goalID: goal.id,
|
|
3917
3938
|
goalName: goal.name,
|
|
3918
|
-
message:
|
|
3939
|
+
message: result.message
|
|
3919
3940
|
})
|
|
3920
3941
|
};
|
|
3921
3942
|
}
|
|
@@ -4048,6 +4069,141 @@ function ownerTools(options) {
|
|
|
4048
4069
|
}
|
|
4049
4070
|
}
|
|
4050
4071
|
}),
|
|
4072
|
+
abort_goal_worker: tool2({
|
|
4073
|
+
description: "Abort the worker session only (N / :abort in TUI). Keeps goal+transcript+session browsable, clears the run lease. Use for compaction-spin or stuck runs. Status unchanged; active goals resume next turn in the same session.",
|
|
4074
|
+
args: {
|
|
4075
|
+
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to abort the first active goal.")
|
|
4076
|
+
},
|
|
4077
|
+
execute: async (args, context) => {
|
|
4078
|
+
const state = await readState(directory);
|
|
4079
|
+
const ownerID = context?.sessionID;
|
|
4080
|
+
const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
|
|
4081
|
+
if (!goal) {
|
|
4082
|
+
return {
|
|
4083
|
+
title: "No goal found",
|
|
4084
|
+
output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
|
|
4085
|
+
};
|
|
4086
|
+
}
|
|
4087
|
+
try {
|
|
4088
|
+
const result = await goalService.abortWorker(directory, goal.id);
|
|
4089
|
+
return {
|
|
4090
|
+
title: result.ok ? "Worker aborted" : "Abort failed",
|
|
4091
|
+
output: JSON.stringify({ ...result, goalID: goal.id, goalName: goal.name })
|
|
4092
|
+
};
|
|
4093
|
+
} catch (error) {
|
|
4094
|
+
return {
|
|
4095
|
+
title: "Abort failed",
|
|
4096
|
+
output: JSON.stringify({
|
|
4097
|
+
ok: false,
|
|
4098
|
+
goalName: goal.name,
|
|
4099
|
+
message: error instanceof Error ? error.message : String(error)
|
|
4100
|
+
})
|
|
4101
|
+
};
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
}),
|
|
4105
|
+
force_complete_goal: tool2({
|
|
4106
|
+
description: "Force-complete a goal with summary/evidence, bypassing checks (:force in TUI). Use when the worker produced the right artifact but checks are stale or you have verified manually.",
|
|
4107
|
+
args: {
|
|
4108
|
+
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to target the first non-complete goal."),
|
|
4109
|
+
summary: tool2.schema.string().describe("What was completed."),
|
|
4110
|
+
evidence: tool2.schema.string().describe("Concrete evidence of completion.")
|
|
4111
|
+
},
|
|
4112
|
+
execute: async (args, context) => {
|
|
4113
|
+
const state = await readState(directory);
|
|
4114
|
+
const ownerID = context?.sessionID;
|
|
4115
|
+
const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
|
|
4116
|
+
if (!goal) {
|
|
4117
|
+
return {
|
|
4118
|
+
title: "No goal found",
|
|
4119
|
+
output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
|
|
4120
|
+
};
|
|
4121
|
+
}
|
|
4122
|
+
await Promise.resolve().then(() => init_state_repository());
|
|
4123
|
+
await Promise.resolve();
|
|
4124
|
+
const { randomUUID } = await import("crypto");
|
|
4125
|
+
const st = await readState(directory);
|
|
4126
|
+
const g = st.goals.find((x) => x.id === goal.id);
|
|
4127
|
+
if (!g)
|
|
4128
|
+
return { title: "No goal", output: JSON.stringify({ ok: false, message: "Goal not found." }) };
|
|
4129
|
+
if (g.status === "complete") {
|
|
4130
|
+
return { title: "Already complete", output: JSON.stringify({ ok: true, message: `Goal "${g.name}" already complete.` }) };
|
|
4131
|
+
}
|
|
4132
|
+
g.status = "complete";
|
|
4133
|
+
g.updatedAt = new Date().toISOString();
|
|
4134
|
+
g.completionEvidence = { summary: args.summary, evidence: args.evidence, at: new Date().toISOString() };
|
|
4135
|
+
const rt = st.runtimes.find((r) => r.goalID === goal.id);
|
|
4136
|
+
if (rt) {
|
|
4137
|
+
Object.assign(rt, releaseLease(rt));
|
|
4138
|
+
rt.activeRunID = undefined;
|
|
4139
|
+
rt.lastError = undefined;
|
|
4140
|
+
rt.updatedAt = new Date().toISOString();
|
|
4141
|
+
}
|
|
4142
|
+
await writeState(directory, st);
|
|
4143
|
+
await appendEvent(directory, {
|
|
4144
|
+
version: 1,
|
|
4145
|
+
eventID: randomUUID(),
|
|
4146
|
+
goalID: goal.id,
|
|
4147
|
+
type: "goal.completed",
|
|
4148
|
+
summary: args.summary,
|
|
4149
|
+
evidence: args.evidence,
|
|
4150
|
+
timestamp: new Date().toISOString(),
|
|
4151
|
+
revision: st.revision
|
|
4152
|
+
});
|
|
4153
|
+
return { title: "Goal force-completed", output: JSON.stringify({ ok: true, goalID: goal.id, goalName: g.name }) };
|
|
4154
|
+
}
|
|
4155
|
+
}),
|
|
4156
|
+
force_block_goal: tool2({
|
|
4157
|
+
description: "Force-block a goal with reason/needed (:block in TUI). Use when the goal is stuck on an external blocker and should stop retrying.",
|
|
4158
|
+
args: {
|
|
4159
|
+
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to target the first non-complete goal."),
|
|
4160
|
+
reason: tool2.schema.string().describe("Why the goal is blocked."),
|
|
4161
|
+
needed: tool2.schema.string().describe("What is needed to unblock.")
|
|
4162
|
+
},
|
|
4163
|
+
execute: async (args, context) => {
|
|
4164
|
+
const state = await readState(directory);
|
|
4165
|
+
const ownerID = context?.sessionID;
|
|
4166
|
+
const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
|
|
4167
|
+
if (!goal) {
|
|
4168
|
+
return {
|
|
4169
|
+
title: "No goal found",
|
|
4170
|
+
output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
|
|
4171
|
+
};
|
|
4172
|
+
}
|
|
4173
|
+
await Promise.resolve().then(() => init_state_repository());
|
|
4174
|
+
await Promise.resolve();
|
|
4175
|
+
const { randomUUID } = await import("crypto");
|
|
4176
|
+
const st = await readState(directory);
|
|
4177
|
+
const g = st.goals.find((x) => x.id === goal.id);
|
|
4178
|
+
if (!g)
|
|
4179
|
+
return { title: "No goal", output: JSON.stringify({ ok: false, message: "Goal not found." }) };
|
|
4180
|
+
if (g.status === "blocked") {
|
|
4181
|
+
return { title: "Already blocked", output: JSON.stringify({ ok: true, message: `Goal "${g.name}" already blocked.` }) };
|
|
4182
|
+
}
|
|
4183
|
+
g.status = "blocked";
|
|
4184
|
+
g.updatedAt = new Date().toISOString();
|
|
4185
|
+
g.blocker = { reason: args.reason, needed: args.needed, at: new Date().toISOString() };
|
|
4186
|
+
const rt = st.runtimes.find((r) => r.goalID === goal.id);
|
|
4187
|
+
if (rt) {
|
|
4188
|
+
Object.assign(rt, releaseLease(rt));
|
|
4189
|
+
rt.activeRunID = undefined;
|
|
4190
|
+
rt.lastError = undefined;
|
|
4191
|
+
rt.updatedAt = new Date().toISOString();
|
|
4192
|
+
}
|
|
4193
|
+
await writeState(directory, st);
|
|
4194
|
+
await appendEvent(directory, {
|
|
4195
|
+
version: 1,
|
|
4196
|
+
eventID: randomUUID(),
|
|
4197
|
+
goalID: goal.id,
|
|
4198
|
+
type: "goal.blocked",
|
|
4199
|
+
reason: args.reason,
|
|
4200
|
+
needed: args.needed,
|
|
4201
|
+
timestamp: new Date().toISOString(),
|
|
4202
|
+
revision: st.revision
|
|
4203
|
+
});
|
|
4204
|
+
return { title: "Goal blocked", output: JSON.stringify({ ok: true, goalID: goal.id, goalName: g.name }) };
|
|
4205
|
+
}
|
|
4206
|
+
}),
|
|
4051
4207
|
clear_goal: tool2({
|
|
4052
4208
|
description: "Clear a goal: aborts worker and removes goal+runtime+ledger (cannot be undone). Use when the goal is no longer needed or to free a stuck writer slot after inspection.",
|
|
4053
4209
|
args: {
|
|
@@ -4088,7 +4244,9 @@ function ownerTools(options) {
|
|
|
4088
4244
|
})
|
|
4089
4245
|
};
|
|
4090
4246
|
}
|
|
4247
|
+
|
|
4091
4248
|
// src/server/plugin.ts
|
|
4249
|
+
init_state_repository();
|
|
4092
4250
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
4093
4251
|
var server = async ({ client, directory }, pluginOptions) => {
|
|
4094
4252
|
const defaults = parsePluginDefaults(pluginOptions);
|
|
@@ -4232,9 +4390,14 @@ function parsePluginDefaults(options) {
|
|
|
4232
4390
|
defaultChecks: checks.length > 0 ? checks : undefined
|
|
4233
4391
|
};
|
|
4234
4392
|
}
|
|
4393
|
+
var v2 = define({
|
|
4394
|
+
id: PLUGIN_ID,
|
|
4395
|
+
async setup() {}
|
|
4396
|
+
});
|
|
4235
4397
|
var plugin_default = {
|
|
4236
4398
|
id: PLUGIN_ID,
|
|
4237
|
-
server
|
|
4399
|
+
server,
|
|
4400
|
+
setup: v2.setup
|
|
4238
4401
|
};
|
|
4239
4402
|
export {
|
|
4240
4403
|
plugin_default as default
|