@bojackduy/opencode-loopd 1.8.1 → 1.8.3

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/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  ![Loop Dashboard — modal TUI with goals, running indicator, and insert mode](assets/demo.png)
13
13
 
14
- *Modal dashboard (`/loop` / `<leader>d`): zero chat pollution — keys are trapped inside the dialog, NORMAL vs INSERT modes, vivid per-status coloring.*
14
+ *Modal dashboard (`/loop` / `<leader>o`): zero chat pollution — keys are trapped inside the dialog, NORMAL vs INSERT modes, vivid per-status coloring.*
15
15
 
16
16
  ## Why opencode-loopd
17
17
 
@@ -20,7 +20,7 @@
20
20
  - **Parent ↔ child visibility** — `list/inspect/read_transcript/send_input` give the parent full observability. Bidirectional inbox lets you steer mid-run.
21
21
  - **Safe by default** — per-goal artifact isolation (`.opencode/loopd/goals/<id>/`), `maxTurns`/`maxFailures`/`maxNoProgress`, force-finish → semantic `complete_goal` summary → parent notification via wake-up injection.
22
22
  - **Scheduled intervals** — `scheduleEveryMs`/`scheduleMaxRuns` auto-requeues the same goal every N ms (e.g., `10s` monitor, `1h` report) without manual `/goal` spam — `5s` poll, `skip-if-running`, `workspaceWrite` serialization, inbox `Scheduled tick N/M`.
23
- - **Modal TUI dashboard** — `<leader>d` or `/loop` opens a focused dialog (no leak to chat prompt). Vim-style navigation, live running indicator, per-status borders.
23
+ - **Modal TUI dashboard** — `<leader>o` or `/loop` opens a focused dialog (no leak to chat prompt). Vim-style navigation, live running indicator, per-status borders.
24
24
 
25
25
  Keywords: `opencode` `opencode-plugin` `background-agent` `autonomous` `subagent` `loop` `goal` `tui` `codex` `claude-code` `worker`
26
26
 
@@ -50,7 +50,7 @@ Add the package to **both** configs.
50
50
  }
51
51
  ```
52
52
 
53
- Then restart OpenCode. Verify with `/loop` (palette → Loop Dashboard) or `<leader>d`.
53
+ Then restart OpenCode. Verify with `/loop` (palette → Loop Dashboard) or `<leader>o`.
54
54
 
55
55
  For a local checkout:
56
56
 
@@ -142,7 +142,7 @@ The agent will clarify (what/where/how to verify) and then call `loopd_create_go
142
142
 
143
143
  ### 2. Monitor with dashboard — `/loop`
144
144
 
145
- Press **`<leader>d`** or open the command palette → **"Loop Dashboard"** (also `/loop`).
145
+ Press **`<leader>o`** or open the command palette → **"Loop Dashboard"** (also `/loop`).
146
146
 
147
147
  Dashboard (NORMAL / INSERT `:`):
148
148
 
package/commands/goal.md CHANGED
@@ -20,6 +20,6 @@ When you have enough to write a concrete contract:
20
20
  - `checkCwd` — optional directory where `checks` run; writers default to project root, artifact-only jobs default to their `artifactDir`
21
21
  - `progressFile` — optional path to a markdown progress file (defaults to `<artifactDir>/progress.md`)
22
22
  - limits — optional `maxTurns` (default 50), `maxNoProgress`, `maxFailures`, `compactEvery`, `timeoutMs`
23
- 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>d). 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`.
23
+ 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`.
24
24
 
25
25
  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,34 +1,5 @@
1
1
  // @bun
2
- var __defProp = Object.defineProperty;
3
- var __returnValue = (v) => v;
4
- function __exportSetter(name, newValue) {
5
- this[name] = __returnValue.bind(null, newValue);
6
- }
7
- var __export = (target, all) => {
8
- for (var name in all)
9
- __defProp(target, name, {
10
- get: all[name],
11
- enumerable: true,
12
- configurable: true,
13
- set: __exportSetter.bind(all, name)
14
- });
15
- };
16
-
17
2
  // src/domain/runtime.ts
18
- var exports_runtime = {};
19
- __export(exports_runtime, {
20
- acquireLease: () => acquireLease,
21
- addToolCall: () => addToolCall,
22
- createRuntimeState: () => createRuntimeState,
23
- hasActiveToolCalls: () => hasActiveToolCalls,
24
- leaseIsValid: () => leaseIsValid,
25
- markParentNotified: () => markParentNotified,
26
- markProgress: () => markProgress,
27
- recordActivity: () => recordActivity,
28
- releaseLease: () => releaseLease,
29
- removeToolCall: () => removeToolCall,
30
- shouldNotifyParent: () => shouldNotifyParent
31
- });
32
3
  function createRuntimeState(goalID) {
33
4
  const now = new Date().toISOString();
34
5
  return {
@@ -135,9 +106,6 @@ function removeToolCall(rt, callID) {
135
106
  updatedAt: new Date().toISOString()
136
107
  };
137
108
  }
138
- function hasActiveToolCalls(rt) {
139
- return (rt.activeToolCallIDs?.length ?? 0) > 0;
140
- }
141
109
  var PARENT_NOTIFY_DEDUPE_MS = 60000;
142
110
 
143
111
  // src/application/control-worker.ts
@@ -176,8 +144,8 @@ async function acquireLock(directory, key, operation) {
176
144
  try {
177
145
  try {
178
146
  const raw = await fs.readFile(lockPath, "utf8");
179
- const meta2 = JSON.parse(raw);
180
- const age = Date.now() - Date.parse(meta2.acquiredAt);
147
+ const meta = JSON.parse(raw);
148
+ const age = Date.now() - Date.parse(meta.acquiredAt);
181
149
  if (age > LOCK_STALE_MS) {
182
150
  await fs.rm(lockPath, { force: true });
183
151
  }
@@ -712,54 +680,54 @@ function createControlWorker(options) {
712
680
  ownerSessionID: args.ownerSessionID,
713
681
  config: resolution.config
714
682
  });
715
- const state2 = await readState(directory);
683
+ const state = await readState(directory);
716
684
  response = {
717
685
  ...base,
718
686
  message: `goal "${args.name}" created (${goal.id.slice(0, 8)}...)`,
719
- stateRevision: state2.revision
687
+ stateRevision: state.revision
720
688
  };
721
689
  break;
722
690
  }
723
691
  case "pause": {
724
692
  await goalSvc.pause(directory, request.goalID);
725
- const state2 = await readState(directory);
726
- const goal = state2.goals.find((g) => g.id === request.goalID);
693
+ const state = await readState(directory);
694
+ const goal = state.goals.find((g) => g.id === request.goalID);
727
695
  response = {
728
696
  ...base,
729
697
  message: `goal "${goal?.name || request.goalID}" paused`,
730
- stateRevision: state2.revision
698
+ stateRevision: state.revision
731
699
  };
732
700
  break;
733
701
  }
734
702
  case "resume": {
735
703
  await goalSvc.resume(directory, request.goalID);
736
- const state2 = await readState(directory);
737
- const goal = state2.goals.find((g) => g.id === request.goalID);
704
+ const state = await readState(directory);
705
+ const goal = state.goals.find((g) => g.id === request.goalID);
738
706
  response = {
739
707
  ...base,
740
708
  message: `goal "${goal?.name || request.goalID}" resumed`,
741
- stateRevision: state2.revision
709
+ stateRevision: state.revision
742
710
  };
743
711
  break;
744
712
  }
745
713
  case "retry": {
746
714
  await goalSvc.retry(directory, request.goalID);
747
- const state2 = await readState(directory);
748
- const goal = state2.goals.find((g) => g.id === request.goalID);
715
+ const state = await readState(directory);
716
+ const goal = state.goals.find((g) => g.id === request.goalID);
749
717
  response = {
750
718
  ...base,
751
719
  message: `goal "${goal?.name || request.goalID}" retried`,
752
- stateRevision: state2.revision
720
+ stateRevision: state.revision
753
721
  };
754
722
  break;
755
723
  }
756
724
  case "clear": {
757
725
  await goalSvc.clear(directory, request.goalID);
758
- const state2 = await readState(directory);
726
+ const state = await readState(directory);
759
727
  response = {
760
728
  ...base,
761
729
  message: `goal cleared`,
762
- stateRevision: state2.revision
730
+ stateRevision: state.revision
763
731
  };
764
732
  break;
765
733
  }
@@ -775,25 +743,25 @@ function createControlWorker(options) {
775
743
  break;
776
744
  }
777
745
  await appendGoalInbox(directory, request.goalID, "user", text);
778
- const state2 = await readState(directory);
779
- const goal = state2.goals.find((g) => g.id === request.goalID);
746
+ const state = await readState(directory);
747
+ const goal = state.goals.find((g) => g.id === request.goalID);
780
748
  response = {
781
749
  ...base,
782
750
  message: `sent to "${goal?.name || request.goalID}"`,
783
- stateRevision: state2.revision
751
+ stateRevision: state.revision
784
752
  };
785
753
  break;
786
754
  }
787
755
  case "force_complete": {
788
756
  const args = request.args;
789
- const state2 = await readState(directory);
790
- const goal = state2.goals.find((g) => g.id === request.goalID);
757
+ const state = await readState(directory);
758
+ const goal = state.goals.find((g) => g.id === request.goalID);
791
759
  if (!goal) {
792
760
  response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
793
761
  break;
794
762
  }
795
763
  if (goal.status === "complete") {
796
- response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision: state2.revision };
764
+ response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision: state.revision };
797
765
  break;
798
766
  }
799
767
  goal.status = "complete";
@@ -803,14 +771,14 @@ function createControlWorker(options) {
803
771
  evidence: String(args.evidence || "Manual override \u2014 no verification checks run."),
804
772
  at: new Date().toISOString()
805
773
  };
806
- const runtime = state2.runtimes.find((r) => r.goalID === goal.id);
774
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
807
775
  if (runtime) {
808
776
  Object.assign(runtime, releaseLease(runtime));
809
777
  runtime.activeRunID = undefined;
810
778
  runtime.lastError = undefined;
811
779
  runtime.updatedAt = new Date().toISOString();
812
780
  }
813
- await writeState(directory, state2);
781
+ await writeState(directory, state);
814
782
  await appendEvent(directory, {
815
783
  version: 1,
816
784
  eventID: randomUUID(),
@@ -819,22 +787,22 @@ function createControlWorker(options) {
819
787
  summary: goal.completionEvidence.summary,
820
788
  evidence: goal.completionEvidence.evidence,
821
789
  timestamp: new Date().toISOString(),
822
- revision: state2.revision
790
+ revision: state.revision
823
791
  });
824
- response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision: state2.revision };
792
+ response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision: state.revision };
825
793
  break;
826
794
  }
827
795
  case "force_block":
828
796
  case "block": {
829
797
  const args = request.args;
830
- const state2 = await readState(directory);
831
- const goal = state2.goals.find((g) => g.id === request.goalID);
798
+ const state = await readState(directory);
799
+ const goal = state.goals.find((g) => g.id === request.goalID);
832
800
  if (!goal) {
833
801
  response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
834
802
  break;
835
803
  }
836
804
  if (goal.status === "blocked") {
837
- response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision: state2.revision };
805
+ response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision: state.revision };
838
806
  break;
839
807
  }
840
808
  goal.status = "blocked";
@@ -844,14 +812,14 @@ function createControlWorker(options) {
844
812
  needed: String(args.needed || "User intervention required."),
845
813
  at: new Date().toISOString()
846
814
  };
847
- const runtime = state2.runtimes.find((r) => r.goalID === goal.id);
815
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
848
816
  if (runtime) {
849
817
  Object.assign(runtime, releaseLease(runtime));
850
818
  runtime.activeRunID = undefined;
851
819
  runtime.lastError = undefined;
852
820
  runtime.updatedAt = new Date().toISOString();
853
821
  }
854
- await writeState(directory, state2);
822
+ await writeState(directory, state);
855
823
  await appendEvent(directory, {
856
824
  version: 1,
857
825
  eventID: randomUUID(),
@@ -860,9 +828,9 @@ function createControlWorker(options) {
860
828
  reason: goal.blocker.reason,
861
829
  needed: goal.blocker.needed,
862
830
  timestamp: new Date().toISOString(),
863
- revision: state2.revision
831
+ revision: state.revision
864
832
  });
865
- response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state2.revision };
833
+ response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state.revision };
866
834
  break;
867
835
  }
868
836
  default: {
@@ -878,8 +846,8 @@ function createControlWorker(options) {
878
846
  await recordInLedger(directory, request);
879
847
  return response;
880
848
  }
881
- async function recordInLedger(directory2, request) {
882
- const state = await readState(directory2);
849
+ async function recordInLedger(directory, request) {
850
+ const state = await readState(directory);
883
851
  if (!state.commandLedger)
884
852
  state.commandLedger = [];
885
853
  state.commandLedger.push({
@@ -892,7 +860,7 @@ function createControlWorker(options) {
892
860
  if (state.commandLedger.length > MAX_LEDGER_SIZE) {
893
861
  state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
894
862
  }
895
- await writeState(directory2, state);
863
+ await writeState(directory, state);
896
864
  }
897
865
  return { start, stop: async () => {
898
866
  await stop();
@@ -1879,9 +1847,9 @@ function createGoalService(host) {
1879
1847
  rt.lastScheduleAt = undefined;
1880
1848
  }
1881
1849
  state.runtimes.push(rt);
1882
- const runtime2 = state.runtimes.find((r) => r.goalID === id);
1883
- if (runtime2)
1884
- runtime2.phase = "queued";
1850
+ const runtime = state.runtimes.find((r) => r.goalID === id);
1851
+ if (runtime)
1852
+ runtime.phase = "queued";
1885
1853
  return state;
1886
1854
  });
1887
1855
  let runtime = state1.runtimes.find((r) => r.goalID === id);
@@ -2122,17 +2090,17 @@ function createGoalService(host) {
2122
2090
  }
2123
2091
  async function resumeUnlocked(directory, goalID) {
2124
2092
  let resumed = false;
2125
- const state = await mutateState(directory, `goal.resume:${goalID}`, async (state2) => {
2126
- const goal2 = state2.goals.find((g) => g.id === goalID);
2127
- if (!goal2)
2128
- return state2;
2129
- if (!canTransition(goal2.status, "active", "user"))
2130
- return state2;
2131
- assertWorkspaceWriteAvailable(state2, goal2);
2132
- goal2.status = "active";
2133
- goal2.updatedAt = new Date().toISOString();
2093
+ const state = await mutateState(directory, `goal.resume:${goalID}`, async (state) => {
2094
+ const goal = state.goals.find((g) => g.id === goalID);
2095
+ if (!goal)
2096
+ return state;
2097
+ if (!canTransition(goal.status, "active", "user"))
2098
+ return state;
2099
+ assertWorkspaceWriteAvailable(state, goal);
2100
+ goal.status = "active";
2101
+ goal.updatedAt = new Date().toISOString();
2134
2102
  resumed = true;
2135
- return state2;
2103
+ return state;
2136
2104
  });
2137
2105
  const goal = state.goals.find((g) => g.id === goalID);
2138
2106
  if (!goal || !resumed)
@@ -2162,15 +2130,15 @@ function createGoalService(host) {
2162
2130
  }
2163
2131
  async function retryUnlocked(directory, goalID) {
2164
2132
  let retried = false;
2165
- const state = await mutateState(directory, `goal.retry:${goalID}`, async (state2) => {
2166
- const goal2 = state2.goals.find((g) => g.id === goalID);
2167
- if (!goal2 || goal2.status !== "blocked")
2168
- return state2;
2169
- assertWorkspaceWriteAvailable(state2, goal2);
2170
- goal2.status = "active";
2171
- goal2.updatedAt = new Date().toISOString();
2133
+ const state = await mutateState(directory, `goal.retry:${goalID}`, async (state) => {
2134
+ const goal = state.goals.find((g) => g.id === goalID);
2135
+ if (!goal || goal.status !== "blocked")
2136
+ return state;
2137
+ assertWorkspaceWriteAvailable(state, goal);
2138
+ goal.status = "active";
2139
+ goal.updatedAt = new Date().toISOString();
2172
2140
  retried = true;
2173
- const runtime = state2.runtimes.find((r) => r.goalID === goalID);
2141
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
2174
2142
  if (runtime) {
2175
2143
  runtime.consecutiveFailures = 0;
2176
2144
  runtime.lastError = undefined;
@@ -2180,7 +2148,7 @@ function createGoalService(host) {
2180
2148
  runtime.phase = "idle";
2181
2149
  runtime.updatedAt = new Date().toISOString();
2182
2150
  }
2183
- return state2;
2151
+ return state;
2184
2152
  });
2185
2153
  const goal = state.goals.find((g) => g.id === goalID);
2186
2154
  if (!goal || !retried)
@@ -2541,16 +2509,22 @@ function createRealHost(client, directory) {
2541
2509
  async sessionStatus(sessionID) {
2542
2510
  try {
2543
2511
  const result = await client.session.status({});
2512
+ if (result?.error)
2513
+ return "unknown";
2544
2514
  const data = result?.data;
2545
- if (!data || typeof data !== "object")
2515
+ if (!data || typeof data !== "object" || Array.isArray(data))
2546
2516
  return "unknown";
2547
2517
  const status = data[sessionID];
2548
- if (!status || typeof status !== "object")
2518
+ if (status === undefined || status === null)
2519
+ return "idle";
2520
+ if (typeof status !== "object" || Array.isArray(status))
2549
2521
  return "unknown";
2550
2522
  const type = status.type;
2551
2523
  if (type === "busy" || type === "retry")
2552
2524
  return type;
2553
- return "idle";
2525
+ if (type === "idle")
2526
+ return "idle";
2527
+ return "unknown";
2554
2528
  } catch {
2555
2529
  return "unknown";
2556
2530
  }
@@ -2861,9 +2835,9 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2861
2835
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
2862
2836
  const checkResults = await runCompletionChecks(goal.config.checks, cwd);
2863
2837
  if (!checkResults.passed) {
2864
- const runtime2 = state.runtimes.find((r) => r.goalID === goal.id);
2865
- if (runtime2) {
2866
- runtime2.evaluatorRejectionCount = (runtime2.evaluatorRejectionCount || 0) + 1;
2838
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
2839
+ if (runtime) {
2840
+ runtime.evaluatorRejectionCount = (runtime.evaluatorRejectionCount || 0) + 1;
2867
2841
  const failureDetails = checkResults.failures.map((f) => {
2868
2842
  const stdoutSnippet = f.stdout ? `
2869
2843
  Stdout: ${f.stdout.slice(0, 500)}` : "";
@@ -2874,7 +2848,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
2874
2848
  }).join(`
2875
2849
 
2876
2850
  `);
2877
- runtime2.lastRejectionDetails = `Rejection #${runtime2.evaluatorRejectionCount} at ${new Date().toISOString()}
2851
+ runtime.lastRejectionDetails = `Rejection #${runtime.evaluatorRejectionCount} at ${new Date().toISOString()}
2878
2852
 
2879
2853
  Working directory: ${cwd}
2880
2854
 
@@ -2882,8 +2856,8 @@ ${failureDetails}`;
2882
2856
  const attemptID = randomUUID5();
2883
2857
  const verificationAttempt = {
2884
2858
  id: attemptID,
2885
- sequence: runtime2.evaluatorRejectionCount,
2886
- runGeneration: runtime2.runGeneration,
2859
+ sequence: runtime.evaluatorRejectionCount,
2860
+ runGeneration: runtime.runGeneration,
2887
2861
  claimedSummary: args.summary,
2888
2862
  claimedEvidence: args.evidence,
2889
2863
  startedAt: new Date().toISOString(),
@@ -2897,15 +2871,15 @@ ${failureDetails}`;
2897
2871
  stdout: f.stdout
2898
2872
  }))
2899
2873
  };
2900
- runtime2.lastVerificationAttempt = verificationAttempt;
2901
- runtime2.recentVerificationAttempts = appendVerificationAttempt(runtime2.recentVerificationAttempts || [], verificationAttempt);
2874
+ runtime.lastVerificationAttempt = verificationAttempt;
2875
+ runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
2902
2876
  const rejectEvent = {
2903
2877
  version: 1,
2904
2878
  eventID: randomUUID5(),
2905
2879
  goalID: goal.id,
2906
2880
  type: "goal.completion_rejected",
2907
2881
  attemptID,
2908
- rejectionCount: runtime2.evaluatorRejectionCount,
2882
+ rejectionCount: runtime.evaluatorRejectionCount,
2909
2883
  failedCheckCount: checkResults.failures.length,
2910
2884
  failureSummary: failureDetails.slice(0, 500),
2911
2885
  timestamp: new Date().toISOString(),
@@ -2913,16 +2887,16 @@ ${failureDetails}`;
2913
2887
  };
2914
2888
  await appendEvent(dir, rejectEvent);
2915
2889
  const maxRejections = goal.config.maxEvaluatorRejections || 3;
2916
- if (runtime2.evaluatorRejectionCount >= maxRejections) {
2890
+ if (runtime.evaluatorRejectionCount >= maxRejections) {
2917
2891
  goal.status = "blocked";
2918
2892
  goal.updatedAt = new Date().toISOString();
2919
2893
  goal.blocker = {
2920
- reason: `Evaluator rejected ${runtime2.evaluatorRejectionCount} time(s). Last failure:
2894
+ reason: `Evaluator rejected ${runtime.evaluatorRejectionCount} time(s). Last failure:
2921
2895
  ${failureDetails.slice(0, 500)}`,
2922
2896
  needed: "Fix the failing checks and retry the goal.",
2923
2897
  at: new Date().toISOString()
2924
2898
  };
2925
- runtime2.forceFinishRequested = undefined;
2899
+ runtime.forceFinishRequested = undefined;
2926
2900
  await appendEvent(dir, {
2927
2901
  version: 1,
2928
2902
  eventID: randomUUID5(),
@@ -2934,10 +2908,10 @@ ${failureDetails.slice(0, 500)}`,
2934
2908
  revision: state.revision
2935
2909
  });
2936
2910
  } else {
2937
- runtime2.forceFinishRequested = false;
2938
- runtime2.freeRetryPending = true;
2911
+ runtime.forceFinishRequested = false;
2912
+ runtime.freeRetryPending = true;
2939
2913
  }
2940
- runtime2.updatedAt = new Date().toISOString();
2914
+ runtime.updatedAt = new Date().toISOString();
2941
2915
  await writeState(dir, state);
2942
2916
  }
2943
2917
  return {
@@ -2946,7 +2920,7 @@ ${failureDetails.slice(0, 500)}`,
2946
2920
  passed: false,
2947
2921
  failedChecks: checkResults.failures,
2948
2922
  message: "Evaluator rejected completion. Fix the issues above and try again.",
2949
- rejectionCount: runtime2?.evaluatorRejectionCount || 0,
2923
+ rejectionCount: runtime?.evaluatorRejectionCount || 0,
2950
2924
  status: goal.status
2951
2925
  })
2952
2926
  };
@@ -3637,8 +3611,8 @@ var server = async ({ client, directory }, pluginOptions) => {
3637
3611
  "tool.execute.before": async (input, _output) => {
3638
3612
  const activeWorkers = goalService.getActiveWorkers();
3639
3613
  let matchedGoalID;
3640
- for (const [goalID, worker2] of activeWorkers) {
3641
- if (worker2.workerSessionID === input.sessionID) {
3614
+ for (const [goalID, worker] of activeWorkers) {
3615
+ if (worker.workerSessionID === input.sessionID) {
3642
3616
  matchedGoalID = goalID;
3643
3617
  break;
3644
3618
  }
@@ -3661,8 +3635,8 @@ var server = async ({ client, directory }, pluginOptions) => {
3661
3635
  }
3662
3636
  const activeWorkers = goalService.getActiveWorkers();
3663
3637
  let matchedGoalID;
3664
- for (const [goalID, worker2] of activeWorkers) {
3665
- if (worker2.workerSessionID === input.sessionID) {
3638
+ for (const [goalID, worker] of activeWorkers) {
3639
+ if (worker.workerSessionID === input.sessionID) {
3666
3640
  matchedGoalID = goalID;
3667
3641
  break;
3668
3642
  }
@@ -3688,20 +3662,20 @@ var server = async ({ client, directory }, pluginOptions) => {
3688
3662
  const goalID = parsed.goalID;
3689
3663
  if (!goalID)
3690
3664
  return;
3691
- const { shouldNotifyParent: shouldNotifyParent2, markParentNotified: markParentNotified2 } = await Promise.resolve().then(() => exports_runtime);
3665
+ await Promise.resolve();
3692
3666
  const state = await readState(directory);
3693
3667
  const goal = state.goals.find((g) => g.id === goalID);
3694
3668
  if (!goal)
3695
3669
  return;
3696
3670
  const runtime = state.runtimes.find((r) => r.goalID === goalID);
3697
3671
  const notifyType = parsed.status === "complete" ? "complete" : "blocked";
3698
- if (runtime && !shouldNotifyParent2(runtime, notifyType))
3672
+ if (runtime && !shouldNotifyParent(runtime, notifyType))
3699
3673
  return;
3700
3674
  if (runtime) {
3701
3675
  await mutateState(directory, `notify-parent:${goalID}`, async (s) => {
3702
3676
  const rt = s.runtimes.find((r) => r.goalID === goalID);
3703
3677
  if (rt)
3704
- markParentNotified2(rt, notifyType);
3678
+ markParentNotified(rt, notifyType);
3705
3679
  return s;
3706
3680
  });
3707
3681
  }
package/dist/tui.js CHANGED
@@ -574,10 +574,10 @@ function LoopDashboard(props) {
574
574
  try {
575
575
  const s = await client.getState();
576
576
  setState(s);
577
- const goals2 = s.goals.filter((g) => showCompleted() || g.status !== "complete");
578
- if (goals2.length > 0 && selected() >= goals2.length)
579
- setSelected(goals2.length - 1);
580
- setSelectedGoal(goals2[selected()] || null);
577
+ const goals = s.goals.filter((g) => showCompleted() || g.status !== "complete");
578
+ if (goals.length > 0 && selected() >= goals.length)
579
+ setSelected(goals.length - 1);
580
+ setSelectedGoal(goals[selected()] || null);
581
581
  setEvents(await client.getEvents(20));
582
582
  } catch (e) {
583
583
  setStatusText(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -1077,7 +1077,7 @@ function LoopDashboard(props) {
1077
1077
  children: (seg, idx) => {
1078
1078
  const hasArrow = seg.includes("\u2192");
1079
1079
  if (hasArrow) {
1080
- const [k2, d2] = seg.split("\u2192").map((s) => s.trim());
1080
+ const [k, d] = seg.split("\u2192").map((s) => s.trim());
1081
1081
  return [_$memo(() => _$memo(() => idx() > 0)() && (() => {
1082
1082
  var _el$54 = _$createElement("span");
1083
1083
  _$insertNode(_el$54, _$createTextNode(` | `));
@@ -1087,7 +1087,7 @@ function LoopDashboard(props) {
1087
1087
  return _el$54;
1088
1088
  })()), (() => {
1089
1089
  var _el$50 = _$createElement("span");
1090
- _$insert(_el$50, k2);
1090
+ _$insert(_el$50, k);
1091
1091
  _$effect((_$p) => _$setProp(_el$50, "style", {
1092
1092
  fg: theme().warning,
1093
1093
  bold: true
@@ -1102,7 +1102,7 @@ function LoopDashboard(props) {
1102
1102
  return _el$51;
1103
1103
  })(), (() => {
1104
1104
  var _el$53 = _$createElement("span");
1105
- _$insert(_el$53, d2);
1105
+ _$insert(_el$53, d);
1106
1106
  _$effect((_$p) => _$setProp(_el$53, "style", {
1107
1107
  fg: theme().text
1108
1108
  }, _$p));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-loopd",
4
- "version": "1.8.1",
5
- "description": "Codex-inspired background goal engine for OpenCode \u2014 autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
4
+ "version": "1.8.3",
5
+ "description": "Codex-inspired background goal engine for OpenCode autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-or-later",
8
8
  "private": false,
@@ -6,14 +6,16 @@ import { fileURLToPath } from "node:url"
6
6
 
7
7
  const root = dirname(dirname(fileURLToPath(import.meta.url)))
8
8
  const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
9
- const pluginDir = join(config, "plugins")
10
9
  const commandDir = join(config, "commands")
11
10
  const skillDir = join(config, "skills", "loopd")
12
11
  const packagePath = join(config, "package.json")
13
- const packageName = "opencode-loopd"
14
- const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
12
+ const rootPackageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8"))
13
+ const packageName = rootPackageJson.name // "@bojackduy/opencode-loopd"
14
+ const legacyPackageName = "opencode-loopd" // unscoped name used by older installs; matched for cleanup/upgrade only
15
+ const packageVersion = rootPackageJson.version
15
16
  const packageSpec = `${packageName}@${packageVersion}`
16
17
  const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
18
+ const tuiConfigName = "tui.json"
17
19
  const installerArgs = process.argv.slice(2)
18
20
  const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
19
21
 
@@ -62,32 +64,76 @@ function parseJsonc(input) {
62
64
  if (!p || typeof p !== "object" || Array.isArray(p)) throw new Error("OpenCode config root must be an object")
63
65
  return p
64
66
  }
65
- function isPackageSpec(v) { const s = String(v||"").trim(); return s === packageName || s.startsWith(`${packageName}@`) }
67
+ function isPackageSpec(v) {
68
+ const s = String(v||"").trim()
69
+ return s === packageName || s.startsWith(`${packageName}@`) || s === legacyPackageName || s.startsWith(`${legacyPackageName}@`)
70
+ }
66
71
  function skipTrivia(s, i) { while (i < s.length) { const c=s[i]||"", n=s[i+1]||""; if (/\s/.test(c)) {i++;continue} if (c==="/"&&n==="/") {i+=2; while(i<s.length&&s[i]!=="\n"&&s[i]!=="\r") i++; continue} if (c==="/"&&n==="*") { const e=s.indexOf("*/",i+2); if(e<0) throw new Error("unterminated block comment"); i=e+2; continue } break } return i }
67
72
  function readJsonString(s, i) { if(s[i]!=='"') throw new Error("expected JSON string"); let esc=false; for(let j=i+1;j<s.length;j++){const c=s[j]||""; if(esc){esc=false; continue} if(c==="\\"){esc=true;continue} if(c==='"'){return {value:JSON.parse(s.slice(i,j+1)), end:j+1}}} throw new Error("unterminated JSON string") }
68
73
  function skipJsonValue(s,i){ const vs=skipTrivia(s,i), f=s[vs]; if(f==='"') return readJsonString(s,vs).end; if(f==="{"||f==="["){const st=[]; let q=false,esc=false,lc=false,bc=false; for(let j=vs;j<s.length;j++){const c=s[j]||"",n=s[j+1]||""; if(lc){if(c==="\n"||c==="\r") lc=false; continue} if(bc){if(c==="*"&&n==="/"){bc=false;j++} continue} if(q){if(esc) esc=false; else if(c==="\\") esc=true; else if(c==='"') q=false; continue} if(c==='"'){q=true;continue} if(c==="/"&&n==="/"){lc=true;j++;continue} if(c==="/"&&n==="*"){bc=true;j++;continue} if(c==="{"||c==="[") st.push(c); else if(c==="}"||c==="]"){const e=c==="}"?"{":"["; if(st.at(-1)!==e) throw new Error("mismatched delimiters"); st.pop(); if(!st.length) return j+1} } throw new Error("unterminated JSON value") } let j=vs; while(j<s.length&&![",","}","]"].includes(s[j])) j++; return j }
69
74
  function findRootProperty(s, name){ let i=skipTrivia(s,0); if(s[i]!=="{") throw new Error("OpenCode config must be root object"); i++; while(true){ i=skipTrivia(s,i); if(s[i]==="}") return null; const k=readJsonString(s,i); i=skipTrivia(s,k.end); if(s[i]!==":") throw new Error(`expected ':' after ${k.value}`); const vs=skipTrivia(s,i+1), ve=skipJsonValue(s,vs); if(k.value===name){ const ls=Math.max(s.lastIndexOf("\n",vs-1),s.lastIndexOf("\r",vs-1))+1; const kls=Math.max(s.lastIndexOf("\n",k.end-1),s.lastIndexOf("\r",k.end-1))+1; const indent=s.slice(kls,k.end-k.value.length-2).match(/^[\t ]*/)?.[0]||" "; return {valueStart:vs,valueEnd:ve,indent,lineStart:ls} } const av=skipTrivia(s,ve); if(s[av]===",") i=av+1; else if(s[av]==="}") return null; else throw new Error(`expected ',' or '}' after ${k.value}`) } }
70
75
  function formatPluginArray(vals, indent, eol){ if(!vals.length) return "[]"; const ci=`${indent} `; return `[${eol}${vals.map(v=>`${ci}${JSON.stringify(v)}`).join(`,${eol}`)}${eol}${indent}]` }
71
76
  function rewriteExistingPluginArray(source, next){ const prop=findRootProperty(source,"plugin"); if(!prop) return source; const eol=source.includes("\r\n")?"\r\n":"\n"; const rep=formatPluginArray(next,prop.indent,eol); return `${source.slice(0,prop.valueStart)}${rep}${source.slice(prop.valueEnd)}` }
77
+ function insertPluginProperty(source, vals) {
78
+ const eol = source.includes("\r\n") ? "\r\n" : "\n"
79
+ const i = skipTrivia(source, 0)
80
+ if (source[i] !== "{") throw new Error("OpenCode config must be root object")
81
+ const openEnd = i + 1
82
+ const after = skipTrivia(source, openEnd)
83
+ const propLine = ` "plugin": ${formatPluginArray(vals, " ", eol)}`
84
+ if (source[after] === "}") return `${source.slice(0, openEnd)}${eol}${propLine}${eol}${source.slice(after)}`
85
+ return `${source.slice(0, openEnd)}${eol}${propLine},${source.slice(openEnd)}`
86
+ }
72
87
 
73
- async function configurePackagePlugin() {
74
- let configured = false; const updatedFiles=[]
88
+ // Adds/updates the plugin entry in `target`, creating the file (with a minimal
89
+ // `{ "plugin": [...] }`) if it doesn't exist yet. Returns true if the file changed.
90
+ async function ensurePluginRegistered(target) {
91
+ let source
92
+ try {
93
+ source = await readFile(target, "utf8")
94
+ } catch (e) {
95
+ if (e?.code !== "ENOENT") throw new Error(`Could not read ${target}: ${e.message}`)
96
+ await mkdir(dirname(target), { recursive: true })
97
+ await writeFile(target, JSON.stringify({ plugin: [packageSpec] }, null, 2) + "\n", "utf8")
98
+ return true
99
+ }
100
+ const parsed = parseJsonc(source)
101
+ if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error(`plugin must be array in ${target}`)
102
+ const plugins = parsed.plugin || []
103
+ const next = plugins.filter(v => !isPackageSpec(v))
104
+ next.push(packageSpec)
105
+ const updated = parsed.plugin !== undefined ? rewriteExistingPluginArray(source, next) : insertPluginProperty(source, next)
106
+ if (updated === source) return false
107
+ await writeFile(target, updated, "utf8")
108
+ return true
109
+ }
110
+ // Removes the plugin entry from `target` if present. Returns true if the file changed.
111
+ async function removePluginFromFile(target) {
112
+ let source
113
+ try {
114
+ source = await readFile(target, "utf8")
115
+ } catch (e) {
116
+ if (e?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`)
117
+ return false
118
+ }
119
+ const parsed = parseJsonc(source)
120
+ if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error(`plugin must be array in ${target}`)
121
+ const plugins = parsed.plugin || []
122
+ const next = plugins.filter(v => !isPackageSpec(v))
123
+ if (next.length === plugins.length) return false
124
+ const updated = rewriteExistingPluginArray(source, next)
125
+ if (updated === source) return false
126
+ await writeFile(target, updated, "utf8")
127
+ return true
128
+ }
129
+ // The server plugin registers into whichever main config file the user already
130
+ // has (first match wins); falls back to opencode.jsonc if none exist yet.
131
+ async function findExistingServerConfig() {
75
132
  for (const name of configCandidates) {
76
133
  const target = join(config, name)
77
- try {
78
- const source = await readFile(target, "utf8")
79
- const parsed = parseJsonc(source)
80
- if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) throw new Error("plugin must be array")
81
- const plugins = parsed.plugin || []
82
- if (!plugins.some(isPackageSpec)) continue
83
- configured = true
84
- const next = plugins.filter(v=>!isPackageSpec(v))
85
- next.push(packageSpec)
86
- const upd = rewriteExistingPluginArray(source, next)
87
- if (upd !== source) { await writeFile(target, upd, "utf8"); updatedFiles.push(target) }
88
- } catch(e){ if(e?.code!=="ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
134
+ try { await readFile(target, "utf8"); return target } catch (e) { if (e?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
89
135
  }
90
- return {configured, updatedFiles}
136
+ return null
91
137
  }
92
138
  async function ensureDependency(){
93
139
  let pkg={}
@@ -100,52 +146,48 @@ async function removePackagedFiles(srcDir, tgtDir){
100
146
  try{ for(const n of await readdir(srcDir)) if(n.endsWith(".md")) await rm(join(tgtDir,n),{force:true}) }catch{}
101
147
  }
102
148
  async function uninstall(){
103
- const plans=[]
104
- for(const name of configCandidates){
105
- const target=join(config,name)
106
- try{
107
- const source=await readFile(target,"utf8")
108
- const parsed=parseJsonc(source)
109
- if(parsed.plugin!==undefined&&!Array.isArray(parsed.plugin)) throw new Error("plugin must be array")
110
- const plugins=parsed.plugin||[]
111
- const next=plugins.filter(v=>!isPackageSpec(v))
112
- const upd= next.length===plugins.length ? source : rewriteExistingPluginArray(source,next)
113
- plans.push({target,source,updated:upd})
114
- }catch(e){ if(e?.code!=="ENOENT") throw new Error(`Could not inspect ${target}: ${e.message}`) }
149
+ const targets = [...configCandidates.map(name => join(config, name)), join(config, tuiConfigName)]
150
+ let changed = 0
151
+ for (const target of targets) {
152
+ if (await removePluginFromFile(target)) changed++
115
153
  }
116
- for(const p of plans) if(p.updated!==p.source) await writeFile(p.target,p.updated,"utf8")
117
154
  // remove loopd artifacts
118
- await rm(join(config,"tui.json"),{force:true}).catch(()=>{}) // legacy if any
119
155
  await removePackagedFiles(join(root,"commands"), join(config,"commands"))
120
156
  await rm(join(config,"skills","loopd","SKILL.md"),{force:true})
121
157
  try{ const d=join(config,"skills","loopd"); const files=await readdir(d); if(!files.length) await rm(d,{force:true}) }catch{}
122
- const changed=plans.filter(p=>p.updated!==p.source).length
123
158
  console.log(changed ? `Removed ${packageName} from ${changed} config file(s).` : `${packageName} was not registered.`)
124
159
  console.log("Removed loopd command and skill when present. Project state under .opencode/loopd is preserved.")
125
160
  console.log("Restart OpenCode to finish unloading.")
126
161
  }
127
162
  async function installOrUpdate(){
128
- await mkdir(join(config,"plugins"),{recursive:true})
163
+ await mkdir(config,{recursive:true})
129
164
  await mkdir(join(config,"commands"),{recursive:true})
130
165
  await mkdir(join(config,"skills","loopd"),{recursive:true})
131
- const pc=await configurePackagePlugin()
132
- const usePkg=pc.configured
133
- if(!usePkg){ await ensureDependency() }
166
+
167
+ // Server engine plugin: registers into whichever main config file the user
168
+ // already has, creating opencode.jsonc if none exist yet.
169
+ const serverTarget = (await findExistingServerConfig()) || join(config, "opencode.jsonc")
170
+ const serverChanged = await ensurePluginRegistered(serverTarget)
171
+
172
+ // TUI dashboard plugin: separate config file, loaded by the TUI process.
173
+ // Without this, `/loop` and the `<leader>o` binding never register.
174
+ const tuiTarget = join(config, tuiConfigName)
175
+ const tuiChanged = await ensurePluginRegistered(tuiTarget)
176
+
177
+ await ensureDependency()
178
+
134
179
  // commands
135
180
  for(const n of await readdir(join(root,"commands"))){
136
181
  if(n.endsWith(".md")) await copyFile(join(root,"commands",n), join(config,"commands",n))
137
182
  }
138
183
  // skill
139
184
  await copyFile(join(root,"skills","loopd","SKILL.md"), join(config,"skills","loopd","SKILL.md"))
140
- if(usePkg){
141
- const pin=pc.updatedFiles.length ? `pinned to ${packageSpec}` : `already pinned to ${packageSpec}`
142
- console.log(`OpenCode Loopd is already configured as package in ${config}; ${pin}.`)
143
- } else {
144
- console.log(`Installed opencode-loopd to ${config}`)
145
- }
185
+
186
+ console.log(serverChanged ? `Registered ${packageSpec} in ${serverTarget}` : `${packageSpec} already registered in ${serverTarget}`)
187
+ console.log(tuiChanged ? `Registered ${packageSpec} in ${tuiTarget}` : `${packageSpec} already registered in ${tuiTarget}`)
146
188
  console.log(`Installed ${packageName} command to ${join(config,"commands","goal.md")}`)
147
189
  console.log(`Installed ${packageName} skill to ${join(config,"skills","loopd","SKILL.md")}`)
148
- console.log("Restart OpenCode, then run: /goal or /loop")
190
+ console.log("Restart OpenCode, then run: /goal or /loop (<leader>o)")
149
191
  }
150
192
  if(uninstallRequested) await uninstall()
151
193
  else await installOrUpdate()
@@ -83,7 +83,7 @@ loopd_create_goal({
83
83
 
84
84
  Returns `ok:true` with `goalID`, `workerSessionID`, `artifactDir`, `agent`, `checks`, `workspaceWrite`, `defaultsApplied:{agent,checks}`. On contract violation you get `ok:false` with `errorCode: "missing_agent"` or `"missing_checks"` or `"already active"` (writer serialization).
85
85
 
86
- The goal starts immediately. The user can monitor it via `/loop` (<leader>d). Plugin options `defaultAgent` / `defaultChecks` in `opencode.jsonc` can supply defaults so callers don’t have to repeat them.
86
+ The goal starts immediately. The user can monitor it via `/loop` (<leader>o). Plugin options `defaultAgent` / `defaultChecks` in `opencode.jsonc` can supply defaults so callers don’t have to repeat them.
87
87
 
88
88
  ## Worker Tools (Running Inside the Goal)
89
89
 
@@ -174,7 +174,7 @@ Force re-prompts a stuck worker even if `sessionStatus` is not `idle`. Clears st
174
174
 
175
175
  ## Dashboard Commands
176
176
 
177
- Open the dashboard with `/loop` or <leader>d.
177
+ Open the dashboard with `/loop` or <leader>o.
178
178
 
179
179
  ### Keyboard Shortcuts (Normal Mode)
180
180