@bojackduy/opencode-loopd 1.10.5 → 1.11.2

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/server.js CHANGED
@@ -1545,6 +1545,64 @@ function createControlWorker(options) {
1545
1545
  };
1546
1546
  break;
1547
1547
  }
1548
+ case "switch_goal_model": {
1549
+ if (!request.goalID) {
1550
+ response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
1551
+ break;
1552
+ }
1553
+ const args = request.args;
1554
+ const model = args?.model;
1555
+ if (typeof model !== "string" || model.trim().length === 0) {
1556
+ response = { ...base, ok: false, message: "model (providerID/modelID) is required", errorCode: "bad_request" };
1557
+ break;
1558
+ }
1559
+ const ownerForSwitch = (await readState(directory)).goals.find((g) => g.id === request.goalID)?.ownerSessionID ?? "";
1560
+ const result = await goalSvc.switchModel(directory, request.goalID, ownerForSwitch, model.trim(), { resume: args?.resume === true });
1561
+ const state = await readState(directory);
1562
+ const outcomeMessage = {
1563
+ applied: `Model switched to ${result.model} on the same worker/session.`,
1564
+ deferred: `Worker busy \u2014 switch to ${result.model} queued; applies on the next turn.`,
1565
+ unsupported: `Model switch unsupported on this host (see loopd_list_models). Goal/session retained.`
1566
+ };
1567
+ response = {
1568
+ ...base,
1569
+ ok: result.outcome !== "unsupported",
1570
+ message: outcomeMessage[result.outcome] ?? `Model switch ${result.outcome}.`,
1571
+ stateRevision: state.revision
1572
+ };
1573
+ break;
1574
+ }
1575
+ case "list_models": {
1576
+ const catalog = await goalSvc.listModels();
1577
+ const state = await readState(directory);
1578
+ response = {
1579
+ ...base,
1580
+ ok: catalog.capability !== "unavailable",
1581
+ message: `Models: ${catalog.models.length} across ${catalog.providers.length} provider(s) [${catalog.capability}/${catalog.switching}]. Quota: ${catalog.quota.status} \u2014 ${catalog.quota.limitation}`,
1582
+ stateRevision: state.revision
1583
+ };
1584
+ break;
1585
+ }
1586
+ case "set_interactive": {
1587
+ if (!request.goalID) {
1588
+ response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
1589
+ break;
1590
+ }
1591
+ const interactive = request.args?.interactive;
1592
+ if (typeof interactive !== "boolean") {
1593
+ response = { ...base, ok: false, message: "interactive (boolean) is required", errorCode: "bad_request" };
1594
+ break;
1595
+ }
1596
+ const result = await goalSvc.setInteractive(directory, request.goalID, interactive);
1597
+ const state = await readState(directory);
1598
+ response = {
1599
+ ...base,
1600
+ ok: result.ok,
1601
+ message: result.message,
1602
+ stateRevision: state.revision
1603
+ };
1604
+ break;
1605
+ }
1548
1606
  case "send": {
1549
1607
  const args = request.args;
1550
1608
  const text = String(args.message || "").trim();
@@ -1957,6 +2015,8 @@ function createLoopEngine(options) {
1957
2015
  knownWorkerSessions.add(goal.workerSessionID);
1958
2016
  if (goal.status !== "active")
1959
2017
  return false;
2018
+ if (goal.interactive === true && type === "session.idle")
2019
+ return false;
1960
2020
  switch (type) {
1961
2021
  case "session.idle":
1962
2022
  return await handleSessionIdle(state, goal);
@@ -2345,6 +2405,7 @@ function createLoopEngine(options) {
2345
2405
  revision: newState.revision
2346
2406
  });
2347
2407
  const updatedGoal = newState.goals.find((g) => g.id === goal.id);
2408
+ await goalService.observeProviderError(directory, goal.id, error, "session-error", goal.config.model);
2348
2409
  if (updatedGoal?.status === "blocked") {
2349
2410
  await appendEvent(directory, {
2350
2411
  version: 1,
@@ -2453,7 +2514,7 @@ function createLoopEngine(options) {
2453
2514
  revision: state.revision
2454
2515
  });
2455
2516
  try {
2456
- await host.compactSession(goal.workerSessionID);
2517
+ await goalService.compact(directory, goal.id);
2457
2518
  } catch {}
2458
2519
  await mutateState(directory, `compact.end:${goal.id}`, async (s) => {
2459
2520
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2568,7 +2629,8 @@ function createLoopEngine(options) {
2568
2629
  if (stopped)
2569
2630
  continue;
2570
2631
  }
2571
- if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
2632
+ const manual = goal.interactive === true;
2633
+ if (!manual && runtime.phase === "waiting_retry" && runtime.retryAfter) {
2572
2634
  if (Date.now() >= Date.parse(runtime.retryAfter)) {
2573
2635
  await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
2574
2636
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2581,7 +2643,7 @@ function createLoopEngine(options) {
2581
2643
  goalService.continueTurn(directory, goal.id).catch(() => {});
2582
2644
  }
2583
2645
  }
2584
- if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
2646
+ if (!manual && (runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
2585
2647
  if (runtime.phase === "idle" && runtime.activeRunID) {
2586
2648
  await mutateState(directory, `maintenance.clear-stale-run:${goal.id}`, async (s) => {
2587
2649
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2886,6 +2948,92 @@ var nativeRpcDefinition = {
2886
2948
  }
2887
2949
  };
2888
2950
 
2951
+ // src/server/model-catalog.ts
2952
+ function emptyCatalog(source, switching, capability = "unsupported") {
2953
+ const observedAt = new Date().toISOString();
2954
+ return {
2955
+ capability,
2956
+ source,
2957
+ observedAt,
2958
+ switching,
2959
+ providers: [],
2960
+ models: [],
2961
+ quota: {
2962
+ status: "unknown",
2963
+ capability: "unsupported",
2964
+ source,
2965
+ observedAt,
2966
+ limitation: "Host catalog does not report remaining quota or reset time. Connected/enabled does not prove remaining balance; local rate-limit observations are not balance data."
2967
+ }
2968
+ };
2969
+ }
2970
+ function record(value) {
2971
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
2972
+ }
2973
+ function model(raw, providerID, usable) {
2974
+ const value = record(raw);
2975
+ if (typeof value.id !== "string" || !value.id)
2976
+ return;
2977
+ const limit = record(value.limit);
2978
+ return {
2979
+ providerID,
2980
+ modelID: value.id,
2981
+ name: typeof value.name === "string" ? value.name : value.id,
2982
+ usable,
2983
+ ...typeof limit.context === "number" && Number.isFinite(limit.context) ? { contextLimit: limit.context } : {},
2984
+ ...typeof limit.output === "number" && Number.isFinite(limit.output) ? { outputLimit: limit.output } : {}
2985
+ };
2986
+ }
2987
+ function normalizeV1Catalog(raw) {
2988
+ const result = emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
2989
+ const value = record(raw);
2990
+ if (!Array.isArray(value.all) || !Array.isArray(value.connected))
2991
+ return result;
2992
+ result.capability = "supported";
2993
+ const connected = new Set(value.connected.filter((id) => typeof id === "string"));
2994
+ for (const rawProvider of value.all) {
2995
+ const p = record(rawProvider);
2996
+ if (typeof p.id !== "string")
2997
+ continue;
2998
+ const available = connected.has(p.id);
2999
+ result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: available });
3000
+ for (const rawModel of Object.values(record(p.models))) {
3001
+ const m = model(rawModel, p.id, available);
3002
+ if (m)
3003
+ result.models.push(m);
3004
+ }
3005
+ }
3006
+ return result;
3007
+ }
3008
+ function normalizeV2Catalog(rawProviders, rawModels) {
3009
+ const result = emptyCatalog("v2 provider.list + model.list", "session", "unavailable");
3010
+ const providers = record(rawProviders).data;
3011
+ const models = record(rawModels).data;
3012
+ if (!Array.isArray(providers) || !Array.isArray(models))
3013
+ return result;
3014
+ result.capability = "supported";
3015
+ const disabled = new Set;
3016
+ for (const rawProvider of providers) {
3017
+ const p = record(rawProvider);
3018
+ if (typeof p.id !== "string")
3019
+ continue;
3020
+ if (p.activation === "disabled")
3021
+ disabled.add(p.id);
3022
+ result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: "unknown" });
3023
+ }
3024
+ const knownProviders = new Set(result.providers.map((p) => p.providerID));
3025
+ for (const rawModel of models) {
3026
+ const value = record(rawModel);
3027
+ if (typeof value.providerID !== "string" || !knownProviders.has(value.providerID))
3028
+ continue;
3029
+ const usable = disabled.has(value.providerID) || value.enabled === false ? false : value.enabled === true ? true : "unknown";
3030
+ const m = model(value, value.providerID, usable);
3031
+ if (m)
3032
+ result.models.push(m);
3033
+ }
3034
+ return result;
3035
+ }
3036
+
2889
3037
  // src/server/host-adapter.ts
2890
3038
  function parseModelRef(value) {
2891
3039
  if (value === undefined)
@@ -2929,6 +3077,21 @@ function markParentNotified2(ownerSessionID, message) {
2929
3077
  }
2930
3078
  function createRealHost(client, directory) {
2931
3079
  return {
3080
+ async listModels() {
3081
+ if (typeof client.provider?.list !== "function")
3082
+ return emptyCatalog("v1 provider.list", "next-prompt");
3083
+ try {
3084
+ const result = await withTimeout(client.provider.list({ query: { directory } }), 1e4, "OpenCode provider.list");
3085
+ if (result?.error)
3086
+ return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
3087
+ return normalizeV1Catalog(result?.data);
3088
+ } catch {
3089
+ return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
3090
+ }
3091
+ },
3092
+ async switchSessionModel() {
3093
+ return "next-prompt";
3094
+ },
2932
3095
  async createWorker({ parentID, title, agent, model }) {
2933
3096
  try {
2934
3097
  const body = { parentID, title };
@@ -2971,7 +3134,7 @@ function createRealHost(client, directory) {
2971
3134
  if (result?.error) {
2972
3135
  const detail = describeError(result.error);
2973
3136
  await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
2974
- throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
3137
+ throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`, { cause: result.error });
2975
3138
  }
2976
3139
  await logServerEvent(directory, "worker.prompted", { sessionID });
2977
3140
  return { messageID: result?.data?.messageID };
@@ -3064,9 +3227,12 @@ function createRealHost(client, directory) {
3064
3227
  return [];
3065
3228
  }
3066
3229
  },
3067
- async compactSession(sessionID) {
3230
+ async compactSession(sessionID, model) {
3068
3231
  try {
3069
- await client.session.compact({ sessionID });
3232
+ const identity = model ?? (await this.readSession(sessionID))?.model;
3233
+ if (!identity || typeof client.session.summarize !== "function")
3234
+ return;
3235
+ await client.session.summarize({ path: { id: sessionID }, body: identity });
3070
3236
  } catch {}
3071
3237
  },
3072
3238
  async notifyOwner(ownerSessionID, message, agent) {
@@ -3112,6 +3278,29 @@ function toWorkerCreation(result) {
3112
3278
  function createV2Host(context, statuses, options = {}) {
3113
3279
  const directory = context.location.directory;
3114
3280
  return {
3281
+ async listModels() {
3282
+ if (typeof context.provider?.list !== "function" || typeof context.model?.list !== "function") {
3283
+ return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported");
3284
+ }
3285
+ try {
3286
+ const [providers, models] = await Promise.all([
3287
+ context.provider.list({ location: { directory } }),
3288
+ context.model.list({ location: { directory } })
3289
+ ]);
3290
+ const catalog = normalizeV2Catalog(providers, models);
3291
+ if (typeof context.session.switchModel !== "function")
3292
+ catalog.switching = "unsupported";
3293
+ return catalog;
3294
+ } catch {
3295
+ return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported", "unavailable");
3296
+ }
3297
+ },
3298
+ async switchSessionModel(sessionID, model) {
3299
+ if (typeof context.session.switchModel !== "function")
3300
+ return "unsupported";
3301
+ await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
3302
+ return "applied";
3303
+ },
3115
3304
  async createWorker({ parentID, title, agent, model, goalID }) {
3116
3305
  if (options.native) {
3117
3306
  let outcome;
@@ -3266,8 +3455,13 @@ function createV2Host(context, statuses, options = {}) {
3266
3455
  return [];
3267
3456
  }
3268
3457
  },
3269
- async compactSession(sessionID) {
3458
+ async compactSession(sessionID, model) {
3270
3459
  try {
3460
+ if (model) {
3461
+ if (typeof context.session.switchModel !== "function")
3462
+ return;
3463
+ await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
3464
+ }
3271
3465
  await context.session.command({ sessionID, name: "compact", text: "" });
3272
3466
  } catch {}
3273
3467
  },
@@ -3298,6 +3492,40 @@ async function withTimeout(promise, timeoutMs, operation) {
3298
3492
  }
3299
3493
  }
3300
3494
 
3495
+ // src/domain/provider-limit.ts
3496
+ function object(value) {
3497
+ return value !== null && typeof value === "object" ? value : {};
3498
+ }
3499
+ function observeProviderLimit(error, source, model) {
3500
+ const wrapper = object(error);
3501
+ const root = wrapper.cause ? object(wrapper.cause) : wrapper;
3502
+ const data = object(root.data);
3503
+ const status = root.statusCode ?? root.status ?? data.statusCode ?? data.status;
3504
+ const statusCode = typeof status === "number" ? status : undefined;
3505
+ const code = root.code ?? data.code;
3506
+ const message = [root.message, data.message, typeof error === "string" ? error : undefined].filter((s) => typeof s === "string").join(" ");
3507
+ if (statusCode === 401 || statusCode === 403 || /context.{0,20}(length|window|overflow)|maximum context|invalid.{0,10}(key|token)|unauthori[sz]ed|authentication|network|ECONN|ENOTFOUND|max.?turn|cost.?budget|budget.?limit/i.test(message))
3508
+ return;
3509
+ const quota = code === "insufficient_quota" || code === "quota_exceeded" || /\b(insufficient quota|quota (exceeded|exhausted)|exceeded.{0,20}quota|usage limit reached|credit balance.{0,20}(low|exhausted|insufficient))\b/i.test(message);
3510
+ const rate = statusCode === 429 || code === "rate_limit_exceeded" || /\b(rate[ -]limit(ed| exceeded| reached| hit)|too many requests)\b/i.test(message);
3511
+ if (!quota && !rate)
3512
+ return;
3513
+ const observedAt = new Date().toISOString();
3514
+ const headers = object(root.responseHeaders ?? data.responseHeaders);
3515
+ const retryAfter = root.retryAfter ?? data.retryAfter ?? headers["retry-after"];
3516
+ let retryAt;
3517
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) {
3518
+ retryAt = new Date(Date.now() + Math.min(retryAfter, 31536000) * 1000).toISOString();
3519
+ } else if (typeof retryAfter === "string") {
3520
+ if (/^\d+(\.\d+)?$/.test(retryAfter)) {
3521
+ retryAt = new Date(Date.now() + Math.min(Number(retryAfter), 31536000) * 1000).toISOString();
3522
+ } else if (Number.isFinite(Date.parse(retryAfter))) {
3523
+ retryAt = new Date(retryAfter).toISOString();
3524
+ }
3525
+ }
3526
+ return { kind: quota ? "quota" : "rate-limit", source, observedAt, ...model ? { model } : {}, ...statusCode !== undefined ? { statusCode } : {}, ...retryAt ? { retryAt } : {} };
3527
+ }
3528
+
3301
3529
  // src/server/worker-session.ts
3302
3530
  function createWorkerManager(host) {
3303
3531
  return {
@@ -3481,6 +3709,217 @@ function createGoalService(host) {
3481
3709
  throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active` + (ownedElsewhere ? ` (owned by session ${activeWriter.ownerSessionID}, not this session)` : "") + ". Pause, block, complete, or clear it before activating another workspace-writing goal." + (ownedElsewhere ? " Note: list_background_goals shows only this session's goals; clear/pause it from its owning session." : ""));
3482
3710
  }
3483
3711
  }
3712
+ async function listModels() {
3713
+ return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
3714
+ }
3715
+ async function validateModel(value) {
3716
+ const ref = parseModelRef(value);
3717
+ if (!ref)
3718
+ throw new Error("Model is required: providerID/modelID.");
3719
+ const model = `${ref.providerID}/${ref.modelID}`;
3720
+ const catalog = await listModels();
3721
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel) {
3722
+ return { model, catalog };
3723
+ }
3724
+ if (!catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
3725
+ throw new Error("Model is unavailable or not positively known usable. Discover models with loopd_list_models first.");
3726
+ }
3727
+ return { model, catalog };
3728
+ }
3729
+ async function applyModel(directory, goal, model, reason) {
3730
+ if (!goal.workerSessionID || !host.switchSessionModel)
3731
+ return "unsupported";
3732
+ const previousModel = parseModelRef(goal.config.model) ?? (await host.readSession(goal.workerSessionID))?.model;
3733
+ const outcome = await host.switchSessionModel(goal.workerSessionID, parseModelRef(model));
3734
+ if (outcome === "unsupported")
3735
+ return outcome;
3736
+ try {
3737
+ await mutateState(directory, `goal.model-switch:${goal.id}`, async (s) => {
3738
+ const current = s.goals.find((g) => g.id === goal.id);
3739
+ if (!current)
3740
+ throw new Error("Goal removed during model switch.");
3741
+ current.config.model = model;
3742
+ current.modelSwitch = {
3743
+ ...current.modelSwitch,
3744
+ pending: undefined,
3745
+ lastFailure: undefined,
3746
+ last: { from: goal.config.model, to: model, at: new Date().toISOString(), outcome, reason }
3747
+ };
3748
+ current.updatedAt = new Date().toISOString();
3749
+ return s;
3750
+ });
3751
+ } catch (error) {
3752
+ if (outcome === "applied") {
3753
+ if (!previousModel)
3754
+ throw new Error("Host accepted model switch but persistence failed; previous host model unknown, rollback unavailable.");
3755
+ await host.switchSessionModel(goal.workerSessionID, previousModel);
3756
+ }
3757
+ throw error;
3758
+ }
3759
+ await logServerEvent(directory, "goal.model-switched", { goalID: goal.id, workerSessionID: goal.workerSessionID, from: goal.config.model, to: model, reason, outcome });
3760
+ return outcome;
3761
+ }
3762
+ function switchModel(directory, goalID, ownerSessionID, value, opts) {
3763
+ return withGoalOperation(goalID, async () => {
3764
+ const state = await readState(directory);
3765
+ const goal = state.goals.find((g) => g.id === goalID);
3766
+ if (!goal || !ownerSessionID || goal.ownerSessionID !== ownerSessionID)
3767
+ throw new Error("Goal not found or not owned by this session.");
3768
+ if (goal.status === "complete")
3769
+ throw new Error("Cannot switch a completed goal.");
3770
+ if (opts?.resume && (goal.status !== "blocked" || goal.blocker?.kind !== "provider-limit")) {
3771
+ throw new Error("resume is only valid for a provider-limit-blocked goal.");
3772
+ }
3773
+ if (opts?.resume)
3774
+ assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
3775
+ const { model, catalog } = await validateModel(value);
3776
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel || !goal.workerSessionID) {
3777
+ return { outcome: "unsupported", model };
3778
+ }
3779
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
3780
+ const status = await host.sessionStatus(goal.workerSessionID);
3781
+ if (status !== "idle" || runtime?.phase === "running" && leaseIsValid(runtime)) {
3782
+ await mutateState(directory, `goal.model-deferred:${goalID}`, async (s) => {
3783
+ const current = s.goals.find((g) => g.id === goalID);
3784
+ if (current) {
3785
+ current.modelSwitch = { ...current.modelSwitch, pending: { model, requestedAt: new Date().toISOString(), reason: "owner" } };
3786
+ current.updatedAt = new Date().toISOString();
3787
+ }
3788
+ return s;
3789
+ });
3790
+ return { outcome: "deferred", model };
3791
+ }
3792
+ const applied = await applyModel(directory, goal, model, "owner");
3793
+ if (opts?.resume && applied !== "unsupported") {
3794
+ await mutateState(directory, `goal.model-resume:${goalID}`, async (s) => {
3795
+ const g = s.goals.find((g) => g.id === goalID);
3796
+ if (g?.status === "blocked" && g.blocker?.kind === "provider-limit") {
3797
+ assertWorkspaceWriteAvailable(s, g, ownerSessionID);
3798
+ g.status = "active";
3799
+ g.blocker = undefined;
3800
+ g.updatedAt = new Date().toISOString();
3801
+ }
3802
+ return s;
3803
+ });
3804
+ await continueTurnUnlocked(directory, goalID);
3805
+ }
3806
+ return { outcome: applied === "next-prompt" ? "deferred" : applied, model, ...opts?.resume && applied !== "unsupported" ? { resumed: true } : {} };
3807
+ });
3808
+ }
3809
+ async function applyPendingModel(directory, goal) {
3810
+ const pending = goal.modelSwitch?.pending;
3811
+ if (!pending)
3812
+ return;
3813
+ let reason = "catalog-unavailable";
3814
+ try {
3815
+ const { catalog } = await validateModel(pending.model);
3816
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported")
3817
+ throw new Error("Pending model switch catalog unavailable.");
3818
+ reason = "host-rejected";
3819
+ const result = await applyModel(directory, goal, pending.model, pending.reason);
3820
+ if (result === "unsupported")
3821
+ throw new Error("Pending model switching unsupported by host.");
3822
+ } catch {
3823
+ await mutateState(directory, `goal.model-switch-failed:${goal.id}`, async (s) => {
3824
+ const g = s.goals.find((g) => g.id === goal.id);
3825
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
3826
+ if (g) {
3827
+ g.modelSwitch = { ...g.modelSwitch, pending: undefined, lastFailure: { at: new Date().toISOString(), reason } };
3828
+ if (g.status === "active") {
3829
+ g.status = "blocked";
3830
+ g.blocker = { reason: "Pending model switch was not accepted.", needed: "Discover available models, switch the same goal explicitly, then retry when safe.", at: new Date().toISOString() };
3831
+ }
3832
+ g.updatedAt = new Date().toISOString();
3833
+ }
3834
+ if (rt) {
3835
+ Object.assign(rt, releaseLease(rt));
3836
+ rt.retryAfter = undefined;
3837
+ }
3838
+ return s;
3839
+ });
3840
+ throw new Error("Pending model switch was not accepted; goal blocked without replacing its worker.");
3841
+ }
3842
+ }
3843
+ async function observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel) {
3844
+ const state = await readState(directory);
3845
+ const goal = state.goals.find((g) => g.id === goalID);
3846
+ if (!goal)
3847
+ return;
3848
+ const observation = observeProviderLimit(error, source, expectedModel ?? goal.config.model);
3849
+ if (!observation)
3850
+ return;
3851
+ const fallbackModels = goal.config.fallbackModels ?? [];
3852
+ const attempted = new Set(goal.modelFallback?.attempted ?? []);
3853
+ if (goal.config.model)
3854
+ attempted.add(goal.config.model);
3855
+ let pending;
3856
+ let status = "disabled";
3857
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
3858
+ const quotaBlocked = goal.status === "blocked" && runtime?.lastError === describeError(error);
3859
+ const sameModel = expectedModel === undefined || expectedModel === goal.config.model;
3860
+ const eligible = sameModel && (goal.status === "active" || quotaBlocked) && (!goal.modelSwitch?.pending || goal.modelSwitch.pending.reason === "quota");
3861
+ if (eligible && goal.modelSwitch?.pending?.reason === "quota") {
3862
+ pending = { ...goal.modelSwitch.pending, reason: "quota" };
3863
+ status = "prepared";
3864
+ } else if (eligible && fallbackModels.length) {
3865
+ const catalog = await listModels();
3866
+ if (catalog.capability === "unsupported" || catalog.switching === "unsupported" || !host.switchSessionModel)
3867
+ status = "unsupported";
3868
+ else if (catalog.capability !== "supported")
3869
+ status = "unavailable";
3870
+ else {
3871
+ status = "exhausted";
3872
+ for (const value of fallbackModels) {
3873
+ if (attempted.has(value))
3874
+ continue;
3875
+ attempted.add(value);
3876
+ const ref = parseModelRef(value);
3877
+ if (ref && catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
3878
+ pending = { model: value, requestedAt: new Date().toISOString(), reason: "quota" };
3879
+ status = "prepared";
3880
+ break;
3881
+ }
3882
+ }
3883
+ }
3884
+ }
3885
+ await mutateState(directory, `goal.provider-limit:${goalID}`, async (s) => {
3886
+ const g = s.goals.find((g) => g.id === goalID);
3887
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
3888
+ if (!g)
3889
+ return s;
3890
+ g.lastProviderLimit = observation;
3891
+ if (sameModel && g.status === "blocked" && g.blocker && rt?.lastError === describeError(error))
3892
+ g.blocker.kind = "provider-limit";
3893
+ if (eligible) {
3894
+ g.modelFallback = { attempted: [...attempted], status };
3895
+ if (pending && (!g.modelSwitch?.pending || g.modelSwitch.pending.reason === "quota")) {
3896
+ g.modelSwitch = { ...g.modelSwitch, pending };
3897
+ }
3898
+ }
3899
+ if (rt?.phase === "waiting_retry" && observation.retryAt && Date.parse(observation.retryAt) > Date.parse(rt.retryAfter ?? ""))
3900
+ rt.retryAfter = observation.retryAt;
3901
+ g.updatedAt = new Date().toISOString();
3902
+ return s;
3903
+ });
3904
+ await logServerEvent(directory, "goal.provider-limit", { goalID, model: goal.config.model, kind: observation.kind, source, fallbackStatus: status, to: pending?.model });
3905
+ }
3906
+ function observeProviderError(directory, goalID, error, source, expectedModel) {
3907
+ return withGoalOperation(goalID, () => observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel));
3908
+ }
3909
+ function compact(directory, goalID) {
3910
+ return withGoalOperation(goalID, async () => {
3911
+ const state = await readState(directory);
3912
+ const goal = state.goals.find((g) => g.id === goalID);
3913
+ if (!goal?.workerSessionID || goal.status !== "active")
3914
+ return;
3915
+ if (await host.sessionStatus(goal.workerSessionID) !== "idle")
3916
+ return;
3917
+ await applyPendingModel(directory, goal);
3918
+ const fresh = (await readState(directory)).goals.find((g) => g.id === goalID);
3919
+ if (fresh?.workerSessionID)
3920
+ await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
3921
+ });
3922
+ }
3484
3923
  async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
3485
3924
  const detail = describeError(error);
3486
3925
  let runID = "unknown";
@@ -3539,6 +3978,7 @@ function createGoalService(host) {
3539
3978
  revision: state.revision
3540
3979
  });
3541
3980
  }
3981
+ await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
3542
3982
  }
3543
3983
  async function ensureWorkerSession(directory, goal) {
3544
3984
  let session = sessions.get(goal.id);
@@ -3573,6 +4013,20 @@ function createGoalService(host) {
3573
4013
  return withGoalOperation(id, () => startUnlocked(directory, input, id));
3574
4014
  }
3575
4015
  async function startUnlocked(directory, input, id) {
4016
+ if (input.config?.fallbackModels?.length) {
4017
+ if (input.config.fallbackModels.length > 16)
4018
+ throw new Error("At most 16 ordered fallback models are allowed.");
4019
+ const catalog = await listModels();
4020
+ for (const value of input.config.fallbackModels) {
4021
+ const ref = parseModelRef(value);
4022
+ if (!ref)
4023
+ throw new Error("Fallback model must be providerID/modelID.");
4024
+ if (catalog.capability === "supported" && !catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
4025
+ throw new Error("Fallback model is unavailable or not positively known usable.");
4026
+ }
4027
+ }
4028
+ input.config.fallbackModels = [...new Set(input.config.fallbackModels.map((m) => m.trim()))];
4029
+ }
3576
4030
  let parentAgent = input.parentAgent;
3577
4031
  let parentModel = input.parentModel;
3578
4032
  if ((!parentAgent || !parentModel) && host.readSession) {
@@ -3598,6 +4052,8 @@ function createGoalService(host) {
3598
4052
  });
3599
4053
  if (typeof input.costBudget === "number")
3600
4054
  goal.costBudget = input.costBudget;
4055
+ if (input.interactive === true)
4056
+ goal.interactive = true;
3601
4057
  if (parentAgent)
3602
4058
  goal.parentAgent = parentAgent;
3603
4059
  if (parentModel)
@@ -3800,6 +4256,11 @@ function createGoalService(host) {
3800
4256
  return;
3801
4257
  if (!opts?.force && !await workers.isIdle(session.workerSessionID))
3802
4258
  return;
4259
+ if (goal.modelSwitch?.pending) {
4260
+ if (await host.sessionStatus(session.workerSessionID) !== "idle")
4261
+ return;
4262
+ await applyPendingModel(directory, goal);
4263
+ }
3803
4264
  let acquired = false;
3804
4265
  const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
3805
4266
  const g = s.goals.find((item) => item.id === goalID);
@@ -3902,6 +4363,38 @@ function createGoalService(host) {
3902
4363
  throw error;
3903
4364
  }
3904
4365
  }
4366
+ async function setInteractiveUnlocked(directory, goalID, interactive) {
4367
+ const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
4368
+ const g = s.goals.find((item) => item.id === goalID);
4369
+ if (!g)
4370
+ return s;
4371
+ if (interactive)
4372
+ g.interactive = true;
4373
+ else
4374
+ delete g.interactive;
4375
+ g.updatedAt = new Date().toISOString();
4376
+ return s;
4377
+ });
4378
+ const goal = state.goals.find((g) => g.id === goalID);
4379
+ if (!goal)
4380
+ return { ok: false, message: "Goal not found." };
4381
+ await appendEvent(directory, {
4382
+ version: 1,
4383
+ eventID: randomUUID5(),
4384
+ goalID,
4385
+ type: "goal.interactive_changed",
4386
+ interactive,
4387
+ timestamp: new Date().toISOString(),
4388
+ revision: state.revision
4389
+ }).catch(() => {});
4390
+ return {
4391
+ ok: true,
4392
+ message: interactive ? `Goal "${goal.name}" is now manual: the engine will not start turns \u2014 steer it with :send/nudge.` : `Goal "${goal.name}" is back on auto: the engine resumes idle recovery and retries.`
4393
+ };
4394
+ }
4395
+ function setInteractive(directory, goalID, interactive) {
4396
+ return withGoalOperation(goalID, () => setInteractiveUnlocked(directory, goalID, interactive));
4397
+ }
3905
4398
  async function pauseUnlocked(directory, goalID) {
3906
4399
  const preState = await readState(directory);
3907
4400
  const goal = preState.goals.find((g) => g.id === goalID);
@@ -4264,7 +4757,7 @@ function createGoalService(host) {
4264
4757
  function accountUsage(directory, goalID) {
4265
4758
  return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
4266
4759
  }
4267
- return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage };
4760
+ return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, observeProviderError, compact };
4268
4761
  }
4269
4762
 
4270
4763
  // src/application/schedule-worker.ts
@@ -6793,6 +7286,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6793
7286
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
6794
7287
  agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Prefer omit \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent. Only pass when explicitly requested.`),
6795
7288
  model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Prefer omit \u2014 inherits the calling session's live model if omitted, else plugin defaultModel. Only pass when explicitly requested.`),
7289
+ fallbackModels: tool.schema.array(tool.schema.string()).optional().describe("Explicit ordered providerID/modelID alternatives for provider quota/rate-limit failures only. Default empty: no automatic paid/provider switch. Discover with loopd_list_models; same worker/session retained. Interactive goals never auto-wake."),
6796
7290
  costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
6797
7291
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
6798
7292
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
@@ -6804,7 +7298,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6804
7298
  compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
6805
7299
  timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
6806
7300
  scheduleEveryMs: tool.schema.number().optional().describe("Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction."),
6807
- scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs.")
7301
+ scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs."),
7302
+ interactive: tool.schema.boolean().optional().describe("Manual mode: the engine never starts turns on its own (no idle recovery, no retries). Every turn comes from explicit steering (:send, nudge, resume, retry). Use for interactive tasks where the worker stops and waits for the next input \u2014 the tick forcing a turn there wastes tokens and breaks the wait. Toggleable later via the dashboard :interactive command.")
6808
7303
  },
6809
7304
  execute: async (args, context) => {
6810
7305
  const sessionID = context?.sessionID || hostSessionID;
@@ -6824,6 +7319,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6824
7319
  config.agent = args.agent;
6825
7320
  if (args.model)
6826
7321
  config.model = args.model;
7322
+ if (args.fallbackModels)
7323
+ config.fallbackModels = args.fallbackModels;
6827
7324
  if (args.checks)
6828
7325
  config.checks = args.checks;
6829
7326
  if (args.checkCwd)
@@ -6899,7 +7396,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6899
7396
  config: resolution.config,
6900
7397
  costBudget,
6901
7398
  parentAgent: parentDefaults.parentAgent,
6902
- parentModel: parentDefaults.parentModel
7399
+ parentModel: parentDefaults.parentModel,
7400
+ ...args.interactive === true ? { interactive: true } : {}
6903
7401
  });
6904
7402
  return {
6905
7403
  title: "Goal created",
@@ -6913,6 +7411,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6913
7411
  agent: resolution.config.agent,
6914
7412
  model: resolution.config.model,
6915
7413
  costBudget: goal.costBudget,
7414
+ interactive: goal.interactive === true,
6916
7415
  checks: resolution.config.checks || [],
6917
7416
  workspaceWrite: resolution.config.workspaceWrite,
6918
7417
  defaultsApplied: resolution.defaultsApplied,
@@ -7457,6 +7956,53 @@ function withTimeout2(promise, ms) {
7457
7956
  function ownerTools(options) {
7458
7957
  const { directory, host, goalService } = options;
7459
7958
  return {
7959
+ loopd_list_models: tool2({
7960
+ description: "Discover host providers/models usable by this agent and inspect quota limitations plus assigned identities of this session's existing goals. " + "Remaining quota is unknown unless a supported integration reports it; connected/enabled is not remaining balance. " + "Workflow: discover -> inspect_background_goal for last local quota error -> switch_goal_model on the SAME goal, never create a duplicate to change provider. No secrets/auth data are returned.",
7961
+ args: {},
7962
+ execute: async (_args, context) => {
7963
+ if (!context?.sessionID)
7964
+ return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
7965
+ const [catalog, state] = await Promise.all([goalService.listModels(), readState(directory)]);
7966
+ return {
7967
+ title: "Provider/model capabilities",
7968
+ output: JSON.stringify({
7969
+ ok: true,
7970
+ ...catalog,
7971
+ assignedGoals: state.goals.filter((g) => g.ownerSessionID === context.sessionID).map((g) => ({
7972
+ id: g.id,
7973
+ model: g.config.model,
7974
+ workerSessionID: g.workerSessionID,
7975
+ modelSwitch: g.modelSwitch,
7976
+ modelFallback: g.modelFallback,
7977
+ fallbackModels: g.config.fallbackModels ?? [],
7978
+ lastProviderLimit: g.lastProviderLimit
7979
+ }))
7980
+ }, null, 2)
7981
+ };
7982
+ }
7983
+ }),
7984
+ switch_goal_model: tool2({
7985
+ description: "Switch provider/model for an EXISTING goal owned by this session, preserving goal ID, worker session, transcript, checks and budgets. " + "First discover usable models with loopd_list_models, then inspect_background_goal for quota/error information. " + "Busy workers defer to the next safe turn; v1 changes apply on the next prompt. No abort, duplicate worker, budget reset, unpause or interactive wake. " + "Returns applied/deferred/unsupported honestly; an unsupported catalog cannot validate a switch. resume=true explicitly resumes ONLY a provider-limit-blocked goal and requests its next turn (including interactive); never resets budgets.",
7986
+ args: {
7987
+ goal_id: tool2.schema.string().describe("Existing owned goal ID; never create a replacement goal to switch models."),
7988
+ model: tool2.schema.string().describe("Available providerID/modelID from loopd_list_models."),
7989
+ resume: tool2.schema.boolean().optional().describe("Explicitly resume only a quota/rate-limit-blocked goal after a safe switch. Default false. Paused/budget-limited/unrelated-blocked goals are not resumed.")
7990
+ },
7991
+ execute: async (args, context) => {
7992
+ if (!context?.sessionID)
7993
+ return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
7994
+ const state = await readState(directory);
7995
+ if (!state.goals.some((g) => g.id === args.goal_id && g.ownerSessionID === context.sessionID)) {
7996
+ return { title: "Switch denied", output: JSON.stringify({ ok: false, message: "Goal not found or not owned by this session." }) };
7997
+ }
7998
+ try {
7999
+ const result = await goalService.switchModel(directory, args.goal_id, context.sessionID, args.model, { resume: args.resume });
8000
+ return { title: `Model switch: ${result.outcome}`, output: JSON.stringify({ ok: result.outcome !== "unsupported", ...result, goalID: args.goal_id, message: "Same goal and worker retained. Deferred requests require the next permitted turn; no automatic interactive wake." }) };
8001
+ } catch {
8002
+ return { title: "Model switch failed", output: JSON.stringify({ ok: false, message: "Switch rejected: verify ownership, model availability and host capability with loopd_list_models and inspect_background_goal. Existing goal/session retained." }) };
8003
+ }
8004
+ }
8005
+ }),
7460
8006
  list_background_goals: tool2({
7461
8007
  description: "List all active background goals owned by this session. Shows contract (name, status, phase, turn, last progress, blocker) \u2014 only goals with your ownerSessionID appear.",
7462
8008
  args: {},
@@ -7498,6 +8044,11 @@ function ownerTools(options) {
7498
8044
  maxTurns: g.config.maxTurns,
7499
8045
  agent: g.config.agent,
7500
8046
  model: g.config.model,
8047
+ modelSwitch: g.modelSwitch,
8048
+ modelFallback: g.modelFallback,
8049
+ lastProviderLimit: g.lastProviderLimit,
8050
+ fallbackModels: g.config.fallbackModels ?? [],
8051
+ quotaLimitation: "Remaining provider quota unknown; connected/enabled is not balance data. See loopd_list_models.",
7501
8052
  lastProgress: g.lastProgress?.summary?.slice(0, 120),
7502
8053
  lastProgressAt: g.lastProgress?.at,
7503
8054
  blocker: g.blocker?.reason?.slice(0, 120),
@@ -7594,11 +8145,16 @@ function ownerTools(options) {
7594
8145
  workspaceWrite: goal.config.workspaceWrite,
7595
8146
  agent: goal.config.agent,
7596
8147
  model: goal.config.model,
8148
+ fallbackModels: goal.config.fallbackModels ?? [],
7597
8149
  parentAgent: goal.parentAgent,
7598
8150
  parentModel: goal.parentModel,
7599
8151
  schedule: goal.config.schedule
7600
8152
  },
7601
8153
  lastProgress: goal.lastProgress,
8154
+ modelSwitch: goal.modelSwitch,
8155
+ modelFallback: goal.modelFallback,
8156
+ lastProviderLimit: goal.lastProviderLimit,
8157
+ quotaLimitation: "Remaining provider quota unknown; local quota errors are observations, not balance data. See loopd_list_models.",
7602
8158
  completionEvidence: goal.completionEvidence,
7603
8159
  blocker: goal.blocker,
7604
8160
  tokensUsed: goal.tokensUsed,
@@ -8341,7 +8897,7 @@ function commandTools(options) {
8341
8897
  // src/server/plugin.ts
8342
8898
  init_state_repository();
8343
8899
  // package.json
8344
- var version = "1.10.5";
8900
+ var version = "1.11.2";
8345
8901
 
8346
8902
  // src/server/plugin.ts
8347
8903
  var PLUGIN_ID = "opencode-loopd.server";