@bojackduy/opencode-loopd 1.11.2 → 1.12.3
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/CHANGELOG.md +2 -1
- package/README.md +3 -3
- package/dist/server.js +419 -80
- package/dist/tui.js +1277 -1272
- package/package.json +1 -1
- package/skills/loopd/SKILL.md +31 -4
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 "
|
|
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
|
-
|
|
1555
|
-
|
|
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.
|
|
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: `
|
|
1564
|
-
deferred: `
|
|
1565
|
-
unsupported: `
|
|
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] ?? `
|
|
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,
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
7985
|
-
description: "Switch
|
|
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
|
|
7988
|
-
|
|
7989
|
-
|
|
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.
|
|
8000
|
-
return { title: `
|
|
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: "
|
|
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.
|
|
9239
|
+
var version = "1.12.3";
|
|
8901
9240
|
|
|
8902
9241
|
// src/server/plugin.ts
|
|
8903
9242
|
var PLUGIN_ID = "opencode-loopd.server";
|