@w32191/just-loop 0.1.6 → 0.1.8

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
@@ -6,23 +6,18 @@ OpenCode plugin package for just-loop.
6
6
 
7
7
  - Node 20+
8
8
 
9
- ## Install
10
-
11
- ```bash
12
- npm install @w32191/just-loop
13
- ```
14
-
15
9
  ## Usage
16
10
 
17
- Put this in your OpenCode plugin entry or config file:
18
-
19
- ```ts
20
- import justLoop from "@w32191/just-loop"
11
+ `opencode.json` `plugin` 欄位加入 plugin 名稱即可載入:
21
12
 
22
- export default justLoop
13
+ ```json
14
+ {
15
+ "$schema": "https://opencode.ai/config.json",
16
+ "plugin": ["@w32191/just-loop"]
17
+ }
23
18
  ```
24
19
 
25
- This package is intended for OpenCode plugin usage.
20
+ 依官方文件,`opencode.json` 放在專案根目錄(專案設定)或 `~/.config/opencode/opencode.json`(全域設定)皆可,OpenCode 會在啟動時自動安裝與載入 npm 套件型 plugin
26
21
 
27
22
  ## Publishing notes
28
23
 
@@ -76,5 +76,8 @@ export function createOpenCodeHostAdapter(ctx) {
76
76
  throw error;
77
77
  }
78
78
  },
79
+ async showToast(toast) {
80
+ await ctx.client.tui?.showToast?.({ body: toast });
81
+ },
79
82
  };
80
83
  }
@@ -8,6 +8,12 @@ export interface HostAdapter {
8
8
  prompt(sessionID: string, text: string): Promise<void>;
9
9
  abortSession(sessionID: string): Promise<void>;
10
10
  sessionExists(sessionID: string): Promise<boolean>;
11
+ showToast?(toast: {
12
+ title: string;
13
+ message: string;
14
+ variant?: "info" | "warning" | "error" | "success";
15
+ duration?: number;
16
+ }): Promise<void>;
11
17
  }
12
18
  export type OpenCodeHostAdapterContext = {
13
19
  directory: string;
@@ -38,5 +44,15 @@ export type OpenCodeHostAdapterContext = {
38
44
  directory?: string;
39
45
  }) => Promise<unknown>;
40
46
  };
47
+ tui?: {
48
+ showToast?: (input: {
49
+ body: {
50
+ title: string;
51
+ message: string;
52
+ variant?: "info" | "warning" | "error" | "success";
53
+ duration?: number;
54
+ };
55
+ }) => Promise<unknown>;
56
+ };
41
57
  };
42
58
  };
@@ -38,12 +38,20 @@ export async function createPlugin(ctx, deps = {}) {
38
38
  prompt: async ({ sessionID, directory, parts }) => ctx.client.session.prompt({ path: { id: sessionID }, body: { parts }, query: { directory } }),
39
39
  abort: async ({ sessionID }) => ctx.client.session.abort({ path: { id: sessionID } }),
40
40
  },
41
+ tui: ctx.client.tui?.showToast
42
+ ? { showToast: async ({ body }) => ctx.client.tui?.showToast?.({ body: body }) }
43
+ : undefined,
41
44
  },
42
45
  });
43
46
  const core = createCore({
44
47
  rootDir: ctx.directory,
45
48
  adapter,
46
49
  getConfig: () => resolvedConfig,
50
+ wait: async (ms) => {
51
+ await new Promise((resolve) => {
52
+ setTimeout(resolve, ms);
53
+ });
54
+ },
47
55
  });
48
56
  return {
49
57
  config: async (input) => {
@@ -1,10 +1,7 @@
1
1
  function hasInterruptCommand(input) {
2
2
  if (!input || typeof input !== "object")
3
3
  return false;
4
- const properties = input.properties;
5
- if (!properties || typeof properties !== "object")
6
- return false;
7
- return properties.command === "session.interrupt";
4
+ return input.command === "session.interrupt";
8
5
  }
9
6
  export async function handleTuiCommandExecute(input, core) {
10
7
  if (!hasInterruptCommand(input))
@@ -2,3 +2,5 @@ export declare const DEFAULT_STATE_PATH = ".loop/ralph-loop.local.md";
2
2
  export declare const DEFAULT_COMPLETION_PROMISE = "<promise>DONE</promise>";
3
3
  export declare const DEFAULT_MAX_ITERATIONS_FALLBACK = 100;
4
4
  export declare const DEFAULT_STRATEGY = "continue";
5
+ export declare const CONTINUATION_COUNTDOWN_SECONDS = 10;
6
+ export declare const CONTINUATION_COUNTDOWN_STEP_MS = 1000;
@@ -2,3 +2,5 @@ export const DEFAULT_STATE_PATH = ".loop/ralph-loop.local.md";
2
2
  export const DEFAULT_COMPLETION_PROMISE = "<promise>DONE</promise>";
3
3
  export const DEFAULT_MAX_ITERATIONS_FALLBACK = 100;
4
4
  export const DEFAULT_STRATEGY = "continue";
5
+ export const CONTINUATION_COUNTDOWN_SECONDS = 10;
6
+ export const CONTINUATION_COUNTDOWN_STEP_MS = 1000;
@@ -16,6 +16,7 @@ export type CreateLoopCoreDeps = {
16
16
  rootDir: string;
17
17
  adapter: HostAdapter;
18
18
  getConfig?: () => RalphLoopRuntimeConfig;
19
+ wait?: (ms: number) => Promise<void>;
19
20
  };
20
21
  export type StartLoopOptions = {
21
22
  maxIterations?: number;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS_FALLBACK, DEFAULT_STRATEGY } from "./constants.js";
1
+ import { CONTINUATION_COUNTDOWN_SECONDS, CONTINUATION_COUNTDOWN_STEP_MS, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS_FALLBACK, DEFAULT_STRATEGY, } from "./constants.js";
2
2
  import { buildContinuationPrompt } from "./continuation-prompt.js";
3
3
  import { detectCompletion } from "./completion-detector.js";
4
4
  import { clearState, readState, writeState } from "./state-store.js";
@@ -12,6 +12,16 @@ export function createLoopCore(deps) {
12
12
  defaultStrategy: DEFAULT_STRATEGY,
13
13
  };
14
14
  const getToken = (state) => state.incarnation_token ?? state.started_at;
15
+ const wait = deps.wait;
16
+ function showToast(title, message, variant = "info") {
17
+ void deps.adapter.showToast?.({ title, message, variant, duration: 1000 }).catch(() => { });
18
+ }
19
+ async function readCurrentState(sessionID, token) {
20
+ const state = await readState(deps.rootDir);
21
+ if (!state || !state.active || state.session_id !== sessionID || getToken(state) !== token)
22
+ return null;
23
+ return state;
24
+ }
15
25
  const runStateMutation = async (mutation) => {
16
26
  const run = stateMutationQueue.then(mutation, mutation);
17
27
  stateMutationQueue = run.then(() => undefined, () => undefined);
@@ -63,6 +73,8 @@ export function createLoopCore(deps) {
63
73
  const state = await readState(deps.rootDir);
64
74
  if (!state || !state.active)
65
75
  return;
76
+ if (state.pending_continuation?.cancelled)
77
+ return;
66
78
  const observedSessionID = state.session_id;
67
79
  const observedToken = getToken(state);
68
80
  // active-loop-scoped only: the host interrupt hook does not provide sessionID,
@@ -74,6 +86,23 @@ export function createLoopCore(deps) {
74
86
  getToken(currentState) !== observedToken) {
75
87
  return;
76
88
  }
89
+ if (currentState.pending_continuation) {
90
+ if (currentState.pending_continuation.dispatch_token) {
91
+ await writeState(deps.rootDir, {
92
+ ...currentState,
93
+ skip_next_continuation: true,
94
+ });
95
+ return;
96
+ }
97
+ if (currentState.pending_continuation.cancelled)
98
+ return;
99
+ await writeState(deps.rootDir, {
100
+ ...currentState,
101
+ pending_continuation: { ...currentState.pending_continuation, cancelled: true },
102
+ });
103
+ showToast("Ralph Loop", "Cancelled the pending Ralph Loop continuation.", "info");
104
+ return;
105
+ }
77
106
  await writeState(deps.rootDir, {
78
107
  ...currentState,
79
108
  skip_next_continuation: true,
@@ -124,20 +153,12 @@ export function createLoopCore(deps) {
124
153
  .some((message) => message.role === "assistant");
125
154
  if (!hasNewAssistantMessages)
126
155
  return;
127
- const liveState = await readState(deps.rootDir);
128
- if (!liveState ||
129
- !liveState.active ||
130
- liveState.session_id !== event.sessionID ||
131
- getToken(liveState) !== incarnationToken) {
156
+ const liveState = await readCurrentState(event.sessionID, incarnationToken);
157
+ if (!liveState)
132
158
  return;
133
- }
134
- const continuationState = await readState(deps.rootDir);
135
- if (!continuationState ||
136
- !continuationState.active ||
137
- continuationState.session_id !== event.sessionID ||
138
- getToken(continuationState) !== incarnationToken) {
159
+ const continuationState = await readCurrentState(event.sessionID, incarnationToken);
160
+ if (!continuationState)
139
161
  return;
140
- }
141
162
  if (detectCompletion(messages, continuationState.completion_promise, continuationState.message_count_at_start)) {
142
163
  await runStateMutation(async () => {
143
164
  const currentState = await readState(deps.rootDir);
@@ -187,12 +208,139 @@ export function createLoopCore(deps) {
187
208
  });
188
209
  return;
189
210
  }
190
- await deps.adapter.prompt(event.sessionID, buildContinuationPrompt({
191
- iteration: nextIteration,
192
- prompt: continuationState.prompt,
193
- completionPromise: continuationState.completion_promise,
194
- maxIterations: continuationState.max_iterations,
195
- }));
211
+ if (wait) {
212
+ await runStateMutation(async () => {
213
+ const currentState = await readCurrentState(event.sessionID, incarnationToken);
214
+ if (!currentState || currentState.pending_continuation)
215
+ return;
216
+ await writeState(deps.rootDir, {
217
+ ...currentState,
218
+ pending_continuation: {
219
+ started_at: new Date().toISOString(),
220
+ countdown_seconds_remaining: CONTINUATION_COUNTDOWN_SECONDS,
221
+ },
222
+ });
223
+ });
224
+ showToast("Ralph Loop", `Injecting next continuation in ${CONTINUATION_COUNTDOWN_SECONDS}s. Use interrupt to cancel once.`, "warning");
225
+ for (let remaining = CONTINUATION_COUNTDOWN_SECONDS; remaining > 0; remaining -= 1) {
226
+ await wait(CONTINUATION_COUNTDOWN_STEP_MS);
227
+ const currentState = await readCurrentState(event.sessionID, incarnationToken);
228
+ if (!currentState?.pending_continuation)
229
+ return;
230
+ if (currentState.pending_continuation.cancelled) {
231
+ await runStateMutation(async () => {
232
+ const latest = await readCurrentState(event.sessionID, incarnationToken);
233
+ if (!latest?.pending_continuation?.cancelled)
234
+ return;
235
+ const nextState = {
236
+ ...latest,
237
+ last_message_count_processed: batchMessageCount,
238
+ };
239
+ delete nextState.pending_continuation;
240
+ await writeState(deps.rootDir, nextState);
241
+ });
242
+ return;
243
+ }
244
+ await runStateMutation(async () => {
245
+ const latest = await readCurrentState(event.sessionID, incarnationToken);
246
+ if (!latest?.pending_continuation || latest.pending_continuation.cancelled)
247
+ return;
248
+ await writeState(deps.rootDir, {
249
+ ...latest,
250
+ pending_continuation: {
251
+ ...latest.pending_continuation,
252
+ countdown_seconds_remaining: remaining - 1,
253
+ },
254
+ });
255
+ });
256
+ if (remaining > 1) {
257
+ showToast("Ralph Loop", `Injecting next continuation in ${remaining - 1}s. Use interrupt to cancel once.`, "warning");
258
+ }
259
+ }
260
+ const dispatchToken = randomUUID();
261
+ const continuationPrompt = buildContinuationPrompt({
262
+ iteration: nextIteration,
263
+ prompt: continuationState.prompt,
264
+ completionPromise: continuationState.completion_promise,
265
+ maxIterations: continuationState.max_iterations,
266
+ });
267
+ const dispatchResult = await runStateMutation(async () => {
268
+ const currentState = await readCurrentState(event.sessionID, incarnationToken);
269
+ if (!currentState?.pending_continuation)
270
+ return { kind: "aborted" };
271
+ if (currentState.pending_continuation.cancelled) {
272
+ const nextState = {
273
+ ...currentState,
274
+ last_message_count_processed: batchMessageCount,
275
+ };
276
+ delete nextState.pending_continuation;
277
+ await writeState(deps.rootDir, nextState);
278
+ return { kind: "cancelled" };
279
+ }
280
+ await writeState(deps.rootDir, {
281
+ ...currentState,
282
+ pending_continuation: {
283
+ ...currentState.pending_continuation,
284
+ countdown_seconds_remaining: 0,
285
+ dispatch_token: dispatchToken,
286
+ },
287
+ });
288
+ });
289
+ if (dispatchResult?.kind === "aborted" || dispatchResult?.kind === "cancelled") {
290
+ return;
291
+ }
292
+ try {
293
+ await deps.adapter.prompt(event.sessionID, continuationPrompt);
294
+ }
295
+ catch (error) {
296
+ await runStateMutation(async () => {
297
+ const latest = await readCurrentState(event.sessionID, incarnationToken);
298
+ if (latest?.pending_continuation?.dispatch_token !== dispatchToken)
299
+ return;
300
+ const nextState = { ...latest };
301
+ delete nextState.pending_continuation;
302
+ await writeState(deps.rootDir, nextState);
303
+ });
304
+ throw error;
305
+ }
306
+ await runStateMutation(async () => {
307
+ const latest = await readCurrentState(event.sessionID, incarnationToken);
308
+ if (!latest?.pending_continuation)
309
+ return;
310
+ if (latest.pending_continuation.dispatch_token !== dispatchToken)
311
+ return;
312
+ const nextState = {
313
+ ...latest,
314
+ iteration: nextIteration,
315
+ last_message_count_processed: batchMessageCount,
316
+ };
317
+ delete nextState.pending_continuation;
318
+ await writeState(deps.rootDir, nextState);
319
+ });
320
+ }
321
+ if (wait) {
322
+ showToast("Ralph Loop", "Injected Ralph Loop continuation.", "success");
323
+ return;
324
+ }
325
+ try {
326
+ await deps.adapter.prompt(event.sessionID, buildContinuationPrompt({
327
+ iteration: nextIteration,
328
+ prompt: continuationState.prompt,
329
+ completionPromise: continuationState.completion_promise,
330
+ maxIterations: continuationState.max_iterations,
331
+ }));
332
+ }
333
+ catch (error) {
334
+ await runStateMutation(async () => {
335
+ const currentState = await readCurrentState(event.sessionID, incarnationToken);
336
+ if (!currentState?.pending_continuation)
337
+ return;
338
+ const nextState = { ...currentState };
339
+ delete nextState.pending_continuation;
340
+ await writeState(deps.rootDir, nextState);
341
+ });
342
+ throw error;
343
+ }
196
344
  await runStateMutation(async () => {
197
345
  const currentState = await readState(deps.rootDir);
198
346
  if (!currentState ||
@@ -208,6 +356,7 @@ export function createLoopCore(deps) {
208
356
  };
209
357
  await writeState(deps.rootDir, nextState);
210
358
  });
359
+ showToast("Ralph Loop", "Injected Ralph Loop continuation.", "success");
211
360
  }
212
361
  finally {
213
362
  const currentToken = inFlight.get(event.sessionID);
@@ -18,6 +18,15 @@ function isRalphLoopState(value) {
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
20
  (record.skip_next_continuation === undefined || typeof record.skip_next_continuation === "boolean") &&
21
+ (record.pending_continuation === undefined ||
22
+ (typeof record.pending_continuation === "object" &&
23
+ record.pending_continuation !== null &&
24
+ typeof record.pending_continuation.started_at === "string" &&
25
+ typeof record.pending_continuation.countdown_seconds_remaining === "number" &&
26
+ (record.pending_continuation.cancelled === undefined ||
27
+ typeof record.pending_continuation.cancelled === "boolean") &&
28
+ (record.pending_continuation.dispatch_token === undefined ||
29
+ typeof record.pending_continuation.dispatch_token === "string"))) &&
21
30
  (record.incarnation_token === undefined || typeof record.incarnation_token === "string") &&
22
31
  typeof record.started_at === "string");
23
32
  }
@@ -8,6 +8,12 @@ export type RalphLoopState = {
8
8
  message_count_at_start: number;
9
9
  last_message_count_processed?: number;
10
10
  skip_next_continuation?: boolean;
11
+ pending_continuation?: {
12
+ started_at: string;
13
+ countdown_seconds_remaining: number;
14
+ cancelled?: boolean;
15
+ dispatch_token?: string;
16
+ };
11
17
  incarnation_token?: string;
12
18
  started_at: string;
13
19
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@w32191/just-loop",
3
3
  "type": "module",
4
- "version": "0.1.6",
4
+ "version": "0.1.8",
5
5
  "description": "OpenCode plugin package for just-loop.",
6
6
  "license": "MIT",
7
7
  "repository": {