@dudousxd/nestjs-agent-opencode 0.5.0 → 1.1.0

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/index.js CHANGED
@@ -37,6 +37,7 @@ function keyValueOpenCodeSessionStore(kv, prefix = "aviary:opencode:session:") {
37
37
  __name(keyValueOpenCodeSessionStore, "keyValueOpenCodeSessionStore");
38
38
 
39
39
  // src/mcp.controller.ts
40
+ import { CanAnyone } from "@dudousxd/nestjs-agent-core";
40
41
  import { Body, Controller, Delete, Get, Inject, Post, Req, Res } from "@nestjs/common";
41
42
 
42
43
  // src/tokens.ts
@@ -133,6 +134,7 @@ _ts_decorate([
133
134
  _ts_metadata("design:returntype", void 0)
134
135
  ], OpenCodeMcpController.prototype, "delete", null);
135
136
  OpenCodeMcpController = _ts_decorate([
137
+ CanAnyone(),
136
138
  Controller("opencode/mcp"),
137
139
  _ts_param(0, Inject(OPENCODE_MCP_ENDPOINT)),
138
140
  _ts_metadata("design:type", Function),
@@ -1997,6 +1999,37 @@ var OpenCodeEventHub = class _OpenCodeEventHub {
1997
1999
  }
1998
2000
  };
1999
2001
 
2002
+ // src/turn-limits.ts
2003
+ var STOPPED_ERROR_CODE = "opencode_stopped";
2004
+ function stopErrorText(stop) {
2005
+ switch (stop.reason) {
2006
+ case "steps":
2007
+ return `The turn was stopped after ${stop.counts.steps - 1} steps, its limit.`;
2008
+ case "compactions":
2009
+ return `The turn was stopped after ${stop.counts.compactions - 1} compactions, its limit.`;
2010
+ case "wall-clock":
2011
+ return "The turn was stopped: it ran longer than allowed.";
2012
+ default:
2013
+ return "The turn was stopped.";
2014
+ }
2015
+ }
2016
+ __name(stopErrorText, "stopErrorText");
2017
+ function cleanLimits(limits) {
2018
+ const ok = /* @__PURE__ */ __name((n) => typeof n === "number" && Number.isFinite(n) && n >= 0, "ok");
2019
+ return {
2020
+ ...ok(limits?.steps) ? {
2021
+ steps: limits.steps
2022
+ } : {},
2023
+ ...ok(limits?.compactions) ? {
2024
+ compactions: limits.compactions
2025
+ } : {},
2026
+ ...ok(limits?.wallClockMs) ? {
2027
+ wallClockMs: limits.wallClockMs
2028
+ } : {}
2029
+ };
2030
+ }
2031
+ __name(cleanLimits, "cleanLimits");
2032
+
2000
2033
  // src/turns.ts
2001
2034
  function _ts_decorate2(decorators, target, key, desc) {
2002
2035
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -2026,6 +2059,16 @@ function _ts_param2(paramIndex, decorator) {
2026
2059
  __name(_ts_param2, "_ts_param");
2027
2060
  var DEFAULT_TURN_TIMEOUT_MS = 30 * 6e4;
2028
2061
  var DEFAULT_TOOLS_TTL_MS = 7 * 24 * 60 * 6e4;
2062
+ var DEFAULT_HOOK_TIMEOUT_MS = 5e3;
2063
+ var PARKED_RECHECKS_MS = [
2064
+ 250,
2065
+ 1e3,
2066
+ 3e3,
2067
+ 1e4,
2068
+ 3e4
2069
+ ];
2070
+ var PARKED_READ_GAP_MS = 250;
2071
+ var RELEASED_USAGE_MS = 10 * 6e4;
2029
2072
  var OpenCodeTurns = class {
2030
2073
  static {
2031
2074
  __name(this, "OpenCodeTurns");
@@ -2049,6 +2092,21 @@ var OpenCodeTurns = class {
2049
2092
  spent = /* @__PURE__ */ new Set();
2050
2093
  /** When the tools endpoint was last registered for a location (`server|boot|directory|actor`). */
2051
2094
  toolsIssued = /* @__PURE__ */ new Map();
2095
+ /**
2096
+ * What a turn let go of here had spent, for a settle that follows here (a cancel in this process
2097
+ * that settles after the turn was released). Dropped when the run settles here, or after
2098
+ * {@link RELEASED_USAGE_MS} when it settles elsewhere, so no run is kept for good.
2099
+ */
2100
+ releasedUsage = /* @__PURE__ */ new Map();
2101
+ keepReleasedUsage(runId, usage) {
2102
+ this.releasedUsage.set(runId, usage);
2103
+ setTimeout(() => {
2104
+ if (this.releasedUsage.get(runId) === usage) this.releasedUsage.delete(runId);
2105
+ }, RELEASED_USAGE_MS).unref?.();
2106
+ }
2107
+ /** The turns this process executes, and which turn owns each session (`server|session`). */
2108
+ watches = /* @__PURE__ */ new Map();
2109
+ owners = /* @__PURE__ */ new Map();
2052
2110
  constructor(host, sessions, settings, store, sink, deps, registry, skills, memory, queue, toolsTokens2, pricingStore, agentOptions) {
2053
2111
  this.host = host;
2054
2112
  this.sessions = sessions;
@@ -2061,6 +2119,10 @@ var OpenCodeTurns = class {
2061
2119
  this.memory = memory;
2062
2120
  this.queue = queue;
2063
2121
  this.toolsTokens = toolsTokens2;
2122
+ const hookTimeoutMs = settings.hookTimeoutMs;
2123
+ if (hookTimeoutMs !== void 0 && !(Number.isFinite(hookTimeoutMs) && hookTimeoutMs > 0)) {
2124
+ throw new Error(`hookTimeoutMs must be a positive number of milliseconds, not ${hookTimeoutMs}`);
2125
+ }
2064
2126
  this.prices = new ModelPriceResolver({
2065
2127
  pricingStore,
2066
2128
  catalog: agentOptions?.priceCatalog,
@@ -2332,8 +2394,12 @@ var OpenCodeTurns = class {
2332
2394
  }
2333
2395
  }
2334
2396
  onApplicationShutdown() {
2397
+ for (const runId of [
2398
+ ...this.watches.keys()
2399
+ ]) this.release(runId, "released");
2335
2400
  for (const live of this.live.values()) live.stop();
2336
2401
  this.live.clear();
2402
+ this.owners.clear();
2337
2403
  this.events.close();
2338
2404
  }
2339
2405
  // ---- begin ---------------------------------------------------------------------------------
@@ -2343,7 +2409,8 @@ var OpenCodeTurns = class {
2343
2409
  * durable `begin` re-run after a crash): the run's user message is written once.
2344
2410
  */
2345
2411
  async begin(runId, input) {
2346
- const earlier = await this.prepareThread(runId, input);
2412
+ const startedAt = this.watches.get(runId)?.startedAt ?? Date.now();
2413
+ const { earlier, rerun } = await this.prepareThread(runId, input);
2347
2414
  const server = await this.host.server(input.actor);
2348
2415
  const known = await this.sessions.get(input.threadId);
2349
2416
  const reusable = known !== null && known.serverKey === server.key && known.bootId === server.bootId && (await this.host.reuse?.({
@@ -2389,7 +2456,12 @@ var OpenCodeTurns = class {
2389
2456
  created
2390
2457
  });
2391
2458
  await this.refreshInstructions(runId, input, server.client, handle.sessionId);
2392
- return handle;
2459
+ const begun = this.limitsFor(input).wallClockMs !== void 0 ? {
2460
+ ...handle,
2461
+ startedAt
2462
+ } : handle;
2463
+ await this.adopt(runId, input, begun, rerun, void 0, startedAt);
2464
+ return begun;
2393
2465
  }
2394
2466
  async createSession(runId, input, server, earlier, note) {
2395
2467
  const { client } = server;
@@ -2459,10 +2531,16 @@ ${transcript}` : "",
2459
2531
  }
2460
2532
  const firstDropped = messages[lastUser + 1];
2461
2533
  if (firstDropped !== void 0) await this.store.truncateFrom(input.threadId, firstDropped.id);
2462
- return messages.slice(0, Math.max(lastUser, 0));
2534
+ return {
2535
+ earlier: messages.slice(0, Math.max(lastUser, 0)),
2536
+ rerun: false
2537
+ };
2463
2538
  }
2464
2539
  const mine = messages.findIndex((m) => m.role === "user" && m.runId === runId);
2465
- if (mine >= 0) return messages.slice(0, mine);
2540
+ if (mine >= 0) return {
2541
+ earlier: messages.slice(0, mine),
2542
+ rerun: true
2543
+ };
2466
2544
  await this.store.appendMessage({
2467
2545
  threadId: input.threadId,
2468
2546
  role: "user",
@@ -2478,7 +2556,10 @@ ${transcript}` : "",
2478
2556
  if (thread !== null && (thread.title === "" || thread.title === "New chat")) {
2479
2557
  await this.store.setTitle(input.threadId, titleOf(input.userText));
2480
2558
  }
2481
- return messages;
2559
+ return {
2560
+ earlier: messages,
2561
+ rerun: false
2562
+ };
2482
2563
  }
2483
2564
  /** Rewind the session to before its last user message, so the regenerated answer replaces it. */
2484
2565
  async revertLastExchange(client, sessionId) {
@@ -2694,6 +2775,7 @@ ${body}
2694
2775
  // ---- prompt / observe / reply --------------------------------------------------------------
2695
2776
  /** Start listening to the session (so nothing it says is missed), then send the user message. */
2696
2777
  async prompt(runId, input, handle, spent) {
2778
+ await this.adopt(runId, input, handle, true);
2697
2779
  const live = await this.ensureLive(runId, input, handle, false, spent);
2698
2780
  try {
2699
2781
  const prompt = await this.host.promptFor?.({
@@ -2736,7 +2818,24 @@ ${body}
2736
2818
  * back from the session's messages, when the execution ends, what nobody heard after that.
2737
2819
  */
2738
2820
  async observe(runId, input, handle, spent, progress) {
2821
+ const watch = await this.adopt(runId, input, handle, true, progress?.counts);
2739
2822
  const live = await this.ensureLive(runId, input, handle, true, spent, progress);
2823
+ watch.parkedOn = void 0;
2824
+ watch.parkedEnd = void 0;
2825
+ watch.series = false;
2826
+ watch.observing = true;
2827
+ const wall = this.watchClock(watch);
2828
+ try {
2829
+ if (watch.pending !== void 0) await this.requestStop(runId, watch.pending);
2830
+ const milestone = this.marked(watch, await this.observeLive(runId, input, handle, live));
2831
+ if (milestone.kind === "ask") watch.parkedOn = milestone.ask.id;
2832
+ return milestone;
2833
+ } finally {
2834
+ watch.observing = false;
2835
+ if (wall !== void 0) clearTimeout(wall);
2836
+ }
2837
+ }
2838
+ async observeLive(runId, input, handle, live) {
2740
2839
  const { turn, client } = live;
2741
2840
  if (turn.hasMilestone()) return this.reached(runId, input, handle, await turn.next());
2742
2841
  let done = false;
@@ -2776,19 +2875,64 @@ ${body}
2776
2875
  * to go on.
2777
2876
  */
2778
2877
  async reply(runId, input, handle, ask, reply, spent, progress) {
2878
+ const watch = await this.adopt(runId, input, handle, true, progress?.counts);
2879
+ watch.parkedOn = void 0;
2880
+ watch.parkedEnd = void 0;
2881
+ watch.series = false;
2882
+ const stopping = watch.pending !== void 0 || watch.stop !== void 0;
2883
+ const answer = stopping ? refusalOf(ask) : reply;
2779
2884
  const server = await this.host.server(input.actor);
2780
2885
  if (server.key !== handle.serverKey || server.bootId !== handle.bootId) {
2781
- return this.continueAfterRestart(runId, input, server, ask, reply, spent);
2886
+ if (stopping) return this.stopAfterRestart(watch, runId, input, handle, ask, answer, spent);
2887
+ return this.continueAfterRestart(runId, input, server, ask, answer, spent, handle.startedAt);
2782
2888
  }
2783
2889
  const live = await this.ensureLive(runId, input, handle, false, spent, progress);
2784
- await live.turn.decide(ask, reply);
2890
+ await live.turn.decide(ask, answer);
2785
2891
  return handle;
2786
2892
  }
2787
- async continueAfterRestart(runId, input, server, ask, reply, spent) {
2893
+ /**
2894
+ * A stopping turn whose OpenCode server restarted while it waited: there is no session left to
2895
+ * abort, and none is opened or prompted. The ask is settled as refused and the turn ends stopped.
2896
+ */
2897
+ async stopAfterRestart(watch, runId, input, handle, ask, answer, spent) {
2898
+ const live = await this.ensureLive(runId, input, handle, false, spent);
2899
+ await live.turn.decide(ask, answer, {
2900
+ tellOpenCode: false
2901
+ });
2902
+ const reason = watch.pending ?? watch.stop?.reason ?? "stopped";
2903
+ if (watch.stop === void 0) {
2904
+ const stop = {
2905
+ reason,
2906
+ counts: this.counts(watch)
2907
+ };
2908
+ watch.stop = stop;
2909
+ this.hook("onStop", {
2910
+ turnId: runId,
2911
+ reason,
2912
+ counts: stop.counts
2913
+ });
2914
+ }
2915
+ watch.aborted = true;
2916
+ watch.pending = void 0;
2917
+ live.turn.handle({
2918
+ type: "session.execution.interrupted",
2919
+ data: {
2920
+ sessionID: handle.sessionId
2921
+ }
2922
+ });
2923
+ return handle;
2924
+ }
2925
+ async continueAfterRestart(runId, input, server, ask, reply, spent, startedAt) {
2788
2926
  const approved = "approved" in reply && reply.approved === true;
2789
2927
  const note = ask.kind === "approval" ? approved ? `The user approved the action ${ask.action} you asked permission for. Continue the task.` : `The user did not approve the action ${ask.action}. Continue without it and explain.` : `The user answered your question: ${JSON.stringify("answers" in reply ? reply.answers : {})}. Continue.`;
2790
2928
  const thread = await this.store.getThread(input.threadId);
2791
- const handle = await this.createSession(runId, input, server, thread?.messages ?? [], note);
2929
+ const created = await this.createSession(runId, input, server, thread?.messages ?? [], note);
2930
+ const handle = {
2931
+ ...created,
2932
+ ...startedAt !== void 0 ? {
2933
+ startedAt
2934
+ } : {}
2935
+ };
2792
2936
  await this.refreshInstructions(runId, input, server.client, handle.sessionId);
2793
2937
  this.drop(runId);
2794
2938
  const live = await this.ensureLive(runId, input, handle, false, spent);
@@ -2873,7 +3017,11 @@ ${body}
2873
3017
  this.grants.set(runId, grants);
2874
3018
  }, "onGranted")
2875
3019
  });
2876
- const stop = await this.events.listen(server.key, server.client, handle.sessionId, (event) => turn.handle(event));
3020
+ const sessionId = handle.sessionId;
3021
+ const stop = await this.events.listen(server.key, server.client, sessionId, (event) => {
3022
+ turn.handle(event);
3023
+ this.sawEvent(runId, sessionId, event);
3024
+ });
2877
3025
  const live = {
2878
3026
  turn,
2879
3027
  input,
@@ -2882,6 +3030,15 @@ ${body}
2882
3030
  stop
2883
3031
  };
2884
3032
  this.live.set(runId, live);
3033
+ this.owners.set(ownerKey(handle), runId);
3034
+ const watch = this.watches.get(runId);
3035
+ if (watch !== void 0 && watch.handle.sessionId !== sessionId) {
3036
+ watch.handle = {
3037
+ ...handle,
3038
+ startedAt: watch.startedAt
3039
+ };
3040
+ watch.ending = false;
3041
+ }
2885
3042
  if (resumed) await turn.catchUp().catch(() => void 0);
2886
3043
  return live;
2887
3044
  }
@@ -2890,11 +3047,46 @@ ${body}
2890
3047
  this.live.get(runId)?.stop();
2891
3048
  this.live.delete(runId);
2892
3049
  }
2893
- /** Stop following a run and drop the approvals it was granted: it is over. */
3050
+ /**
3051
+ * Stop following a run and drop the approvals it was granted: it is over. Returns the turn this
3052
+ * process executed, now settled (no stop reaches it any more), for its `onTurnEnd`.
3053
+ */
2894
3054
  forget(runId) {
2895
3055
  this.drop(runId);
3056
+ this.releasedUsage.delete(runId);
2896
3057
  for (const grant of this.grants.get(runId) ?? []) this.spent.delete(grant.id);
2897
3058
  this.grants.delete(runId);
3059
+ const watch = this.watches.get(runId);
3060
+ if (watch === void 0) return void 0;
3061
+ watch.settled = true;
3062
+ this.watches.delete(runId);
3063
+ for (const [key, owner] of this.owners) if (owner === runId) this.owners.delete(key);
3064
+ return watch;
3065
+ }
3066
+ /**
3067
+ * A run that ended without settling here (cancelled while this process executed it; whoever
3068
+ * cancelled settled it): stop following it, and end the turn this process executed.
3069
+ */
3070
+ async abandon(runId) {
3071
+ const watch = this.forget(runId);
3072
+ if (watch !== void 0) this.ended(watch, "interrupted", void 0);
3073
+ }
3074
+ /**
3075
+ * Let go of a turn this process executed without settling it here: it was cancelled while parked
3076
+ * (its session ended under it, `interrupted`), another worker took it over, or this process shuts
3077
+ * down (`released`). The per-turn state goes, `onTurnEnd` fires once, and a later stop of the turn
3078
+ * here is ignored. Nothing settles the run here afterwards: whoever answered or cancelled it does.
3079
+ */
3080
+ release(runId, status) {
3081
+ const watch = this.watches.get(runId);
3082
+ if (watch === void 0) return;
3083
+ const seen = this.live.get(runId)?.turn.totalUsage();
3084
+ if (seen !== void 0) this.keepReleasedUsage(runId, seen);
3085
+ this.drop(runId);
3086
+ watch.settled = true;
3087
+ this.watches.delete(runId);
3088
+ for (const [key, owner] of this.owners) if (owner === runId) this.owners.delete(key);
3089
+ this.ended(watch, status, void 0);
2898
3090
  }
2899
3091
  // ---- settle --------------------------------------------------------------------------------
2900
3092
  /** A person was asked something: tell the host (cards in other channels, wake-ups). */
@@ -2967,7 +3159,7 @@ ${body}
2967
3159
  * two milestones.
2968
3160
  */
2969
3161
  spentBy(runId, spent) {
2970
- const seen = this.live.get(runId)?.turn.totalUsage();
3162
+ const seen = this.live.get(runId)?.turn.totalUsage() ?? this.releasedUsage.get(runId);
2971
3163
  if (spent === void 0) return seen ?? emptyUsage();
2972
3164
  if (seen === void 0) return spent;
2973
3165
  const tokens = /* @__PURE__ */ __name((u) => u.inputTokens + u.outputTokens, "tokens");
@@ -2977,13 +3169,13 @@ ${body}
2977
3169
  * End the run's stream and bookkeeping for how its execution ended. `spent` is what the run spent
2978
3170
  * (the sum of its milestones' `usage`); omit → what the turn this process follows saw.
2979
3171
  */
2980
- async settle(runId, input, outcome, durationMs, spent) {
3172
+ async settle(runId, input, ended, durationMs, spent) {
3173
+ const outcome = this.withStop(ended, this.watches.get(runId));
2981
3174
  if (outcome.status === "failed") {
2982
- await this.settleFailed(runId, input, outcome.error, durationMs, spent);
2983
- return;
3175
+ return this.settleFailed(runId, input, outcome.error, durationMs, spent, outcome.stop);
2984
3176
  }
2985
3177
  const usage = this.spentBy(runId, spent);
2986
- this.forget(runId);
3178
+ const watch = this.forget(runId);
2987
3179
  const writer = await this.sink.open(runId);
2988
3180
  const result = await this.runResult(runId, input, outcome, durationMs, usage);
2989
3181
  await this.amend(writer, result);
@@ -3018,22 +3210,30 @@ ${body}
3018
3210
  });
3019
3211
  }
3020
3212
  await this.settled(result);
3213
+ if (watch !== void 0) this.ended(watch, outcome.status, outcome.status === "succeeded" ? outcome.stop : void 0);
3214
+ return outcome;
3021
3215
  }
3022
- async settleFailed(runId, input, error, durationMs = 0, spent) {
3216
+ async settleFailed(runId, input, error, durationMs = 0, spent, stop) {
3023
3217
  const usage = this.spentBy(runId, spent);
3024
- this.forget(runId);
3218
+ const watch = this.forget(runId);
3219
+ const stopped = stop ?? watch?.stop;
3025
3220
  const writer = await this.sink.open(runId);
3026
3221
  const result = await this.runResult(runId, input, {
3027
3222
  status: "failed",
3028
- error
3223
+ error,
3224
+ ...stopped !== void 0 ? {
3225
+ stop: stopped
3226
+ } : {}
3029
3227
  }, durationMs, usage);
3030
3228
  const worded = (await this.amend(writer, result)).error;
3031
- const message = worded ?? streamFailure(new Error(error)).message;
3032
- this.logger.error(`agent run ${runId} failed: ${error}`);
3229
+ const message = worded ?? (stopped !== void 0 ? error : streamFailure(new Error(error)).message);
3230
+ const code = stopped !== void 0 ? STOPPED_ERROR_CODE : "opencode_failed";
3231
+ if (stopped !== void 0) this.logger.log(`agent run ${runId} stopped: ${stopped.reason}`);
3232
+ else this.logger.error(`agent run ${runId} failed: ${error}`);
3033
3233
  publishAgentRunFailed({
3034
3234
  runId,
3035
3235
  threadId: input.threadId,
3036
- code: "opencode_failed",
3236
+ code,
3037
3237
  message: error,
3038
3238
  durationMs,
3039
3239
  context: agentRunContext(input)
@@ -3042,23 +3242,370 @@ ${body}
3042
3242
  runId,
3043
3243
  status: "failed",
3044
3244
  durationMs,
3045
- errorCode: "opencode_failed",
3245
+ errorCode: code,
3046
3246
  errorMessage: error
3047
3247
  });
3048
3248
  await Promise.resolve(this.store.failUnsettledToolCalls?.(runId, RUN_ENDED_BEFORE_TOOL_CALL)).catch(() => 0);
3049
3249
  await this.handoff(writer, input, runId, "failed", error);
3050
3250
  await releaseThreadRun(this.store, input.threadId, runId);
3051
3251
  await writer.fail({
3052
- code: "opencode_failed",
3252
+ code,
3053
3253
  message
3054
3254
  });
3055
3255
  await this.settled({
3056
3256
  ...result,
3057
3257
  outcome: {
3058
3258
  status: "failed",
3059
- error: worded ?? error
3259
+ error: worded ?? error,
3260
+ ...stopped !== void 0 ? {
3261
+ stop: stopped
3262
+ } : {}
3060
3263
  }
3061
3264
  });
3265
+ if (watch !== void 0) this.ended(watch, "failed", stopped);
3266
+ return {
3267
+ status: "failed",
3268
+ error,
3269
+ ...stopped !== void 0 ? {
3270
+ stop: stopped
3271
+ } : {}
3272
+ };
3273
+ }
3274
+ // ---- limits, stops and the turn's lifecycle -------------------------------------------------
3275
+ /**
3276
+ * Stop a turn's own work: abort its session so OpenCode starts no new round, only while the turn
3277
+ * still owns the session (a thread's session is reused by its next turn) — checked immediately
3278
+ * before the abort. Acts in the process executing the turn; see {@link OpenCodeStopResult}. The
3279
+ * turn ends failed with `outcome.stop` (code `opencode_stopped`), and `onStop` is called.
3280
+ */
3281
+ stopTurn(turnId, reason) {
3282
+ return this.requestStop(turnId, reason);
3283
+ }
3284
+ /** The turn this process executes, made known here on its first step in this process. */
3285
+ async adopt(runId, input, handle, resumed, counts, startedAt) {
3286
+ const known = this.watches.get(runId);
3287
+ if (known !== void 0) return known;
3288
+ const watch = {
3289
+ runId,
3290
+ input,
3291
+ handle,
3292
+ limits: this.limitsFor(input),
3293
+ startedAt: handle.startedAt ?? startedAt ?? Date.now(),
3294
+ steps: counts?.steps ?? 0,
3295
+ compactions: counts?.compactions ?? 0,
3296
+ observing: false,
3297
+ ending: false,
3298
+ settled: false,
3299
+ aborted: false,
3300
+ checking: false,
3301
+ lastRead: 0
3302
+ };
3303
+ this.watches.set(runId, watch);
3304
+ await this.hook("onTurnStart", {
3305
+ turnId: runId,
3306
+ threadId: input.threadId,
3307
+ sessionId: handle.sessionId,
3308
+ resumed
3309
+ });
3310
+ return watch;
3311
+ }
3312
+ limitsFor(input) {
3313
+ const limits = this.settings.limits;
3314
+ return cleanLimits(typeof limits === "function" ? limits(input) : limits);
3315
+ }
3316
+ /**
3317
+ * One event of the turn's session: counted for the limits, and told to `onTurnEvent`. While the
3318
+ * turn is parked on a person here, it may also say the turn went on without this process
3319
+ * ({@link wentOnElsewhere}).
3320
+ */
3321
+ sawEvent(runId, sessionId, event) {
3322
+ if (sessionOf(event) !== sessionId) return;
3323
+ const watch = this.watches.get(runId);
3324
+ if (watch === void 0 || watch.settled || watch.handle.sessionId !== sessionId) return;
3325
+ if (watch.parkedOn !== void 0 && this.wentOnElsewhere(watch, event)) return;
3326
+ let reached;
3327
+ if (event.type.startsWith("session.execution.")) watch.ending = true;
3328
+ else if (event.type === "session.step.started") {
3329
+ watch.steps += 1;
3330
+ if (passed(watch.steps, watch.limits.steps)) reached = "steps";
3331
+ } else if (event.type === "session.compaction.started") {
3332
+ watch.compactions += 1;
3333
+ if (passed(watch.compactions, watch.limits.compactions)) reached = "compactions";
3334
+ }
3335
+ this.hook("onTurnEvent", {
3336
+ turnId: runId,
3337
+ type: event.type,
3338
+ sessionId
3339
+ });
3340
+ if (reached !== void 0) void this.limitStop(runId, reached);
3341
+ }
3342
+ /**
3343
+ * Whether a turn parked here went on without this process, which then lets it go. While a tool of
3344
+ * the step waits on a person, OpenCode keeps running the step's other tools, so their events say
3345
+ * nothing. What does: a new round (`session.step.started`, which cannot begin while the step
3346
+ * waits), or the store showing the parked ask decided by someone else (another worker answered
3347
+ * it, or a cancel elsewhere settled it) — this process clears the park before it answers. When
3348
+ * the session's execution ends while parked, the store is asked again for a while, as a cancel
3349
+ * elsewhere settles the ask just after it interrupted the session; with the ask still open, the
3350
+ * turn stays here for its answer.
3351
+ */
3352
+ wentOnElsewhere(watch, event) {
3353
+ if (event.type === "session.step.started") {
3354
+ this.release(watch.runId, "released");
3355
+ return true;
3356
+ }
3357
+ const ended = event.type.startsWith("session.execution.");
3358
+ if (ended) watch.parkedEnd = event.type === "session.execution.interrupted" ? "interrupted" : "released";
3359
+ this.checkParked(watch, ended);
3360
+ return false;
3361
+ }
3362
+ /**
3363
+ * Ask the store whether the parked ask was decided elsewhere, and let the turn go when it was —
3364
+ * one read at a time, at most one every {@link PARKED_READ_GAP_MS}. `series`: the session's
3365
+ * execution ended, so the store is asked again over {@link parkedRechecksMs}. An execution that
3366
+ * was interrupted under a parked turn means a cancel elsewhere: when the store cannot say (it has
3367
+ * no `toolCallApproval`, the read fails, the ask is not on file) past the first recheck, or the
3368
+ * rechecks run out with the ask still open, the turn is let go as `interrupted` anyway.
3369
+ */
3370
+ checkParked(watch, series) {
3371
+ if (series) watch.series = true;
3372
+ if (watch.checking) {
3373
+ watch.again = true;
3374
+ return;
3375
+ }
3376
+ watch.checking = true;
3377
+ void this.runParkedChecks(watch);
3378
+ }
3379
+ /**
3380
+ * The checks of {@link checkParked}. `checking` is cleared in the same step the loop returns in,
3381
+ * so a check asked for afterwards starts a new loop; one asked for while it ran (`again`) but not
3382
+ * read because the loop returned starts one too.
3383
+ */
3384
+ async runParkedChecks(watch) {
3385
+ try {
3386
+ await this.parkedChecks(watch);
3387
+ } finally {
3388
+ watch.checking = false;
3389
+ if (watch.again && !watch.settled && watch.parkedOn !== void 0) {
3390
+ watch.checking = true;
3391
+ void this.runParkedChecks(watch);
3392
+ }
3393
+ }
3394
+ }
3395
+ async parkedChecks(watch) {
3396
+ const ask = watch.parkedOn;
3397
+ const gone = /* @__PURE__ */ __name(() => watch.settled || ask === void 0 || watch.parkedOn !== ask, "gone");
3398
+ let step = 0;
3399
+ for (; ; ) {
3400
+ watch.again = false;
3401
+ const wait = watch.lastRead + PARKED_READ_GAP_MS - Date.now();
3402
+ if (wait > 0) await sleep(wait);
3403
+ if (gone()) return;
3404
+ const answer = await this.parkedAsk(ask);
3405
+ watch.lastRead = Date.now();
3406
+ if (gone()) return;
3407
+ if (answer === "decided") {
3408
+ this.release(watch.runId, watch.parkedEnd ?? "released");
3409
+ return;
3410
+ }
3411
+ if (watch.series) {
3412
+ const cancelled = watch.parkedEnd === "interrupted";
3413
+ if (cancelled && answer === "unknown" && step > 0) {
3414
+ this.release(watch.runId, "interrupted");
3415
+ return;
3416
+ }
3417
+ const gap = this.parkedRechecksMs[step];
3418
+ step += 1;
3419
+ if (gap === void 0) {
3420
+ if (cancelled) this.release(watch.runId, "interrupted");
3421
+ return;
3422
+ }
3423
+ await sleep(gap);
3424
+ continue;
3425
+ }
3426
+ if (!watch.again) return;
3427
+ }
3428
+ }
3429
+ /** What the store says of an ask: decided, still open, or nothing it can tell. */
3430
+ async parkedAsk(id) {
3431
+ if (this.store.toolCallApproval === void 0) return "unknown";
3432
+ try {
3433
+ const state = await this.store.toolCallApproval(id);
3434
+ if (state == null) return "unknown";
3435
+ return state.status === "pending_approval" ? "open" : "decided";
3436
+ } catch {
3437
+ return "unknown";
3438
+ }
3439
+ }
3440
+ /**
3441
+ * The waits between the store's rereads of a parked ask after its execution ended (cumulative
3442
+ * 0.25, 1.25, 4.25, 14.25 and 44.25 seconds after the first). Overridden by tests only.
3443
+ *
3444
+ * @internal
3445
+ */
3446
+ parkedRechecksMs = PARKED_RECHECKS_MS;
3447
+ /** A limit's stop: what it did is the engine's to log, there is no caller to tell. */
3448
+ async limitStop(runId, reason) {
3449
+ const result = await this.requestStop(runId, reason);
3450
+ if (result === "unconfirmed") this.logger.warn(`run ${runId}: passed its ${reason} limit, but its owner could not be read`);
3451
+ }
3452
+ /** The wall-clock bound of an observation: a timer for what is left of it, if anything. */
3453
+ watchClock(watch) {
3454
+ const max = watch.limits.wallClockMs;
3455
+ if (max === void 0) return void 0;
3456
+ const left = watch.startedAt + max - Date.now();
3457
+ if (left <= 0) {
3458
+ watch.pending ??= "wall-clock";
3459
+ return void 0;
3460
+ }
3461
+ const timer = setTimeout(() => void this.limitStop(watch.runId, "wall-clock"), left);
3462
+ timer.unref?.();
3463
+ return timer;
3464
+ }
3465
+ counts(watch) {
3466
+ return {
3467
+ steps: watch.steps,
3468
+ compactions: watch.compactions,
3469
+ elapsedMs: Date.now() - watch.startedAt
3470
+ };
3471
+ }
3472
+ async requestStop(runId, reason) {
3473
+ const watch = this.watches.get(runId);
3474
+ if (watch === void 0 || watch.settled || watch.stop !== void 0) return "ignored";
3475
+ const live = this.live.get(runId);
3476
+ if (watch.parkedOn !== void 0) {
3477
+ watch.pending ??= reason;
3478
+ return "pending";
3479
+ }
3480
+ if (watch.ending || live?.turn.finished === true) return this.landed(watch, reason);
3481
+ if (!watch.observing || live === void 0) {
3482
+ watch.pending ??= reason;
3483
+ return "pending";
3484
+ }
3485
+ watch.pending = void 0;
3486
+ let active;
3487
+ try {
3488
+ active = await this.activeRun(watch.input.threadId);
3489
+ } catch (error) {
3490
+ this.logger.warn(`run ${runId}: could not read the thread's run: ${errorText(error, "error")}`);
3491
+ return "unconfirmed";
3492
+ }
3493
+ if (watch.settled || watch.stop !== void 0 || this.live.get(runId) !== live) return "ignored";
3494
+ if (active !== null && active !== runId) return "ignored";
3495
+ if (this.owners.get(ownerKey(watch.handle)) !== runId) return "ignored";
3496
+ if (watch.ending || live.turn.finished) return this.landed(watch, reason);
3497
+ const stop = {
3498
+ reason,
3499
+ counts: this.counts(watch)
3500
+ };
3501
+ watch.stop = stop;
3502
+ watch.aborted = true;
3503
+ try {
3504
+ await live.client.session.interrupt({
3505
+ sessionID: watch.handle.sessionId
3506
+ });
3507
+ } catch (error) {
3508
+ this.logger.warn(`run ${runId}: could not abort the session: ${errorText(error, "error")}`);
3509
+ }
3510
+ this.hook("onStop", {
3511
+ turnId: runId,
3512
+ reason,
3513
+ counts: stop.counts
3514
+ });
3515
+ return "aborted";
3516
+ }
3517
+ /** A stop that arrived while the answer settles: recorded and reported, the answer kept. */
3518
+ landed(watch, reason) {
3519
+ const stop = {
3520
+ reason,
3521
+ counts: this.counts(watch)
3522
+ };
3523
+ watch.stop = stop;
3524
+ watch.pending = void 0;
3525
+ this.hook("onStop", {
3526
+ turnId: watch.runId,
3527
+ reason,
3528
+ counts: stop.counts
3529
+ });
3530
+ return "settling";
3531
+ }
3532
+ /** The milestone as the runner journals it: the limits' counts on an ask, the stop on an end. */
3533
+ marked(watch, milestone) {
3534
+ if (milestone.kind === "finished") return {
3535
+ ...milestone,
3536
+ outcome: this.withStop(milestone.outcome, watch)
3537
+ };
3538
+ if (milestone.progress === void 0) return milestone;
3539
+ const counts = {
3540
+ steps: watch.steps,
3541
+ compactions: watch.compactions
3542
+ };
3543
+ return {
3544
+ ...milestone,
3545
+ progress: {
3546
+ ...milestone.progress,
3547
+ counts
3548
+ }
3549
+ };
3550
+ }
3551
+ /**
3552
+ * How a turn a stop landed on ends. An answer that settled anyway is kept, with the stop on it; an
3553
+ * execution that failed on its own keeps its error, with the stop on it; an execution the stop
3554
+ * aborted ends failed with the stop's own text. One interrupted by a person's cancel stays so.
3555
+ */
3556
+ withStop(outcome, watch) {
3557
+ const stop = watch?.stop;
3558
+ if (stop === void 0) return outcome;
3559
+ if (outcome.status !== "interrupted") return outcome.stop !== void 0 ? outcome : {
3560
+ ...outcome,
3561
+ stop
3562
+ };
3563
+ if (!watch?.aborted) return outcome;
3564
+ return {
3565
+ status: "failed",
3566
+ error: stopErrorText(stop),
3567
+ stop
3568
+ };
3569
+ }
3570
+ /** `onTurnEnd`, once per turn this process executed; not waited for. */
3571
+ ended(watch, status, stop) {
3572
+ this.hook("onTurnEnd", {
3573
+ turnId: watch.runId,
3574
+ status,
3575
+ ...stop !== void 0 ? {
3576
+ stop
3577
+ } : {}
3578
+ });
3579
+ }
3580
+ /**
3581
+ * A lifecycle hook, from the settings and the host, called at once. The promise settles when both
3582
+ * have, or after `hookTimeoutMs` (default 5 s) — only `onTurnStart` is waited for. Errors are
3583
+ * logged, never the turn's.
3584
+ */
3585
+ hook(name, event) {
3586
+ const calls = [];
3587
+ for (const target of [
3588
+ this.settings,
3589
+ this.host
3590
+ ]) {
3591
+ const fn = target[name];
3592
+ if (typeof fn !== "function") continue;
3593
+ calls.push(Promise.resolve().then(() => fn.call(target, event)).catch((error) => this.logger.warn(`${name} failed: ${errorText(error, "error")}`)));
3594
+ }
3595
+ if (calls.length === 0) return Promise.resolve();
3596
+ const ms = this.settings.hookTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
3597
+ let timer;
3598
+ const late = new Promise((resolve) => {
3599
+ timer = setTimeout(() => {
3600
+ this.logger.warn(`${name} took longer than ${ms}ms; the turn went on`);
3601
+ resolve();
3602
+ }, ms);
3603
+ timer.unref?.();
3604
+ });
3605
+ return Promise.race([
3606
+ Promise.all(calls).then(() => void 0),
3607
+ late
3608
+ ]).finally(() => clearTimeout(timer));
3062
3609
  }
3063
3610
  /** Starts the next queued message of the thread; set by the runner (it owns `start`). */
3064
3611
  startNext;
@@ -3121,6 +3668,28 @@ OpenCodeTurns = _ts_decorate2([
3121
3668
  Object
3122
3669
  ])
3123
3670
  ], OpenCodeTurns);
3671
+ function passed(count, limit) {
3672
+ return limit !== void 0 && count > limit;
3673
+ }
3674
+ __name(passed, "passed");
3675
+ function sleep(ms) {
3676
+ return new Promise((resolve) => setTimeout(resolve, ms).unref?.());
3677
+ }
3678
+ __name(sleep, "sleep");
3679
+ function refusalOf(ask) {
3680
+ return ask.kind === "approval" ? {
3681
+ approved: false,
3682
+ reason: "The turn was stopped."
3683
+ } : {
3684
+ answers: {},
3685
+ skipped: true
3686
+ };
3687
+ }
3688
+ __name(refusalOf, "refusalOf");
3689
+ function ownerKey(handle) {
3690
+ return `${handle.serverKey}|${handle.sessionId}`;
3691
+ }
3692
+ __name(ownerKey, "ownerKey");
3124
3693
  function titleOf(text) {
3125
3694
  const trimmed = text.trim().replace(/\s+/g, " ");
3126
3695
  return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed || "New chat";
@@ -3294,7 +3863,7 @@ var OpenCodeAgentRunner = class {
3294
3863
  await this.turns.interrupt(input).catch(() => void 0);
3295
3864
  await this.turns.settle(runId, input, {
3296
3865
  status: "interrupted"
3297
- }, Date.now() - started);
3866
+ }, Date.now() - started, spent);
3298
3867
  return;
3299
3868
  }
3300
3869
  await this.turns.settleFailed(runId, input, errorText(error, "the turn failed"), Date.now() - started);
@@ -3450,6 +4019,7 @@ export {
3450
4019
  OpenCodeToolsTokens,
3451
4020
  OpenCodeTurn,
3452
4021
  OpenCodeTurns,
4022
+ STOPPED_ERROR_CODE,
3453
4023
  addUsage,
3454
4024
  emptyUsage,
3455
4025
  keyValueOpenCodeSessionStore,