@bojackduy/opencode-loopd 1.10.4 → 1.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/README.md +24 -1
- package/commands/goal.md +2 -2
- package/dist/server.js +771 -48
- package/dist/tui.js +1246 -868
- package/package.json +1 -1
- package/skills/loopd/SKILL.md +76 -11
package/dist/server.js
CHANGED
|
@@ -576,10 +576,16 @@ function tailLastBytes(text, maxBytes = MAX_AWAIT_TAIL_BYTES) {
|
|
|
576
576
|
return text;
|
|
577
577
|
return buf.subarray(buf.length - maxBytes).toString("utf8");
|
|
578
578
|
}
|
|
579
|
+
function safeCommandText(text) {
|
|
580
|
+
return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][\s\S]*?\x1b\\|\x1b[\x20-\x2f]*[\x30-\x7e]/g, "").replace(/\r\n?/g, `
|
|
581
|
+
`).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "").replace(/\n{3,}/g, `
|
|
582
|
+
|
|
583
|
+
`);
|
|
584
|
+
}
|
|
579
585
|
function formatAwaitEvidence(input) {
|
|
580
|
-
const short = input.commandID.slice(0, 8);
|
|
581
|
-
const header = `[command "${input.title}" (${input.argv.join(" ") || input.title}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${input.signal ?? "none"}]`;
|
|
582
|
-
const tail = tailLastBytes(input.tail);
|
|
586
|
+
const short = safeCommandText(input.commandID.slice(0, 8));
|
|
587
|
+
const header = `[command "${safeCommandText(input.title)}" (${safeCommandText(input.argv.join(" ") || input.title)}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${safeCommandText(input.signal ?? "none")}]`;
|
|
588
|
+
const tail = tailLastBytes(safeCommandText(input.tail));
|
|
583
589
|
return tail ? `${header}
|
|
584
590
|
${tail}` : header;
|
|
585
591
|
}
|
|
@@ -1020,13 +1026,13 @@ async function fireUntilAwaits(directory, commandID, lines) {
|
|
|
1020
1026
|
continue;
|
|
1021
1027
|
}
|
|
1022
1028
|
const short = commandID.slice(0, 8);
|
|
1023
|
-
const header = `[command "${cmd.title}" (${[cmd.command, ...cmd.args].join(" ") || cmd.title}) ${cmd.status}` + ` ${short}... watch-until "${pattern}" matched]`;
|
|
1024
|
-
const matchedText = tailLastBytes(matched.slice(0, 20).join(`
|
|
1025
|
-
`), MAX_AWAIT_TAIL_BYTES);
|
|
1029
|
+
const header = `[command "${safeCommandText(cmd.title)}" (${safeCommandText([cmd.command, ...cmd.args].join(" ") || cmd.title)}) ${cmd.status}` + ` ${short}... watch-until "${safeCommandText(pattern)}" matched]`;
|
|
1030
|
+
const matchedText = tailLastBytes(safeCommandText(matched.slice(0, 20).join(`
|
|
1031
|
+
`)), MAX_AWAIT_TAIL_BYTES);
|
|
1026
1032
|
const evidence = tail ? `${header}
|
|
1027
1033
|
${matchedText}
|
|
1028
1034
|
--- tail ---
|
|
1029
|
-
${tail}` : `${header}
|
|
1035
|
+
${tailLastBytes(safeCommandText(tail))}` : `${header}
|
|
1030
1036
|
${matchedText}`;
|
|
1031
1037
|
await appendGoalInbox(directory, a.goalID, "worker", evidence);
|
|
1032
1038
|
await appendEvent(directory, ledgerEvent({
|
|
@@ -1539,6 +1545,64 @@ function createControlWorker(options) {
|
|
|
1539
1545
|
};
|
|
1540
1546
|
break;
|
|
1541
1547
|
}
|
|
1548
|
+
case "switch_goal_model": {
|
|
1549
|
+
if (!request.goalID) {
|
|
1550
|
+
response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
const args = request.args;
|
|
1554
|
+
const model = args?.model;
|
|
1555
|
+
if (typeof model !== "string" || model.trim().length === 0) {
|
|
1556
|
+
response = { ...base, ok: false, message: "model (providerID/modelID) is required", errorCode: "bad_request" };
|
|
1557
|
+
break;
|
|
1558
|
+
}
|
|
1559
|
+
const ownerForSwitch = (await readState(directory)).goals.find((g) => g.id === request.goalID)?.ownerSessionID ?? "";
|
|
1560
|
+
const result = await goalSvc.switchModel(directory, request.goalID, ownerForSwitch, model.trim(), { resume: args?.resume === true });
|
|
1561
|
+
const state = await readState(directory);
|
|
1562
|
+
const outcomeMessage = {
|
|
1563
|
+
applied: `Model switched to ${result.model} on the same worker/session.`,
|
|
1564
|
+
deferred: `Worker busy \u2014 switch to ${result.model} queued; applies on the next turn.`,
|
|
1565
|
+
unsupported: `Model switch unsupported on this host (see loopd_list_models). Goal/session retained.`
|
|
1566
|
+
};
|
|
1567
|
+
response = {
|
|
1568
|
+
...base,
|
|
1569
|
+
ok: result.outcome !== "unsupported",
|
|
1570
|
+
message: outcomeMessage[result.outcome] ?? `Model switch ${result.outcome}.`,
|
|
1571
|
+
stateRevision: state.revision
|
|
1572
|
+
};
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
case "list_models": {
|
|
1576
|
+
const catalog = await goalSvc.listModels();
|
|
1577
|
+
const state = await readState(directory);
|
|
1578
|
+
response = {
|
|
1579
|
+
...base,
|
|
1580
|
+
ok: catalog.capability !== "unavailable",
|
|
1581
|
+
message: `Models: ${catalog.models.length} across ${catalog.providers.length} provider(s) [${catalog.capability}/${catalog.switching}]. Quota: ${catalog.quota.status} \u2014 ${catalog.quota.limitation}`,
|
|
1582
|
+
stateRevision: state.revision
|
|
1583
|
+
};
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
case "set_interactive": {
|
|
1587
|
+
if (!request.goalID) {
|
|
1588
|
+
response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
const interactive = request.args?.interactive;
|
|
1592
|
+
if (typeof interactive !== "boolean") {
|
|
1593
|
+
response = { ...base, ok: false, message: "interactive (boolean) is required", errorCode: "bad_request" };
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
const result = await goalSvc.setInteractive(directory, request.goalID, interactive);
|
|
1597
|
+
const state = await readState(directory);
|
|
1598
|
+
response = {
|
|
1599
|
+
...base,
|
|
1600
|
+
ok: result.ok,
|
|
1601
|
+
message: result.message,
|
|
1602
|
+
stateRevision: state.revision
|
|
1603
|
+
};
|
|
1604
|
+
break;
|
|
1605
|
+
}
|
|
1542
1606
|
case "send": {
|
|
1543
1607
|
const args = request.args;
|
|
1544
1608
|
const text = String(args.message || "").trim();
|
|
@@ -1687,6 +1751,8 @@ function createControlWorker(options) {
|
|
|
1687
1751
|
case "cmd_write":
|
|
1688
1752
|
case "cmd_interrupt":
|
|
1689
1753
|
case "cmd_terminate":
|
|
1754
|
+
case "cmd_kill":
|
|
1755
|
+
case "cmd_restart":
|
|
1690
1756
|
case "cmd_remove":
|
|
1691
1757
|
case "cmd_resize":
|
|
1692
1758
|
case "cmd_watch": {
|
|
@@ -1739,6 +1805,14 @@ function createControlWorker(options) {
|
|
|
1739
1805
|
const r = await cmdSvc.terminate(directory, id, ownerSessionID);
|
|
1740
1806
|
const state = await readState(directory);
|
|
1741
1807
|
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1808
|
+
} else if (request.command === "cmd_kill") {
|
|
1809
|
+
const r = await cmdSvc.kill(directory, id, ownerSessionID);
|
|
1810
|
+
const state = await readState(directory);
|
|
1811
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1812
|
+
} else if (request.command === "cmd_restart") {
|
|
1813
|
+
const r = await cmdSvc.restart(directory, id, ownerSessionID);
|
|
1814
|
+
const state = await readState(directory);
|
|
1815
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1742
1816
|
} else if (request.command === "cmd_remove") {
|
|
1743
1817
|
const r = await cmdSvc.remove(directory, id, ownerSessionID);
|
|
1744
1818
|
const state = await readState(directory);
|
|
@@ -1941,6 +2015,8 @@ function createLoopEngine(options) {
|
|
|
1941
2015
|
knownWorkerSessions.add(goal.workerSessionID);
|
|
1942
2016
|
if (goal.status !== "active")
|
|
1943
2017
|
return false;
|
|
2018
|
+
if (goal.interactive === true && type === "session.idle")
|
|
2019
|
+
return false;
|
|
1944
2020
|
switch (type) {
|
|
1945
2021
|
case "session.idle":
|
|
1946
2022
|
return await handleSessionIdle(state, goal);
|
|
@@ -2329,6 +2405,7 @@ function createLoopEngine(options) {
|
|
|
2329
2405
|
revision: newState.revision
|
|
2330
2406
|
});
|
|
2331
2407
|
const updatedGoal = newState.goals.find((g) => g.id === goal.id);
|
|
2408
|
+
await goalService.observeProviderError(directory, goal.id, error, "session-error", goal.config.model);
|
|
2332
2409
|
if (updatedGoal?.status === "blocked") {
|
|
2333
2410
|
await appendEvent(directory, {
|
|
2334
2411
|
version: 1,
|
|
@@ -2437,7 +2514,7 @@ function createLoopEngine(options) {
|
|
|
2437
2514
|
revision: state.revision
|
|
2438
2515
|
});
|
|
2439
2516
|
try {
|
|
2440
|
-
await
|
|
2517
|
+
await goalService.compact(directory, goal.id);
|
|
2441
2518
|
} catch {}
|
|
2442
2519
|
await mutateState(directory, `compact.end:${goal.id}`, async (s) => {
|
|
2443
2520
|
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -2552,7 +2629,8 @@ function createLoopEngine(options) {
|
|
|
2552
2629
|
if (stopped)
|
|
2553
2630
|
continue;
|
|
2554
2631
|
}
|
|
2555
|
-
|
|
2632
|
+
const manual = goal.interactive === true;
|
|
2633
|
+
if (!manual && runtime.phase === "waiting_retry" && runtime.retryAfter) {
|
|
2556
2634
|
if (Date.now() >= Date.parse(runtime.retryAfter)) {
|
|
2557
2635
|
await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
|
|
2558
2636
|
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -2565,7 +2643,7 @@ function createLoopEngine(options) {
|
|
|
2565
2643
|
goalService.continueTurn(directory, goal.id).catch(() => {});
|
|
2566
2644
|
}
|
|
2567
2645
|
}
|
|
2568
|
-
if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
|
|
2646
|
+
if (!manual && (runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
|
|
2569
2647
|
if (runtime.phase === "idle" && runtime.activeRunID) {
|
|
2570
2648
|
await mutateState(directory, `maintenance.clear-stale-run:${goal.id}`, async (s) => {
|
|
2571
2649
|
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -2870,6 +2948,92 @@ var nativeRpcDefinition = {
|
|
|
2870
2948
|
}
|
|
2871
2949
|
};
|
|
2872
2950
|
|
|
2951
|
+
// src/server/model-catalog.ts
|
|
2952
|
+
function emptyCatalog(source, switching, capability = "unsupported") {
|
|
2953
|
+
const observedAt = new Date().toISOString();
|
|
2954
|
+
return {
|
|
2955
|
+
capability,
|
|
2956
|
+
source,
|
|
2957
|
+
observedAt,
|
|
2958
|
+
switching,
|
|
2959
|
+
providers: [],
|
|
2960
|
+
models: [],
|
|
2961
|
+
quota: {
|
|
2962
|
+
status: "unknown",
|
|
2963
|
+
capability: "unsupported",
|
|
2964
|
+
source,
|
|
2965
|
+
observedAt,
|
|
2966
|
+
limitation: "Host catalog does not report remaining quota or reset time. Connected/enabled does not prove remaining balance; local rate-limit observations are not balance data."
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
function record(value) {
|
|
2971
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
2972
|
+
}
|
|
2973
|
+
function model(raw, providerID, usable) {
|
|
2974
|
+
const value = record(raw);
|
|
2975
|
+
if (typeof value.id !== "string" || !value.id)
|
|
2976
|
+
return;
|
|
2977
|
+
const limit = record(value.limit);
|
|
2978
|
+
return {
|
|
2979
|
+
providerID,
|
|
2980
|
+
modelID: value.id,
|
|
2981
|
+
name: typeof value.name === "string" ? value.name : value.id,
|
|
2982
|
+
usable,
|
|
2983
|
+
...typeof limit.context === "number" && Number.isFinite(limit.context) ? { contextLimit: limit.context } : {},
|
|
2984
|
+
...typeof limit.output === "number" && Number.isFinite(limit.output) ? { outputLimit: limit.output } : {}
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
function normalizeV1Catalog(raw) {
|
|
2988
|
+
const result = emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
|
|
2989
|
+
const value = record(raw);
|
|
2990
|
+
if (!Array.isArray(value.all) || !Array.isArray(value.connected))
|
|
2991
|
+
return result;
|
|
2992
|
+
result.capability = "supported";
|
|
2993
|
+
const connected = new Set(value.connected.filter((id) => typeof id === "string"));
|
|
2994
|
+
for (const rawProvider of value.all) {
|
|
2995
|
+
const p = record(rawProvider);
|
|
2996
|
+
if (typeof p.id !== "string")
|
|
2997
|
+
continue;
|
|
2998
|
+
const available = connected.has(p.id);
|
|
2999
|
+
result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: available });
|
|
3000
|
+
for (const rawModel of Object.values(record(p.models))) {
|
|
3001
|
+
const m = model(rawModel, p.id, available);
|
|
3002
|
+
if (m)
|
|
3003
|
+
result.models.push(m);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
return result;
|
|
3007
|
+
}
|
|
3008
|
+
function normalizeV2Catalog(rawProviders, rawModels) {
|
|
3009
|
+
const result = emptyCatalog("v2 provider.list + model.list", "session", "unavailable");
|
|
3010
|
+
const providers = record(rawProviders).data;
|
|
3011
|
+
const models = record(rawModels).data;
|
|
3012
|
+
if (!Array.isArray(providers) || !Array.isArray(models))
|
|
3013
|
+
return result;
|
|
3014
|
+
result.capability = "supported";
|
|
3015
|
+
const disabled = new Set;
|
|
3016
|
+
for (const rawProvider of providers) {
|
|
3017
|
+
const p = record(rawProvider);
|
|
3018
|
+
if (typeof p.id !== "string")
|
|
3019
|
+
continue;
|
|
3020
|
+
if (p.activation === "disabled")
|
|
3021
|
+
disabled.add(p.id);
|
|
3022
|
+
result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: "unknown" });
|
|
3023
|
+
}
|
|
3024
|
+
const knownProviders = new Set(result.providers.map((p) => p.providerID));
|
|
3025
|
+
for (const rawModel of models) {
|
|
3026
|
+
const value = record(rawModel);
|
|
3027
|
+
if (typeof value.providerID !== "string" || !knownProviders.has(value.providerID))
|
|
3028
|
+
continue;
|
|
3029
|
+
const usable = disabled.has(value.providerID) || value.enabled === false ? false : value.enabled === true ? true : "unknown";
|
|
3030
|
+
const m = model(value, value.providerID, usable);
|
|
3031
|
+
if (m)
|
|
3032
|
+
result.models.push(m);
|
|
3033
|
+
}
|
|
3034
|
+
return result;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2873
3037
|
// src/server/host-adapter.ts
|
|
2874
3038
|
function parseModelRef(value) {
|
|
2875
3039
|
if (value === undefined)
|
|
@@ -2901,7 +3065,6 @@ function shouldDedupParentNotify(ownerSessionID, message) {
|
|
|
2901
3065
|
const last = recentParentNotifies.get(key);
|
|
2902
3066
|
if (last !== undefined && now - last < 60000)
|
|
2903
3067
|
return true;
|
|
2904
|
-
recentParentNotifies.set(key, now);
|
|
2905
3068
|
if (recentParentNotifies.size > 200) {
|
|
2906
3069
|
for (const [k, t] of recentParentNotifies.entries())
|
|
2907
3070
|
if (now - t > 60000)
|
|
@@ -2909,8 +3072,26 @@ function shouldDedupParentNotify(ownerSessionID, message) {
|
|
|
2909
3072
|
}
|
|
2910
3073
|
return false;
|
|
2911
3074
|
}
|
|
3075
|
+
function markParentNotified2(ownerSessionID, message) {
|
|
3076
|
+
recentParentNotifies.set(`${ownerSessionID}:${message.slice(0, 200)}`, Date.now());
|
|
3077
|
+
}
|
|
2912
3078
|
function createRealHost(client, directory) {
|
|
2913
3079
|
return {
|
|
3080
|
+
async listModels() {
|
|
3081
|
+
if (typeof client.provider?.list !== "function")
|
|
3082
|
+
return emptyCatalog("v1 provider.list", "next-prompt");
|
|
3083
|
+
try {
|
|
3084
|
+
const result = await withTimeout(client.provider.list({ query: { directory } }), 1e4, "OpenCode provider.list");
|
|
3085
|
+
if (result?.error)
|
|
3086
|
+
return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
|
|
3087
|
+
return normalizeV1Catalog(result?.data);
|
|
3088
|
+
} catch {
|
|
3089
|
+
return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
|
|
3090
|
+
}
|
|
3091
|
+
},
|
|
3092
|
+
async switchSessionModel() {
|
|
3093
|
+
return "next-prompt";
|
|
3094
|
+
},
|
|
2914
3095
|
async createWorker({ parentID, title, agent, model }) {
|
|
2915
3096
|
try {
|
|
2916
3097
|
const body = { parentID, title };
|
|
@@ -2953,7 +3134,7 @@ function createRealHost(client, directory) {
|
|
|
2953
3134
|
if (result?.error) {
|
|
2954
3135
|
const detail = describeError(result.error);
|
|
2955
3136
|
await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
|
|
2956
|
-
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}
|
|
3137
|
+
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`, { cause: result.error });
|
|
2957
3138
|
}
|
|
2958
3139
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2959
3140
|
return { messageID: result?.data?.messageID };
|
|
@@ -3046,15 +3227,18 @@ function createRealHost(client, directory) {
|
|
|
3046
3227
|
return [];
|
|
3047
3228
|
}
|
|
3048
3229
|
},
|
|
3049
|
-
async compactSession(sessionID) {
|
|
3230
|
+
async compactSession(sessionID, model) {
|
|
3050
3231
|
try {
|
|
3051
|
-
await
|
|
3232
|
+
const identity = model ?? (await this.readSession(sessionID))?.model;
|
|
3233
|
+
if (!identity || typeof client.session.summarize !== "function")
|
|
3234
|
+
return;
|
|
3235
|
+
await client.session.summarize({ path: { id: sessionID }, body: identity });
|
|
3052
3236
|
} catch {}
|
|
3053
3237
|
},
|
|
3054
3238
|
async notifyOwner(ownerSessionID, message, agent) {
|
|
3055
3239
|
if (shouldDedupParentNotify(ownerSessionID, message)) {
|
|
3056
3240
|
await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
|
|
3057
|
-
return;
|
|
3241
|
+
return true;
|
|
3058
3242
|
}
|
|
3059
3243
|
let resolvedAgent = agent?.trim() || undefined;
|
|
3060
3244
|
if (!resolvedAgent) {
|
|
@@ -3075,11 +3259,15 @@ function createRealHost(client, directory) {
|
|
|
3075
3259
|
}), 1e4, "OpenCode parent notify");
|
|
3076
3260
|
if (result?.error) {
|
|
3077
3261
|
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
|
|
3262
|
+
return false;
|
|
3078
3263
|
} else {
|
|
3264
|
+
markParentNotified2(ownerSessionID, message);
|
|
3079
3265
|
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
3266
|
+
return true;
|
|
3080
3267
|
}
|
|
3081
3268
|
} catch (error) {
|
|
3082
3269
|
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
|
|
3270
|
+
return false;
|
|
3083
3271
|
}
|
|
3084
3272
|
}
|
|
3085
3273
|
};
|
|
@@ -3090,6 +3278,29 @@ function toWorkerCreation(result) {
|
|
|
3090
3278
|
function createV2Host(context, statuses, options = {}) {
|
|
3091
3279
|
const directory = context.location.directory;
|
|
3092
3280
|
return {
|
|
3281
|
+
async listModels() {
|
|
3282
|
+
if (typeof context.provider?.list !== "function" || typeof context.model?.list !== "function") {
|
|
3283
|
+
return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported");
|
|
3284
|
+
}
|
|
3285
|
+
try {
|
|
3286
|
+
const [providers, models] = await Promise.all([
|
|
3287
|
+
context.provider.list({ location: { directory } }),
|
|
3288
|
+
context.model.list({ location: { directory } })
|
|
3289
|
+
]);
|
|
3290
|
+
const catalog = normalizeV2Catalog(providers, models);
|
|
3291
|
+
if (typeof context.session.switchModel !== "function")
|
|
3292
|
+
catalog.switching = "unsupported";
|
|
3293
|
+
return catalog;
|
|
3294
|
+
} catch {
|
|
3295
|
+
return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported", "unavailable");
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
async switchSessionModel(sessionID, model) {
|
|
3299
|
+
if (typeof context.session.switchModel !== "function")
|
|
3300
|
+
return "unsupported";
|
|
3301
|
+
await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
|
|
3302
|
+
return "applied";
|
|
3303
|
+
},
|
|
3093
3304
|
async createWorker({ parentID, title, agent, model, goalID }) {
|
|
3094
3305
|
if (options.native) {
|
|
3095
3306
|
let outcome;
|
|
@@ -3244,8 +3455,13 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3244
3455
|
return [];
|
|
3245
3456
|
}
|
|
3246
3457
|
},
|
|
3247
|
-
async compactSession(sessionID) {
|
|
3458
|
+
async compactSession(sessionID, model) {
|
|
3248
3459
|
try {
|
|
3460
|
+
if (model) {
|
|
3461
|
+
if (typeof context.session.switchModel !== "function")
|
|
3462
|
+
return;
|
|
3463
|
+
await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
|
|
3464
|
+
}
|
|
3249
3465
|
await context.session.command({ sessionID, name: "compact", text: "" });
|
|
3250
3466
|
} catch {}
|
|
3251
3467
|
},
|
|
@@ -3253,8 +3469,10 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3253
3469
|
try {
|
|
3254
3470
|
await context.session.prompt({ sessionID: ownerSessionID, text: message });
|
|
3255
3471
|
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
3472
|
+
return true;
|
|
3256
3473
|
} catch (error) {
|
|
3257
3474
|
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
|
|
3475
|
+
return false;
|
|
3258
3476
|
}
|
|
3259
3477
|
}
|
|
3260
3478
|
};
|
|
@@ -3274,6 +3492,40 @@ async function withTimeout(promise, timeoutMs, operation) {
|
|
|
3274
3492
|
}
|
|
3275
3493
|
}
|
|
3276
3494
|
|
|
3495
|
+
// src/domain/provider-limit.ts
|
|
3496
|
+
function object(value) {
|
|
3497
|
+
return value !== null && typeof value === "object" ? value : {};
|
|
3498
|
+
}
|
|
3499
|
+
function observeProviderLimit(error, source, model) {
|
|
3500
|
+
const wrapper = object(error);
|
|
3501
|
+
const root = wrapper.cause ? object(wrapper.cause) : wrapper;
|
|
3502
|
+
const data = object(root.data);
|
|
3503
|
+
const status = root.statusCode ?? root.status ?? data.statusCode ?? data.status;
|
|
3504
|
+
const statusCode = typeof status === "number" ? status : undefined;
|
|
3505
|
+
const code = root.code ?? data.code;
|
|
3506
|
+
const message = [root.message, data.message, typeof error === "string" ? error : undefined].filter((s) => typeof s === "string").join(" ");
|
|
3507
|
+
if (statusCode === 401 || statusCode === 403 || /context.{0,20}(length|window|overflow)|maximum context|invalid.{0,10}(key|token)|unauthori[sz]ed|authentication|network|ECONN|ENOTFOUND|max.?turn|cost.?budget|budget.?limit/i.test(message))
|
|
3508
|
+
return;
|
|
3509
|
+
const quota = code === "insufficient_quota" || code === "quota_exceeded" || /\b(insufficient quota|quota (exceeded|exhausted)|exceeded.{0,20}quota|usage limit reached|credit balance.{0,20}(low|exhausted|insufficient))\b/i.test(message);
|
|
3510
|
+
const rate = statusCode === 429 || code === "rate_limit_exceeded" || /\b(rate[ -]limit(ed| exceeded| reached| hit)|too many requests)\b/i.test(message);
|
|
3511
|
+
if (!quota && !rate)
|
|
3512
|
+
return;
|
|
3513
|
+
const observedAt = new Date().toISOString();
|
|
3514
|
+
const headers = object(root.responseHeaders ?? data.responseHeaders);
|
|
3515
|
+
const retryAfter = root.retryAfter ?? data.retryAfter ?? headers["retry-after"];
|
|
3516
|
+
let retryAt;
|
|
3517
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) {
|
|
3518
|
+
retryAt = new Date(Date.now() + Math.min(retryAfter, 31536000) * 1000).toISOString();
|
|
3519
|
+
} else if (typeof retryAfter === "string") {
|
|
3520
|
+
if (/^\d+(\.\d+)?$/.test(retryAfter)) {
|
|
3521
|
+
retryAt = new Date(Date.now() + Math.min(Number(retryAfter), 31536000) * 1000).toISOString();
|
|
3522
|
+
} else if (Number.isFinite(Date.parse(retryAfter))) {
|
|
3523
|
+
retryAt = new Date(retryAfter).toISOString();
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
return { kind: quota ? "quota" : "rate-limit", source, observedAt, ...model ? { model } : {}, ...statusCode !== undefined ? { statusCode } : {}, ...retryAt ? { retryAt } : {} };
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3277
3529
|
// src/server/worker-session.ts
|
|
3278
3530
|
function createWorkerManager(host) {
|
|
3279
3531
|
return {
|
|
@@ -3457,6 +3709,217 @@ function createGoalService(host) {
|
|
|
3457
3709
|
throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active` + (ownedElsewhere ? ` (owned by session ${activeWriter.ownerSessionID}, not this session)` : "") + ". Pause, block, complete, or clear it before activating another workspace-writing goal." + (ownedElsewhere ? " Note: list_background_goals shows only this session's goals; clear/pause it from its owning session." : ""));
|
|
3458
3710
|
}
|
|
3459
3711
|
}
|
|
3712
|
+
async function listModels() {
|
|
3713
|
+
return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
|
|
3714
|
+
}
|
|
3715
|
+
async function validateModel(value) {
|
|
3716
|
+
const ref = parseModelRef(value);
|
|
3717
|
+
if (!ref)
|
|
3718
|
+
throw new Error("Model is required: providerID/modelID.");
|
|
3719
|
+
const model = `${ref.providerID}/${ref.modelID}`;
|
|
3720
|
+
const catalog = await listModels();
|
|
3721
|
+
if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel) {
|
|
3722
|
+
return { model, catalog };
|
|
3723
|
+
}
|
|
3724
|
+
if (!catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
|
|
3725
|
+
throw new Error("Model is unavailable or not positively known usable. Discover models with loopd_list_models first.");
|
|
3726
|
+
}
|
|
3727
|
+
return { model, catalog };
|
|
3728
|
+
}
|
|
3729
|
+
async function applyModel(directory, goal, model, reason) {
|
|
3730
|
+
if (!goal.workerSessionID || !host.switchSessionModel)
|
|
3731
|
+
return "unsupported";
|
|
3732
|
+
const previousModel = parseModelRef(goal.config.model) ?? (await host.readSession(goal.workerSessionID))?.model;
|
|
3733
|
+
const outcome = await host.switchSessionModel(goal.workerSessionID, parseModelRef(model));
|
|
3734
|
+
if (outcome === "unsupported")
|
|
3735
|
+
return outcome;
|
|
3736
|
+
try {
|
|
3737
|
+
await mutateState(directory, `goal.model-switch:${goal.id}`, async (s) => {
|
|
3738
|
+
const current = s.goals.find((g) => g.id === goal.id);
|
|
3739
|
+
if (!current)
|
|
3740
|
+
throw new Error("Goal removed during model switch.");
|
|
3741
|
+
current.config.model = model;
|
|
3742
|
+
current.modelSwitch = {
|
|
3743
|
+
...current.modelSwitch,
|
|
3744
|
+
pending: undefined,
|
|
3745
|
+
lastFailure: undefined,
|
|
3746
|
+
last: { from: goal.config.model, to: model, at: new Date().toISOString(), outcome, reason }
|
|
3747
|
+
};
|
|
3748
|
+
current.updatedAt = new Date().toISOString();
|
|
3749
|
+
return s;
|
|
3750
|
+
});
|
|
3751
|
+
} catch (error) {
|
|
3752
|
+
if (outcome === "applied") {
|
|
3753
|
+
if (!previousModel)
|
|
3754
|
+
throw new Error("Host accepted model switch but persistence failed; previous host model unknown, rollback unavailable.");
|
|
3755
|
+
await host.switchSessionModel(goal.workerSessionID, previousModel);
|
|
3756
|
+
}
|
|
3757
|
+
throw error;
|
|
3758
|
+
}
|
|
3759
|
+
await logServerEvent(directory, "goal.model-switched", { goalID: goal.id, workerSessionID: goal.workerSessionID, from: goal.config.model, to: model, reason, outcome });
|
|
3760
|
+
return outcome;
|
|
3761
|
+
}
|
|
3762
|
+
function switchModel(directory, goalID, ownerSessionID, value, opts) {
|
|
3763
|
+
return withGoalOperation(goalID, async () => {
|
|
3764
|
+
const state = await readState(directory);
|
|
3765
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
3766
|
+
if (!goal || !ownerSessionID || goal.ownerSessionID !== ownerSessionID)
|
|
3767
|
+
throw new Error("Goal not found or not owned by this session.");
|
|
3768
|
+
if (goal.status === "complete")
|
|
3769
|
+
throw new Error("Cannot switch a completed goal.");
|
|
3770
|
+
if (opts?.resume && (goal.status !== "blocked" || goal.blocker?.kind !== "provider-limit")) {
|
|
3771
|
+
throw new Error("resume is only valid for a provider-limit-blocked goal.");
|
|
3772
|
+
}
|
|
3773
|
+
if (opts?.resume)
|
|
3774
|
+
assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
|
|
3775
|
+
const { model, catalog } = await validateModel(value);
|
|
3776
|
+
if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel || !goal.workerSessionID) {
|
|
3777
|
+
return { outcome: "unsupported", model };
|
|
3778
|
+
}
|
|
3779
|
+
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
3780
|
+
const status = await host.sessionStatus(goal.workerSessionID);
|
|
3781
|
+
if (status !== "idle" || runtime?.phase === "running" && leaseIsValid(runtime)) {
|
|
3782
|
+
await mutateState(directory, `goal.model-deferred:${goalID}`, async (s) => {
|
|
3783
|
+
const current = s.goals.find((g) => g.id === goalID);
|
|
3784
|
+
if (current) {
|
|
3785
|
+
current.modelSwitch = { ...current.modelSwitch, pending: { model, requestedAt: new Date().toISOString(), reason: "owner" } };
|
|
3786
|
+
current.updatedAt = new Date().toISOString();
|
|
3787
|
+
}
|
|
3788
|
+
return s;
|
|
3789
|
+
});
|
|
3790
|
+
return { outcome: "deferred", model };
|
|
3791
|
+
}
|
|
3792
|
+
const applied = await applyModel(directory, goal, model, "owner");
|
|
3793
|
+
if (opts?.resume && applied !== "unsupported") {
|
|
3794
|
+
await mutateState(directory, `goal.model-resume:${goalID}`, async (s) => {
|
|
3795
|
+
const g = s.goals.find((g) => g.id === goalID);
|
|
3796
|
+
if (g?.status === "blocked" && g.blocker?.kind === "provider-limit") {
|
|
3797
|
+
assertWorkspaceWriteAvailable(s, g, ownerSessionID);
|
|
3798
|
+
g.status = "active";
|
|
3799
|
+
g.blocker = undefined;
|
|
3800
|
+
g.updatedAt = new Date().toISOString();
|
|
3801
|
+
}
|
|
3802
|
+
return s;
|
|
3803
|
+
});
|
|
3804
|
+
await continueTurnUnlocked(directory, goalID);
|
|
3805
|
+
}
|
|
3806
|
+
return { outcome: applied === "next-prompt" ? "deferred" : applied, model, ...opts?.resume && applied !== "unsupported" ? { resumed: true } : {} };
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
async function applyPendingModel(directory, goal) {
|
|
3810
|
+
const pending = goal.modelSwitch?.pending;
|
|
3811
|
+
if (!pending)
|
|
3812
|
+
return;
|
|
3813
|
+
let reason = "catalog-unavailable";
|
|
3814
|
+
try {
|
|
3815
|
+
const { catalog } = await validateModel(pending.model);
|
|
3816
|
+
if (catalog.capability !== "supported" || catalog.switching === "unsupported")
|
|
3817
|
+
throw new Error("Pending model switch catalog unavailable.");
|
|
3818
|
+
reason = "host-rejected";
|
|
3819
|
+
const result = await applyModel(directory, goal, pending.model, pending.reason);
|
|
3820
|
+
if (result === "unsupported")
|
|
3821
|
+
throw new Error("Pending model switching unsupported by host.");
|
|
3822
|
+
} catch {
|
|
3823
|
+
await mutateState(directory, `goal.model-switch-failed:${goal.id}`, async (s) => {
|
|
3824
|
+
const g = s.goals.find((g) => g.id === goal.id);
|
|
3825
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
3826
|
+
if (g) {
|
|
3827
|
+
g.modelSwitch = { ...g.modelSwitch, pending: undefined, lastFailure: { at: new Date().toISOString(), reason } };
|
|
3828
|
+
if (g.status === "active") {
|
|
3829
|
+
g.status = "blocked";
|
|
3830
|
+
g.blocker = { reason: "Pending model switch was not accepted.", needed: "Discover available models, switch the same goal explicitly, then retry when safe.", at: new Date().toISOString() };
|
|
3831
|
+
}
|
|
3832
|
+
g.updatedAt = new Date().toISOString();
|
|
3833
|
+
}
|
|
3834
|
+
if (rt) {
|
|
3835
|
+
Object.assign(rt, releaseLease(rt));
|
|
3836
|
+
rt.retryAfter = undefined;
|
|
3837
|
+
}
|
|
3838
|
+
return s;
|
|
3839
|
+
});
|
|
3840
|
+
throw new Error("Pending model switch was not accepted; goal blocked without replacing its worker.");
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
async function observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel) {
|
|
3844
|
+
const state = await readState(directory);
|
|
3845
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
3846
|
+
if (!goal)
|
|
3847
|
+
return;
|
|
3848
|
+
const observation = observeProviderLimit(error, source, expectedModel ?? goal.config.model);
|
|
3849
|
+
if (!observation)
|
|
3850
|
+
return;
|
|
3851
|
+
const fallbackModels = goal.config.fallbackModels ?? [];
|
|
3852
|
+
const attempted = new Set(goal.modelFallback?.attempted ?? []);
|
|
3853
|
+
if (goal.config.model)
|
|
3854
|
+
attempted.add(goal.config.model);
|
|
3855
|
+
let pending;
|
|
3856
|
+
let status = "disabled";
|
|
3857
|
+
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
3858
|
+
const quotaBlocked = goal.status === "blocked" && runtime?.lastError === describeError(error);
|
|
3859
|
+
const sameModel = expectedModel === undefined || expectedModel === goal.config.model;
|
|
3860
|
+
const eligible = sameModel && (goal.status === "active" || quotaBlocked) && (!goal.modelSwitch?.pending || goal.modelSwitch.pending.reason === "quota");
|
|
3861
|
+
if (eligible && goal.modelSwitch?.pending?.reason === "quota") {
|
|
3862
|
+
pending = { ...goal.modelSwitch.pending, reason: "quota" };
|
|
3863
|
+
status = "prepared";
|
|
3864
|
+
} else if (eligible && fallbackModels.length) {
|
|
3865
|
+
const catalog = await listModels();
|
|
3866
|
+
if (catalog.capability === "unsupported" || catalog.switching === "unsupported" || !host.switchSessionModel)
|
|
3867
|
+
status = "unsupported";
|
|
3868
|
+
else if (catalog.capability !== "supported")
|
|
3869
|
+
status = "unavailable";
|
|
3870
|
+
else {
|
|
3871
|
+
status = "exhausted";
|
|
3872
|
+
for (const value of fallbackModels) {
|
|
3873
|
+
if (attempted.has(value))
|
|
3874
|
+
continue;
|
|
3875
|
+
attempted.add(value);
|
|
3876
|
+
const ref = parseModelRef(value);
|
|
3877
|
+
if (ref && catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
|
|
3878
|
+
pending = { model: value, requestedAt: new Date().toISOString(), reason: "quota" };
|
|
3879
|
+
status = "prepared";
|
|
3880
|
+
break;
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
await mutateState(directory, `goal.provider-limit:${goalID}`, async (s) => {
|
|
3886
|
+
const g = s.goals.find((g) => g.id === goalID);
|
|
3887
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
3888
|
+
if (!g)
|
|
3889
|
+
return s;
|
|
3890
|
+
g.lastProviderLimit = observation;
|
|
3891
|
+
if (sameModel && g.status === "blocked" && g.blocker && rt?.lastError === describeError(error))
|
|
3892
|
+
g.blocker.kind = "provider-limit";
|
|
3893
|
+
if (eligible) {
|
|
3894
|
+
g.modelFallback = { attempted: [...attempted], status };
|
|
3895
|
+
if (pending && (!g.modelSwitch?.pending || g.modelSwitch.pending.reason === "quota")) {
|
|
3896
|
+
g.modelSwitch = { ...g.modelSwitch, pending };
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
if (rt?.phase === "waiting_retry" && observation.retryAt && Date.parse(observation.retryAt) > Date.parse(rt.retryAfter ?? ""))
|
|
3900
|
+
rt.retryAfter = observation.retryAt;
|
|
3901
|
+
g.updatedAt = new Date().toISOString();
|
|
3902
|
+
return s;
|
|
3903
|
+
});
|
|
3904
|
+
await logServerEvent(directory, "goal.provider-limit", { goalID, model: goal.config.model, kind: observation.kind, source, fallbackStatus: status, to: pending?.model });
|
|
3905
|
+
}
|
|
3906
|
+
function observeProviderError(directory, goalID, error, source, expectedModel) {
|
|
3907
|
+
return withGoalOperation(goalID, () => observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel));
|
|
3908
|
+
}
|
|
3909
|
+
function compact(directory, goalID) {
|
|
3910
|
+
return withGoalOperation(goalID, async () => {
|
|
3911
|
+
const state = await readState(directory);
|
|
3912
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
3913
|
+
if (!goal?.workerSessionID || goal.status !== "active")
|
|
3914
|
+
return;
|
|
3915
|
+
if (await host.sessionStatus(goal.workerSessionID) !== "idle")
|
|
3916
|
+
return;
|
|
3917
|
+
await applyPendingModel(directory, goal);
|
|
3918
|
+
const fresh = (await readState(directory)).goals.find((g) => g.id === goalID);
|
|
3919
|
+
if (fresh?.workerSessionID)
|
|
3920
|
+
await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3460
3923
|
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
3461
3924
|
const detail = describeError(error);
|
|
3462
3925
|
let runID = "unknown";
|
|
@@ -3515,6 +3978,7 @@ function createGoalService(host) {
|
|
|
3515
3978
|
revision: state.revision
|
|
3516
3979
|
});
|
|
3517
3980
|
}
|
|
3981
|
+
await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
|
|
3518
3982
|
}
|
|
3519
3983
|
async function ensureWorkerSession(directory, goal) {
|
|
3520
3984
|
let session = sessions.get(goal.id);
|
|
@@ -3549,6 +4013,20 @@ function createGoalService(host) {
|
|
|
3549
4013
|
return withGoalOperation(id, () => startUnlocked(directory, input, id));
|
|
3550
4014
|
}
|
|
3551
4015
|
async function startUnlocked(directory, input, id) {
|
|
4016
|
+
if (input.config?.fallbackModels?.length) {
|
|
4017
|
+
if (input.config.fallbackModels.length > 16)
|
|
4018
|
+
throw new Error("At most 16 ordered fallback models are allowed.");
|
|
4019
|
+
const catalog = await listModels();
|
|
4020
|
+
for (const value of input.config.fallbackModels) {
|
|
4021
|
+
const ref = parseModelRef(value);
|
|
4022
|
+
if (!ref)
|
|
4023
|
+
throw new Error("Fallback model must be providerID/modelID.");
|
|
4024
|
+
if (catalog.capability === "supported" && !catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
|
|
4025
|
+
throw new Error("Fallback model is unavailable or not positively known usable.");
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
input.config.fallbackModels = [...new Set(input.config.fallbackModels.map((m) => m.trim()))];
|
|
4029
|
+
}
|
|
3552
4030
|
let parentAgent = input.parentAgent;
|
|
3553
4031
|
let parentModel = input.parentModel;
|
|
3554
4032
|
if ((!parentAgent || !parentModel) && host.readSession) {
|
|
@@ -3574,6 +4052,8 @@ function createGoalService(host) {
|
|
|
3574
4052
|
});
|
|
3575
4053
|
if (typeof input.costBudget === "number")
|
|
3576
4054
|
goal.costBudget = input.costBudget;
|
|
4055
|
+
if (input.interactive === true)
|
|
4056
|
+
goal.interactive = true;
|
|
3577
4057
|
if (parentAgent)
|
|
3578
4058
|
goal.parentAgent = parentAgent;
|
|
3579
4059
|
if (parentModel)
|
|
@@ -3776,6 +4256,11 @@ function createGoalService(host) {
|
|
|
3776
4256
|
return;
|
|
3777
4257
|
if (!opts?.force && !await workers.isIdle(session.workerSessionID))
|
|
3778
4258
|
return;
|
|
4259
|
+
if (goal.modelSwitch?.pending) {
|
|
4260
|
+
if (await host.sessionStatus(session.workerSessionID) !== "idle")
|
|
4261
|
+
return;
|
|
4262
|
+
await applyPendingModel(directory, goal);
|
|
4263
|
+
}
|
|
3779
4264
|
let acquired = false;
|
|
3780
4265
|
const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
|
|
3781
4266
|
const g = s.goals.find((item) => item.id === goalID);
|
|
@@ -3878,6 +4363,38 @@ function createGoalService(host) {
|
|
|
3878
4363
|
throw error;
|
|
3879
4364
|
}
|
|
3880
4365
|
}
|
|
4366
|
+
async function setInteractiveUnlocked(directory, goalID, interactive) {
|
|
4367
|
+
const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
|
|
4368
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
4369
|
+
if (!g)
|
|
4370
|
+
return s;
|
|
4371
|
+
if (interactive)
|
|
4372
|
+
g.interactive = true;
|
|
4373
|
+
else
|
|
4374
|
+
delete g.interactive;
|
|
4375
|
+
g.updatedAt = new Date().toISOString();
|
|
4376
|
+
return s;
|
|
4377
|
+
});
|
|
4378
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
4379
|
+
if (!goal)
|
|
4380
|
+
return { ok: false, message: "Goal not found." };
|
|
4381
|
+
await appendEvent(directory, {
|
|
4382
|
+
version: 1,
|
|
4383
|
+
eventID: randomUUID5(),
|
|
4384
|
+
goalID,
|
|
4385
|
+
type: "goal.interactive_changed",
|
|
4386
|
+
interactive,
|
|
4387
|
+
timestamp: new Date().toISOString(),
|
|
4388
|
+
revision: state.revision
|
|
4389
|
+
}).catch(() => {});
|
|
4390
|
+
return {
|
|
4391
|
+
ok: true,
|
|
4392
|
+
message: interactive ? `Goal "${goal.name}" is now manual: the engine will not start turns \u2014 steer it with :send/nudge.` : `Goal "${goal.name}" is back on auto: the engine resumes idle recovery and retries.`
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
function setInteractive(directory, goalID, interactive) {
|
|
4396
|
+
return withGoalOperation(goalID, () => setInteractiveUnlocked(directory, goalID, interactive));
|
|
4397
|
+
}
|
|
3881
4398
|
async function pauseUnlocked(directory, goalID) {
|
|
3882
4399
|
const preState = await readState(directory);
|
|
3883
4400
|
const goal = preState.goals.find((g) => g.id === goalID);
|
|
@@ -4240,7 +4757,7 @@ function createGoalService(host) {
|
|
|
4240
4757
|
function accountUsage(directory, goalID) {
|
|
4241
4758
|
return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
|
|
4242
4759
|
}
|
|
4243
|
-
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage };
|
|
4760
|
+
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, observeProviderError, compact };
|
|
4244
4761
|
}
|
|
4245
4762
|
|
|
4246
4763
|
// src/application/schedule-worker.ts
|
|
@@ -4808,6 +5325,7 @@ function createCommandSession(input) {
|
|
|
4808
5325
|
ownerSessionID: input.ownerSessionID,
|
|
4809
5326
|
goalID: input.goalID,
|
|
4810
5327
|
notifyOnExit: input.notifyOnExit,
|
|
5328
|
+
notifySuccessfulExit: input.notifySuccessfulExit,
|
|
4811
5329
|
status: "running",
|
|
4812
5330
|
pid: input.pid,
|
|
4813
5331
|
cols: input.cols,
|
|
@@ -4839,6 +5357,8 @@ function shouldNotifyOwnerOnExit(session) {
|
|
|
4839
5357
|
return false;
|
|
4840
5358
|
if (session.exitCode !== undefined && session.exitCode !== 0)
|
|
4841
5359
|
return true;
|
|
5360
|
+
if (session.notifySuccessfulExit === true)
|
|
5361
|
+
return true;
|
|
4842
5362
|
const started = Date.parse(session.createdAt);
|
|
4843
5363
|
const ended = session.endedAt ? Date.parse(session.endedAt) : Date.now();
|
|
4844
5364
|
if (!Number.isFinite(started) || !Number.isFinite(ended))
|
|
@@ -5066,6 +5586,28 @@ function createCommandService(host, opts) {
|
|
|
5066
5586
|
const commandDirs = new Map;
|
|
5067
5587
|
const timeouts = new Map;
|
|
5068
5588
|
const timeoutOwners = new Map;
|
|
5589
|
+
const notifyInFlight = new Set;
|
|
5590
|
+
const notifyRetries = new Map;
|
|
5591
|
+
const notifyAttempts = new Map;
|
|
5592
|
+
function clearNotifyRetry(id) {
|
|
5593
|
+
const timer = notifyRetries.get(id);
|
|
5594
|
+
if (timer)
|
|
5595
|
+
clearTimeout(timer);
|
|
5596
|
+
notifyRetries.delete(id);
|
|
5597
|
+
notifyAttempts.delete(id);
|
|
5598
|
+
}
|
|
5599
|
+
function retryOwnerNotification(directory, id) {
|
|
5600
|
+
const attempt = (notifyAttempts.get(id) ?? 0) + 1;
|
|
5601
|
+
notifyAttempts.set(id, attempt);
|
|
5602
|
+
if (attempt > 3 || notifyRetries.has(id))
|
|
5603
|
+
return;
|
|
5604
|
+
const timer = setTimeout(() => {
|
|
5605
|
+
notifyRetries.delete(id);
|
|
5606
|
+
notifyOwnerIfNeeded(directory, id);
|
|
5607
|
+
}, 1000 * 2 ** (attempt - 1));
|
|
5608
|
+
timer.unref?.();
|
|
5609
|
+
notifyRetries.set(id, timer);
|
|
5610
|
+
}
|
|
5069
5611
|
const watchers = new Map;
|
|
5070
5612
|
const watchTimers = new Map;
|
|
5071
5613
|
const awaitAsm = new Map;
|
|
@@ -5214,23 +5756,12 @@ function createCommandService(host, opts) {
|
|
|
5214
5756
|
} catch {}
|
|
5215
5757
|
}
|
|
5216
5758
|
async function notifyOwnerIfNeeded(directory, id) {
|
|
5759
|
+
if (!opts?.onOwnerNotify || notifyInFlight.has(id))
|
|
5760
|
+
return;
|
|
5761
|
+
notifyInFlight.add(id);
|
|
5217
5762
|
try {
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5221
|
-
if (!c)
|
|
5222
|
-
return s;
|
|
5223
|
-
if (c.ownerNotifiedAt)
|
|
5224
|
-
return s;
|
|
5225
|
-
if (!isTerminalCommandStatus(c.status))
|
|
5226
|
-
return s;
|
|
5227
|
-
if (!shouldNotifyOwnerOnExit(c))
|
|
5228
|
-
return s;
|
|
5229
|
-
c.ownerNotifiedAt = new Date().toISOString();
|
|
5230
|
-
target = { ...c };
|
|
5231
|
-
return s;
|
|
5232
|
-
});
|
|
5233
|
-
if (!target)
|
|
5763
|
+
const target = (await readState(directory)).commands?.find((c) => c.id === id);
|
|
5764
|
+
if (!target || target.ownerNotifiedAt || !isTerminalCommandStatus(target.status) || !shouldNotifyOwnerOnExit(target))
|
|
5234
5765
|
return;
|
|
5235
5766
|
const tail = await readBoundedTail(directory, id);
|
|
5236
5767
|
const message = formatAwaitEvidence({
|
|
@@ -5242,8 +5773,25 @@ function createCommandService(host, opts) {
|
|
|
5242
5773
|
signal: target.signal,
|
|
5243
5774
|
tail
|
|
5244
5775
|
});
|
|
5245
|
-
await opts
|
|
5246
|
-
|
|
5776
|
+
const accepted = await opts.onOwnerNotify(directory, target.ownerSessionID, message);
|
|
5777
|
+
if (accepted === false)
|
|
5778
|
+
throw new Error("host did not accept owner prompt");
|
|
5779
|
+
await mutateState(directory, `cmd.notify-delivered:${id}`, async (s) => {
|
|
5780
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5781
|
+
if (c && !c.ownerNotifiedAt)
|
|
5782
|
+
c.ownerNotifiedAt = new Date().toISOString();
|
|
5783
|
+
return s;
|
|
5784
|
+
});
|
|
5785
|
+
clearNotifyRetry(id);
|
|
5786
|
+
} catch (error) {
|
|
5787
|
+
await logServerEvent(directory, "command.owner-notify-failed", {
|
|
5788
|
+
commandID: id,
|
|
5789
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
5790
|
+
}).catch(() => {});
|
|
5791
|
+
retryOwnerNotification(directory, id);
|
|
5792
|
+
} finally {
|
|
5793
|
+
notifyInFlight.delete(id);
|
|
5794
|
+
}
|
|
5247
5795
|
}
|
|
5248
5796
|
async function flushWatch(directory, id) {
|
|
5249
5797
|
const watcher = watchers.get(id);
|
|
@@ -5650,6 +6198,7 @@ function createCommandService(host, opts) {
|
|
|
5650
6198
|
ownerSessionID: input.ownerSessionID,
|
|
5651
6199
|
goalID: input.goalID,
|
|
5652
6200
|
notifyOnExit: input.notifyOnExit,
|
|
6201
|
+
notifySuccessfulExit: true,
|
|
5653
6202
|
pid: handle.pid,
|
|
5654
6203
|
cols: input.cols,
|
|
5655
6204
|
rows: input.rows,
|
|
@@ -5931,6 +6480,104 @@ function createCommandService(host, opts) {
|
|
|
5931
6480
|
}
|
|
5932
6481
|
return { ok: true, message: `Command "${session.title}" ${status}.` };
|
|
5933
6482
|
},
|
|
6483
|
+
async kill(directory, id, ownerSessionID) {
|
|
6484
|
+
rememberDir(id, directory);
|
|
6485
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
6486
|
+
if (!session)
|
|
6487
|
+
return { ok: false, message: "Command not found." };
|
|
6488
|
+
if (session.status !== "running") {
|
|
6489
|
+
return { ok: false, message: `Command is ${session.status}; nothing to kill.` };
|
|
6490
|
+
}
|
|
6491
|
+
await mutateState(directory, `cmd.kill-claim:${id}`, async (s) => {
|
|
6492
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
6493
|
+
if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
|
|
6494
|
+
c.signal = "SIGKILL";
|
|
6495
|
+
c.updatedAt = new Date().toISOString();
|
|
6496
|
+
}
|
|
6497
|
+
return s;
|
|
6498
|
+
}).catch(() => {});
|
|
6499
|
+
try {
|
|
6500
|
+
const claimed = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
|
|
6501
|
+
if (claimed)
|
|
6502
|
+
emitBroker(id, { type: "status", command: claimed });
|
|
6503
|
+
} catch {}
|
|
6504
|
+
const entry = live.get(id);
|
|
6505
|
+
if (entry) {
|
|
6506
|
+
if (entry.handle.isAlive())
|
|
6507
|
+
entry.handle.kill();
|
|
6508
|
+
try {
|
|
6509
|
+
await entry.handle.exited();
|
|
6510
|
+
} catch {}
|
|
6511
|
+
live.delete(id);
|
|
6512
|
+
}
|
|
6513
|
+
clearCommandTimeout(id);
|
|
6514
|
+
clearWatchTimer(id);
|
|
6515
|
+
await mutateState(directory, `cmd.kill:${id}`, async (s) => {
|
|
6516
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
6517
|
+
if (!c || c.ownerSessionID !== ownerSessionID)
|
|
6518
|
+
return s;
|
|
6519
|
+
if (c.status === "running") {
|
|
6520
|
+
c.status = "terminated";
|
|
6521
|
+
if (c.endReason !== "timeout")
|
|
6522
|
+
c.endReason = "terminate";
|
|
6523
|
+
c.exitCode = c.exitCode ?? 137;
|
|
6524
|
+
c.signal = c.signal ?? "SIGKILL";
|
|
6525
|
+
c.endedAt = new Date().toISOString();
|
|
6526
|
+
c.updatedAt = c.endedAt;
|
|
6527
|
+
} else if (c.status === "terminated" && !c.endedAt) {
|
|
6528
|
+
if (c.endReason !== "timeout" && !c.endReason)
|
|
6529
|
+
c.endReason = "terminate";
|
|
6530
|
+
c.exitCode = c.exitCode ?? 137;
|
|
6531
|
+
c.endedAt = new Date().toISOString();
|
|
6532
|
+
c.updatedAt = c.endedAt;
|
|
6533
|
+
}
|
|
6534
|
+
return s;
|
|
6535
|
+
});
|
|
6536
|
+
try {
|
|
6537
|
+
const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
|
|
6538
|
+
if (fresh)
|
|
6539
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
6540
|
+
} catch {}
|
|
6541
|
+
await fireAwaits(directory, id);
|
|
6542
|
+
await notifyOwnerIfNeeded(directory, id);
|
|
6543
|
+
return { ok: true, message: `Command "${session.title}" killed (SIGKILL).` };
|
|
6544
|
+
},
|
|
6545
|
+
async restart(directory, id, ownerSessionID) {
|
|
6546
|
+
rememberDir(id, directory);
|
|
6547
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
6548
|
+
if (!session)
|
|
6549
|
+
return { ok: false, message: "Command not found." };
|
|
6550
|
+
const spec = {
|
|
6551
|
+
title: session.title,
|
|
6552
|
+
command: session.command,
|
|
6553
|
+
args: [...session.args],
|
|
6554
|
+
cwd: session.cwd,
|
|
6555
|
+
ownerSessionID,
|
|
6556
|
+
...session.goalID !== undefined ? { goalID: session.goalID } : {},
|
|
6557
|
+
...session.notifyOnExit !== undefined ? { notifyOnExit: session.notifyOnExit } : {},
|
|
6558
|
+
...session.cols !== undefined ? { cols: session.cols } : {},
|
|
6559
|
+
...session.rows !== undefined ? { rows: session.rows } : {},
|
|
6560
|
+
...session.shell !== undefined ? { shell: session.shell } : {},
|
|
6561
|
+
...session.timeoutSeconds !== undefined ? { timeoutSeconds: session.timeoutSeconds } : {},
|
|
6562
|
+
...session.watchFilter !== undefined ? { watchFilter: session.watchFilter } : {},
|
|
6563
|
+
...session.watchUntil !== undefined ? { watchUntil: session.watchUntil } : {},
|
|
6564
|
+
...session.watchIgnoreCase !== undefined ? { watchIgnoreCase: session.watchIgnoreCase } : {},
|
|
6565
|
+
...session.watchUntilAction !== undefined ? { watchUntilAction: session.watchUntilAction } : {}
|
|
6566
|
+
};
|
|
6567
|
+
if (session.status === "running") {
|
|
6568
|
+
const stopped = await service.terminate(directory, id, ownerSessionID);
|
|
6569
|
+
if (!stopped.ok) {
|
|
6570
|
+
return { ok: false, message: `Restart aborted: could not stop "${session.title}": ${stopped.message}` };
|
|
6571
|
+
}
|
|
6572
|
+
}
|
|
6573
|
+
const next = await service.start(directory, spec);
|
|
6574
|
+
const envNote = (session.envKeys?.length ?? 0) > 0 ? ` (env ${session.envKeys.join(",")} NOT restored \u2014 values are never persisted)` : "";
|
|
6575
|
+
return {
|
|
6576
|
+
ok: true,
|
|
6577
|
+
message: `Command "${session.title}" restarted as ${next.id.slice(0, 8)}...${envNote}`,
|
|
6578
|
+
command: next
|
|
6579
|
+
};
|
|
6580
|
+
},
|
|
5934
6581
|
async remove(directory, id, ownerSessionID) {
|
|
5935
6582
|
rememberDir(id, directory);
|
|
5936
6583
|
const session = await service.get(directory, id, ownerSessionID);
|
|
@@ -5939,6 +6586,7 @@ function createCommandService(host, opts) {
|
|
|
5939
6586
|
if (session.status === "running") {
|
|
5940
6587
|
return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
|
|
5941
6588
|
}
|
|
6589
|
+
clearNotifyRetry(id);
|
|
5942
6590
|
deleteWatch(id);
|
|
5943
6591
|
const lastKnown = { ...session };
|
|
5944
6592
|
await mutateState(directory, `cmd.remove:${id}`, async (s) => {
|
|
@@ -6037,8 +6685,11 @@ function createCommandService(host, opts) {
|
|
|
6037
6685
|
rememberDir(c.id, directory);
|
|
6038
6686
|
let markedMissing = 0;
|
|
6039
6687
|
for (const c of cmds) {
|
|
6040
|
-
if (c.status !== "running")
|
|
6688
|
+
if (c.status !== "running") {
|
|
6689
|
+
if ((c.notifySuccessfulExit === true || c.notifyOnExit === true) && !c.ownerNotifiedAt)
|
|
6690
|
+
await notifyOwnerIfNeeded(directory, c.id);
|
|
6041
6691
|
continue;
|
|
6692
|
+
}
|
|
6042
6693
|
const entry = live.get(c.id);
|
|
6043
6694
|
if (entry) {
|
|
6044
6695
|
if (!entry.handle.isAlive()) {
|
|
@@ -6091,6 +6742,8 @@ function createCommandService(host, opts) {
|
|
|
6091
6742
|
return { markedMissing };
|
|
6092
6743
|
},
|
|
6093
6744
|
async dispose(directory) {
|
|
6745
|
+
for (const id of [...notifyRetries.keys()])
|
|
6746
|
+
clearNotifyRetry(id);
|
|
6094
6747
|
for (const id of [...watchTimers.keys()])
|
|
6095
6748
|
clearWatchTimer(id);
|
|
6096
6749
|
watchers.clear();
|
|
@@ -6114,6 +6767,8 @@ function createCommandService(host, opts) {
|
|
|
6114
6767
|
}));
|
|
6115
6768
|
for (const id of [...timeouts.keys()])
|
|
6116
6769
|
clearCommandTimeout(id);
|
|
6770
|
+
for (const id of [...notifyRetries.keys()])
|
|
6771
|
+
clearNotifyRetry(id);
|
|
6117
6772
|
}
|
|
6118
6773
|
};
|
|
6119
6774
|
return service;
|
|
@@ -6625,12 +7280,13 @@ var execAsync = promisify(execChild);
|
|
|
6625
7280
|
function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
6626
7281
|
return {
|
|
6627
7282
|
loopd_create_goal: tool({
|
|
6628
|
-
description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`).
|
|
7283
|
+
description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
|
|
6629
7284
|
args: {
|
|
6630
7285
|
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
6631
7286
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
6632
|
-
agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent").
|
|
6633
|
-
model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b").
|
|
7287
|
+
agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Prefer omit \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent. Only pass when explicitly requested.`),
|
|
7288
|
+
model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Prefer omit \u2014 inherits the calling session's live model if omitted, else plugin defaultModel. Only pass when explicitly requested.`),
|
|
7289
|
+
fallbackModels: tool.schema.array(tool.schema.string()).optional().describe("Explicit ordered providerID/modelID alternatives for provider quota/rate-limit failures only. Default empty: no automatic paid/provider switch. Discover with loopd_list_models; same worker/session retained. Interactive goals never auto-wake."),
|
|
6634
7290
|
costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
|
|
6635
7291
|
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
6636
7292
|
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
@@ -6642,7 +7298,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6642
7298
|
compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
|
|
6643
7299
|
timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
|
|
6644
7300
|
scheduleEveryMs: tool.schema.number().optional().describe("Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction."),
|
|
6645
|
-
scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs.")
|
|
7301
|
+
scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs."),
|
|
7302
|
+
interactive: tool.schema.boolean().optional().describe("Manual mode: the engine never starts turns on its own (no idle recovery, no retries). Every turn comes from explicit steering (:send, nudge, resume, retry). Use for interactive tasks where the worker stops and waits for the next input \u2014 the tick forcing a turn there wastes tokens and breaks the wait. Toggleable later via the dashboard :interactive command.")
|
|
6646
7303
|
},
|
|
6647
7304
|
execute: async (args, context) => {
|
|
6648
7305
|
const sessionID = context?.sessionID || hostSessionID;
|
|
@@ -6662,6 +7319,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6662
7319
|
config.agent = args.agent;
|
|
6663
7320
|
if (args.model)
|
|
6664
7321
|
config.model = args.model;
|
|
7322
|
+
if (args.fallbackModels)
|
|
7323
|
+
config.fallbackModels = args.fallbackModels;
|
|
6665
7324
|
if (args.checks)
|
|
6666
7325
|
config.checks = args.checks;
|
|
6667
7326
|
if (args.checkCwd)
|
|
@@ -6737,7 +7396,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6737
7396
|
config: resolution.config,
|
|
6738
7397
|
costBudget,
|
|
6739
7398
|
parentAgent: parentDefaults.parentAgent,
|
|
6740
|
-
parentModel: parentDefaults.parentModel
|
|
7399
|
+
parentModel: parentDefaults.parentModel,
|
|
7400
|
+
...args.interactive === true ? { interactive: true } : {}
|
|
6741
7401
|
});
|
|
6742
7402
|
return {
|
|
6743
7403
|
title: "Goal created",
|
|
@@ -6751,6 +7411,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6751
7411
|
agent: resolution.config.agent,
|
|
6752
7412
|
model: resolution.config.model,
|
|
6753
7413
|
costBudget: goal.costBudget,
|
|
7414
|
+
interactive: goal.interactive === true,
|
|
6754
7415
|
checks: resolution.config.checks || [],
|
|
6755
7416
|
workspaceWrite: resolution.config.workspaceWrite,
|
|
6756
7417
|
defaultsApplied: resolution.defaultsApplied,
|
|
@@ -7295,6 +7956,53 @@ function withTimeout2(promise, ms) {
|
|
|
7295
7956
|
function ownerTools(options) {
|
|
7296
7957
|
const { directory, host, goalService } = options;
|
|
7297
7958
|
return {
|
|
7959
|
+
loopd_list_models: tool2({
|
|
7960
|
+
description: "Discover host providers/models usable by this agent and inspect quota limitations plus assigned identities of this session's existing goals. " + "Remaining quota is unknown unless a supported integration reports it; connected/enabled is not remaining balance. " + "Workflow: discover -> inspect_background_goal for last local quota error -> switch_goal_model on the SAME goal, never create a duplicate to change provider. No secrets/auth data are returned.",
|
|
7961
|
+
args: {},
|
|
7962
|
+
execute: async (_args, context) => {
|
|
7963
|
+
if (!context?.sessionID)
|
|
7964
|
+
return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
|
|
7965
|
+
const [catalog, state] = await Promise.all([goalService.listModels(), readState(directory)]);
|
|
7966
|
+
return {
|
|
7967
|
+
title: "Provider/model capabilities",
|
|
7968
|
+
output: JSON.stringify({
|
|
7969
|
+
ok: true,
|
|
7970
|
+
...catalog,
|
|
7971
|
+
assignedGoals: state.goals.filter((g) => g.ownerSessionID === context.sessionID).map((g) => ({
|
|
7972
|
+
id: g.id,
|
|
7973
|
+
model: g.config.model,
|
|
7974
|
+
workerSessionID: g.workerSessionID,
|
|
7975
|
+
modelSwitch: g.modelSwitch,
|
|
7976
|
+
modelFallback: g.modelFallback,
|
|
7977
|
+
fallbackModels: g.config.fallbackModels ?? [],
|
|
7978
|
+
lastProviderLimit: g.lastProviderLimit
|
|
7979
|
+
}))
|
|
7980
|
+
}, null, 2)
|
|
7981
|
+
};
|
|
7982
|
+
}
|
|
7983
|
+
}),
|
|
7984
|
+
switch_goal_model: tool2({
|
|
7985
|
+
description: "Switch provider/model for an EXISTING goal owned by this session, preserving goal ID, worker session, transcript, checks and budgets. " + "First discover usable models with loopd_list_models, then inspect_background_goal for quota/error information. " + "Busy workers defer to the next safe turn; v1 changes apply on the next prompt. No abort, duplicate worker, budget reset, unpause or interactive wake. " + "Returns applied/deferred/unsupported honestly; an unsupported catalog cannot validate a switch. resume=true explicitly resumes ONLY a provider-limit-blocked goal and requests its next turn (including interactive); never resets budgets.",
|
|
7986
|
+
args: {
|
|
7987
|
+
goal_id: tool2.schema.string().describe("Existing owned goal ID; never create a replacement goal to switch models."),
|
|
7988
|
+
model: tool2.schema.string().describe("Available providerID/modelID from loopd_list_models."),
|
|
7989
|
+
resume: tool2.schema.boolean().optional().describe("Explicitly resume only a quota/rate-limit-blocked goal after a safe switch. Default false. Paused/budget-limited/unrelated-blocked goals are not resumed.")
|
|
7990
|
+
},
|
|
7991
|
+
execute: async (args, context) => {
|
|
7992
|
+
if (!context?.sessionID)
|
|
7993
|
+
return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
|
|
7994
|
+
const state = await readState(directory);
|
|
7995
|
+
if (!state.goals.some((g) => g.id === args.goal_id && g.ownerSessionID === context.sessionID)) {
|
|
7996
|
+
return { title: "Switch denied", output: JSON.stringify({ ok: false, message: "Goal not found or not owned by this session." }) };
|
|
7997
|
+
}
|
|
7998
|
+
try {
|
|
7999
|
+
const result = await goalService.switchModel(directory, args.goal_id, context.sessionID, args.model, { resume: args.resume });
|
|
8000
|
+
return { title: `Model switch: ${result.outcome}`, output: JSON.stringify({ ok: result.outcome !== "unsupported", ...result, goalID: args.goal_id, message: "Same goal and worker retained. Deferred requests require the next permitted turn; no automatic interactive wake." }) };
|
|
8001
|
+
} catch {
|
|
8002
|
+
return { title: "Model switch failed", output: JSON.stringify({ ok: false, message: "Switch rejected: verify ownership, model availability and host capability with loopd_list_models and inspect_background_goal. Existing goal/session retained." }) };
|
|
8003
|
+
}
|
|
8004
|
+
}
|
|
8005
|
+
}),
|
|
7298
8006
|
list_background_goals: tool2({
|
|
7299
8007
|
description: "List all active background goals owned by this session. Shows contract (name, status, phase, turn, last progress, blocker) \u2014 only goals with your ownerSessionID appear.",
|
|
7300
8008
|
args: {},
|
|
@@ -7336,6 +8044,11 @@ function ownerTools(options) {
|
|
|
7336
8044
|
maxTurns: g.config.maxTurns,
|
|
7337
8045
|
agent: g.config.agent,
|
|
7338
8046
|
model: g.config.model,
|
|
8047
|
+
modelSwitch: g.modelSwitch,
|
|
8048
|
+
modelFallback: g.modelFallback,
|
|
8049
|
+
lastProviderLimit: g.lastProviderLimit,
|
|
8050
|
+
fallbackModels: g.config.fallbackModels ?? [],
|
|
8051
|
+
quotaLimitation: "Remaining provider quota unknown; connected/enabled is not balance data. See loopd_list_models.",
|
|
7339
8052
|
lastProgress: g.lastProgress?.summary?.slice(0, 120),
|
|
7340
8053
|
lastProgressAt: g.lastProgress?.at,
|
|
7341
8054
|
blocker: g.blocker?.reason?.slice(0, 120),
|
|
@@ -7432,11 +8145,16 @@ function ownerTools(options) {
|
|
|
7432
8145
|
workspaceWrite: goal.config.workspaceWrite,
|
|
7433
8146
|
agent: goal.config.agent,
|
|
7434
8147
|
model: goal.config.model,
|
|
8148
|
+
fallbackModels: goal.config.fallbackModels ?? [],
|
|
7435
8149
|
parentAgent: goal.parentAgent,
|
|
7436
8150
|
parentModel: goal.parentModel,
|
|
7437
8151
|
schedule: goal.config.schedule
|
|
7438
8152
|
},
|
|
7439
8153
|
lastProgress: goal.lastProgress,
|
|
8154
|
+
modelSwitch: goal.modelSwitch,
|
|
8155
|
+
modelFallback: goal.modelFallback,
|
|
8156
|
+
lastProviderLimit: goal.lastProviderLimit,
|
|
8157
|
+
quotaLimitation: "Remaining provider quota unknown; local quota errors are observations, not balance data. See loopd_list_models.",
|
|
7440
8158
|
completionEvidence: goal.completionEvidence,
|
|
7441
8159
|
blocker: goal.blocker,
|
|
7442
8160
|
tokensUsed: goal.tokensUsed,
|
|
@@ -7918,14 +8636,14 @@ function commandTools(options) {
|
|
|
7918
8636
|
const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
|
|
7919
8637
|
return {
|
|
7920
8638
|
loopd_command_start: tool3({
|
|
7921
|
-
description: "Start a standalone interactive OS process (arbitrary shell command) in the background \u2014 a dev server, `npm test --watch`, a REPL, a log tail, a build, or a one-off script. This is a raw process, NOT an AI worker: no agent, no checks, no turn loop. For multi-turn autonomous AI work with completion criteria, use loopd_create_goal instead. " + "Returns a command_id for loopd_command_get (read output)/loopd_command_write (send stdin)/loopd_command_interrupt (Ctrl+C)/loopd_command_terminate (kill)/loopd_command_remove (delete). " + "The user can also open it live: /loop or /commands \u2192 Tab/l to the Commands tab \u2192 select it \u2192 `o` opens a fullscreen interactive terminal page (type directly, Ctrl+C interrupts, Ctrl+] detaches without stopping it). " + "Independent from goals: an optional goal_id is display-only metadata and never couples lifecycles \u2014 pausing/clearing a goal never touches the command, and terminating a command never touches the goal. " + "To make a specific goal wake up when this command finishes, call loopd_command_await separately after starting it (linking alone does not wake anything). " + "The OWNER session
|
|
8639
|
+
description: "PREFER THIS over the built-in shell/bash tool whenever a command might take a while or never return: installs (npm/pip/brew/cargo...), builds, test suites, downloads, migrations, dev servers, watchers, log tails, REPLs, or anything interactive. The built-in shell BLOCKS your whole turn until the process exits \u2014 a long install freezes you (and can time out); this returns immediately with a command_id and pushes you a message on exit or pattern match, so you stay responsive and can do other work meanwhile. Only use the built-in shell for quick one-shots (ls, git status, a single fast test) that finish in seconds. " + "Start a standalone interactive OS process (arbitrary shell command) in the background \u2014 a dev server, `npm test --watch`, a REPL, a log tail, a build, or a one-off script. This is a raw process, NOT an AI worker: no agent, no checks, no turn loop. For multi-turn autonomous AI work with completion criteria, use loopd_create_goal instead. " + "Returns a command_id for loopd_command_get (read output)/loopd_command_write (send stdin)/loopd_command_interrupt (Ctrl+C)/loopd_command_terminate (kill)/loopd_command_remove (delete). " + "The user can also open it live: /loop or /commands \u2192 Tab/l to the Commands tab \u2192 select it \u2192 `o` opens a fullscreen interactive terminal page (type directly, Ctrl+C interrupts, Ctrl+] detaches without stopping it). " + "Independent from goals: an optional goal_id is display-only metadata and never couples lifecycles \u2014 pausing/clearing a goal never touches the command, and terminating a command never touches the goal. " + "To make a specific goal wake up when this command finishes, call loopd_command_await separately after starting it (linking alone does not wake anything). " + "The OWNER session gets a notification on natural exit (including code 0), missing process, or timeout \u2014 no polling required for completion. Manual terminate stays silent by default. Raw PTY output stays in the log; any short tail included in the notification is sanitized for chat. Set notify_on_exit:false to suppress owner exit notifications.",
|
|
7922
8640
|
args: {
|
|
7923
8641
|
title: tool3.schema.string().describe("Short human label for the session."),
|
|
7924
8642
|
command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
|
|
7925
8643
|
args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
|
|
7926
8644
|
cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
|
|
7927
8645
|
goal_id: tool3.schema.string().optional().describe("Optional goal linkage (display only \u2014 no lifecycle coupling)."),
|
|
7928
|
-
notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override.
|
|
8646
|
+
notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override. Omit to notify on natural exits (including quick success), missing or timeout; manual terminate stays silent. true also notifies on manual terminate; false disables owner exit notifications."),
|
|
7929
8647
|
cols: tool3.schema.number().optional().describe(`Requested terminal width (${sizeNote}).`),
|
|
7930
8648
|
rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`),
|
|
7931
8649
|
env: tool3.schema.record(tool3.schema.string(), tool3.schema.string()).optional().describe("Extra environment variables for the child. Values are passed to the host but never persisted \u2014 only names appear as envKeys in summaries."),
|
|
@@ -8179,7 +8897,7 @@ function commandTools(options) {
|
|
|
8179
8897
|
// src/server/plugin.ts
|
|
8180
8898
|
init_state_repository();
|
|
8181
8899
|
// package.json
|
|
8182
|
-
var version = "1.
|
|
8900
|
+
var version = "1.11.2";
|
|
8183
8901
|
|
|
8184
8902
|
// src/server/plugin.ts
|
|
8185
8903
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
@@ -8208,10 +8926,15 @@ function createServerHooks(directory, host, defaults) {
|
|
|
8208
8926
|
}
|
|
8209
8927
|
},
|
|
8210
8928
|
onOwnerNotify: async (dir, ownerSessionID, message) => {
|
|
8211
|
-
|
|
8212
|
-
ownerSessionID,
|
|
8213
|
-
|
|
8214
|
-
|
|
8929
|
+
try {
|
|
8930
|
+
return await host.notifyOwner(ownerSessionID, message) !== false;
|
|
8931
|
+
} catch (error) {
|
|
8932
|
+
await logServerEvent(dir, "command.owner-notify-failed", {
|
|
8933
|
+
ownerSessionID,
|
|
8934
|
+
detail: describeError(error)
|
|
8935
|
+
});
|
|
8936
|
+
return false;
|
|
8937
|
+
}
|
|
8215
8938
|
},
|
|
8216
8939
|
onWatchNotify: async (dir, ownerSessionID, message) => {
|
|
8217
8940
|
await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.watch-notify-failed", {
|