@bybrawe/opencode-loop 0.5.37 → 0.5.38

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.
@@ -157,6 +157,36 @@ try {
157
157
  completion = "unknown"
158
158
  }
159
159
 
160
+ {
161
+ const sessionID = "concurrent-recovery-log-dedupe"
162
+ track(sessionID)
163
+ runtime.clearSessionStatus(sessionID)
164
+ activeRuns.set(sessionID, { jobId: "job-dedupe", job: { id: "job-dedupe", name: "dedupe" }, startedAt: 1_000 })
165
+ completion = "completed"
166
+ let releaseStatus
167
+ const gate = new Promise((resolve) => { releaseStatus = resolve })
168
+ let calls = 0
169
+ const client = {
170
+ session: {
171
+ status: async () => {
172
+ calls += 1
173
+ await gate
174
+ return { data: { [sessionID]: { type: "busy" } } }
175
+ },
176
+ },
177
+ }
178
+ const before = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
179
+ const pending = Array.from({ length: 20 }, () => runtime.sessionStatusType(client, sessionID, "/repo"))
180
+ await Promise.resolve()
181
+ assert.equal(calls, 20, "all concurrent status reads should be in flight before release")
182
+ releaseStatus()
183
+ assert.deepEqual(await Promise.all(pending), Array(20).fill("idle"))
184
+ const after = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
185
+ assert.equal(after - before, 1, "one active run must emit at most one copy of the same stale-recovery diagnostic")
186
+ activeRuns.delete(sessionID)
187
+ completion = "unknown"
188
+ }
189
+
160
190
  {
161
191
  const sessionID = "retry-remains-owned"
162
192
  track(sessionID)
@@ -56,7 +56,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
56
56
  await adapter.start()
57
57
  const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
58
58
  assert.deepEqual(result, { accepted: true })
59
- assert.deepEqual(commands, [{ sessionID: "ses_adapter", command: "review", arguments: "--quick" }])
59
+ assert.deepEqual(commands, [{
60
+ sessionID: "ses_adapter",
61
+ name: "review",
62
+ command: "review",
63
+ arguments: "--quick",
64
+ text: "--quick",
65
+ }])
60
66
  assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
61
67
  await adapter.dispose()
62
68
  }
@@ -103,7 +109,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
103
109
  })
104
110
  events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
105
111
  await waitFor(() => commands.length === 1, "V2 command dispatch")
106
- assert.deepEqual(commands[0], { sessionID, command: "review", arguments: "--quick" })
112
+ assert.deepEqual(commands[0], {
113
+ sessionID,
114
+ name: "review",
115
+ command: "review",
116
+ arguments: "--quick",
117
+ text: "--quick",
118
+ })
107
119
  } finally {
108
120
  await cleanup?.()
109
121
  await rm(directory, { recursive: true, force: true })
@@ -3,7 +3,7 @@ import plugin, {
3
3
  OPENCODE_LOOP_V2_COMMANDS,
4
4
  OPENCODE_LOOP_V2_PLUGIN_ID,
5
5
  } from "../src/source/opencode2/experimental.js"
6
- import { parseOpenCode2LoopCommandText } from "../src/source/opencode2/commands.js"
6
+ import { parseOpenCode2LoopCommandText, registerOpenCode2LoopCommands } from "../src/source/opencode2/commands.js"
7
7
  import {
8
8
  OPENCODE_LOOP_V2_COMMAND_SOURCE,
9
9
  OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
@@ -77,13 +77,43 @@ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
77
77
  assert.equal(command?.description, definition.description)
78
78
  }
79
79
 
80
- let missingDraftUpdateFailed = false
80
+ let missingDraftMutationFailed = false
81
81
  try {
82
82
  await registered({})
83
83
  } catch (error) {
84
- missingDraftUpdateFailed = /command draft\.update capability is unavailable/.test(String(error))
84
+ missingDraftMutationFailed = /neither add\(\) nor update\(\)/.test(String(error))
85
85
  }
86
- assert.equal(missingDraftUpdateFailed, true)
86
+ assert.equal(missingDraftMutationFailed, true)
87
+
88
+ const addedCommands = new Map()
89
+ const executedCommands = []
90
+ registerOpenCode2LoopCommands({
91
+ add(definition) {
92
+ addedCommands.set(definition.name, definition)
93
+ },
94
+ }, {
95
+ execute: async (event) => {
96
+ executedCommands.push(structuredClone(event))
97
+ },
98
+ })
99
+ assert.deepEqual([...addedCommands.keys()], Object.keys(OPENCODE_LOOP_V2_COMMANDS))
100
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
101
+ const command = addedCommands.get(name)
102
+ assert.equal(command?.name, name)
103
+ assert.equal(command?.description, definition.description)
104
+ assert.equal(typeof command?.execute, "function")
105
+ }
106
+ await addedCommands.get("loop").execute({
107
+ sessionID: "ses_2_0_11",
108
+ prompt: { text: "devam et" },
109
+ delivery: "steer",
110
+ })
111
+ assert.deepEqual(executedCommands, [{
112
+ name: "loop",
113
+ sessionID: "ses_2_0_11",
114
+ arguments: "devam et",
115
+ delivery: "steer",
116
+ }])
87
117
 
88
118
  assert.deepEqual(inspectOpenCode2Context(currentContext), {
89
119
  commandTransform: true,
@@ -98,6 +128,7 @@ assert.deepEqual(inspectOpenCode2Context(currentContext), {
98
128
  assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
99
129
  list: true,
100
130
  get: true,
131
+ add: false,
101
132
  update: true,
102
133
  remove: true,
103
134
  })
@@ -164,6 +164,8 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
164
164
  sessionID: "ses_v2",
165
165
  noReply: true,
166
166
  parts: [{ type: "text", text: "OpenCode loop status:\nNo active loop jobs." }],
167
+ text: "OpenCode loop status:\nNo active loop jobs.",
168
+ resume: false,
167
169
  agent: "build",
168
170
  model: { providerID: "canary", modelID: "canary" },
169
171
  })
@@ -301,6 +303,79 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
301
303
  }
302
304
  }
303
305
 
306
+ {
307
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-2-0-11-command-add-"))
308
+ const sessionID = "ses_v2_2_0_11_add"
309
+ const events = controllableStream()
310
+ const prompts = []
311
+ const commands = new Map()
312
+ const ctx = {
313
+ app: { name: "opencode", version: "2.0.11", channel: "latest" },
314
+ location: { directory },
315
+ command: {
316
+ transform: async (callback) => {
317
+ await callback({
318
+ add(definition) {
319
+ commands.set(definition.name, definition)
320
+ },
321
+ })
322
+ return { dispose: async () => {} }
323
+ },
324
+ },
325
+ event: {
326
+ // OpenCode 2.0.11 exposes subscribe(options), so Function.length === 1
327
+ // even though it returns an AsyncIterable rather than accepting a callback.
328
+ subscribe(options) {
329
+ assert.equal(options, undefined)
330
+ return events.stream
331
+ },
332
+ },
333
+ session: {
334
+ prompt: async (input) => {
335
+ prompts.push(structuredClone(input))
336
+ return { accepted: true }
337
+ },
338
+ },
339
+ }
340
+
341
+ const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
342
+ try {
343
+ assert.ok(commands.has("loop"), "OpenCode 2.0.11 add() command editor must receive /loop")
344
+ await commands.get("loop").execute({
345
+ sessionID,
346
+ prompt: { text: "0s --max-runs 2 continue exact 2.0.11 command path" },
347
+ delivery: "steer",
348
+ })
349
+
350
+ const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
351
+ await waitFor(async () => {
352
+ try { return JSON.parse(await readFile(stateFile, "utf8")).jobs?.length === 1 } catch { return false }
353
+ }, "OpenCode 2.0.11 direct command state creation")
354
+
355
+ await waitFor(() => prompts.length === 1, "OpenCode 2.0.11 direct command first local-kick dispatch")
356
+ assert.equal(prompts[0].sessionID, sessionID)
357
+ assert.match(prompts[0].text || "", /continue exact 2\.0\.11 command path/)
358
+
359
+ events.push({
360
+ type: "session.status",
361
+ location: { directory },
362
+ data: { sessionID, status: { type: "busy" } },
363
+ })
364
+ events.push({
365
+ type: "session.status",
366
+ location: { directory },
367
+ data: { sessionID, status: { type: "idle" } },
368
+ })
369
+ await waitFor(() => prompts.length === 2, "OpenCode 2.0.11 second dispatch from native idle status")
370
+ const state = JSON.parse(await readFile(stateFile, "utf8"))
371
+ assert.equal(state.jobs[0].runCount, 2)
372
+ assert.equal(state.jobs[0].enabled, false)
373
+ } finally {
374
+ await cleanup?.()
375
+ await rm(directory, { recursive: true, force: true })
376
+ }
377
+ }
378
+
304
379
  {
305
380
  let commandDisposals = 0
306
381
  const result = await OpenCodeLoopV2ExperimentalPlugin.setup({
package/src/index.js CHANGED
@@ -1086,6 +1086,19 @@ var NEXT_WORK_PATTERNS = [
1086
1086
  /\bremaining (?:work|task|todo|step)/i,
1087
1087
  /devam (?:edece\u011Fim|ediyorum|etmek gerek)(?=\s|[.,;:!\u2014-]|$)/i
1088
1088
  ];
1089
+ var WAITING_USER_PATTERNS = [
1090
+ /(?:^|\n)\s*onay bekleyenler\s*:/i,
1091
+ /(?:^|\n)\s*(?:kullan\u0131c\u0131|user) (?:onay\u0131|onayi|aksiyonu|i\u015Flemi|islemi|eri\u015Fimi|erisimi) (?:bekleniyor|gerekiyor|gerekli)\b/i,
1092
+ /\b(?:senin|sizin) (?:onay\u0131n|onay\u0131n\u0131z|onayiniz|aksiyonun|aksiyonunuz|eri\u015Fimin|erisimin) (?:bekleniyor|gerekiyor|gerekli)\b/i,
1093
+ /\b(?:onay|eri\u015Fim|erisim|yetki) olmadan (?:devam edemem|ilerleyemem|i\u015Flem yapamam|islem yapamam)\b/i,
1094
+ /(?:^|\n)\s*(?:waiting (?:for|on) (?:your|user) (?:approval|input|action|access|credentials?)|(?:approval|user action|access) (?:required|pending))\s*:?/i,
1095
+ /\b(?:requires?|needs?) (?:your|user) (?:approval|input|action|access|credentials?)\b/i,
1096
+ /\bblocked (?:pending|until|on) (?:your|user|approval|access|credentials?)\b/i
1097
+ ];
1098
+ var WAITING_USER_NEGATIONS = [
1099
+ /\b(?:onay|eri\u015Fim|erisim|yetki) (?:gerekmiyor|gerekli de\u011Fil|gerekli degil|beklenmiyor)\b/i,
1100
+ /\bno (?:user )?(?:approval|input|action|access|credentials?) (?:is )?(?:needed|required|pending)\b/i
1101
+ ];
1089
1102
  function isContinuationShorthand(value) {
1090
1103
  return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "));
1091
1104
  }
@@ -1093,6 +1106,16 @@ function isCompletionBoundedContinuation(value) {
1093
1106
  const text = String(value || "").trim();
1094
1107
  return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text));
1095
1108
  }
1109
+ function isWaitingUserReply(value) {
1110
+ const text = String(value || "").trim();
1111
+ if (!text)
1112
+ return false;
1113
+ if (WAITING_USER_NEGATIONS.some((pattern) => pattern.test(text)))
1114
+ return false;
1115
+ if (NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
1116
+ return false;
1117
+ return WAITING_USER_PATTERNS.some((pattern) => pattern.test(text));
1118
+ }
1096
1119
  function isTerminalNoWorkReply(value) {
1097
1120
  const text = String(value || "").trim();
1098
1121
  if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text)))
@@ -3048,7 +3071,11 @@ function createSessionStatusRuntime(options = {}) {
3048
3071
  ...completion === "completed" ? {} : { staleStatus: live.type }
3049
3072
  };
3050
3073
  const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
3051
- await appendLoopLog2(directory, recoveryEvent, logDetails);
3074
+ active.statusRecoveryLogEvents ??= new Set;
3075
+ if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
3076
+ active.statusRecoveryLogEvents.add(recoveryEvent);
3077
+ await appendLoopLog2(directory, recoveryEvent, logDetails);
3078
+ }
3052
3079
  return "idle";
3053
3080
  }
3054
3081
  }
@@ -3280,8 +3307,10 @@ function messageText(message) {
3280
3307
  return "";
3281
3308
  }
3282
3309
  async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
3283
- if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
3284
- return { job, terminal: false, pausedNow: false };
3310
+ const completionBounded = isCompletionBoundedContinuation(job?.action);
3311
+ const continuation = completionBounded || isContinuationShorthand(job?.action);
3312
+ if (job?.scheduleMode !== "idle" || !continuation) {
3313
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false };
3285
3314
  }
3286
3315
  const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8);
3287
3316
  if (!messages)
@@ -3299,11 +3328,31 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3299
3328
  return { job, terminal: false, pausedNow: false };
3300
3329
  }
3301
3330
  const text = messageText(tail);
3331
+ const waitingUser = isWaitingUserReply(text);
3332
+ if (waitingUser) {
3333
+ if (job.terminalNoWorkCount)
3334
+ job.terminalNoWorkCount = 0;
3335
+ job.waitingUserCount = (job.waitingUserCount || 0) + 1;
3336
+ job.lastWaitingUserAt = Date.now();
3337
+ job.lastWaitingUserSummary = text.slice(0, 1000);
3338
+ const threshold = Math.max(2, Number(options.waitingUserThreshold) || 2);
3339
+ const waitingUserPausedNow = job.waitingUserCount >= threshold && !job.paused;
3340
+ if (waitingUserPausedNow) {
3341
+ job.paused = true;
3342
+ job.lastFailureReason = "waiting_user";
3343
+ }
3344
+ return { job, terminal: false, pausedNow: false, waitingUser: true, waitingUserPausedNow, text };
3345
+ }
3346
+ if (job.waitingUserCount)
3347
+ job.waitingUserCount = 0;
3348
+ if (!completionBounded) {
3349
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
3350
+ }
3302
3351
  const terminal = isTerminalNoWorkReply(text);
3303
3352
  if (!terminal) {
3304
3353
  if (job.terminalNoWorkCount)
3305
3354
  job.terminalNoWorkCount = 0;
3306
- return { job, terminal: false, pausedNow: false, text };
3355
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text };
3307
3356
  }
3308
3357
  job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1;
3309
3358
  job.lastTerminalNoWorkAt = Date.now();
@@ -3314,7 +3363,7 @@ async function applyTerminalContinuationGuard(directory, client, sessionID, job,
3314
3363
  job.paused = true;
3315
3364
  job.lastFailureReason = "terminal_no_work";
3316
3365
  }
3317
- return { job, terminal: true, pausedNow, text };
3366
+ return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text };
3318
3367
  }
3319
3368
 
3320
3369
  // src/source/runtime/run-finalization.js
@@ -3408,6 +3457,16 @@ exit=` + postrun.code + `
3408
3457
  }
3409
3458
  const terminal = await applyTerminalContinuationGuard2(directory, client, sessionID, job);
3410
3459
  job = terminal.job;
3460
+ if (terminal.waitingUserPausedNow) {
3461
+ await appendLoopLog2(directory, "waiting-user", {
3462
+ sessionID,
3463
+ job: job.name || job.id,
3464
+ count: job.waitingUserCount,
3465
+ summary: String(terminal.text || "").slice(0, 1000)
3466
+ });
3467
+ await notifyJob2(directory, job, "waiting_user");
3468
+ await toast2(client, "Loop paused: autonomous continuation is waiting for explicit user approval, access, or action.", "warning");
3469
+ }
3411
3470
  if (terminal.pausedNow) {
3412
3471
  await appendLoopLog2(directory, "terminal-no-work", {
3413
3472
  sessionID,
@@ -3615,6 +3674,25 @@ function requireFunction10(value, label) {
3615
3674
  throw new TypeError(`createLoopExecutor requires ${label}`);
3616
3675
  return value;
3617
3676
  }
3677
+ function sessionErrorDetails(event, fallbackMessage) {
3678
+ if (event?.type !== "session.error")
3679
+ return;
3680
+ const sessionID = event?.properties?.sessionID;
3681
+ if (typeof sessionID !== "string" || !sessionID)
3682
+ return;
3683
+ const error = event?.properties?.error;
3684
+ const name = error && typeof error === "object" && typeof error.name === "string" ? error.name : "SessionError";
3685
+ const data = error && typeof error === "object" && error.data && typeof error.data === "object" ? error.data : undefined;
3686
+ const message = typeof data?.message === "string" && data.message.trim() ? data.message.trim() : fallbackMessage(error);
3687
+ return {
3688
+ sessionID,
3689
+ error,
3690
+ name,
3691
+ message,
3692
+ aborted: name === "MessageAbortedError",
3693
+ retryable: name === "APIError" && data?.isRetryable === true
3694
+ };
3695
+ }
3618
3696
  function createLoopExecutor(options = {}) {
3619
3697
  const workspace = options.workspace || {};
3620
3698
  const goalPolicy = options.goalPolicy || {};
@@ -3793,6 +3871,56 @@ function createLoopExecutor(options = {}) {
3793
3871
  await scheduleDueWork(directory, client, sessionID, retryDelay);
3794
3872
  return true;
3795
3873
  }
3874
+ async function handleSessionError(directory, client, event) {
3875
+ const details = sessionErrorDetails(event, errorMessage);
3876
+ if (!details || details.aborted)
3877
+ return false;
3878
+ const active = activeRuns.get(details.sessionID);
3879
+ if (!active || active.compactionOnly)
3880
+ return false;
3881
+ const snapshot = active;
3882
+ clearActiveRun(details.sessionID);
3883
+ clearSessionStatus(details.sessionID);
3884
+ if (details.retryable) {
3885
+ const refunded = await persistInfrastructureRefund(directory, details.sessionID, snapshot, {
3886
+ reason: "session_error_retryable",
3887
+ error: details.message
3888
+ });
3889
+ await appendLoopLog2(directory, "provider-session-error-retry", {
3890
+ sessionID: details.sessionID,
3891
+ job: refunded.job?.name || snapshot.jobId,
3892
+ errorName: details.name,
3893
+ error: details.message,
3894
+ retryInMs: refunded.delayMs
3895
+ });
3896
+ await toast2(client, `Loop provider turn failed after OpenCode retries; the logical run was refunded and will retry with backoff: ${details.message}`, "warning");
3897
+ await scheduleDueWork(directory, client, details.sessionID, refunded.delayMs);
3898
+ return true;
3899
+ }
3900
+ const state = await readState2(directory, details.sessionID);
3901
+ const job = (state.jobs || []).find((candidate) => candidate.id === snapshot.jobId);
3902
+ if (job) {
3903
+ job.failureCount = (job.failureCount || 0) + 1;
3904
+ job.paused = true;
3905
+ job.lastFailureReason = "session_error";
3906
+ job.lastSessionErrorName = details.name;
3907
+ job.lastSessionError = details.message.slice(0, 4000);
3908
+ job.lastSessionErrorAt = now2();
3909
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3910
+ await writeState2(directory, details.sessionID, state);
3911
+ await notifyJob2(directory, job, "session_error");
3912
+ }
3913
+ await appendLoopLog2(directory, "session-error", {
3914
+ sessionID: details.sessionID,
3915
+ job: job?.name || snapshot.jobId,
3916
+ errorName: details.name,
3917
+ error: details.message,
3918
+ paused: Boolean(job?.paused)
3919
+ });
3920
+ await toast2(client, `Loop paused after a terminal OpenCode session error: ${details.message}`, "error");
3921
+ await scheduleDueWork(directory, client, details.sessionID);
3922
+ return true;
3923
+ }
3796
3924
  async function recoverStuckProviderRetry(directory, client, sessionID) {
3797
3925
  const active = activeRuns.get(sessionID);
3798
3926
  if (!active || active.compactionOnly) {
@@ -4067,6 +4195,7 @@ function createLoopExecutor(options = {}) {
4067
4195
  clearActiveRun,
4068
4196
  disposeSession,
4069
4197
  recoverActiveDispatchFailure,
4198
+ handleSessionError,
4070
4199
  recoverStuckProviderRetry,
4071
4200
  finalizeActiveRun,
4072
4201
  fireAction,
@@ -4288,6 +4417,7 @@ var executorRuntime = createLoopExecutor({
4288
4417
  });
4289
4418
  var {
4290
4419
  clearActiveRun,
4420
+ handleSessionError,
4291
4421
  finalizeActiveRun,
4292
4422
  maybeRunDueJobs: runDueJobs,
4293
4423
  sessionIsIdle,
@@ -4517,6 +4647,11 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
4517
4647
  const steering = await goalSteeringRuntime.handleEvent(directory, client, event);
4518
4648
  if (steering?.handled && steering.sessionID)
4519
4649
  rememberSession(directory, client, steering.sessionID);
4650
+ if (event.type === "session.error") {
4651
+ const handledError = await handleSessionError(directory, client, event);
4652
+ if (handledError && event?.properties?.sessionID)
4653
+ rememberSession(directory, client, event.properties.sessionID);
4654
+ }
4520
4655
  const statusUpdate = updateSessionStatusFromEvent(event);
4521
4656
  if (statusUpdate?.sessionID)
4522
4657
  rememberSession(directory, client, statusUpdate.sessionID);