@bojackduy/opencode-loopd 1.10.4 → 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
@@ -576,10 +576,16 @@ function tailLastBytes(text, maxBytes = MAX_AWAIT_TAIL_BYTES) {
576
576
  return text;
577
577
  return buf.subarray(buf.length - maxBytes).toString("utf8");
578
578
  }
579
+ function safeCommandText(text) {
580
+ return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][\s\S]*?\x1b\\|\x1b[\x20-\x2f]*[\x30-\x7e]/g, "").replace(/\r\n?/g, `
581
+ `).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "").replace(/\n{3,}/g, `
582
+
583
+ `);
584
+ }
579
585
  function formatAwaitEvidence(input) {
580
- const short = input.commandID.slice(0, 8);
581
- const header = `[command "${input.title}" (${input.argv.join(" ") || input.title}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${input.signal ?? "none"}]`;
582
- const tail = tailLastBytes(input.tail);
586
+ const short = safeCommandText(input.commandID.slice(0, 8));
587
+ const header = `[command "${safeCommandText(input.title)}" (${safeCommandText(input.argv.join(" ") || input.title)}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${safeCommandText(input.signal ?? "none")}]`;
588
+ const tail = tailLastBytes(safeCommandText(input.tail));
583
589
  return tail ? `${header}
584
590
  ${tail}` : header;
585
591
  }
@@ -1020,13 +1026,13 @@ async function fireUntilAwaits(directory, commandID, lines) {
1020
1026
  continue;
1021
1027
  }
1022
1028
  const short = commandID.slice(0, 8);
1023
- const header = `[command "${cmd.title}" (${[cmd.command, ...cmd.args].join(" ") || cmd.title}) ${cmd.status}` + ` ${short}... watch-until "${pattern}" matched]`;
1024
- const matchedText = tailLastBytes(matched.slice(0, 20).join(`
1025
- `), MAX_AWAIT_TAIL_BYTES);
1029
+ const header = `[command "${safeCommandText(cmd.title)}" (${safeCommandText([cmd.command, ...cmd.args].join(" ") || cmd.title)}) ${cmd.status}` + ` ${short}... watch-until "${safeCommandText(pattern)}" matched]`;
1030
+ const matchedText = tailLastBytes(safeCommandText(matched.slice(0, 20).join(`
1031
+ `)), MAX_AWAIT_TAIL_BYTES);
1026
1032
  const evidence = tail ? `${header}
1027
1033
  ${matchedText}
1028
1034
  --- tail ---
1029
- ${tail}` : `${header}
1035
+ ${tailLastBytes(safeCommandText(tail))}` : `${header}
1030
1036
  ${matchedText}`;
1031
1037
  await appendGoalInbox(directory, a.goalID, "worker", evidence);
1032
1038
  await appendEvent(directory, ledgerEvent({
@@ -1539,6 +1545,64 @@ function createControlWorker(options) {
1539
1545
  };
1540
1546
  break;
1541
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
+ }
1542
1606
  case "send": {
1543
1607
  const args = request.args;
1544
1608
  const text = String(args.message || "").trim();
@@ -1687,6 +1751,8 @@ function createControlWorker(options) {
1687
1751
  case "cmd_write":
1688
1752
  case "cmd_interrupt":
1689
1753
  case "cmd_terminate":
1754
+ case "cmd_kill":
1755
+ case "cmd_restart":
1690
1756
  case "cmd_remove":
1691
1757
  case "cmd_resize":
1692
1758
  case "cmd_watch": {
@@ -1739,6 +1805,14 @@ function createControlWorker(options) {
1739
1805
  const r = await cmdSvc.terminate(directory, id, ownerSessionID);
1740
1806
  const state = await readState(directory);
1741
1807
  response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1808
+ } else if (request.command === "cmd_kill") {
1809
+ const r = await cmdSvc.kill(directory, id, ownerSessionID);
1810
+ const state = await readState(directory);
1811
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1812
+ } else if (request.command === "cmd_restart") {
1813
+ const r = await cmdSvc.restart(directory, id, ownerSessionID);
1814
+ const state = await readState(directory);
1815
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1742
1816
  } else if (request.command === "cmd_remove") {
1743
1817
  const r = await cmdSvc.remove(directory, id, ownerSessionID);
1744
1818
  const state = await readState(directory);
@@ -1941,6 +2015,8 @@ function createLoopEngine(options) {
1941
2015
  knownWorkerSessions.add(goal.workerSessionID);
1942
2016
  if (goal.status !== "active")
1943
2017
  return false;
2018
+ if (goal.interactive === true && type === "session.idle")
2019
+ return false;
1944
2020
  switch (type) {
1945
2021
  case "session.idle":
1946
2022
  return await handleSessionIdle(state, goal);
@@ -2329,6 +2405,7 @@ function createLoopEngine(options) {
2329
2405
  revision: newState.revision
2330
2406
  });
2331
2407
  const updatedGoal = newState.goals.find((g) => g.id === goal.id);
2408
+ await goalService.observeProviderError(directory, goal.id, error, "session-error", goal.config.model);
2332
2409
  if (updatedGoal?.status === "blocked") {
2333
2410
  await appendEvent(directory, {
2334
2411
  version: 1,
@@ -2437,7 +2514,7 @@ function createLoopEngine(options) {
2437
2514
  revision: state.revision
2438
2515
  });
2439
2516
  try {
2440
- await host.compactSession(goal.workerSessionID);
2517
+ await goalService.compact(directory, goal.id);
2441
2518
  } catch {}
2442
2519
  await mutateState(directory, `compact.end:${goal.id}`, async (s) => {
2443
2520
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2552,7 +2629,8 @@ function createLoopEngine(options) {
2552
2629
  if (stopped)
2553
2630
  continue;
2554
2631
  }
2555
- if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
2632
+ const manual = goal.interactive === true;
2633
+ if (!manual && runtime.phase === "waiting_retry" && runtime.retryAfter) {
2556
2634
  if (Date.now() >= Date.parse(runtime.retryAfter)) {
2557
2635
  await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
2558
2636
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2565,7 +2643,7 @@ function createLoopEngine(options) {
2565
2643
  goalService.continueTurn(directory, goal.id).catch(() => {});
2566
2644
  }
2567
2645
  }
2568
- if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
2646
+ if (!manual && (runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
2569
2647
  if (runtime.phase === "idle" && runtime.activeRunID) {
2570
2648
  await mutateState(directory, `maintenance.clear-stale-run:${goal.id}`, async (s) => {
2571
2649
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
@@ -2870,6 +2948,92 @@ var nativeRpcDefinition = {
2870
2948
  }
2871
2949
  };
2872
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
+
2873
3037
  // src/server/host-adapter.ts
2874
3038
  function parseModelRef(value) {
2875
3039
  if (value === undefined)
@@ -2901,7 +3065,6 @@ function shouldDedupParentNotify(ownerSessionID, message) {
2901
3065
  const last = recentParentNotifies.get(key);
2902
3066
  if (last !== undefined && now - last < 60000)
2903
3067
  return true;
2904
- recentParentNotifies.set(key, now);
2905
3068
  if (recentParentNotifies.size > 200) {
2906
3069
  for (const [k, t] of recentParentNotifies.entries())
2907
3070
  if (now - t > 60000)
@@ -2909,8 +3072,26 @@ function shouldDedupParentNotify(ownerSessionID, message) {
2909
3072
  }
2910
3073
  return false;
2911
3074
  }
3075
+ function markParentNotified2(ownerSessionID, message) {
3076
+ recentParentNotifies.set(`${ownerSessionID}:${message.slice(0, 200)}`, Date.now());
3077
+ }
2912
3078
  function createRealHost(client, directory) {
2913
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
+ },
2914
3095
  async createWorker({ parentID, title, agent, model }) {
2915
3096
  try {
2916
3097
  const body = { parentID, title };
@@ -2953,7 +3134,7 @@ function createRealHost(client, directory) {
2953
3134
  if (result?.error) {
2954
3135
  const detail = describeError(result.error);
2955
3136
  await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
2956
- 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 });
2957
3138
  }
2958
3139
  await logServerEvent(directory, "worker.prompted", { sessionID });
2959
3140
  return { messageID: result?.data?.messageID };
@@ -3046,15 +3227,18 @@ function createRealHost(client, directory) {
3046
3227
  return [];
3047
3228
  }
3048
3229
  },
3049
- async compactSession(sessionID) {
3230
+ async compactSession(sessionID, model) {
3050
3231
  try {
3051
- 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 });
3052
3236
  } catch {}
3053
3237
  },
3054
3238
  async notifyOwner(ownerSessionID, message, agent) {
3055
3239
  if (shouldDedupParentNotify(ownerSessionID, message)) {
3056
3240
  await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
3057
- return;
3241
+ return true;
3058
3242
  }
3059
3243
  let resolvedAgent = agent?.trim() || undefined;
3060
3244
  if (!resolvedAgent) {
@@ -3075,11 +3259,15 @@ function createRealHost(client, directory) {
3075
3259
  }), 1e4, "OpenCode parent notify");
3076
3260
  if (result?.error) {
3077
3261
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
3262
+ return false;
3078
3263
  } else {
3264
+ markParentNotified2(ownerSessionID, message);
3079
3265
  await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
3266
+ return true;
3080
3267
  }
3081
3268
  } catch (error) {
3082
3269
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
3270
+ return false;
3083
3271
  }
3084
3272
  }
3085
3273
  };
@@ -3090,6 +3278,29 @@ function toWorkerCreation(result) {
3090
3278
  function createV2Host(context, statuses, options = {}) {
3091
3279
  const directory = context.location.directory;
3092
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
+ },
3093
3304
  async createWorker({ parentID, title, agent, model, goalID }) {
3094
3305
  if (options.native) {
3095
3306
  let outcome;
@@ -3244,8 +3455,13 @@ function createV2Host(context, statuses, options = {}) {
3244
3455
  return [];
3245
3456
  }
3246
3457
  },
3247
- async compactSession(sessionID) {
3458
+ async compactSession(sessionID, model) {
3248
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
+ }
3249
3465
  await context.session.command({ sessionID, name: "compact", text: "" });
3250
3466
  } catch {}
3251
3467
  },
@@ -3253,8 +3469,10 @@ function createV2Host(context, statuses, options = {}) {
3253
3469
  try {
3254
3470
  await context.session.prompt({ sessionID: ownerSessionID, text: message });
3255
3471
  await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
3472
+ return true;
3256
3473
  } catch (error) {
3257
3474
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
3475
+ return false;
3258
3476
  }
3259
3477
  }
3260
3478
  };
@@ -3274,6 +3492,40 @@ async function withTimeout(promise, timeoutMs, operation) {
3274
3492
  }
3275
3493
  }
3276
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
+
3277
3529
  // src/server/worker-session.ts
3278
3530
  function createWorkerManager(host) {
3279
3531
  return {
@@ -3457,6 +3709,217 @@ function createGoalService(host) {
3457
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." : ""));
3458
3710
  }
3459
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
+ }
3460
3923
  async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
3461
3924
  const detail = describeError(error);
3462
3925
  let runID = "unknown";
@@ -3515,6 +3978,7 @@ function createGoalService(host) {
3515
3978
  revision: state.revision
3516
3979
  });
3517
3980
  }
3981
+ await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
3518
3982
  }
3519
3983
  async function ensureWorkerSession(directory, goal) {
3520
3984
  let session = sessions.get(goal.id);
@@ -3549,6 +4013,20 @@ function createGoalService(host) {
3549
4013
  return withGoalOperation(id, () => startUnlocked(directory, input, id));
3550
4014
  }
3551
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
+ }
3552
4030
  let parentAgent = input.parentAgent;
3553
4031
  let parentModel = input.parentModel;
3554
4032
  if ((!parentAgent || !parentModel) && host.readSession) {
@@ -3574,6 +4052,8 @@ function createGoalService(host) {
3574
4052
  });
3575
4053
  if (typeof input.costBudget === "number")
3576
4054
  goal.costBudget = input.costBudget;
4055
+ if (input.interactive === true)
4056
+ goal.interactive = true;
3577
4057
  if (parentAgent)
3578
4058
  goal.parentAgent = parentAgent;
3579
4059
  if (parentModel)
@@ -3776,6 +4256,11 @@ function createGoalService(host) {
3776
4256
  return;
3777
4257
  if (!opts?.force && !await workers.isIdle(session.workerSessionID))
3778
4258
  return;
4259
+ if (goal.modelSwitch?.pending) {
4260
+ if (await host.sessionStatus(session.workerSessionID) !== "idle")
4261
+ return;
4262
+ await applyPendingModel(directory, goal);
4263
+ }
3779
4264
  let acquired = false;
3780
4265
  const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
3781
4266
  const g = s.goals.find((item) => item.id === goalID);
@@ -3878,6 +4363,38 @@ function createGoalService(host) {
3878
4363
  throw error;
3879
4364
  }
3880
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
+ }
3881
4398
  async function pauseUnlocked(directory, goalID) {
3882
4399
  const preState = await readState(directory);
3883
4400
  const goal = preState.goals.find((g) => g.id === goalID);
@@ -4240,7 +4757,7 @@ function createGoalService(host) {
4240
4757
  function accountUsage(directory, goalID) {
4241
4758
  return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
4242
4759
  }
4243
- 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 };
4244
4761
  }
4245
4762
 
4246
4763
  // src/application/schedule-worker.ts
@@ -4808,6 +5325,7 @@ function createCommandSession(input) {
4808
5325
  ownerSessionID: input.ownerSessionID,
4809
5326
  goalID: input.goalID,
4810
5327
  notifyOnExit: input.notifyOnExit,
5328
+ notifySuccessfulExit: input.notifySuccessfulExit,
4811
5329
  status: "running",
4812
5330
  pid: input.pid,
4813
5331
  cols: input.cols,
@@ -4839,6 +5357,8 @@ function shouldNotifyOwnerOnExit(session) {
4839
5357
  return false;
4840
5358
  if (session.exitCode !== undefined && session.exitCode !== 0)
4841
5359
  return true;
5360
+ if (session.notifySuccessfulExit === true)
5361
+ return true;
4842
5362
  const started = Date.parse(session.createdAt);
4843
5363
  const ended = session.endedAt ? Date.parse(session.endedAt) : Date.now();
4844
5364
  if (!Number.isFinite(started) || !Number.isFinite(ended))
@@ -5066,6 +5586,28 @@ function createCommandService(host, opts) {
5066
5586
  const commandDirs = new Map;
5067
5587
  const timeouts = new Map;
5068
5588
  const timeoutOwners = new Map;
5589
+ const notifyInFlight = new Set;
5590
+ const notifyRetries = new Map;
5591
+ const notifyAttempts = new Map;
5592
+ function clearNotifyRetry(id) {
5593
+ const timer = notifyRetries.get(id);
5594
+ if (timer)
5595
+ clearTimeout(timer);
5596
+ notifyRetries.delete(id);
5597
+ notifyAttempts.delete(id);
5598
+ }
5599
+ function retryOwnerNotification(directory, id) {
5600
+ const attempt = (notifyAttempts.get(id) ?? 0) + 1;
5601
+ notifyAttempts.set(id, attempt);
5602
+ if (attempt > 3 || notifyRetries.has(id))
5603
+ return;
5604
+ const timer = setTimeout(() => {
5605
+ notifyRetries.delete(id);
5606
+ notifyOwnerIfNeeded(directory, id);
5607
+ }, 1000 * 2 ** (attempt - 1));
5608
+ timer.unref?.();
5609
+ notifyRetries.set(id, timer);
5610
+ }
5069
5611
  const watchers = new Map;
5070
5612
  const watchTimers = new Map;
5071
5613
  const awaitAsm = new Map;
@@ -5214,23 +5756,12 @@ function createCommandService(host, opts) {
5214
5756
  } catch {}
5215
5757
  }
5216
5758
  async function notifyOwnerIfNeeded(directory, id) {
5759
+ if (!opts?.onOwnerNotify || notifyInFlight.has(id))
5760
+ return;
5761
+ notifyInFlight.add(id);
5217
5762
  try {
5218
- let target;
5219
- await mutateState(directory, `cmd.notify-claim:${id}`, async (s) => {
5220
- const c = (s.commands ?? []).find((x) => x.id === id);
5221
- if (!c)
5222
- return s;
5223
- if (c.ownerNotifiedAt)
5224
- return s;
5225
- if (!isTerminalCommandStatus(c.status))
5226
- return s;
5227
- if (!shouldNotifyOwnerOnExit(c))
5228
- return s;
5229
- c.ownerNotifiedAt = new Date().toISOString();
5230
- target = { ...c };
5231
- return s;
5232
- });
5233
- if (!target)
5763
+ const target = (await readState(directory)).commands?.find((c) => c.id === id);
5764
+ if (!target || target.ownerNotifiedAt || !isTerminalCommandStatus(target.status) || !shouldNotifyOwnerOnExit(target))
5234
5765
  return;
5235
5766
  const tail = await readBoundedTail(directory, id);
5236
5767
  const message = formatAwaitEvidence({
@@ -5242,8 +5773,25 @@ function createCommandService(host, opts) {
5242
5773
  signal: target.signal,
5243
5774
  tail
5244
5775
  });
5245
- await opts?.onOwnerNotify?.(directory, target.ownerSessionID, message);
5246
- } catch {}
5776
+ const accepted = await opts.onOwnerNotify(directory, target.ownerSessionID, message);
5777
+ if (accepted === false)
5778
+ throw new Error("host did not accept owner prompt");
5779
+ await mutateState(directory, `cmd.notify-delivered:${id}`, async (s) => {
5780
+ const c = (s.commands ?? []).find((x) => x.id === id);
5781
+ if (c && !c.ownerNotifiedAt)
5782
+ c.ownerNotifiedAt = new Date().toISOString();
5783
+ return s;
5784
+ });
5785
+ clearNotifyRetry(id);
5786
+ } catch (error) {
5787
+ await logServerEvent(directory, "command.owner-notify-failed", {
5788
+ commandID: id,
5789
+ detail: error instanceof Error ? error.message : String(error)
5790
+ }).catch(() => {});
5791
+ retryOwnerNotification(directory, id);
5792
+ } finally {
5793
+ notifyInFlight.delete(id);
5794
+ }
5247
5795
  }
5248
5796
  async function flushWatch(directory, id) {
5249
5797
  const watcher = watchers.get(id);
@@ -5650,6 +6198,7 @@ function createCommandService(host, opts) {
5650
6198
  ownerSessionID: input.ownerSessionID,
5651
6199
  goalID: input.goalID,
5652
6200
  notifyOnExit: input.notifyOnExit,
6201
+ notifySuccessfulExit: true,
5653
6202
  pid: handle.pid,
5654
6203
  cols: input.cols,
5655
6204
  rows: input.rows,
@@ -5931,6 +6480,104 @@ function createCommandService(host, opts) {
5931
6480
  }
5932
6481
  return { ok: true, message: `Command "${session.title}" ${status}.` };
5933
6482
  },
6483
+ async kill(directory, id, ownerSessionID) {
6484
+ rememberDir(id, directory);
6485
+ const session = await service.get(directory, id, ownerSessionID);
6486
+ if (!session)
6487
+ return { ok: false, message: "Command not found." };
6488
+ if (session.status !== "running") {
6489
+ return { ok: false, message: `Command is ${session.status}; nothing to kill.` };
6490
+ }
6491
+ await mutateState(directory, `cmd.kill-claim:${id}`, async (s) => {
6492
+ const c = (s.commands ?? []).find((x) => x.id === id);
6493
+ if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
6494
+ c.signal = "SIGKILL";
6495
+ c.updatedAt = new Date().toISOString();
6496
+ }
6497
+ return s;
6498
+ }).catch(() => {});
6499
+ try {
6500
+ const claimed = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6501
+ if (claimed)
6502
+ emitBroker(id, { type: "status", command: claimed });
6503
+ } catch {}
6504
+ const entry = live.get(id);
6505
+ if (entry) {
6506
+ if (entry.handle.isAlive())
6507
+ entry.handle.kill();
6508
+ try {
6509
+ await entry.handle.exited();
6510
+ } catch {}
6511
+ live.delete(id);
6512
+ }
6513
+ clearCommandTimeout(id);
6514
+ clearWatchTimer(id);
6515
+ await mutateState(directory, `cmd.kill:${id}`, async (s) => {
6516
+ const c = (s.commands ?? []).find((x) => x.id === id);
6517
+ if (!c || c.ownerSessionID !== ownerSessionID)
6518
+ return s;
6519
+ if (c.status === "running") {
6520
+ c.status = "terminated";
6521
+ if (c.endReason !== "timeout")
6522
+ c.endReason = "terminate";
6523
+ c.exitCode = c.exitCode ?? 137;
6524
+ c.signal = c.signal ?? "SIGKILL";
6525
+ c.endedAt = new Date().toISOString();
6526
+ c.updatedAt = c.endedAt;
6527
+ } else if (c.status === "terminated" && !c.endedAt) {
6528
+ if (c.endReason !== "timeout" && !c.endReason)
6529
+ c.endReason = "terminate";
6530
+ c.exitCode = c.exitCode ?? 137;
6531
+ c.endedAt = new Date().toISOString();
6532
+ c.updatedAt = c.endedAt;
6533
+ }
6534
+ return s;
6535
+ });
6536
+ try {
6537
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6538
+ if (fresh)
6539
+ emitBroker(id, { type: "status", command: fresh });
6540
+ } catch {}
6541
+ await fireAwaits(directory, id);
6542
+ await notifyOwnerIfNeeded(directory, id);
6543
+ return { ok: true, message: `Command "${session.title}" killed (SIGKILL).` };
6544
+ },
6545
+ async restart(directory, id, ownerSessionID) {
6546
+ rememberDir(id, directory);
6547
+ const session = await service.get(directory, id, ownerSessionID);
6548
+ if (!session)
6549
+ return { ok: false, message: "Command not found." };
6550
+ const spec = {
6551
+ title: session.title,
6552
+ command: session.command,
6553
+ args: [...session.args],
6554
+ cwd: session.cwd,
6555
+ ownerSessionID,
6556
+ ...session.goalID !== undefined ? { goalID: session.goalID } : {},
6557
+ ...session.notifyOnExit !== undefined ? { notifyOnExit: session.notifyOnExit } : {},
6558
+ ...session.cols !== undefined ? { cols: session.cols } : {},
6559
+ ...session.rows !== undefined ? { rows: session.rows } : {},
6560
+ ...session.shell !== undefined ? { shell: session.shell } : {},
6561
+ ...session.timeoutSeconds !== undefined ? { timeoutSeconds: session.timeoutSeconds } : {},
6562
+ ...session.watchFilter !== undefined ? { watchFilter: session.watchFilter } : {},
6563
+ ...session.watchUntil !== undefined ? { watchUntil: session.watchUntil } : {},
6564
+ ...session.watchIgnoreCase !== undefined ? { watchIgnoreCase: session.watchIgnoreCase } : {},
6565
+ ...session.watchUntilAction !== undefined ? { watchUntilAction: session.watchUntilAction } : {}
6566
+ };
6567
+ if (session.status === "running") {
6568
+ const stopped = await service.terminate(directory, id, ownerSessionID);
6569
+ if (!stopped.ok) {
6570
+ return { ok: false, message: `Restart aborted: could not stop "${session.title}": ${stopped.message}` };
6571
+ }
6572
+ }
6573
+ const next = await service.start(directory, spec);
6574
+ const envNote = (session.envKeys?.length ?? 0) > 0 ? ` (env ${session.envKeys.join(",")} NOT restored \u2014 values are never persisted)` : "";
6575
+ return {
6576
+ ok: true,
6577
+ message: `Command "${session.title}" restarted as ${next.id.slice(0, 8)}...${envNote}`,
6578
+ command: next
6579
+ };
6580
+ },
5934
6581
  async remove(directory, id, ownerSessionID) {
5935
6582
  rememberDir(id, directory);
5936
6583
  const session = await service.get(directory, id, ownerSessionID);
@@ -5939,6 +6586,7 @@ function createCommandService(host, opts) {
5939
6586
  if (session.status === "running") {
5940
6587
  return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
5941
6588
  }
6589
+ clearNotifyRetry(id);
5942
6590
  deleteWatch(id);
5943
6591
  const lastKnown = { ...session };
5944
6592
  await mutateState(directory, `cmd.remove:${id}`, async (s) => {
@@ -6037,8 +6685,11 @@ function createCommandService(host, opts) {
6037
6685
  rememberDir(c.id, directory);
6038
6686
  let markedMissing = 0;
6039
6687
  for (const c of cmds) {
6040
- if (c.status !== "running")
6688
+ if (c.status !== "running") {
6689
+ if ((c.notifySuccessfulExit === true || c.notifyOnExit === true) && !c.ownerNotifiedAt)
6690
+ await notifyOwnerIfNeeded(directory, c.id);
6041
6691
  continue;
6692
+ }
6042
6693
  const entry = live.get(c.id);
6043
6694
  if (entry) {
6044
6695
  if (!entry.handle.isAlive()) {
@@ -6091,6 +6742,8 @@ function createCommandService(host, opts) {
6091
6742
  return { markedMissing };
6092
6743
  },
6093
6744
  async dispose(directory) {
6745
+ for (const id of [...notifyRetries.keys()])
6746
+ clearNotifyRetry(id);
6094
6747
  for (const id of [...watchTimers.keys()])
6095
6748
  clearWatchTimer(id);
6096
6749
  watchers.clear();
@@ -6114,6 +6767,8 @@ function createCommandService(host, opts) {
6114
6767
  }));
6115
6768
  for (const id of [...timeouts.keys()])
6116
6769
  clearCommandTimeout(id);
6770
+ for (const id of [...notifyRetries.keys()])
6771
+ clearNotifyRetry(id);
6117
6772
  }
6118
6773
  };
6119
6774
  return service;
@@ -6625,12 +7280,13 @@ var execAsync = promisify(execChild);
6625
7280
  function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6626
7281
  return {
6627
7282
  loopd_create_goal: tool({
6628
- description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). When omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
7283
+ description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
6629
7284
  args: {
6630
7285
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
6631
7286
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
6632
- agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Optional \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent.`),
6633
- 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"). Optional \u2014 inherits the calling session's live model if omitted, else plugin defaultModel.`),
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.`),
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."),
6634
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."),
6635
7291
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
6636
7292
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
@@ -6642,7 +7298,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6642
7298
  compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
6643
7299
  timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
6644
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."),
6645
- 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.")
6646
7303
  },
6647
7304
  execute: async (args, context) => {
6648
7305
  const sessionID = context?.sessionID || hostSessionID;
@@ -6662,6 +7319,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6662
7319
  config.agent = args.agent;
6663
7320
  if (args.model)
6664
7321
  config.model = args.model;
7322
+ if (args.fallbackModels)
7323
+ config.fallbackModels = args.fallbackModels;
6665
7324
  if (args.checks)
6666
7325
  config.checks = args.checks;
6667
7326
  if (args.checkCwd)
@@ -6737,7 +7396,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6737
7396
  config: resolution.config,
6738
7397
  costBudget,
6739
7398
  parentAgent: parentDefaults.parentAgent,
6740
- parentModel: parentDefaults.parentModel
7399
+ parentModel: parentDefaults.parentModel,
7400
+ ...args.interactive === true ? { interactive: true } : {}
6741
7401
  });
6742
7402
  return {
6743
7403
  title: "Goal created",
@@ -6751,6 +7411,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
6751
7411
  agent: resolution.config.agent,
6752
7412
  model: resolution.config.model,
6753
7413
  costBudget: goal.costBudget,
7414
+ interactive: goal.interactive === true,
6754
7415
  checks: resolution.config.checks || [],
6755
7416
  workspaceWrite: resolution.config.workspaceWrite,
6756
7417
  defaultsApplied: resolution.defaultsApplied,
@@ -7295,6 +7956,53 @@ function withTimeout2(promise, ms) {
7295
7956
  function ownerTools(options) {
7296
7957
  const { directory, host, goalService } = options;
7297
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
+ }),
7298
8006
  list_background_goals: tool2({
7299
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.",
7300
8008
  args: {},
@@ -7336,6 +8044,11 @@ function ownerTools(options) {
7336
8044
  maxTurns: g.config.maxTurns,
7337
8045
  agent: g.config.agent,
7338
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.",
7339
8052
  lastProgress: g.lastProgress?.summary?.slice(0, 120),
7340
8053
  lastProgressAt: g.lastProgress?.at,
7341
8054
  blocker: g.blocker?.reason?.slice(0, 120),
@@ -7432,11 +8145,16 @@ function ownerTools(options) {
7432
8145
  workspaceWrite: goal.config.workspaceWrite,
7433
8146
  agent: goal.config.agent,
7434
8147
  model: goal.config.model,
8148
+ fallbackModels: goal.config.fallbackModels ?? [],
7435
8149
  parentAgent: goal.parentAgent,
7436
8150
  parentModel: goal.parentModel,
7437
8151
  schedule: goal.config.schedule
7438
8152
  },
7439
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.",
7440
8158
  completionEvidence: goal.completionEvidence,
7441
8159
  blocker: goal.blocker,
7442
8160
  tokensUsed: goal.tokensUsed,
@@ -7918,14 +8636,14 @@ function commandTools(options) {
7918
8636
  const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
7919
8637
  return {
7920
8638
  loopd_command_start: tool3({
7921
- description: "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 (you) gets pushed a real message when the command reaches a terminal status \u2014 no polling required to find out: by default (auto) that fires on a non-zero exit, on 'missing' (host restarted mid-run), on 'timeout' (timeout_seconds deadline reached \u2014 always notifies), or once total runtime crosses ~2 minutes (the long-running/monitor case); quick successful commands stay silent. Override with notify_on_exit.",
8639
+ 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.",
7922
8640
  args: {
7923
8641
  title: tool3.schema.string().describe("Short human label for the session."),
7924
8642
  command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
7925
8643
  args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
7926
8644
  cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
7927
8645
  goal_id: tool3.schema.string().optional().describe("Optional goal linkage (display only \u2014 no lifecycle coupling)."),
7928
- notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override. true = always push a message to you when this command finishes. false = never (dashboard/loopd_command_get only). Omit for auto (failure, lost-host, timeout, or long-running success)."),
8646
+ notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override. Omit to notify on natural exits (including quick success), missing or timeout; manual terminate stays silent. true also notifies on manual terminate; false disables owner exit notifications."),
7929
8647
  cols: tool3.schema.number().optional().describe(`Requested terminal width (${sizeNote}).`),
7930
8648
  rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`),
7931
8649
  env: tool3.schema.record(tool3.schema.string(), tool3.schema.string()).optional().describe("Extra environment variables for the child. Values are passed to the host but never persisted \u2014 only names appear as envKeys in summaries."),
@@ -8179,7 +8897,7 @@ function commandTools(options) {
8179
8897
  // src/server/plugin.ts
8180
8898
  init_state_repository();
8181
8899
  // package.json
8182
- var version = "1.10.4";
8900
+ var version = "1.11.2";
8183
8901
 
8184
8902
  // src/server/plugin.ts
8185
8903
  var PLUGIN_ID = "opencode-loopd.server";
@@ -8208,10 +8926,15 @@ function createServerHooks(directory, host, defaults) {
8208
8926
  }
8209
8927
  },
8210
8928
  onOwnerNotify: async (dir, ownerSessionID, message) => {
8211
- await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.owner-notify-failed", {
8212
- ownerSessionID,
8213
- detail: describeError(error)
8214
- }));
8929
+ try {
8930
+ return await host.notifyOwner(ownerSessionID, message) !== false;
8931
+ } catch (error) {
8932
+ await logServerEvent(dir, "command.owner-notify-failed", {
8933
+ ownerSessionID,
8934
+ detail: describeError(error)
8935
+ });
8936
+ return false;
8937
+ }
8215
8938
  },
8216
8939
  onWatchNotify: async (dir, ownerSessionID, message) => {
8217
8940
  await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.watch-notify-failed", {