@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 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
- - limits — optional `maxTurns` (default 50), `maxNoProgress`, `maxFailures`, `compactEvery`, `timeoutMs`
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 an inbox message to the worker (injected as ## USER INSTRUCTIONS on next turn). Use to answer `question`, redirect, or refine scope. Does NOT itself re-prompt \u2014 the engine re-prompts on next idle/maintenance; use nudge_goal if the worker is stuck with no activity.",
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 appendGoalInbox(directory, goal.id, "user", args.message);
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: true,
3936
+ ok: result.ok,
3916
3937
  goalID: goal.id,
3917
3938
  goalName: goal.name,
3918
- message: `Message delivered to "${goal.name}". It will appear in the worker's next turn.`
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