@w32191/just-loop 0.1.4 → 0.1.6

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.
@@ -1,5 +1,6 @@
1
1
  import { createOpenCodeHostAdapter } from "../host-adapter/opencode-host-adapter.js";
2
2
  import { createLoopCore } from "../ralph-loop/loop-core.js";
3
+ import { type TuiCommandExecuteInput } from "./tui-command-execute-handler.js";
3
4
  import type { Plugin } from "@opencode-ai/plugin";
4
5
  export type CreatePluginDeps = {
5
6
  createOpenCodeHostAdapter?: typeof createOpenCodeHostAdapter;
@@ -48,6 +49,7 @@ export type PluginHooks = {
48
49
  event: (input: EventInput) => Promise<void>;
49
50
  config: (input: Record<string, unknown>) => Promise<void>;
50
51
  "command.execute.before": (input: CommandExecuteBeforeInput, output: CommandExecuteBeforeOutput) => Promise<void>;
52
+ "tui.command.execute": (input: TuiCommandExecuteInput) => Promise<void>;
51
53
  "tool.execute.before": (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>;
52
54
  };
53
55
  export declare function createPlugin(ctx?: PluginInput, deps?: CreatePluginDeps): Promise<PluginHooks>;
@@ -4,6 +4,7 @@ import { handleCommandExecuteBefore } from "./command-execute-before-handler.js"
4
4
  import { handleConfig } from "./config-handler.js";
5
5
  import { handleEvent } from "./event-handler.js";
6
6
  import { resolvePluginConfig } from "./plugin-config.js";
7
+ import { handleTuiCommandExecute } from "./tui-command-execute-handler.js";
7
8
  import { handleToolExecuteBefore } from "./tool-execute-before-handler.js";
8
9
  function extractSessionID(input) {
9
10
  if (typeof input.sessionID === "string")
@@ -68,6 +69,11 @@ export async function createPlugin(ctx, deps = {}) {
68
69
  defaultMaxIterations: resolvedConfig.defaultMaxIterations,
69
70
  });
70
71
  },
72
+ "tui.command.execute": async (input) => {
73
+ if (!resolvedConfig.enabled)
74
+ return;
75
+ await handleTuiCommandExecute(input, core);
76
+ },
71
77
  "tool.execute.before": async (input, output) => {
72
78
  if (!resolvedConfig.enabled)
73
79
  return;
@@ -0,0 +1,8 @@
1
+ type LoopCore = {
2
+ handleEvent: (event: {
3
+ type: "session.interrupt";
4
+ }) => Promise<unknown>;
5
+ };
6
+ export type TuiCommandExecuteInput = unknown;
7
+ export declare function handleTuiCommandExecute(input: TuiCommandExecuteInput, core: LoopCore): Promise<void>;
8
+ export {};
@@ -0,0 +1,15 @@
1
+ function hasInterruptCommand(input) {
2
+ if (!input || typeof input !== "object")
3
+ return false;
4
+ const properties = input.properties;
5
+ if (!properties || typeof properties !== "object")
6
+ return false;
7
+ return properties.command === "session.interrupt";
8
+ }
9
+ export async function handleTuiCommandExecute(input, core) {
10
+ if (!hasInterruptCommand(input))
11
+ return;
12
+ // active-loop-scoped only: even though the core event name is session.interrupt,
13
+ // tui.command.execute does not provide sessionID, so this forwards a loop-level interrupt.
14
+ await core.handleEvent({ type: "session.interrupt" });
15
+ }
@@ -9,6 +9,8 @@ export type LoopEvent = {
9
9
  } | {
10
10
  type: "session.error";
11
11
  sessionID: string;
12
+ } | {
13
+ type: "session.interrupt";
12
14
  };
13
15
  export type CreateLoopCoreDeps = {
14
16
  rootDir: string;
@@ -5,55 +5,88 @@ import { clearState, readState, writeState } from "./state-store.js";
5
5
  import { randomUUID } from "node:crypto";
6
6
  export function createLoopCore(deps) {
7
7
  const inFlight = new Map();
8
+ let stateMutationQueue = Promise.resolve();
8
9
  const getConfig = () => deps.getConfig?.() ?? {
9
10
  enabled: true,
10
11
  defaultMaxIterations: DEFAULT_MAX_ITERATIONS_FALLBACK,
11
12
  defaultStrategy: DEFAULT_STRATEGY,
12
13
  };
13
14
  const getToken = (state) => state.incarnation_token ?? state.started_at;
15
+ const runStateMutation = async (mutation) => {
16
+ const run = stateMutationQueue.then(mutation, mutation);
17
+ stateMutationQueue = run.then(() => undefined, () => undefined);
18
+ return run;
19
+ };
14
20
  return {
15
21
  async startLoop(sessionID, prompt, options = {}) {
16
- const config = getConfig();
17
- const existing = await readState(deps.rootDir);
18
- if (existing?.active) {
19
- const stillExists = await deps.adapter.sessionExists(existing.session_id);
20
- if (stillExists) {
21
- throw new Error("an active Ralph Loop already exists; use /cancel-ralph first");
22
+ return await runStateMutation(async () => {
23
+ const config = getConfig();
24
+ const existing = await readState(deps.rootDir);
25
+ if (existing?.active) {
26
+ const stillExists = await deps.adapter.sessionExists(existing.session_id);
27
+ if (stillExists) {
28
+ throw new Error("an active Ralph Loop already exists; use /cancel-ralph first");
29
+ }
30
+ await clearState(deps.rootDir);
22
31
  }
23
- await clearState(deps.rootDir);
24
- }
25
- else if (existing) {
26
- await clearState(deps.rootDir);
27
- }
28
- const messageCountAtStart = await deps.adapter.getMessageCount(sessionID);
29
- const incarnationToken = randomUUID();
30
- const state = {
31
- active: true,
32
- session_id: sessionID,
33
- prompt,
34
- iteration: 0,
35
- max_iterations: options.maxIterations ?? config.defaultMaxIterations,
36
- completion_promise: options.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
37
- message_count_at_start: messageCountAtStart,
38
- incarnation_token: incarnationToken,
39
- started_at: new Date().toISOString(),
40
- };
41
- await writeState(deps.rootDir, state);
32
+ else if (existing) {
33
+ await clearState(deps.rootDir);
34
+ }
35
+ const messageCountAtStart = await deps.adapter.getMessageCount(sessionID);
36
+ const incarnationToken = randomUUID();
37
+ const state = {
38
+ active: true,
39
+ session_id: sessionID,
40
+ prompt,
41
+ iteration: 0,
42
+ max_iterations: options.maxIterations ?? config.defaultMaxIterations,
43
+ completion_promise: options.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
44
+ message_count_at_start: messageCountAtStart,
45
+ incarnation_token: incarnationToken,
46
+ started_at: new Date().toISOString(),
47
+ };
48
+ await writeState(deps.rootDir, state);
49
+ });
42
50
  },
43
51
  async cancelLoop(sessionID) {
44
- const state = await readState(deps.rootDir);
45
- if (!state || !state.active || state.session_id !== sessionID)
46
- return;
47
- await deps.adapter.abortSession(sessionID);
48
- await clearState(deps.rootDir);
52
+ return await runStateMutation(async () => {
53
+ const state = await readState(deps.rootDir);
54
+ if (!state || !state.active || state.session_id !== sessionID)
55
+ return;
56
+ await deps.adapter.abortSession(sessionID);
57
+ await clearState(deps.rootDir);
58
+ });
49
59
  },
50
60
  async handleEvent(event) {
61
+ if (event.type === "session.interrupt") {
62
+ return await runStateMutation(async () => {
63
+ const state = await readState(deps.rootDir);
64
+ if (!state || !state.active)
65
+ return;
66
+ const observedSessionID = state.session_id;
67
+ const observedToken = getToken(state);
68
+ // active-loop-scoped only: the host interrupt hook does not provide sessionID,
69
+ // so we re-read before write and only stamp the currently active incarnation.
70
+ const currentState = await readState(deps.rootDir);
71
+ if (!currentState ||
72
+ !currentState.active ||
73
+ currentState.session_id !== observedSessionID ||
74
+ getToken(currentState) !== observedToken) {
75
+ return;
76
+ }
77
+ await writeState(deps.rootDir, {
78
+ ...currentState,
79
+ skip_next_continuation: true,
80
+ });
81
+ });
82
+ }
51
83
  if (event.type === "session.deleted" || event.type === "session.error") {
52
- const state = await readState(deps.rootDir);
53
- if (state && state.active && state.session_id === event.sessionID) {
54
- await clearState(deps.rootDir);
55
- }
56
- return;
84
+ return await runStateMutation(async () => {
85
+ const state = await readState(deps.rootDir);
86
+ if (state && state.active && state.session_id === event.sessionID) {
87
+ await clearState(deps.rootDir);
88
+ }
89
+ });
57
90
  }
58
91
  try {
59
92
  const state = await readState(deps.rootDir);
@@ -64,13 +97,15 @@ export function createLoopCore(deps) {
64
97
  const observedToken = getToken(state);
65
98
  const stillExists = await deps.adapter.sessionExists(observedSessionID);
66
99
  if (!stillExists) {
67
- const currentState = await readState(deps.rootDir);
68
- if (currentState &&
69
- currentState.active &&
70
- currentState.session_id === observedSessionID &&
71
- getToken(currentState) === observedToken) {
72
- await clearState(deps.rootDir);
73
- }
100
+ await runStateMutation(async () => {
101
+ const currentState = await readState(deps.rootDir);
102
+ if (currentState &&
103
+ currentState.active &&
104
+ currentState.session_id === observedSessionID &&
105
+ getToken(currentState) === observedToken) {
106
+ await clearState(deps.rootDir);
107
+ }
108
+ });
74
109
  }
75
110
  return;
76
111
  }
@@ -96,37 +131,83 @@ export function createLoopCore(deps) {
96
131
  getToken(liveState) !== incarnationToken) {
97
132
  return;
98
133
  }
99
- const currentLiveState = liveState;
100
- if (detectCompletion(messages, currentLiveState.completion_promise, currentLiveState.message_count_at_start)) {
101
- await clearState(deps.rootDir);
134
+ const continuationState = await readState(deps.rootDir);
135
+ if (!continuationState ||
136
+ !continuationState.active ||
137
+ continuationState.session_id !== event.sessionID ||
138
+ getToken(continuationState) !== incarnationToken) {
102
139
  return;
103
140
  }
104
- const nextIteration = currentLiveState.iteration + 1;
105
- if (typeof currentLiveState.max_iterations === "number" &&
106
- nextIteration > currentLiveState.max_iterations) {
107
- await clearState(deps.rootDir);
141
+ if (detectCompletion(messages, continuationState.completion_promise, continuationState.message_count_at_start)) {
142
+ await runStateMutation(async () => {
143
+ const currentState = await readState(deps.rootDir);
144
+ if (currentState &&
145
+ currentState.active &&
146
+ currentState.session_id === event.sessionID &&
147
+ getToken(currentState) === incarnationToken &&
148
+ detectCompletion(messages, currentState.completion_promise, currentState.message_count_at_start)) {
149
+ await clearState(deps.rootDir);
150
+ }
151
+ });
108
152
  return;
109
153
  }
110
- await deps.adapter.prompt(event.sessionID, buildContinuationPrompt({
111
- iteration: nextIteration,
112
- prompt: currentLiveState.prompt,
113
- completionPromise: currentLiveState.completion_promise,
114
- maxIterations: currentLiveState.max_iterations,
115
- }));
116
- const currentState = await readState(deps.rootDir);
117
- if (!currentState ||
118
- !currentState.active ||
119
- currentState.session_id !== event.sessionID ||
120
- getToken(currentState) !== incarnationToken) {
154
+ const nextIteration = continuationState.iteration + 1;
155
+ if (typeof continuationState.max_iterations === "number" &&
156
+ nextIteration > continuationState.max_iterations) {
157
+ await runStateMutation(async () => {
158
+ const currentState = await readState(deps.rootDir);
159
+ if (currentState &&
160
+ currentState.active &&
161
+ currentState.session_id === event.sessionID &&
162
+ getToken(currentState) === incarnationToken &&
163
+ typeof currentState.max_iterations === "number" &&
164
+ currentState.iteration + 1 > currentState.max_iterations) {
165
+ await clearState(deps.rootDir);
166
+ }
167
+ });
168
+ return;
169
+ }
170
+ if (continuationState.skip_next_continuation) {
171
+ // Consume the one-shot suppress flag for the currently active loop only.
172
+ await runStateMutation(async () => {
173
+ const currentState = await readState(deps.rootDir);
174
+ if (!currentState ||
175
+ !currentState.active ||
176
+ currentState.session_id !== event.sessionID ||
177
+ getToken(currentState) !== incarnationToken ||
178
+ !currentState.skip_next_continuation) {
179
+ return;
180
+ }
181
+ const nextState = {
182
+ ...currentState,
183
+ last_message_count_processed: batchMessageCount,
184
+ };
185
+ delete nextState.skip_next_continuation;
186
+ await writeState(deps.rootDir, nextState);
187
+ });
121
188
  return;
122
189
  }
123
- const persistedState = currentState;
124
- const nextState = {
125
- ...persistedState,
190
+ await deps.adapter.prompt(event.sessionID, buildContinuationPrompt({
126
191
  iteration: nextIteration,
127
- last_message_count_processed: batchMessageCount,
128
- };
129
- await writeState(deps.rootDir, nextState);
192
+ prompt: continuationState.prompt,
193
+ completionPromise: continuationState.completion_promise,
194
+ maxIterations: continuationState.max_iterations,
195
+ }));
196
+ await runStateMutation(async () => {
197
+ const currentState = await readState(deps.rootDir);
198
+ if (!currentState ||
199
+ !currentState.active ||
200
+ currentState.session_id !== event.sessionID ||
201
+ getToken(currentState) !== incarnationToken) {
202
+ return;
203
+ }
204
+ const nextState = {
205
+ ...currentState,
206
+ iteration: nextIteration,
207
+ last_message_count_processed: batchMessageCount,
208
+ };
209
+ await writeState(deps.rootDir, nextState);
210
+ });
130
211
  }
131
212
  finally {
132
213
  const currentToken = inFlight.get(event.sessionID);
@@ -17,6 +17,7 @@ function isRalphLoopState(value) {
17
17
  typeof record.completion_promise === "string" &&
18
18
  typeof record.message_count_at_start === "number" &&
19
19
  (record.last_message_count_processed === undefined || typeof record.last_message_count_processed === "number") &&
20
+ (record.skip_next_continuation === undefined || typeof record.skip_next_continuation === "boolean") &&
20
21
  (record.incarnation_token === undefined || typeof record.incarnation_token === "string") &&
21
22
  typeof record.started_at === "string");
22
23
  }
@@ -7,6 +7,7 @@ export type RalphLoopState = {
7
7
  completion_promise: string;
8
8
  message_count_at_start: number;
9
9
  last_message_count_processed?: number;
10
+ skip_next_continuation?: boolean;
10
11
  incarnation_token?: string;
11
12
  started_at: string;
12
13
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@w32191/just-loop",
3
3
  "type": "module",
4
- "version": "0.1.4",
4
+ "version": "0.1.6",
5
5
  "description": "OpenCode plugin package for just-loop.",
6
6
  "license": "MIT",
7
7
  "repository": {