@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.
- package/dist/src/plugin/create-plugin.d.ts +2 -0
- package/dist/src/plugin/create-plugin.js +6 -0
- package/dist/src/plugin/tui-command-execute-handler.d.ts +8 -0
- package/dist/src/plugin/tui-command-execute-handler.js +15 -0
- package/dist/src/ralph-loop/loop-core.d.ts +2 -0
- package/dist/src/ralph-loop/loop-core.js +147 -66
- package/dist/src/ralph-loop/state-store.js +1 -0
- package/dist/src/ralph-loop/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
await
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
currentState
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
100
|
-
if (
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
const nextState = {
|
|
125
|
-
...persistedState,
|
|
190
|
+
await deps.adapter.prompt(event.sessionID, buildContinuationPrompt({
|
|
126
191
|
iteration: nextIteration,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
}
|