@bojackduy/opencode-loopd 1.10.5 → 1.12.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
@@ -129,6 +129,7 @@ var PARENT_NOTIFY_DEDUPE_MS = 60000;
129
129
  import { promises as fs } from "fs";
130
130
  import path from "path";
131
131
  import os from "os";
132
+ import { createHash } from "crypto";
132
133
  function emptyState() {
133
134
  return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [], commands: [] };
134
135
  }
@@ -142,23 +143,32 @@ function eventsFile(directory) {
142
143
  return path.join(loopDir(directory), "events.ndjson");
143
144
  }
144
145
  function lockDir(directory) {
145
- const projectHash = Buffer.from(directory).toString("base64url").slice(0, 32);
146
+ const projectHash = createHash("sha256").update(path.resolve(directory)).digest("hex");
146
147
  return path.join(os.tmpdir(), "loopd-locks", projectHash);
147
148
  }
148
149
  function lockFile(directory, key) {
149
150
  return path.join(lockDir(directory), `${key}.lock`);
150
151
  }
152
+ function processIsAlive(pid) {
153
+ try {
154
+ process.kill(pid, 0);
155
+ return true;
156
+ } catch (error) {
157
+ return error?.code !== "ESRCH";
158
+ }
159
+ }
151
160
  async function acquireLock(directory, key, operation) {
152
161
  const dir = lockDir(directory);
153
162
  await fs.mkdir(dir, { recursive: true });
154
163
  const lockPath = lockFile(directory, key);
155
- for (let attempt = 0;attempt < 10; attempt++) {
164
+ const deadline = performance.now() + LOCK_TIMEOUT_MS;
165
+ for (let attempt = 0;performance.now() < deadline; attempt++) {
156
166
  try {
157
167
  try {
158
168
  const raw = await fs.readFile(lockPath, "utf8");
159
169
  const meta = JSON.parse(raw);
160
170
  const age = Date.now() - Date.parse(meta.acquiredAt);
161
- if (age > LOCK_STALE_MS) {
171
+ if (age > LOCK_STALE_MS && !processIsAlive(meta.pid)) {
162
172
  await fs.rm(lockPath, { force: true });
163
173
  }
164
174
  } catch {}
@@ -178,17 +188,16 @@ async function acquireLock(directory, key, operation) {
178
188
  throw error;
179
189
  }
180
190
  }
181
- await delay(25 * (attempt + 1));
191
+ await delay(Math.min(25 * (attempt + 1), 250, Math.max(0, deadline - performance.now())));
182
192
  }
183
- throw new Error(`failed to acquire lock "${key}" for "${operation}" after retries`);
193
+ throw new Error(`failed to acquire lock "${key}" for "${operation}" after ${LOCK_TIMEOUT_MS}ms`);
184
194
  }
185
195
  async function releaseLock(directory, key) {
186
196
  const lockPath = lockFile(directory, key);
187
197
  try {
188
198
  const raw = await fs.readFile(lockPath, "utf8");
189
199
  const meta = JSON.parse(raw);
190
- const age = Date.now() - Date.parse(meta.acquiredAt);
191
- const shouldRelease = meta.pid === process.pid || age > LOCK_STALE_MS;
200
+ const shouldRelease = meta.pid === process.pid;
192
201
  if (!shouldRelease)
193
202
  return;
194
203
  try {
@@ -563,7 +572,7 @@ async function removeCommandLog(directory, commandID) {
563
572
  function delay(ms) {
564
573
  return new Promise((resolve) => setTimeout(resolve, ms));
565
574
  }
566
- var CURRENT_VERSION = 9, LOCK_STALE_MS = 1e4;
575
+ var CURRENT_VERSION = 9, LOCK_TIMEOUT_MS = 30000, LOCK_STALE_MS = 1e4;
567
576
  var init_state_repository = () => {};
568
577
 
569
578
  // src/domain/command-await.ts
@@ -1545,6 +1554,69 @@ function createControlWorker(options) {
1545
1554
  };
1546
1555
  break;
1547
1556
  }
1557
+ case "list_agents": {
1558
+ const catalog = await goalSvc.listAgents();
1559
+ const state = await readState(directory);
1560
+ response = { ...base, ok: catalog.capability === "supported", stateRevision: state.revision, message: `Agents [${catalog.capability}/${catalog.switching}]: ${catalog.agents.map((a) => `${a.name} (${a.mode})`).join(", ") || "none"}` };
1561
+ break;
1562
+ }
1563
+ case "switch_goal_identity": {
1564
+ if (!request.goalID) {
1565
+ response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
1566
+ break;
1567
+ }
1568
+ const args = request.args;
1569
+ if (args?.model === undefined && args?.agent === undefined || args?.model !== undefined && (typeof args.model !== "string" || !args.model.trim()) || args?.agent !== undefined && (typeof args.agent !== "string" || !args.agent.trim())) {
1570
+ response = { ...base, ok: false, message: "At least one of model (providerID/modelID) or agent is required; provided fields must be non-empty strings.", errorCode: "bad_request" };
1571
+ break;
1572
+ }
1573
+ const ownerForSwitch = (await readState(directory)).goals.find((g) => g.id === request.goalID)?.ownerSessionID ?? "";
1574
+ const result = await goalSvc.switchIdentity(directory, request.goalID, ownerForSwitch, { model: args?.model, agent: args?.agent, resume: args?.resume === true });
1575
+ const state = await readState(directory);
1576
+ const outcomeMessage = {
1577
+ applied: `Identity switched to ${[result.model, result.agent].filter(Boolean).join(" / ")} on the same worker/session.`,
1578
+ deferred: `Identity switch deferred until the next permitted idle turn/prompt; no implicit wake.`,
1579
+ unsupported: `Identity switch unsupported on this host (see loopd_list_models). Goal/session retained.`
1580
+ };
1581
+ response = {
1582
+ ...base,
1583
+ ok: result.outcome !== "unsupported",
1584
+ message: outcomeMessage[result.outcome] ?? `Identity switch ${result.outcome}.`,
1585
+ stateRevision: state.revision
1586
+ };
1587
+ break;
1588
+ }
1589
+ case "list_models": {
1590
+ const catalog = await goalSvc.listModels();
1591
+ const state = await readState(directory);
1592
+ response = {
1593
+ ...base,
1594
+ ok: catalog.capability !== "unavailable",
1595
+ message: `Models: ${catalog.models.length} across ${catalog.providers.length} provider(s) [${catalog.capability}/${catalog.switching}]. Quota: ${catalog.quota.status} \u2014 ${catalog.quota.limitation}`,
1596
+ stateRevision: state.revision
1597
+ };
1598
+ break;
1599
+ }
1600
+ case "set_interactive": {
1601
+ if (!request.goalID) {
1602
+ response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
1603
+ break;
1604
+ }
1605
+ const interactive = request.args?.interactive;
1606
+ if (typeof interactive !== "boolean") {
1607
+ response = { ...base, ok: false, message: "interactive (boolean) is required", errorCode: "bad_request" };
1608
+ break;
1609
+ }
1610
+ const result = await goalSvc.setInteractive(directory, request.goalID, interactive);
1611
+ const state = await readState(directory);
1612
+ response = {
1613
+ ...base,
1614
+ ok: result.ok,
1615
+ message: result.message,
1616
+ stateRevision: state.revision
1617
+ };
1618
+ break;
1619
+ }
1548
1620
  case "send": {
1549
1621
  const args = request.args;
1550
1622
  const text = String(args.message || "").trim();
@@ -1957,6 +2029,8 @@ function createLoopEngine(options) {
1957
2029
  knownWorkerSessions.add(goal.workerSessionID);
1958
2030
  if (goal.status !== "active")
1959
2031
  return false;
2032
+ if (goal.interactive === true && type === "session.idle")
2033
+ return false;
1960
2034
  switch (type) {
1961
2035
  case "session.idle":
1962
2036
  return await handleSessionIdle(state, goal);
@@ -2345,6 +2419,7 @@ function createLoopEngine(options) {
2345
2419
  revision: newState.revision
2346
2420
  });
2347
2421
  const updatedGoal = newState.goals.find((g) => g.id === goal.id);
2422
+ await goalService.observeProviderError(directory, goal.id, error, "session-error", goal.config.model);
2348
2423
  if (updatedGoal?.status === "blocked") {
2349
2424
  await appendEvent(directory, {
2350
2425
  version: 1,
@@ -2453,7 +2528,7 @@ function createLoopEngine(options) {
2453
2528
  revision: state.revision
2454
2529
  });
2455
2530
  try {
2456
- await host.compactSession(goal.workerSessionID);
2531
+ await goalService.compact(directory, goal.id);
2457
2532
  } catch {}
2458
2533
  await mutateState(directory, `compact.end:${goal.id}`, async (s) => {
2459
2534
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2568,7 +2643,8 @@ function createLoopEngine(options) {
2568
2643
  if (stopped)
2569
2644
  continue;
2570
2645
  }
2571
- if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
2646
+ const manual = goal.interactive === true;
2647
+ if (!manual && runtime.phase === "waiting_retry" && runtime.retryAfter) {
2572
2648
  if (Date.now() >= Date.parse(runtime.retryAfter)) {
2573
2649
  await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
2574
2650
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2581,7 +2657,7 @@ function createLoopEngine(options) {
2581
2657
  goalService.continueTurn(directory, goal.id).catch(() => {});
2582
2658
  }
2583
2659
  }
2584
- if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
2660
+ if (!manual && (runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
2585
2661
  if (runtime.phase === "idle" && runtime.activeRunID) {
2586
2662
  await mutateState(directory, `maintenance.clear-stale-run:${goal.id}`, async (s) => {
2587
2663
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2886,6 +2962,111 @@ var nativeRpcDefinition = {
2886
2962
  }
2887
2963
  };
2888
2964
 
2965
+ // src/server/model-catalog.ts
2966
+ function emptyCatalog(source, switching, capability = "unsupported") {
2967
+ const observedAt = new Date().toISOString();
2968
+ return {
2969
+ capability,
2970
+ source,
2971
+ observedAt,
2972
+ switching,
2973
+ providers: [],
2974
+ models: [],
2975
+ quota: {
2976
+ status: "unknown",
2977
+ capability: "unsupported",
2978
+ source,
2979
+ observedAt,
2980
+ 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."
2981
+ }
2982
+ };
2983
+ }
2984
+ function record(value) {
2985
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
2986
+ }
2987
+ function model(raw, providerID, usable) {
2988
+ const value = record(raw);
2989
+ if (typeof value.id !== "string" || !value.id)
2990
+ return;
2991
+ const limit = record(value.limit);
2992
+ return {
2993
+ providerID,
2994
+ modelID: value.id,
2995
+ name: typeof value.name === "string" ? value.name : value.id,
2996
+ usable,
2997
+ ...typeof limit.context === "number" && Number.isFinite(limit.context) ? { contextLimit: limit.context } : {},
2998
+ ...typeof limit.output === "number" && Number.isFinite(limit.output) ? { outputLimit: limit.output } : {}
2999
+ };
3000
+ }
3001
+ function normalizeV1Catalog(raw) {
3002
+ const result = emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
3003
+ const value = record(raw);
3004
+ if (!Array.isArray(value.all) || !Array.isArray(value.connected))
3005
+ return result;
3006
+ result.capability = "supported";
3007
+ const connected = new Set(value.connected.filter((id) => typeof id === "string"));
3008
+ for (const rawProvider of value.all) {
3009
+ const p = record(rawProvider);
3010
+ if (typeof p.id !== "string")
3011
+ continue;
3012
+ const available = connected.has(p.id);
3013
+ result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: available });
3014
+ for (const rawModel of Object.values(record(p.models))) {
3015
+ const m = model(rawModel, p.id, available);
3016
+ if (m)
3017
+ result.models.push(m);
3018
+ }
3019
+ }
3020
+ return result;
3021
+ }
3022
+ function normalizeV2Catalog(rawProviders, rawModels) {
3023
+ const result = emptyCatalog("v2 provider.list + model.list", "session", "unavailable");
3024
+ const providers = record(rawProviders).data;
3025
+ const models = record(rawModels).data;
3026
+ if (!Array.isArray(providers) || !Array.isArray(models))
3027
+ return result;
3028
+ result.capability = "supported";
3029
+ const disabled = new Set;
3030
+ for (const rawProvider of providers) {
3031
+ const p = record(rawProvider);
3032
+ if (typeof p.id !== "string")
3033
+ continue;
3034
+ if (p.activation === "disabled")
3035
+ disabled.add(p.id);
3036
+ result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: "unknown" });
3037
+ }
3038
+ const knownProviders = new Set(result.providers.map((p) => p.providerID));
3039
+ for (const rawModel of models) {
3040
+ const value = record(rawModel);
3041
+ if (typeof value.providerID !== "string" || !knownProviders.has(value.providerID))
3042
+ continue;
3043
+ const usable = disabled.has(value.providerID) || value.enabled === false ? false : value.enabled === true ? true : "unknown";
3044
+ const m = model(value, value.providerID, usable);
3045
+ if (m)
3046
+ result.models.push(m);
3047
+ }
3048
+ return result;
3049
+ }
3050
+
3051
+ // src/server/agent-catalog.ts
3052
+ function emptyAgentCatalog(source, switching, capability = "unsupported") {
3053
+ return { capability, source, observedAt: new Date().toISOString(), switching, agents: [] };
3054
+ }
3055
+ function normalizeAgentCatalog(raw, source, switching) {
3056
+ const result = emptyAgentCatalog(source, switching, "unavailable");
3057
+ if (!Array.isArray(raw))
3058
+ return result;
3059
+ result.capability = "supported";
3060
+ for (const value of raw) {
3061
+ if (!value || typeof value !== "object" || typeof value.name !== "string" || !value.name.trim())
3062
+ continue;
3063
+ if (value.mode !== "primary" && value.mode !== "subagent" && value.mode !== "all")
3064
+ continue;
3065
+ result.agents.push({ name: value.name, mode: value.mode, ...typeof value.description === "string" ? { description: value.description } : {} });
3066
+ }
3067
+ return result;
3068
+ }
3069
+
2889
3070
  // src/server/host-adapter.ts
2890
3071
  function parseModelRef(value) {
2891
3072
  if (value === undefined)
@@ -2929,6 +3110,42 @@ function markParentNotified2(ownerSessionID, message) {
2929
3110
  }
2930
3111
  function createRealHost(client, directory) {
2931
3112
  return {
3113
+ async listAgents() {
3114
+ const switching = typeof client.session?.switchAgent === "function" ? "session" : "next-prompt";
3115
+ if (typeof client.agent?.list !== "function")
3116
+ return emptyAgentCatalog("v1 agent.list", switching);
3117
+ try {
3118
+ const result = await withTimeout(client.agent.list({ query: { directory } }), 1e4, "OpenCode agent.list");
3119
+ if (result?.error)
3120
+ return emptyAgentCatalog("v1 agent.list", switching, "unavailable");
3121
+ return normalizeAgentCatalog(result?.data, "v1 agent.list", switching);
3122
+ } catch {
3123
+ return emptyAgentCatalog("v1 agent.list", switching, "unavailable");
3124
+ }
3125
+ },
3126
+ async switchSessionAgent(sessionID, agent) {
3127
+ if (typeof client.session?.switchAgent !== "function")
3128
+ return "next-prompt";
3129
+ const result = await withTimeout(client.session.switchAgent({ path: { id: sessionID }, body: { agent } }), 1e4, "OpenCode session.switchAgent");
3130
+ if (result?.error)
3131
+ throw new Error("OpenCode session.switchAgent rejected the agent.");
3132
+ return "applied";
3133
+ },
3134
+ async listModels() {
3135
+ if (typeof client.provider?.list !== "function")
3136
+ return emptyCatalog("v1 provider.list", "next-prompt");
3137
+ try {
3138
+ const result = await withTimeout(client.provider.list({ query: { directory } }), 1e4, "OpenCode provider.list");
3139
+ if (result?.error)
3140
+ return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
3141
+ return normalizeV1Catalog(result?.data);
3142
+ } catch {
3143
+ return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
3144
+ }
3145
+ },
3146
+ async switchSessionModel() {
3147
+ return "next-prompt";
3148
+ },
2932
3149
  async createWorker({ parentID, title, agent, model }) {
2933
3150
  try {
2934
3151
  const body = { parentID, title };
@@ -2971,7 +3188,7 @@ function createRealHost(client, directory) {
2971
3188
  if (result?.error) {
2972
3189
  const detail = describeError(result.error);
2973
3190
  await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
2974
- throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
3191
+ throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`, { cause: result.error });
2975
3192
  }
2976
3193
  await logServerEvent(directory, "worker.prompted", { sessionID });
2977
3194
  return { messageID: result?.data?.messageID };
@@ -3064,9 +3281,12 @@ function createRealHost(client, directory) {
3064
3281
  return [];
3065
3282
  }
3066
3283
  },
3067
- async compactSession(sessionID) {
3284
+ async compactSession(sessionID, model) {
3068
3285
  try {
3069
- await client.session.compact({ sessionID });
3286
+ const identity = model ?? (await this.readSession(sessionID))?.model;
3287
+ if (!identity || typeof client.session.summarize !== "function")
3288
+ return;
3289
+ await client.session.summarize({ path: { id: sessionID }, body: identity });
3070
3290
  } catch {}
3071
3291
  },
3072
3292
  async notifyOwner(ownerSessionID, message, agent) {
@@ -3112,6 +3332,46 @@ function toWorkerCreation(result) {
3112
3332
  function createV2Host(context, statuses, options = {}) {
3113
3333
  const directory = context.location.directory;
3114
3334
  return {
3335
+ async listAgents() {
3336
+ const switching = typeof context.session.switchAgent === "function" ? "session" : "unsupported";
3337
+ if (typeof context.agent?.list !== "function")
3338
+ return emptyAgentCatalog("v2 agent.list", switching);
3339
+ try {
3340
+ const result = await context.agent.list({ location: { directory } });
3341
+ return normalizeAgentCatalog(result.data, "v2 agent.list", switching);
3342
+ } catch {
3343
+ return emptyAgentCatalog("v2 agent.list", switching, "unavailable");
3344
+ }
3345
+ },
3346
+ async switchSessionAgent(sessionID, agent) {
3347
+ if (typeof context.session.switchAgent !== "function")
3348
+ return "unsupported";
3349
+ await context.session.switchAgent({ sessionID, agent });
3350
+ return "applied";
3351
+ },
3352
+ async listModels() {
3353
+ if (typeof context.provider?.list !== "function" || typeof context.model?.list !== "function") {
3354
+ return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported");
3355
+ }
3356
+ try {
3357
+ const [providers, models] = await Promise.all([
3358
+ context.provider.list({ location: { directory } }),
3359
+ context.model.list({ location: { directory } })
3360
+ ]);
3361
+ const catalog = normalizeV2Catalog(providers, models);
3362
+ if (typeof context.session.switchModel !== "function")
3363
+ catalog.switching = "unsupported";
3364
+ return catalog;
3365
+ } catch {
3366
+ return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported", "unavailable");
3367
+ }
3368
+ },
3369
+ async switchSessionModel(sessionID, model) {
3370
+ if (typeof context.session.switchModel !== "function")
3371
+ return "unsupported";
3372
+ await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
3373
+ return "applied";
3374
+ },
3115
3375
  async createWorker({ parentID, title, agent, model, goalID }) {
3116
3376
  if (options.native) {
3117
3377
  let outcome;
@@ -3266,8 +3526,13 @@ function createV2Host(context, statuses, options = {}) {
3266
3526
  return [];
3267
3527
  }
3268
3528
  },
3269
- async compactSession(sessionID) {
3529
+ async compactSession(sessionID, model) {
3270
3530
  try {
3531
+ if (model) {
3532
+ if (typeof context.session.switchModel !== "function")
3533
+ return;
3534
+ await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
3535
+ }
3271
3536
  await context.session.command({ sessionID, name: "compact", text: "" });
3272
3537
  } catch {}
3273
3538
  },
@@ -3298,6 +3563,40 @@ async function withTimeout(promise, timeoutMs, operation) {
3298
3563
  }
3299
3564
  }
3300
3565
 
3566
+ // src/domain/provider-limit.ts
3567
+ function object(value) {
3568
+ return value !== null && typeof value === "object" ? value : {};
3569
+ }
3570
+ function observeProviderLimit(error, source, model) {
3571
+ const wrapper = object(error);
3572
+ const root = wrapper.cause ? object(wrapper.cause) : wrapper;
3573
+ const data = object(root.data);
3574
+ const status = root.statusCode ?? root.status ?? data.statusCode ?? data.status;
3575
+ const statusCode = typeof status === "number" ? status : undefined;
3576
+ const code = root.code ?? data.code;
3577
+ const message = [root.message, data.message, typeof error === "string" ? error : undefined].filter((s) => typeof s === "string").join(" ");
3578
+ 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))
3579
+ return;
3580
+ 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);
3581
+ const rate = statusCode === 429 || code === "rate_limit_exceeded" || /\b(rate[ -]limit(ed| exceeded| reached| hit)|too many requests)\b/i.test(message);
3582
+ if (!quota && !rate)
3583
+ return;
3584
+ const observedAt = new Date().toISOString();
3585
+ const headers = object(root.responseHeaders ?? data.responseHeaders);
3586
+ const retryAfter = root.retryAfter ?? data.retryAfter ?? headers["retry-after"];
3587
+ let retryAt;
3588
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) {
3589
+ retryAt = new Date(Date.now() + Math.min(retryAfter, 31536000) * 1000).toISOString();
3590
+ } else if (typeof retryAfter === "string") {
3591
+ if (/^\d+(\.\d+)?$/.test(retryAfter)) {
3592
+ retryAt = new Date(Date.now() + Math.min(Number(retryAfter), 31536000) * 1000).toISOString();
3593
+ } else if (Number.isFinite(Date.parse(retryAfter))) {
3594
+ retryAt = new Date(retryAfter).toISOString();
3595
+ }
3596
+ }
3597
+ return { kind: quota ? "quota" : "rate-limit", source, observedAt, ...model ? { model } : {}, ...statusCode !== undefined ? { statusCode } : {}, ...retryAt ? { retryAt } : {} };
3598
+ }
3599
+
3301
3600
  // src/server/worker-session.ts
3302
3601
  function createWorkerManager(host) {
3303
3602
  return {
@@ -3481,6 +3780,466 @@ function createGoalService(host) {
3481
3780
  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
3781
  }
3483
3782
  }
3783
+ async function listModels() {
3784
+ return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
3785
+ }
3786
+ async function listAgents() {
3787
+ return host.listAgents ? host.listAgents() : emptyAgentCatalog("host agent catalog capability absent", "unsupported");
3788
+ }
3789
+ async function validateAgent(value) {
3790
+ const agent = value.trim();
3791
+ if (!agent)
3792
+ throw new Error("Agent name is required.");
3793
+ const catalog = await listAgents();
3794
+ if (catalog.capability === "supported" && !catalog.agents.some((a) => a.name === agent)) {
3795
+ throw new Error("Agent is unavailable. Discover agents with loopd_list_models first.");
3796
+ }
3797
+ return { agent, catalog };
3798
+ }
3799
+ async function applyAgent(directory, goal, agent, persist = true) {
3800
+ if (!goal.workerSessionID || !host.switchSessionAgent)
3801
+ return "unsupported";
3802
+ const previousAgent = goal.config.agent ?? (await host.readSession(goal.workerSessionID))?.agent;
3803
+ const outcome = await host.switchSessionAgent(goal.workerSessionID, agent);
3804
+ if (outcome === "unsupported")
3805
+ return outcome;
3806
+ if (!persist)
3807
+ return outcome;
3808
+ try {
3809
+ await mutateState(directory, `goal.agent-switch:${goal.id}`, async (s) => {
3810
+ const current = s.goals.find((g) => g.id === goal.id);
3811
+ if (!current)
3812
+ throw new Error("Goal removed during agent switch.");
3813
+ current.config.agent = agent;
3814
+ current.agentSwitch = { pending: undefined, lastFailure: undefined, last: { from: goal.config.agent, to: agent, at: new Date().toISOString(), outcome } };
3815
+ current.updatedAt = new Date().toISOString();
3816
+ return s;
3817
+ });
3818
+ } catch (error) {
3819
+ if (outcome === "applied") {
3820
+ if (!previousAgent)
3821
+ throw new Error("Host accepted agent switch but persistence failed; previous host agent unknown, rollback unavailable.");
3822
+ await host.switchSessionAgent(goal.workerSessionID, previousAgent);
3823
+ }
3824
+ throw error;
3825
+ }
3826
+ await logServerEvent(directory, "goal.agent-switched", { goalID: goal.id, workerSessionID: goal.workerSessionID, from: goal.config.agent, to: agent, outcome });
3827
+ return outcome;
3828
+ }
3829
+ function switchAgent(directory, goalID, ownerSessionID, value, opts) {
3830
+ return withGoalOperation(goalID, () => switchAgentUnlocked(directory, goalID, ownerSessionID, value, opts));
3831
+ }
3832
+ async function switchAgentUnlocked(directory, goalID, ownerSessionID, value, opts) {
3833
+ const state = await readState(directory);
3834
+ const goal = state.goals.find((g) => g.id === goalID);
3835
+ if (!goal || !ownerSessionID || goal.ownerSessionID !== ownerSessionID)
3836
+ throw new Error("Goal not found or not owned by this session.");
3837
+ if (goal.status === "complete")
3838
+ throw new Error("Cannot switch a completed goal.");
3839
+ if (opts?.resume && goal.status !== "blocked")
3840
+ throw new Error("resume is only valid for a blocked goal.");
3841
+ if (opts?.resume)
3842
+ assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
3843
+ const { agent, catalog } = await validateAgent(value);
3844
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionAgent || !goal.workerSessionID) {
3845
+ return { outcome: "unsupported", agent };
3846
+ }
3847
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
3848
+ if (await host.sessionStatus(goal.workerSessionID) !== "idle" || runtime?.phase === "running" && leaseIsValid(runtime)) {
3849
+ if (opts?.persist === false)
3850
+ throw new Error("Worker became busy during identity switch.");
3851
+ await mutateState(directory, `goal.agent-deferred:${goalID}`, async (s) => {
3852
+ const current = s.goals.find((g) => g.id === goalID);
3853
+ if (current) {
3854
+ current.agentSwitch = { ...current.agentSwitch, pending: { agent, requestedAt: new Date().toISOString() } };
3855
+ current.updatedAt = new Date().toISOString();
3856
+ }
3857
+ return s;
3858
+ });
3859
+ return { outcome: "deferred", agent };
3860
+ }
3861
+ const applied = await applyAgent(directory, goal, agent, opts?.persist !== false);
3862
+ if (opts?.resume && applied !== "unsupported") {
3863
+ await mutateState(directory, `goal.agent-resume:${goalID}`, async (s) => {
3864
+ const current = s.goals.find((g) => g.id === goalID);
3865
+ if (current?.status === "blocked") {
3866
+ assertWorkspaceWriteAvailable(s, current, ownerSessionID);
3867
+ current.status = "active";
3868
+ current.blocker = undefined;
3869
+ current.updatedAt = new Date().toISOString();
3870
+ }
3871
+ return s;
3872
+ });
3873
+ await continueTurnUnlocked(directory, goalID);
3874
+ }
3875
+ return { outcome: applied === "next-prompt" ? "deferred" : applied, agent, ...opts?.resume && applied !== "unsupported" ? { resumed: true } : {} };
3876
+ }
3877
+ async function applyPendingAgent(directory, goal) {
3878
+ const pending = goal.agentSwitch?.pending;
3879
+ if (!pending)
3880
+ return;
3881
+ let reason = "catalog-unavailable";
3882
+ try {
3883
+ const { catalog } = await validateAgent(pending.agent);
3884
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported")
3885
+ throw new Error("Agent catalog unavailable.");
3886
+ reason = "host-rejected";
3887
+ if (await applyAgent(directory, goal, pending.agent) === "unsupported")
3888
+ throw new Error("Agent switching unsupported.");
3889
+ } catch {
3890
+ await mutateState(directory, `goal.agent-switch-failed:${goal.id}`, async (s) => {
3891
+ const current = s.goals.find((g) => g.id === goal.id);
3892
+ const runtime = s.runtimes.find((r) => r.goalID === goal.id);
3893
+ if (current) {
3894
+ current.agentSwitch = { ...current.agentSwitch, pending: undefined, lastFailure: { at: new Date().toISOString(), reason } };
3895
+ if (current.status === "active") {
3896
+ current.status = "blocked";
3897
+ current.blocker = { reason: "Pending agent switch was not accepted.", needed: "Discover agents, switch the same goal explicitly, then retry when safe.", at: new Date().toISOString() };
3898
+ }
3899
+ current.updatedAt = new Date().toISOString();
3900
+ }
3901
+ if (runtime) {
3902
+ Object.assign(runtime, releaseLease(runtime));
3903
+ runtime.retryAfter = undefined;
3904
+ }
3905
+ return s;
3906
+ });
3907
+ throw new Error("Pending agent switch was not accepted; goal blocked without replacing its worker.");
3908
+ }
3909
+ }
3910
+ async function validateModel(value) {
3911
+ const ref = parseModelRef(value);
3912
+ if (!ref)
3913
+ throw new Error("Model is required: providerID/modelID.");
3914
+ const model = `${ref.providerID}/${ref.modelID}`;
3915
+ const catalog = await listModels();
3916
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel) {
3917
+ return { model, catalog };
3918
+ }
3919
+ if (!catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
3920
+ throw new Error("Model is unavailable or not positively known usable. Discover models with loopd_list_models first.");
3921
+ }
3922
+ return { model, catalog };
3923
+ }
3924
+ async function applyModel(directory, goal, model, reason, persist = true) {
3925
+ if (!goal.workerSessionID || !host.switchSessionModel)
3926
+ return "unsupported";
3927
+ const previousModel = parseModelRef(goal.config.model) ?? (await host.readSession(goal.workerSessionID))?.model;
3928
+ const outcome = await host.switchSessionModel(goal.workerSessionID, parseModelRef(model));
3929
+ if (outcome === "unsupported")
3930
+ return outcome;
3931
+ if (!persist)
3932
+ return outcome;
3933
+ try {
3934
+ await mutateState(directory, `goal.model-switch:${goal.id}`, async (s) => {
3935
+ const current = s.goals.find((g) => g.id === goal.id);
3936
+ if (!current)
3937
+ throw new Error("Goal removed during model switch.");
3938
+ current.config.model = model;
3939
+ current.modelSwitch = {
3940
+ ...current.modelSwitch,
3941
+ pending: undefined,
3942
+ lastFailure: undefined,
3943
+ last: { from: goal.config.model, to: model, at: new Date().toISOString(), outcome, reason }
3944
+ };
3945
+ current.updatedAt = new Date().toISOString();
3946
+ return s;
3947
+ });
3948
+ } catch (error) {
3949
+ if (outcome === "applied") {
3950
+ if (!previousModel)
3951
+ throw new Error("Host accepted model switch but persistence failed; previous host model unknown, rollback unavailable.");
3952
+ await host.switchSessionModel(goal.workerSessionID, previousModel);
3953
+ }
3954
+ throw error;
3955
+ }
3956
+ await logServerEvent(directory, "goal.model-switched", { goalID: goal.id, workerSessionID: goal.workerSessionID, from: goal.config.model, to: model, reason, outcome });
3957
+ return outcome;
3958
+ }
3959
+ function switchModel(directory, goalID, ownerSessionID, value, opts) {
3960
+ return withGoalOperation(goalID, () => switchModelUnlocked(directory, goalID, ownerSessionID, value, opts));
3961
+ }
3962
+ async function switchModelUnlocked(directory, goalID, ownerSessionID, value, opts) {
3963
+ const state = await readState(directory);
3964
+ const goal = state.goals.find((g) => g.id === goalID);
3965
+ if (!goal || !ownerSessionID || goal.ownerSessionID !== ownerSessionID)
3966
+ throw new Error("Goal not found or not owned by this session.");
3967
+ if (goal.status === "complete")
3968
+ throw new Error("Cannot switch a completed goal.");
3969
+ if (opts?.resume && (goal.status !== "blocked" || goal.blocker?.kind !== "provider-limit")) {
3970
+ throw new Error("resume is only valid for a provider-limit-blocked goal.");
3971
+ }
3972
+ if (opts?.resume)
3973
+ assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
3974
+ const { model, catalog } = await validateModel(value);
3975
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel || !goal.workerSessionID) {
3976
+ return { outcome: "unsupported", model };
3977
+ }
3978
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
3979
+ const status = await host.sessionStatus(goal.workerSessionID);
3980
+ if (status !== "idle" || runtime?.phase === "running" && leaseIsValid(runtime)) {
3981
+ if (opts?.persist === false)
3982
+ throw new Error("Worker became busy during identity switch.");
3983
+ await mutateState(directory, `goal.model-deferred:${goalID}`, async (s) => {
3984
+ const current = s.goals.find((g) => g.id === goalID);
3985
+ if (current) {
3986
+ current.modelSwitch = { ...current.modelSwitch, pending: { model, requestedAt: new Date().toISOString(), reason: "owner" } };
3987
+ current.updatedAt = new Date().toISOString();
3988
+ }
3989
+ return s;
3990
+ });
3991
+ return { outcome: "deferred", model };
3992
+ }
3993
+ const applied = await applyModel(directory, goal, model, "owner", opts?.persist !== false);
3994
+ if (opts?.resume && applied !== "unsupported") {
3995
+ await mutateState(directory, `goal.model-resume:${goalID}`, async (s) => {
3996
+ const g = s.goals.find((g) => g.id === goalID);
3997
+ if (g?.status === "blocked" && g.blocker?.kind === "provider-limit") {
3998
+ assertWorkspaceWriteAvailable(s, g, ownerSessionID);
3999
+ g.status = "active";
4000
+ g.blocker = undefined;
4001
+ g.updatedAt = new Date().toISOString();
4002
+ }
4003
+ return s;
4004
+ });
4005
+ await continueTurnUnlocked(directory, goalID);
4006
+ }
4007
+ return { outcome: applied === "next-prompt" ? "deferred" : applied, model, ...opts?.resume && applied !== "unsupported" ? { resumed: true } : {} };
4008
+ }
4009
+ function switchIdentity(directory, goalID, ownerSessionID, opts) {
4010
+ return withGoalOperation(goalID, () => switchIdentityUnlocked(directory, goalID, ownerSessionID, opts));
4011
+ }
4012
+ async function switchIdentityUnlocked(directory, goalID, ownerSessionID, opts) {
4013
+ if (opts.model === undefined && opts.agent === undefined)
4014
+ throw new Error("At least one of model or agent is required.");
4015
+ const pending = (await readState(directory)).goals.find((g) => g.id === goalID)?.pendingIdentity;
4016
+ if (pending)
4017
+ opts = { model: opts.model ?? pending.model, agent: opts.agent ?? pending.agent, resume: opts.resume };
4018
+ if (opts.agent === undefined)
4019
+ return switchModelUnlocked(directory, goalID, ownerSessionID, opts.model, opts);
4020
+ if (opts.model === undefined)
4021
+ return switchAgentUnlocked(directory, goalID, ownerSessionID, opts.agent, opts);
4022
+ const state = await readState(directory);
4023
+ const goal = state.goals.find((g) => g.id === goalID);
4024
+ if (!goal || !ownerSessionID || goal.ownerSessionID !== ownerSessionID)
4025
+ throw new Error("Goal not found or not owned by this session.");
4026
+ if (goal.status === "complete")
4027
+ throw new Error("Cannot switch a completed goal.");
4028
+ if (opts.resume && goal.status !== "blocked")
4029
+ throw new Error("resume is only valid for a blocked goal.");
4030
+ if (opts.resume)
4031
+ assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
4032
+ const { model, catalog: models } = await validateModel(opts.model);
4033
+ const { agent, catalog: agents } = await validateAgent(opts.agent);
4034
+ if (models.capability !== "supported" || models.switching === "unsupported" || agents.capability !== "supported" || agents.switching === "unsupported" || !host.switchSessionModel || !host.switchSessionAgent || !goal.workerSessionID) {
4035
+ return { outcome: "unsupported", model, agent };
4036
+ }
4037
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
4038
+ if (await host.sessionStatus(goal.workerSessionID) !== "idle" || runtime?.phase === "running" && leaseIsValid(runtime)) {
4039
+ await mutateState(directory, `goal.identity-deferred:${goalID}`, async (s) => {
4040
+ const current = s.goals.find((g) => g.id === goalID);
4041
+ current.pendingIdentity = { model, agent };
4042
+ current.modelSwitch = { ...current.modelSwitch, pending: undefined };
4043
+ current.agentSwitch = { ...current.agentSwitch, pending: undefined };
4044
+ current.updatedAt = new Date().toISOString();
4045
+ return s;
4046
+ });
4047
+ return { outcome: "deferred", model, agent };
4048
+ }
4049
+ const live = await host.readSession(goal.workerSessionID);
4050
+ const previousModel = parseModelRef(goal.config.model) ?? live?.model;
4051
+ const previousAgent = goal.config.agent ?? live?.agent;
4052
+ if (!previousModel || !previousAgent)
4053
+ throw new Error("Combined switch requires a known previous identity for rollback.");
4054
+ let modelChanged = false;
4055
+ let agentChanged = false;
4056
+ let outcome = "applied";
4057
+ try {
4058
+ const changedModel = await switchModelUnlocked(directory, goalID, ownerSessionID, model, { persist: false });
4059
+ if (changedModel.outcome === "unsupported")
4060
+ return { outcome: "unsupported", model, agent };
4061
+ modelChanged = true;
4062
+ const changedAgent = await switchAgentUnlocked(directory, goalID, ownerSessionID, agent, { persist: false });
4063
+ if (changedAgent.outcome === "unsupported")
4064
+ throw new Error("Agent switch unsupported after model switch.");
4065
+ agentChanged = true;
4066
+ if (changedModel.outcome === "deferred" || changedAgent.outcome === "deferred")
4067
+ outcome = "deferred";
4068
+ await mutateState(directory, `goal.identity-switched:${goalID}`, async (s) => {
4069
+ const current = s.goals.find((g) => g.id === goalID);
4070
+ const at = new Date().toISOString();
4071
+ current.config.model = model;
4072
+ current.config.agent = agent;
4073
+ current.modelSwitch = { ...current.modelSwitch, pending: undefined, lastFailure: undefined, last: { from: goal.config.model, to: model, at, outcome: changedModel.outcome === "deferred" ? "next-prompt" : "applied", reason: "owner" } };
4074
+ current.agentSwitch = { pending: undefined, lastFailure: undefined, last: { from: goal.config.agent, to: agent, at, outcome: changedAgent.outcome === "deferred" ? "next-prompt" : "applied" } };
4075
+ current.pendingIdentity = undefined;
4076
+ current.updatedAt = at;
4077
+ return s;
4078
+ });
4079
+ } catch (error) {
4080
+ const rollbacks = await Promise.allSettled([
4081
+ ...agentChanged ? [host.switchSessionAgent(goal.workerSessionID, previousAgent)] : [],
4082
+ ...modelChanged ? [host.switchSessionModel(goal.workerSessionID, previousModel)] : []
4083
+ ]);
4084
+ if (rollbacks.some((result) => result.status === "rejected" || result.value === "unsupported")) {
4085
+ throw new Error("Identity switch failed and host rollback failed; inspect the existing session before retrying.");
4086
+ }
4087
+ throw error;
4088
+ }
4089
+ if (opts.resume) {
4090
+ await mutateState(directory, `goal.identity-resume:${goalID}`, async (s) => {
4091
+ const current = s.goals.find((g) => g.id === goalID);
4092
+ assertWorkspaceWriteAvailable(s, current, ownerSessionID);
4093
+ current.status = "active";
4094
+ current.blocker = undefined;
4095
+ current.updatedAt = new Date().toISOString();
4096
+ return s;
4097
+ });
4098
+ await continueTurnUnlocked(directory, goalID);
4099
+ }
4100
+ return { outcome, model, agent, ...opts.resume ? { resumed: true } : {} };
4101
+ }
4102
+ async function applyPendingIdentity(directory, goal) {
4103
+ if (!goal.pendingIdentity)
4104
+ return;
4105
+ try {
4106
+ const result = await switchIdentityUnlocked(directory, goal.id, goal.ownerSessionID, goal.pendingIdentity);
4107
+ if (result.outcome === "unsupported")
4108
+ throw new Error("Identity switching unsupported.");
4109
+ } catch {
4110
+ await mutateState(directory, `goal.identity-switch-failed:${goal.id}`, async (s) => {
4111
+ const current = s.goals.find((g) => g.id === goal.id);
4112
+ current.pendingIdentity = undefined;
4113
+ if (current.status === "active") {
4114
+ current.status = "blocked";
4115
+ current.blocker = { reason: "Pending identity switch was not accepted.", needed: "Inspect the existing session, switch identity explicitly, then retry when safe.", at: new Date().toISOString() };
4116
+ }
4117
+ const runtime = s.runtimes.find((r) => r.goalID === goal.id);
4118
+ if (runtime) {
4119
+ Object.assign(runtime, releaseLease(runtime));
4120
+ runtime.retryAfter = undefined;
4121
+ }
4122
+ return s;
4123
+ });
4124
+ throw new Error("Pending identity switch was not accepted; goal blocked without replacing its worker.");
4125
+ }
4126
+ }
4127
+ async function applyPendingModel(directory, goal) {
4128
+ const pending = goal.modelSwitch?.pending;
4129
+ if (!pending)
4130
+ return;
4131
+ let reason = "catalog-unavailable";
4132
+ try {
4133
+ const { catalog } = await validateModel(pending.model);
4134
+ if (catalog.capability !== "supported" || catalog.switching === "unsupported")
4135
+ throw new Error("Pending model switch catalog unavailable.");
4136
+ reason = "host-rejected";
4137
+ const result = await applyModel(directory, goal, pending.model, pending.reason);
4138
+ if (result === "unsupported")
4139
+ throw new Error("Pending model switching unsupported by host.");
4140
+ } catch {
4141
+ await mutateState(directory, `goal.model-switch-failed:${goal.id}`, async (s) => {
4142
+ const g = s.goals.find((g) => g.id === goal.id);
4143
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
4144
+ if (g) {
4145
+ g.modelSwitch = { ...g.modelSwitch, pending: undefined, lastFailure: { at: new Date().toISOString(), reason } };
4146
+ if (g.status === "active") {
4147
+ g.status = "blocked";
4148
+ 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() };
4149
+ }
4150
+ g.updatedAt = new Date().toISOString();
4151
+ }
4152
+ if (rt) {
4153
+ Object.assign(rt, releaseLease(rt));
4154
+ rt.retryAfter = undefined;
4155
+ }
4156
+ return s;
4157
+ });
4158
+ throw new Error("Pending model switch was not accepted; goal blocked without replacing its worker.");
4159
+ }
4160
+ }
4161
+ async function observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel) {
4162
+ const state = await readState(directory);
4163
+ const goal = state.goals.find((g) => g.id === goalID);
4164
+ if (!goal)
4165
+ return;
4166
+ const observation = observeProviderLimit(error, source, expectedModel ?? goal.config.model);
4167
+ if (!observation)
4168
+ return;
4169
+ const fallbackModels = goal.config.fallbackModels ?? [];
4170
+ const attempted = new Set(goal.modelFallback?.attempted ?? []);
4171
+ if (goal.config.model)
4172
+ attempted.add(goal.config.model);
4173
+ let pending;
4174
+ let status = "disabled";
4175
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
4176
+ const quotaBlocked = goal.status === "blocked" && runtime?.lastError === describeError(error);
4177
+ const sameModel = expectedModel === undefined || expectedModel === goal.config.model;
4178
+ const eligible = sameModel && !goal.pendingIdentity && (goal.status === "active" || quotaBlocked) && (!goal.modelSwitch?.pending || goal.modelSwitch.pending.reason === "quota");
4179
+ if (eligible && goal.modelSwitch?.pending?.reason === "quota") {
4180
+ pending = { ...goal.modelSwitch.pending, reason: "quota" };
4181
+ status = "prepared";
4182
+ } else if (eligible && fallbackModels.length) {
4183
+ const catalog = await listModels();
4184
+ if (catalog.capability === "unsupported" || catalog.switching === "unsupported" || !host.switchSessionModel)
4185
+ status = "unsupported";
4186
+ else if (catalog.capability !== "supported")
4187
+ status = "unavailable";
4188
+ else {
4189
+ status = "exhausted";
4190
+ for (const value of fallbackModels) {
4191
+ if (attempted.has(value))
4192
+ continue;
4193
+ attempted.add(value);
4194
+ const ref = parseModelRef(value);
4195
+ if (ref && catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
4196
+ pending = { model: value, requestedAt: new Date().toISOString(), reason: "quota" };
4197
+ status = "prepared";
4198
+ break;
4199
+ }
4200
+ }
4201
+ }
4202
+ }
4203
+ await mutateState(directory, `goal.provider-limit:${goalID}`, async (s) => {
4204
+ const g = s.goals.find((g) => g.id === goalID);
4205
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
4206
+ if (!g)
4207
+ return s;
4208
+ g.lastProviderLimit = observation;
4209
+ if (sameModel && g.status === "blocked" && g.blocker && rt?.lastError === describeError(error))
4210
+ g.blocker.kind = "provider-limit";
4211
+ if (eligible) {
4212
+ g.modelFallback = { attempted: [...attempted], status };
4213
+ if (pending && (!g.modelSwitch?.pending || g.modelSwitch.pending.reason === "quota")) {
4214
+ g.modelSwitch = { ...g.modelSwitch, pending };
4215
+ }
4216
+ }
4217
+ if (rt?.phase === "waiting_retry" && observation.retryAt && Date.parse(observation.retryAt) > Date.parse(rt.retryAfter ?? ""))
4218
+ rt.retryAfter = observation.retryAt;
4219
+ g.updatedAt = new Date().toISOString();
4220
+ return s;
4221
+ });
4222
+ await logServerEvent(directory, "goal.provider-limit", { goalID, model: goal.config.model, kind: observation.kind, source, fallbackStatus: status, to: pending?.model });
4223
+ }
4224
+ function observeProviderError(directory, goalID, error, source, expectedModel) {
4225
+ return withGoalOperation(goalID, () => observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel));
4226
+ }
4227
+ function compact(directory, goalID) {
4228
+ return withGoalOperation(goalID, async () => {
4229
+ const state = await readState(directory);
4230
+ const goal = state.goals.find((g) => g.id === goalID);
4231
+ if (!goal?.workerSessionID || goal.status !== "active")
4232
+ return;
4233
+ if (await host.sessionStatus(goal.workerSessionID) !== "idle")
4234
+ return;
4235
+ await applyPendingIdentity(directory, goal);
4236
+ await applyPendingModel(directory, goal);
4237
+ await applyPendingAgent(directory, goal);
4238
+ const fresh = (await readState(directory)).goals.find((g) => g.id === goalID);
4239
+ if (fresh?.workerSessionID)
4240
+ await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
4241
+ });
4242
+ }
3484
4243
  async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
3485
4244
  const detail = describeError(error);
3486
4245
  let runID = "unknown";
@@ -3539,6 +4298,7 @@ function createGoalService(host) {
3539
4298
  revision: state.revision
3540
4299
  });
3541
4300
  }
4301
+ await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
3542
4302
  }
3543
4303
  async function ensureWorkerSession(directory, goal) {
3544
4304
  let session = sessions.get(goal.id);
@@ -3573,6 +4333,20 @@ function createGoalService(host) {
3573
4333
  return withGoalOperation(id, () => startUnlocked(directory, input, id));
3574
4334
  }
3575
4335
  async function startUnlocked(directory, input, id) {
4336
+ if (input.config?.fallbackModels?.length) {
4337
+ if (input.config.fallbackModels.length > 16)
4338
+ throw new Error("At most 16 ordered fallback models are allowed.");
4339
+ const catalog = await listModels();
4340
+ for (const value of input.config.fallbackModels) {
4341
+ const ref = parseModelRef(value);
4342
+ if (!ref)
4343
+ throw new Error("Fallback model must be providerID/modelID.");
4344
+ if (catalog.capability === "supported" && !catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
4345
+ throw new Error("Fallback model is unavailable or not positively known usable.");
4346
+ }
4347
+ }
4348
+ input.config.fallbackModels = [...new Set(input.config.fallbackModels.map((m) => m.trim()))];
4349
+ }
3576
4350
  let parentAgent = input.parentAgent;
3577
4351
  let parentModel = input.parentModel;
3578
4352
  if ((!parentAgent || !parentModel) && host.readSession) {
@@ -3598,6 +4372,8 @@ function createGoalService(host) {
3598
4372
  });
3599
4373
  if (typeof input.costBudget === "number")
3600
4374
  goal.costBudget = input.costBudget;
4375
+ if (input.interactive === true)
4376
+ goal.interactive = true;
3601
4377
  if (parentAgent)
3602
4378
  goal.parentAgent = parentAgent;
3603
4379
  if (parentModel)
@@ -3800,6 +4576,13 @@ function createGoalService(host) {
3800
4576
  return;
3801
4577
  if (!opts?.force && !await workers.isIdle(session.workerSessionID))
3802
4578
  return;
4579
+ if (goal.pendingIdentity || goal.modelSwitch?.pending || goal.agentSwitch?.pending) {
4580
+ if (await host.sessionStatus(session.workerSessionID) !== "idle")
4581
+ return;
4582
+ await applyPendingIdentity(directory, goal);
4583
+ await applyPendingModel(directory, goal);
4584
+ await applyPendingAgent(directory, goal);
4585
+ }
3803
4586
  let acquired = false;
3804
4587
  const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
3805
4588
  const g = s.goals.find((item) => item.id === goalID);
@@ -3902,6 +4685,38 @@ function createGoalService(host) {
3902
4685
  throw error;
3903
4686
  }
3904
4687
  }
4688
+ async function setInteractiveUnlocked(directory, goalID, interactive) {
4689
+ const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
4690
+ const g = s.goals.find((item) => item.id === goalID);
4691
+ if (!g)
4692
+ return s;
4693
+ if (interactive)
4694
+ g.interactive = true;
4695
+ else
4696
+ delete g.interactive;
4697
+ g.updatedAt = new Date().toISOString();
4698
+ return s;
4699
+ });
4700
+ const goal = state.goals.find((g) => g.id === goalID);
4701
+ if (!goal)
4702
+ return { ok: false, message: "Goal not found." };
4703
+ await appendEvent(directory, {
4704
+ version: 1,
4705
+ eventID: randomUUID5(),
4706
+ goalID,
4707
+ type: "goal.interactive_changed",
4708
+ interactive,
4709
+ timestamp: new Date().toISOString(),
4710
+ revision: state.revision
4711
+ }).catch(() => {});
4712
+ return {
4713
+ ok: true,
4714
+ 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.`
4715
+ };
4716
+ }
4717
+ function setInteractive(directory, goalID, interactive) {
4718
+ return withGoalOperation(goalID, () => setInteractiveUnlocked(directory, goalID, interactive));
4719
+ }
3905
4720
  async function pauseUnlocked(directory, goalID) {
3906
4721
  const preState = await readState(directory);
3907
4722
  const goal = preState.goals.find((g) => g.id === goalID);
@@ -4264,7 +5079,7 @@ function createGoalService(host) {
4264
5079
  function accountUsage(directory, goalID) {
4265
5080
  return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
4266
5081
  }
4267
- return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage };
5082
+ return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, listAgents, switchAgent, switchIdentity, observeProviderError, compact };
4268
5083
  }
4269
5084
 
4270
5085
  // src/application/schedule-worker.ts
@@ -5621,7 +6436,8 @@ function createCommandService(host, opts) {
5621
6436
  c.exitCode = info.exitCode;
5622
6437
  if (info.signal && (c.signal === "SIGKILL" || c.signal === "SIGTERM")) {
5623
6438
  c.status = "terminated";
5624
- c.endReason = "terminate";
6439
+ if (c.endReason !== "until")
6440
+ c.endReason = "terminate";
5625
6441
  } else {
5626
6442
  c.status = "exited";
5627
6443
  c.endReason = "exit";
@@ -5917,6 +6733,9 @@ function createCommandService(host, opts) {
5917
6733
  const c = (s.commands ?? []).find((x) => x.id === id);
5918
6734
  if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
5919
6735
  c.signal = "SIGTERM";
6736
+ if (c.endReason !== "timeout" && c.watchState?.state === "until-matched" && c.watchUntilAction === "stop") {
6737
+ c.endReason = "until";
6738
+ }
5920
6739
  c.updatedAt = new Date().toISOString();
5921
6740
  }
5922
6741
  return s;
@@ -5955,8 +6774,9 @@ function createCommandService(host, opts) {
5955
6774
  return s;
5956
6775
  if (c.status === "running") {
5957
6776
  c.status = status;
5958
- if (c.endReason !== "timeout")
5959
- c.endReason = status === "terminated" ? "terminate" : "exit";
6777
+ if (c.endReason !== "timeout") {
6778
+ c.endReason = status === "terminated" ? c.endReason === "until" ? "until" : "terminate" : "exit";
6779
+ }
5960
6780
  if (exitCode !== undefined)
5961
6781
  c.exitCode = c.exitCode ?? exitCode;
5962
6782
  c.signal = c.signal ?? "SIGTERM";
@@ -6793,6 +7613,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6793
7613
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
6794
7614
  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
7615
  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.`),
7616
+ 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
7617
  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
7618
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
6798
7619
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
@@ -6804,7 +7625,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6804
7625
  compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
6805
7626
  timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
6806
7627
  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.")
7628
+ scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs."),
7629
+ 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
7630
  },
6809
7631
  execute: async (args, context) => {
6810
7632
  const sessionID = context?.sessionID || hostSessionID;
@@ -6824,6 +7646,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6824
7646
  config.agent = args.agent;
6825
7647
  if (args.model)
6826
7648
  config.model = args.model;
7649
+ if (args.fallbackModels)
7650
+ config.fallbackModels = args.fallbackModels;
6827
7651
  if (args.checks)
6828
7652
  config.checks = args.checks;
6829
7653
  if (args.checkCwd)
@@ -6899,7 +7723,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6899
7723
  config: resolution.config,
6900
7724
  costBudget,
6901
7725
  parentAgent: parentDefaults.parentAgent,
6902
- parentModel: parentDefaults.parentModel
7726
+ parentModel: parentDefaults.parentModel,
7727
+ ...args.interactive === true ? { interactive: true } : {}
6903
7728
  });
6904
7729
  return {
6905
7730
  title: "Goal created",
@@ -6913,6 +7738,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6913
7738
  agent: resolution.config.agent,
6914
7739
  model: resolution.config.model,
6915
7740
  costBudget: goal.costBudget,
7741
+ interactive: goal.interactive === true,
6916
7742
  checks: resolution.config.checks || [],
6917
7743
  workspaceWrite: resolution.config.workspaceWrite,
6918
7744
  defaultsApplied: resolution.defaultsApplied,
@@ -7457,6 +8283,61 @@ function withTimeout2(promise, ms) {
7457
8283
  function ownerTools(options) {
7458
8284
  const { directory, host, goalService } = options;
7459
8285
  return {
8286
+ loopd_list_models: tool2({
8287
+ description: "Discover host providers/models and available OpenCode agents, 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_identity on the SAME goal, never create a duplicate to change provider. No secrets/auth data are returned.",
8288
+ args: {},
8289
+ execute: async (_args, context) => {
8290
+ if (!context?.sessionID)
8291
+ return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
8292
+ const [catalog, agentCatalog, state] = await Promise.all([goalService.listModels(), goalService.listAgents(), readState(directory)]);
8293
+ return {
8294
+ title: "Provider/model capabilities",
8295
+ output: JSON.stringify({
8296
+ ok: true,
8297
+ ...catalog,
8298
+ agents: agentCatalog.agents,
8299
+ agentCatalog,
8300
+ assignedGoals: state.goals.filter((g) => g.ownerSessionID === context.sessionID).map((g) => ({
8301
+ id: g.id,
8302
+ model: g.config.model,
8303
+ agent: g.config.agent,
8304
+ agentSwitch: g.agentSwitch,
8305
+ pendingIdentity: g.pendingIdentity,
8306
+ workerSessionID: g.workerSessionID,
8307
+ modelSwitch: g.modelSwitch,
8308
+ modelFallback: g.modelFallback,
8309
+ fallbackModels: g.config.fallbackModels ?? [],
8310
+ lastProviderLimit: g.lastProviderLimit
8311
+ }))
8312
+ }, null, 2)
8313
+ };
8314
+ }
8315
+ }),
8316
+ switch_goal_identity: tool2({
8317
+ description: "Switch an EXISTING owned goal's model, agent, or both atomically, preserving the same worker/session, transcript, progress, inbox, checks, budgets and topology. At least one of model or agent is required. Discover identities with loopd_list_models first. Busy workers defer until the next permitted idle turn; legacy hosts apply on the next prompt. No implicit unpause or interactive wake. resume=true explicitly resumes only a blocked goal after a safe switch (model-only requires a provider-limit blocker); busy requests remain blocked until explicitly retried/resumed when idle. Combined host failures roll back successful changes; rollback failure requires inspecting the existing session.",
8318
+ args: {
8319
+ goal_id: tool2.schema.string().describe("Existing owned goal ID."),
8320
+ agent: tool2.schema.string().optional().describe("Available agent name from loopd_list_models."),
8321
+ model: tool2.schema.string().optional().describe("Available providerID/modelID from loopd_list_models. Provide model, agent, or both."),
8322
+ resume: tool2.schema.boolean().optional().describe("Explicitly resume a blocked goal after a safe switch. Default false.")
8323
+ },
8324
+ execute: async (args, context) => {
8325
+ if (!context?.sessionID)
8326
+ return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
8327
+ if (args.model === undefined && args.agent === undefined)
8328
+ return { title: "Identity switch failed", output: JSON.stringify({ ok: false, message: "At least one of model or agent is required." }) };
8329
+ const state = await readState(directory);
8330
+ if (!state.goals.some((g) => g.id === args.goal_id && g.ownerSessionID === context.sessionID)) {
8331
+ return { title: "Switch denied", output: JSON.stringify({ ok: false, message: "Goal not found or not owned by this session." }) };
8332
+ }
8333
+ try {
8334
+ const result = await goalService.switchIdentity(directory, args.goal_id, context.sessionID, args);
8335
+ return { title: `Identity 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 idle turn; no automatic interactive wake." }) };
8336
+ } catch {
8337
+ return { title: "Identity switch failed", output: JSON.stringify({ ok: false, message: "Switch rejected: verify ownership, identity availability, resume eligibility and host capability with loopd_list_models and inspect_background_goal. Existing goal/session retained. Inspect live session identity before retrying if host rollback failed." }) };
8338
+ }
8339
+ }
8340
+ }),
7460
8341
  list_background_goals: tool2({
7461
8342
  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
8343
  args: {},
@@ -7498,6 +8379,13 @@ function ownerTools(options) {
7498
8379
  maxTurns: g.config.maxTurns,
7499
8380
  agent: g.config.agent,
7500
8381
  model: g.config.model,
8382
+ modelSwitch: g.modelSwitch,
8383
+ agentSwitch: g.agentSwitch,
8384
+ pendingIdentity: g.pendingIdentity,
8385
+ modelFallback: g.modelFallback,
8386
+ lastProviderLimit: g.lastProviderLimit,
8387
+ fallbackModels: g.config.fallbackModels ?? [],
8388
+ quotaLimitation: "Remaining provider quota unknown; connected/enabled is not balance data. See loopd_list_models.",
7501
8389
  lastProgress: g.lastProgress?.summary?.slice(0, 120),
7502
8390
  lastProgressAt: g.lastProgress?.at,
7503
8391
  blocker: g.blocker?.reason?.slice(0, 120),
@@ -7594,11 +8482,18 @@ function ownerTools(options) {
7594
8482
  workspaceWrite: goal.config.workspaceWrite,
7595
8483
  agent: goal.config.agent,
7596
8484
  model: goal.config.model,
8485
+ fallbackModels: goal.config.fallbackModels ?? [],
7597
8486
  parentAgent: goal.parentAgent,
7598
8487
  parentModel: goal.parentModel,
7599
8488
  schedule: goal.config.schedule
7600
8489
  },
7601
8490
  lastProgress: goal.lastProgress,
8491
+ modelSwitch: goal.modelSwitch,
8492
+ agentSwitch: goal.agentSwitch,
8493
+ pendingIdentity: goal.pendingIdentity,
8494
+ modelFallback: goal.modelFallback,
8495
+ lastProviderLimit: goal.lastProviderLimit,
8496
+ quotaLimitation: "Remaining provider quota unknown; local quota errors are observations, not balance data. See loopd_list_models.",
7602
8497
  completionEvidence: goal.completionEvidence,
7603
8498
  blocker: goal.blocker,
7604
8499
  tokensUsed: goal.tokensUsed,
@@ -8080,7 +8975,7 @@ function commandTools(options) {
8080
8975
  const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
8081
8976
  return {
8082
8977
  loopd_command_start: tool3({
8083
- description: "PREFER THIS over the built-in shell/bash tool whenever a command might take a while or never return: installs (npm/pip/brew/cargo...), builds, test suites, downloads, migrations, dev servers, watchers, log tails, REPLs, or anything interactive. The built-in shell BLOCKS your whole turn until the process exits \u2014 a long install freezes you (and can time out); this returns immediately with a command_id and pushes you a message on exit or pattern match, so you stay responsive and can do other work meanwhile. Only use the built-in shell for quick one-shots (ls, git status, a single fast test) that finish in seconds. " + "Start a standalone interactive OS process (arbitrary shell command) in the background \u2014 a dev server, `npm test --watch`, a REPL, a log tail, a build, or a one-off script. This is a raw process, NOT an AI worker: no agent, no checks, no turn loop. For multi-turn autonomous AI work with completion criteria, use loopd_create_goal instead. " + "Returns a command_id for loopd_command_get (read output)/loopd_command_write (send stdin)/loopd_command_interrupt (Ctrl+C)/loopd_command_terminate (kill)/loopd_command_remove (delete). " + "The user can also open it live: /loop or /commands \u2192 Tab/l to the Commands tab \u2192 select it \u2192 `o` opens a fullscreen interactive terminal page (type directly, Ctrl+C interrupts, Ctrl+] detaches without stopping it). " + "Independent from goals: an optional goal_id is display-only metadata and never couples lifecycles \u2014 pausing/clearing a goal never touches the command, and terminating a command never touches the goal. " + "To make a specific goal wake up when this command finishes, call loopd_command_await separately after starting it (linking alone does not wake anything). " + "The OWNER session gets a notification on natural exit (including code 0), missing process, or timeout \u2014 no polling required for completion. Manual terminate stays silent by default. Raw PTY output stays in the log; any short tail included in the notification is sanitized for chat. Set notify_on_exit:false to suppress owner exit notifications.",
8978
+ description: "PREFER THIS over the built-in shell/bash tool whenever a command might take a while or never return: installs (npm/pip/brew/cargo...), builds, test suites, downloads, migrations, dev servers, watchers, log tails, REPLs, or anything interactive. The built-in shell BLOCKS your whole turn until the process exits \u2014 a long install freezes you (and can time out); this returns immediately with a command_id and pushes you a message on exit or pattern match, so you stay responsive and can do other work meanwhile. Only use the built-in shell for quick one-shots (ls, git status, a single fast test) that finish in seconds. " + "Start a standalone interactive OS process (arbitrary shell command) in the background \u2014 a dev server, `npm test --watch`, a REPL, a log tail, a build, or a one-off script. This is a raw process, NOT an AI worker: no agent, no checks, no turn loop. For multi-turn autonomous AI work with completion criteria, use loopd_create_goal instead. " + "Returns a command_id for loopd_command_get (read output)/loopd_command_write (send stdin)/loopd_command_interrupt (Ctrl+C)/loopd_command_terminate (kill)/loopd_command_remove (delete). " + "The user can also open it live: /loop or /commands \u2192 Tab/l to the Commands tab \u2192 select it \u2192 `o` opens a fullscreen interactive terminal page (type directly, Ctrl+C interrupts, Ctrl+] detaches without stopping it). " + "Independent from goals: an optional goal_id is display-only metadata and never couples lifecycles \u2014 pausing/clearing a goal never touches the command, and terminating a command never touches the goal. " + "To make a specific goal wake up when this command finishes, call loopd_command_await separately after starting it (linking alone does not wake anything). " + "The OWNER session gets a notification on natural exit (including code 0), missing process, or timeout \u2014 no polling required for completion. Manual terminate stays silent by default. Raw PTY output stays in the log; any short tail included in the notification is sanitized for chat. Set notify_on_exit:false to suppress owner exit notifications." + " HOW TO WAIT \u2014 declare your wake upfront, then go do other work; you will be pushed. Never poll loopd_command_get in a loop and never sleep-then-read: quick one-shot finishing in seconds \u2192 built-in bash, or start here and read once. " + "Minutes-long or never-exiting where only FINISHING matters \u2192 just start it; the automatic exit/timeout notification is your wake. " + "Never-exiting where a LINE appearing matters (dev-server READY, deploy COMPLETED) \u2192 pass watch_until (+ watch_filter to cut noise); stop (default) ends the command with exactly one message, keep lets it run on. " + "A GOAL worker that must block until that happens \u2192 call loopd_command_await (passing goal_id alone never wakes anything). " + "Polling the WORLD inside the command (a shell loop curling an endpoint until it flips) is fine and costs no turns; polling loopd from YOUR side is the forbidden pattern \u2014 one mid-run peek is fine, a timer loop is not. " + "Emit a printed marker line for EVERY outcome (DONE and FAILED), or a silent failure wakes nobody. " + "Watches longer than ~30min are fragile (in-memory only, die on restart) \u2014 prefer a scheduled goal that checks state and completes.",
8084
8979
  args: {
8085
8980
  title: tool3.schema.string().describe("Short human label for the session."),
8086
8981
  command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
@@ -8341,7 +9236,7 @@ function commandTools(options) {
8341
9236
  // src/server/plugin.ts
8342
9237
  init_state_repository();
8343
9238
  // package.json
8344
- var version = "1.10.5";
9239
+ var version = "1.12.2";
8345
9240
 
8346
9241
  // src/server/plugin.ts
8347
9242
  var PLUGIN_ID = "opencode-loopd.server";