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