@bojackduy/opencode-loopd 1.11.2 → 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,29 +1554,34 @@ function createControlWorker(options) {
1545
1554
  };
1546
1555
  break;
1547
1556
  }
1548
- case "switch_goal_model": {
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": {
1549
1564
  if (!request.goalID) {
1550
1565
  response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
1551
1566
  break;
1552
1567
  }
1553
1568
  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" };
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" };
1557
1571
  break;
1558
1572
  }
1559
1573
  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 });
1574
+ const result = await goalSvc.switchIdentity(directory, request.goalID, ownerForSwitch, { model: args?.model, agent: args?.agent, resume: args?.resume === true });
1561
1575
  const state = await readState(directory);
1562
1576
  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.`
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.`
1566
1580
  };
1567
1581
  response = {
1568
1582
  ...base,
1569
1583
  ok: result.outcome !== "unsupported",
1570
- message: outcomeMessage[result.outcome] ?? `Model switch ${result.outcome}.`,
1584
+ message: outcomeMessage[result.outcome] ?? `Identity switch ${result.outcome}.`,
1571
1585
  stateRevision: state.revision
1572
1586
  };
1573
1587
  break;
@@ -3034,6 +3048,25 @@ function normalizeV2Catalog(rawProviders, rawModels) {
3034
3048
  return result;
3035
3049
  }
3036
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
+
3037
3070
  // src/server/host-adapter.ts
3038
3071
  function parseModelRef(value) {
3039
3072
  if (value === undefined)
@@ -3077,6 +3110,27 @@ function markParentNotified2(ownerSessionID, message) {
3077
3110
  }
3078
3111
  function createRealHost(client, directory) {
3079
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
+ },
3080
3134
  async listModels() {
3081
3135
  if (typeof client.provider?.list !== "function")
3082
3136
  return emptyCatalog("v1 provider.list", "next-prompt");
@@ -3278,6 +3332,23 @@ function toWorkerCreation(result) {
3278
3332
  function createV2Host(context, statuses, options = {}) {
3279
3333
  const directory = context.location.directory;
3280
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
+ },
3281
3352
  async listModels() {
3282
3353
  if (typeof context.provider?.list !== "function" || typeof context.model?.list !== "function") {
3283
3354
  return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported");
@@ -3712,6 +3783,130 @@ function createGoalService(host) {
3712
3783
  async function listModels() {
3713
3784
  return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
3714
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
+ }
3715
3910
  async function validateModel(value) {
3716
3911
  const ref = parseModelRef(value);
3717
3912
  if (!ref)
@@ -3726,13 +3921,15 @@ function createGoalService(host) {
3726
3921
  }
3727
3922
  return { model, catalog };
3728
3923
  }
3729
- async function applyModel(directory, goal, model, reason) {
3924
+ async function applyModel(directory, goal, model, reason, persist = true) {
3730
3925
  if (!goal.workerSessionID || !host.switchSessionModel)
3731
3926
  return "unsupported";
3732
3927
  const previousModel = parseModelRef(goal.config.model) ?? (await host.readSession(goal.workerSessionID))?.model;
3733
3928
  const outcome = await host.switchSessionModel(goal.workerSessionID, parseModelRef(model));
3734
3929
  if (outcome === "unsupported")
3735
3930
  return outcome;
3931
+ if (!persist)
3932
+ return outcome;
3736
3933
  try {
3737
3934
  await mutateState(directory, `goal.model-switch:${goal.id}`, async (s) => {
3738
3935
  const current = s.goals.find((g) => g.id === goal.id);
@@ -3760,51 +3957,172 @@ function createGoalService(host) {
3760
3957
  return outcome;
3761
3958
  }
3762
3959
  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);
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.");
3805
4086
  }
3806
- return { outcome: applied === "next-prompt" ? "deferred" : applied, model, ...opts?.resume && applied !== "unsupported" ? { resumed: true } : {} };
3807
- });
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
+ }
3808
4126
  }
3809
4127
  async function applyPendingModel(directory, goal) {
3810
4128
  const pending = goal.modelSwitch?.pending;
@@ -3857,7 +4175,7 @@ function createGoalService(host) {
3857
4175
  const runtime = state.runtimes.find((r) => r.goalID === goalID);
3858
4176
  const quotaBlocked = goal.status === "blocked" && runtime?.lastError === describeError(error);
3859
4177
  const sameModel = expectedModel === undefined || expectedModel === goal.config.model;
3860
- const eligible = sameModel && (goal.status === "active" || quotaBlocked) && (!goal.modelSwitch?.pending || goal.modelSwitch.pending.reason === "quota");
4178
+ const eligible = sameModel && !goal.pendingIdentity && (goal.status === "active" || quotaBlocked) && (!goal.modelSwitch?.pending || goal.modelSwitch.pending.reason === "quota");
3861
4179
  if (eligible && goal.modelSwitch?.pending?.reason === "quota") {
3862
4180
  pending = { ...goal.modelSwitch.pending, reason: "quota" };
3863
4181
  status = "prepared";
@@ -3914,7 +4232,9 @@ function createGoalService(host) {
3914
4232
  return;
3915
4233
  if (await host.sessionStatus(goal.workerSessionID) !== "idle")
3916
4234
  return;
4235
+ await applyPendingIdentity(directory, goal);
3917
4236
  await applyPendingModel(directory, goal);
4237
+ await applyPendingAgent(directory, goal);
3918
4238
  const fresh = (await readState(directory)).goals.find((g) => g.id === goalID);
3919
4239
  if (fresh?.workerSessionID)
3920
4240
  await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
@@ -4256,10 +4576,12 @@ function createGoalService(host) {
4256
4576
  return;
4257
4577
  if (!opts?.force && !await workers.isIdle(session.workerSessionID))
4258
4578
  return;
4259
- if (goal.modelSwitch?.pending) {
4579
+ if (goal.pendingIdentity || goal.modelSwitch?.pending || goal.agentSwitch?.pending) {
4260
4580
  if (await host.sessionStatus(session.workerSessionID) !== "idle")
4261
4581
  return;
4582
+ await applyPendingIdentity(directory, goal);
4262
4583
  await applyPendingModel(directory, goal);
4584
+ await applyPendingAgent(directory, goal);
4263
4585
  }
4264
4586
  let acquired = false;
4265
4587
  const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
@@ -4757,7 +5079,7 @@ function createGoalService(host) {
4757
5079
  function accountUsage(directory, goalID) {
4758
5080
  return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
4759
5081
  }
4760
- return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, observeProviderError, compact };
5082
+ return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, listAgents, switchAgent, switchIdentity, observeProviderError, compact };
4761
5083
  }
4762
5084
 
4763
5085
  // src/application/schedule-worker.ts
@@ -6114,7 +6436,8 @@ function createCommandService(host, opts) {
6114
6436
  c.exitCode = info.exitCode;
6115
6437
  if (info.signal && (c.signal === "SIGKILL" || c.signal === "SIGTERM")) {
6116
6438
  c.status = "terminated";
6117
- c.endReason = "terminate";
6439
+ if (c.endReason !== "until")
6440
+ c.endReason = "terminate";
6118
6441
  } else {
6119
6442
  c.status = "exited";
6120
6443
  c.endReason = "exit";
@@ -6410,6 +6733,9 @@ function createCommandService(host, opts) {
6410
6733
  const c = (s.commands ?? []).find((x) => x.id === id);
6411
6734
  if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
6412
6735
  c.signal = "SIGTERM";
6736
+ if (c.endReason !== "timeout" && c.watchState?.state === "until-matched" && c.watchUntilAction === "stop") {
6737
+ c.endReason = "until";
6738
+ }
6413
6739
  c.updatedAt = new Date().toISOString();
6414
6740
  }
6415
6741
  return s;
@@ -6448,8 +6774,9 @@ function createCommandService(host, opts) {
6448
6774
  return s;
6449
6775
  if (c.status === "running") {
6450
6776
  c.status = status;
6451
- if (c.endReason !== "timeout")
6452
- c.endReason = status === "terminated" ? "terminate" : "exit";
6777
+ if (c.endReason !== "timeout") {
6778
+ c.endReason = status === "terminated" ? c.endReason === "until" ? "until" : "terminate" : "exit";
6779
+ }
6453
6780
  if (exitCode !== undefined)
6454
6781
  c.exitCode = c.exitCode ?? exitCode;
6455
6782
  c.signal = c.signal ?? "SIGTERM";
@@ -7957,20 +8284,25 @@ function ownerTools(options) {
7957
8284
  const { directory, host, goalService } = options;
7958
8285
  return {
7959
8286
  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.",
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.",
7961
8288
  args: {},
7962
8289
  execute: async (_args, context) => {
7963
8290
  if (!context?.sessionID)
7964
8291
  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)]);
8292
+ const [catalog, agentCatalog, state] = await Promise.all([goalService.listModels(), goalService.listAgents(), readState(directory)]);
7966
8293
  return {
7967
8294
  title: "Provider/model capabilities",
7968
8295
  output: JSON.stringify({
7969
8296
  ok: true,
7970
8297
  ...catalog,
8298
+ agents: agentCatalog.agents,
8299
+ agentCatalog,
7971
8300
  assignedGoals: state.goals.filter((g) => g.ownerSessionID === context.sessionID).map((g) => ({
7972
8301
  id: g.id,
7973
8302
  model: g.config.model,
8303
+ agent: g.config.agent,
8304
+ agentSwitch: g.agentSwitch,
8305
+ pendingIdentity: g.pendingIdentity,
7974
8306
  workerSessionID: g.workerSessionID,
7975
8307
  modelSwitch: g.modelSwitch,
7976
8308
  modelFallback: g.modelFallback,
@@ -7981,25 +8313,28 @@ function ownerTools(options) {
7981
8313
  };
7982
8314
  }
7983
8315
  }),
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.",
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.",
7986
8318
  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.")
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.")
7990
8323
  },
7991
8324
  execute: async (args, context) => {
7992
8325
  if (!context?.sessionID)
7993
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." }) };
7994
8329
  const state = await readState(directory);
7995
8330
  if (!state.goals.some((g) => g.id === args.goal_id && g.ownerSessionID === context.sessionID)) {
7996
8331
  return { title: "Switch denied", output: JSON.stringify({ ok: false, message: "Goal not found or not owned by this session." }) };
7997
8332
  }
7998
8333
  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." }) };
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." }) };
8001
8336
  } 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." }) };
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." }) };
8003
8338
  }
8004
8339
  }
8005
8340
  }),
@@ -8045,6 +8380,8 @@ function ownerTools(options) {
8045
8380
  agent: g.config.agent,
8046
8381
  model: g.config.model,
8047
8382
  modelSwitch: g.modelSwitch,
8383
+ agentSwitch: g.agentSwitch,
8384
+ pendingIdentity: g.pendingIdentity,
8048
8385
  modelFallback: g.modelFallback,
8049
8386
  lastProviderLimit: g.lastProviderLimit,
8050
8387
  fallbackModels: g.config.fallbackModels ?? [],
@@ -8152,6 +8489,8 @@ function ownerTools(options) {
8152
8489
  },
8153
8490
  lastProgress: goal.lastProgress,
8154
8491
  modelSwitch: goal.modelSwitch,
8492
+ agentSwitch: goal.agentSwitch,
8493
+ pendingIdentity: goal.pendingIdentity,
8155
8494
  modelFallback: goal.modelFallback,
8156
8495
  lastProviderLimit: goal.lastProviderLimit,
8157
8496
  quotaLimitation: "Remaining provider quota unknown; local quota errors are observations, not balance data. See loopd_list_models.",
@@ -8636,7 +8975,7 @@ function commandTools(options) {
8636
8975
  const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
8637
8976
  return {
8638
8977
  loopd_command_start: tool3({
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.",
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.",
8640
8979
  args: {
8641
8980
  title: tool3.schema.string().describe("Short human label for the session."),
8642
8981
  command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
@@ -8897,7 +9236,7 @@ function commandTools(options) {
8897
9236
  // src/server/plugin.ts
8898
9237
  init_state_repository();
8899
9238
  // package.json
8900
- var version = "1.11.2";
9239
+ var version = "1.12.2";
8901
9240
 
8902
9241
  // src/server/plugin.ts
8903
9242
  var PLUGIN_ID = "opencode-loopd.server";