@evident-ai/cli 3.0.1-dev.ff1c4ac → 3.1.1-dev.120303a
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/README.md +25 -17
- package/dist/index.js +854 -156
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -499,6 +499,12 @@ function log(level, event, fields) {
|
|
|
499
499
|
);
|
|
500
500
|
}
|
|
501
501
|
}
|
|
502
|
+
function errorFields(err) {
|
|
503
|
+
if (err instanceof Error) {
|
|
504
|
+
return { error: err.message, error_name: err.name };
|
|
505
|
+
}
|
|
506
|
+
return { error: String(err) };
|
|
507
|
+
}
|
|
502
508
|
function stripQuery(url) {
|
|
503
509
|
try {
|
|
504
510
|
return new URL(url).pathname;
|
|
@@ -645,14 +651,27 @@ var EventTypes = {
|
|
|
645
651
|
// CLI lifecycle
|
|
646
652
|
CLI_STARTED: "cli.started",
|
|
647
653
|
CLI_COMMAND: "cli.command",
|
|
648
|
-
CLI_ERROR: "cli.error"
|
|
654
|
+
CLI_ERROR: "cli.error",
|
|
655
|
+
// Deprecation telemetry (#412) — usage of the old `--agent`/`EVIDENT_AGENT_KEY`
|
|
656
|
+
// names instead of the preferred `--runner`/`EVIDENT_RUNNER_KEY` (#409).
|
|
657
|
+
DEPRECATED_AGENT_FLAG_USED: "cli.deprecated_agent_flag_used",
|
|
658
|
+
DEPRECATED_AGENT_KEY_ENV_USED: "cli.deprecated_agent_key_env_used"
|
|
649
659
|
};
|
|
650
660
|
|
|
651
661
|
// src/lib/auth.ts
|
|
652
662
|
async function getAuthCredentials() {
|
|
663
|
+
const runnerKey = process.env.EVIDENT_RUNNER_KEY;
|
|
653
664
|
const agentKey = process.env.EVIDENT_AGENT_KEY;
|
|
665
|
+
if (runnerKey) {
|
|
666
|
+
return {
|
|
667
|
+
token: runnerKey,
|
|
668
|
+
authType: "agent_key",
|
|
669
|
+
keySource: "runner_key",
|
|
670
|
+
notice: agentKey ? "Both EVIDENT_RUNNER_KEY and EVIDENT_AGENT_KEY are set; using EVIDENT_RUNNER_KEY." : void 0
|
|
671
|
+
};
|
|
672
|
+
}
|
|
654
673
|
if (agentKey) {
|
|
655
|
-
return { token: agentKey, authType: "agent_key" };
|
|
674
|
+
return { token: agentKey, authType: "agent_key", keySource: "agent_key" };
|
|
656
675
|
}
|
|
657
676
|
const userToken = process.env.EVIDENT_TOKEN;
|
|
658
677
|
if (userToken) {
|
|
@@ -721,7 +740,7 @@ function buildOpenCodeVersionWarning(version2) {
|
|
|
721
740
|
if (isQueueValidatedVersion(version2)) return null;
|
|
722
741
|
const detected = version2 ? `v${version2}` : "unknown";
|
|
723
742
|
const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
|
|
724
|
-
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack
|
|
743
|
+
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
|
|
725
744
|
}
|
|
726
745
|
|
|
727
746
|
// src/lib/opencode/process.ts
|
|
@@ -1013,6 +1032,12 @@ async function promptOpenCodeInstall(interactive) {
|
|
|
1013
1032
|
return action;
|
|
1014
1033
|
}
|
|
1015
1034
|
|
|
1035
|
+
// src/lib/opencode/provider-check.ts
|
|
1036
|
+
function buildNoProviderWarning(hasProvider) {
|
|
1037
|
+
if (hasProvider !== false) return null;
|
|
1038
|
+
return "Warning: opencode has no authenticated model provider configured, so it won't be able to answer prompts. Run `opencode auth login` to set one up (see https://opencode.ai for details).";
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1016
1041
|
// src/lib/opencode/session.ts
|
|
1017
1042
|
function opencodeBase(port) {
|
|
1018
1043
|
return `http://127.0.0.1:${port}`;
|
|
@@ -1173,17 +1198,128 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1173
1198
|
const data = await response.json();
|
|
1174
1199
|
return data.id;
|
|
1175
1200
|
}
|
|
1201
|
+
async function getModelAttachmentCapability(port, model) {
|
|
1202
|
+
try {
|
|
1203
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1204
|
+
if (!res.ok) {
|
|
1205
|
+
console.error(
|
|
1206
|
+
`[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1207
|
+
);
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
const body = await res.json();
|
|
1211
|
+
const providers = Array.isArray(body?.providers) ? body.providers : null;
|
|
1212
|
+
if (!providers) {
|
|
1213
|
+
console.error(
|
|
1214
|
+
`[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
|
|
1215
|
+
);
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
const slash = model ? model.indexOf("/") : -1;
|
|
1219
|
+
const providerId = slash > 0 ? model.slice(0, slash) : void 0;
|
|
1220
|
+
let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
|
|
1221
|
+
const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
|
|
1222
|
+
let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
|
|
1223
|
+
if (!provider && !providerId) {
|
|
1224
|
+
const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
|
|
1225
|
+
if (defaultProviderIds.length === 1) {
|
|
1226
|
+
provider = providers.find((p) => p?.id === defaultProviderIds[0]);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if (!provider || !provider.models) return null;
|
|
1230
|
+
if (!modelId && defaults2 && typeof provider.id === "string") {
|
|
1231
|
+
const def = defaults2[provider.id];
|
|
1232
|
+
if (typeof def === "string") modelId = def;
|
|
1233
|
+
}
|
|
1234
|
+
if (!modelId) {
|
|
1235
|
+
if (providerId) {
|
|
1236
|
+
const keys = Object.keys(provider.models);
|
|
1237
|
+
if (keys.length === 1) modelId = keys[0];
|
|
1238
|
+
}
|
|
1239
|
+
if (!modelId) return null;
|
|
1240
|
+
}
|
|
1241
|
+
const entry = provider.models[modelId];
|
|
1242
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1243
|
+
if (entry.capabilities && typeof entry.capabilities === "object") {
|
|
1244
|
+
if (typeof entry.capabilities.attachment === "boolean") {
|
|
1245
|
+
return entry.capabilities.attachment;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return typeof entry.attachment === "boolean" ? entry.attachment : null;
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
console.error(
|
|
1251
|
+
`[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1252
|
+
);
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async function buildFileParts(attachments, capable) {
|
|
1257
|
+
const outcomes = [];
|
|
1258
|
+
const parts = [];
|
|
1259
|
+
const capabilityUnknown = capable === null;
|
|
1260
|
+
if (capable !== true) {
|
|
1261
|
+
for (const a of attachments.inputs) {
|
|
1262
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
|
|
1263
|
+
}
|
|
1264
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1265
|
+
}
|
|
1266
|
+
for (const a of attachments.inputs) {
|
|
1267
|
+
let dataUrl = null;
|
|
1268
|
+
try {
|
|
1269
|
+
dataUrl = await attachments.fetchDataUrl(a.index);
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
console.error(
|
|
1272
|
+
`[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
|
|
1273
|
+
);
|
|
1274
|
+
dataUrl = null;
|
|
1275
|
+
}
|
|
1276
|
+
if (dataUrl !== null && typeof dataUrl === "object") {
|
|
1277
|
+
outcomes.push({
|
|
1278
|
+
index: a.index,
|
|
1279
|
+
mime: a.mime,
|
|
1280
|
+
filename: a.filename,
|
|
1281
|
+
status: "failed",
|
|
1282
|
+
reason: "needs_reauth"
|
|
1283
|
+
});
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (dataUrl == null) {
|
|
1287
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
parts.push({
|
|
1291
|
+
type: "file",
|
|
1292
|
+
mime: a.mime,
|
|
1293
|
+
url: dataUrl,
|
|
1294
|
+
...a.filename ? { filename: a.filename } : {}
|
|
1295
|
+
});
|
|
1296
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
|
|
1297
|
+
}
|
|
1298
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1299
|
+
}
|
|
1176
1300
|
function messageText(m) {
|
|
1177
1301
|
if (!m || !Array.isArray(m.parts)) return "";
|
|
1178
1302
|
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1179
1303
|
}
|
|
1180
|
-
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1304
|
+
async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
1181
1305
|
const before = await getSessionMessages(port, sessionId);
|
|
1182
1306
|
const knownUserIds = new Set(
|
|
1183
1307
|
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1184
1308
|
);
|
|
1309
|
+
const parts = [{ type: "text", text: content }];
|
|
1310
|
+
let pendingOutcomes = null;
|
|
1311
|
+
if (attachments && attachments.inputs.length > 0) {
|
|
1312
|
+
const capable = await getModelAttachmentCapability(port, options?.model);
|
|
1313
|
+
const {
|
|
1314
|
+
parts: fileParts,
|
|
1315
|
+
outcomes,
|
|
1316
|
+
capabilityUnknown
|
|
1317
|
+
} = await buildFileParts(attachments, capable);
|
|
1318
|
+
parts.push(...fileParts);
|
|
1319
|
+
if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
|
|
1320
|
+
}
|
|
1185
1321
|
const body = {
|
|
1186
|
-
parts
|
|
1322
|
+
parts
|
|
1187
1323
|
};
|
|
1188
1324
|
if (options?.agent) {
|
|
1189
1325
|
body.agent = options.agent;
|
|
@@ -1222,7 +1358,10 @@ async function sendPromptAsync(port, sessionId, content, options) {
|
|
|
1222
1358
|
best = { id, created };
|
|
1223
1359
|
}
|
|
1224
1360
|
}
|
|
1225
|
-
if (best)
|
|
1361
|
+
if (best) {
|
|
1362
|
+
if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
|
|
1363
|
+
return best.id;
|
|
1364
|
+
}
|
|
1226
1365
|
}
|
|
1227
1366
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1228
1367
|
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
@@ -1271,6 +1410,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
|
|
|
1271
1410
|
}
|
|
1272
1411
|
return lastOk ?? last;
|
|
1273
1412
|
}
|
|
1413
|
+
function messageUsage(messages, userMessageId) {
|
|
1414
|
+
if (!messages || messages.length === 0) return null;
|
|
1415
|
+
const byParentAll = messages.filter(
|
|
1416
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1417
|
+
);
|
|
1418
|
+
const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
|
|
1419
|
+
const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
|
|
1420
|
+
let correlated;
|
|
1421
|
+
if (byParent.length > 0) {
|
|
1422
|
+
correlated = byParent;
|
|
1423
|
+
} else {
|
|
1424
|
+
const reply = findAssistantReplyAfter(messages, userMessageId);
|
|
1425
|
+
correlated = reply ? [reply] : [];
|
|
1426
|
+
}
|
|
1427
|
+
if (correlated.length === 0) return null;
|
|
1428
|
+
let sawAnyUsage = false;
|
|
1429
|
+
let inputSum = 0;
|
|
1430
|
+
let outputSum = 0;
|
|
1431
|
+
let reasoningSum = 0;
|
|
1432
|
+
let cacheReadSum = 0;
|
|
1433
|
+
let cacheWriteSum = 0;
|
|
1434
|
+
let costSum = 0;
|
|
1435
|
+
let sawCost = false;
|
|
1436
|
+
let modelId = null;
|
|
1437
|
+
let providerId = null;
|
|
1438
|
+
for (const m of correlated) {
|
|
1439
|
+
const info = m.info;
|
|
1440
|
+
if (!info) continue;
|
|
1441
|
+
const tokens = info.tokens;
|
|
1442
|
+
if (tokens) {
|
|
1443
|
+
sawAnyUsage = true;
|
|
1444
|
+
inputSum += tokens.input ?? 0;
|
|
1445
|
+
outputSum += tokens.output ?? 0;
|
|
1446
|
+
reasoningSum += tokens.reasoning ?? 0;
|
|
1447
|
+
cacheReadSum += tokens.cache?.read ?? 0;
|
|
1448
|
+
cacheWriteSum += tokens.cache?.write ?? 0;
|
|
1449
|
+
}
|
|
1450
|
+
if (typeof info.cost === "number") {
|
|
1451
|
+
sawAnyUsage = true;
|
|
1452
|
+
sawCost = true;
|
|
1453
|
+
costSum += info.cost;
|
|
1454
|
+
}
|
|
1455
|
+
if (typeof info.modelID === "string") {
|
|
1456
|
+
sawAnyUsage = true;
|
|
1457
|
+
modelId = info.modelID;
|
|
1458
|
+
}
|
|
1459
|
+
if (typeof info.providerID === "string") {
|
|
1460
|
+
sawAnyUsage = true;
|
|
1461
|
+
providerId = info.providerID;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (!sawAnyUsage) return null;
|
|
1465
|
+
return {
|
|
1466
|
+
usage_provider_id: providerId,
|
|
1467
|
+
usage_model_id: modelId,
|
|
1468
|
+
usage_tokens_input: inputSum,
|
|
1469
|
+
usage_tokens_output: outputSum,
|
|
1470
|
+
usage_tokens_reasoning: reasoningSum,
|
|
1471
|
+
usage_tokens_cache_read: cacheReadSum,
|
|
1472
|
+
usage_tokens_cache_write: cacheWriteSum,
|
|
1473
|
+
// NULL means "OpenCode never reported a cost" (never inferred from
|
|
1474
|
+
// tokens) — distinct from a genuine 0-cost turn, which would set
|
|
1475
|
+
// `sawCost` true with `costSum === 0`.
|
|
1476
|
+
usage_cost_usd: sawCost ? costSum : null
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1274
1479
|
function messageRunState(messages, userMessageId) {
|
|
1275
1480
|
if (!messages || messages.length === 0) return "unknown";
|
|
1276
1481
|
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
@@ -1306,6 +1511,37 @@ function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
|
1306
1511
|
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1307
1512
|
);
|
|
1308
1513
|
}
|
|
1514
|
+
async function hasAnyConfiguredProvider(port) {
|
|
1515
|
+
try {
|
|
1516
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1517
|
+
if (!res.ok) {
|
|
1518
|
+
console.error(
|
|
1519
|
+
`[hasAnyConfiguredProvider] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1520
|
+
);
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
const body = await res.json();
|
|
1524
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1525
|
+
console.error(
|
|
1526
|
+
`[hasAnyConfiguredProvider] GET /config/providers body was not a plain object (port ${port})`
|
|
1527
|
+
);
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
const defaults2 = body.default;
|
|
1531
|
+
if (!defaults2 || typeof defaults2 !== "object" || Array.isArray(defaults2)) {
|
|
1532
|
+
console.error(
|
|
1533
|
+
`[hasAnyConfiguredProvider] GET /config/providers body had no \`default\` object (port ${port})`
|
|
1534
|
+
);
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
return Object.keys(defaults2).length > 0;
|
|
1538
|
+
} catch (err) {
|
|
1539
|
+
console.error(
|
|
1540
|
+
`[hasAnyConfiguredProvider] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1541
|
+
);
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1309
1545
|
|
|
1310
1546
|
// src/lib/opencode/session-cleanup.ts
|
|
1311
1547
|
var DURATION_UNIT_MS = {
|
|
@@ -1464,10 +1700,11 @@ var StreamForwarder = class {
|
|
|
1464
1700
|
* Abort every in-flight stream (e.g. on WebSocket close).
|
|
1465
1701
|
*/
|
|
1466
1702
|
abortAll() {
|
|
1467
|
-
for (const stream of this.inflight.
|
|
1703
|
+
for (const [sid, stream] of this.inflight.entries()) {
|
|
1468
1704
|
try {
|
|
1469
1705
|
stream.abort();
|
|
1470
|
-
} catch {
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
log("error", "forwarder_abort_failed", { sid, ...errorFields(err) });
|
|
1471
1708
|
}
|
|
1472
1709
|
}
|
|
1473
1710
|
this.inflight.clear();
|
|
@@ -1617,7 +1854,6 @@ function connectTunnel(options) {
|
|
|
1617
1854
|
onConnected,
|
|
1618
1855
|
onDisconnected,
|
|
1619
1856
|
onError,
|
|
1620
|
-
onRequest,
|
|
1621
1857
|
onResponse,
|
|
1622
1858
|
onInfo,
|
|
1623
1859
|
onDrainPing
|
|
@@ -1630,18 +1866,8 @@ function connectTunnel(options) {
|
|
|
1630
1866
|
Authorization: authHeader
|
|
1631
1867
|
}
|
|
1632
1868
|
});
|
|
1633
|
-
const streamStartTimes = /* @__PURE__ */ new Map();
|
|
1634
1869
|
const forwarder = new StreamForwarder(ws, port, {
|
|
1635
|
-
|
|
1636
|
-
if (path === TUNNEL_DRAIN_PING_PATH) return;
|
|
1637
|
-
streamStartTimes.set(sid, Date.now());
|
|
1638
|
-
onRequest?.(method, path, sid);
|
|
1639
|
-
},
|
|
1640
|
-
onHead: (sid, status) => {
|
|
1641
|
-
const startedAt = streamStartTimes.get(sid);
|
|
1642
|
-
streamStartTimes.delete(sid);
|
|
1643
|
-
onResponse?.(status, startedAt ? Date.now() - startedAt : 0, sid);
|
|
1644
|
-
},
|
|
1870
|
+
onHead: () => onResponse?.(),
|
|
1645
1871
|
onDrainPing: () => onDrainPing?.()
|
|
1646
1872
|
});
|
|
1647
1873
|
const connectionTimeout = setTimeout(() => {
|
|
@@ -1717,7 +1943,6 @@ function connectTunnel(options) {
|
|
|
1717
1943
|
ws.on("close", (code, reason) => {
|
|
1718
1944
|
const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
|
|
1719
1945
|
forwarder.abortAll();
|
|
1720
|
-
streamStartTimes.clear();
|
|
1721
1946
|
onDisconnected?.(code, reasonStr);
|
|
1722
1947
|
});
|
|
1723
1948
|
});
|
|
@@ -1752,7 +1977,11 @@ var RunnerConnection = class {
|
|
|
1752
1977
|
if (this.connection) {
|
|
1753
1978
|
try {
|
|
1754
1979
|
this.connection.close();
|
|
1755
|
-
} catch {
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
log("error", "runner_connection_close_failed", {
|
|
1982
|
+
agent_id: this.resolvedAgentId,
|
|
1983
|
+
...errorFields(err)
|
|
1984
|
+
});
|
|
1756
1985
|
}
|
|
1757
1986
|
this.connection = null;
|
|
1758
1987
|
}
|
|
@@ -1811,6 +2040,17 @@ function messageIdOf(m) {
|
|
|
1811
2040
|
const infoId = m.info?.id;
|
|
1812
2041
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1813
2042
|
}
|
|
2043
|
+
function cleanImageMime(contentType) {
|
|
2044
|
+
if (!contentType) return null;
|
|
2045
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
2046
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
2047
|
+
}
|
|
2048
|
+
var LOG_LEVELS = {
|
|
2049
|
+
debug: 0,
|
|
2050
|
+
info: 1,
|
|
2051
|
+
warn: 2,
|
|
2052
|
+
error: 3
|
|
2053
|
+
};
|
|
1814
2054
|
var DEFAULT_RETRY_POLICY = {
|
|
1815
2055
|
maxAttempts: 6,
|
|
1816
2056
|
baseDelayMs: 500,
|
|
@@ -1822,6 +2062,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
|
1822
2062
|
var HEARTBEAT_MS = 6e4;
|
|
1823
2063
|
var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
|
|
1824
2064
|
var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
|
|
2065
|
+
var MAX_SUPERSEDED_CONVERSATIONS = 256;
|
|
1825
2066
|
var ChannelAuthError = class extends Error {
|
|
1826
2067
|
constructor(message) {
|
|
1827
2068
|
super(message);
|
|
@@ -1844,7 +2085,7 @@ function backoffDelay(attempt, policy) {
|
|
|
1844
2085
|
function isRetryableStatus(status) {
|
|
1845
2086
|
return status === 429 || status >= 500 && status <= 599;
|
|
1846
2087
|
}
|
|
1847
|
-
var ChannelDriver = class {
|
|
2088
|
+
var ChannelDriver = class _ChannelDriver {
|
|
1848
2089
|
agentId;
|
|
1849
2090
|
port;
|
|
1850
2091
|
apiUrl;
|
|
@@ -1860,6 +2101,34 @@ var ChannelDriver = class {
|
|
|
1860
2101
|
now;
|
|
1861
2102
|
/** Cache of conversationId → opencode sessionId. */
|
|
1862
2103
|
sessions = /* @__PURE__ */ new Map();
|
|
2104
|
+
/**
|
|
2105
|
+
* conversationId → the opencode session this runner has ABANDONED as that
|
|
2106
|
+
* conversation's binding (#553), after a genuine (`sessionExists === true`)
|
|
2107
|
+
* dispatch failure: the session still exists but is wedged, so #485's self-heal
|
|
2108
|
+
* must bind a fresh one.
|
|
2109
|
+
*
|
|
2110
|
+
* Dropping the local binding + clearing the server row is not enough on its own:
|
|
2111
|
+
* a SIBLING message dispatched earlier in the same drain is still in-flight under
|
|
2112
|
+
* the same session, and its watcher's routine status writes carry
|
|
2113
|
+
* `opencode_session_id`, RESURRECTING the wedged id server-side after the clear —
|
|
2114
|
+
* and `ensureSession`'s persisted-id fallback then reuses it, defeating the
|
|
2115
|
+
* self-heal. This map makes the runner authoritative instead of racing those
|
|
2116
|
+
* writes: *`ensureSession` never reuses an abandoned id for that conversation,
|
|
2117
|
+
* whatever the server row says* — which holds even when the resurrecting write
|
|
2118
|
+
* is one we deliberately keep (see `markDone`).
|
|
2119
|
+
*
|
|
2120
|
+
* Bounded by construction, on both axes: keyed by CONVERSATION, so N failures on
|
|
2121
|
+
* one conversation hold ONE entry (the newest abandonment replaces the older), and
|
|
2122
|
+
* hard-capped at `MAX_SUPERSEDED_CONVERSATIONS` with FIFO eviction. Only the
|
|
2123
|
+
* NEWEST abandoned id per conversation is guarded: after a second abandonment a
|
|
2124
|
+
* late sibling of the FIRST session can write that id back and `ensureSession`
|
|
2125
|
+
* will reuse it — costing ONE repeat failure, which re-supersedes it. Deliberately
|
|
2126
|
+
* NOT dropped when the session's watcher tears down: `markDone` still writes the
|
|
2127
|
+
* abandoned id back (it must, or the reply is lost), so the guard has to outlive
|
|
2128
|
+
* the turn that resurrects it. In-memory only — a restart forgets it, at the same
|
|
2129
|
+
* bounded cost.
|
|
2130
|
+
*/
|
|
2131
|
+
supersededSessions = /* @__PURE__ */ new Map();
|
|
1863
2132
|
/**
|
|
1864
2133
|
* Per-opencode-session dispatch lock (Task 2.1a). `sendPromptAsync` is no
|
|
1865
2134
|
* longer idempotent (no caller-supplied `messageID`), and its read-back picks
|
|
@@ -1941,6 +2210,15 @@ var ChannelDriver = class {
|
|
|
1941
2210
|
* so the NEXT tick may retry exactly once more).
|
|
1942
2211
|
*/
|
|
1943
2212
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2213
|
+
/**
|
|
2214
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2215
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2216
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2217
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2218
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2219
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2220
|
+
*/
|
|
2221
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1944
2222
|
/**
|
|
1945
2223
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1946
2224
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1958,6 +2236,19 @@ var ChannelDriver = class {
|
|
|
1958
2236
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1959
2237
|
*/
|
|
1960
2238
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2239
|
+
/**
|
|
2240
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2241
|
+
* NON-EMPTY, non-placeholder name is stored (terminal — a real session name
|
|
2242
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick. "Non-empty"
|
|
2243
|
+
* excludes OpenCode's synchronous default title (see
|
|
2244
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX`, #549) — that placeholder is treated the same
|
|
2245
|
+
* as an empty title so it never latches. A missing entry = not yet resolved OR
|
|
2246
|
+
* resolved-but-still-empty/placeholder → re-fetch on next need, since OpenCode
|
|
2247
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2248
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2249
|
+
* no watcher) can resolve the title.
|
|
2250
|
+
*/
|
|
2251
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1961
2252
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1962
2253
|
draining = false;
|
|
1963
2254
|
/**
|
|
@@ -1995,9 +2286,6 @@ var ChannelDriver = class {
|
|
|
1995
2286
|
get opencodeBase() {
|
|
1996
2287
|
return `http://127.0.0.1:${this.port}`;
|
|
1997
2288
|
}
|
|
1998
|
-
// -------------------------------------------------------------------------
|
|
1999
|
-
// Public API
|
|
2000
|
-
// -------------------------------------------------------------------------
|
|
2001
2289
|
/**
|
|
2002
2290
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
2003
2291
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2139,9 +2427,7 @@ var ChannelDriver = class {
|
|
|
2139
2427
|
if (!stillLive) return;
|
|
2140
2428
|
}
|
|
2141
2429
|
}
|
|
2142
|
-
// -------------------------------------------------------------------------
|
|
2143
2430
|
// Conversation processing (WI-3 — async dispatch)
|
|
2144
|
-
// -------------------------------------------------------------------------
|
|
2145
2431
|
/**
|
|
2146
2432
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2147
2433
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2151,10 +2437,15 @@ var ChannelDriver = class {
|
|
|
2151
2437
|
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
2152
2438
|
*/
|
|
2153
2439
|
async processConversation(conv) {
|
|
2154
|
-
const sessionId = await this.ensureSession(conv);
|
|
2440
|
+
const { sessionId, refusedSessionId } = await this.ensureSession(conv);
|
|
2155
2441
|
const messages = await this.getPendingMessages(conv.id);
|
|
2156
2442
|
let dispatched = 0;
|
|
2157
2443
|
let skippedAlreadyDispatched = 0;
|
|
2444
|
+
if (refusedSessionId && messages.length > 0) {
|
|
2445
|
+
void this.postSignal(conv.id, messages[0].id, "session_superseded", {
|
|
2446
|
+
superseded_session_id: refusedSessionId
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2158
2449
|
for (const message of messages) {
|
|
2159
2450
|
if (this.stopped) break;
|
|
2160
2451
|
if (this.dispatched.has(message.id)) {
|
|
@@ -2173,36 +2464,62 @@ var ChannelDriver = class {
|
|
|
2173
2464
|
conversation_id: conv.id,
|
|
2174
2465
|
message_id: message.id
|
|
2175
2466
|
});
|
|
2467
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2176
2468
|
opencodeMessageId = await this.dispatchLocked(
|
|
2177
2469
|
sessionId,
|
|
2178
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2470
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2179
2471
|
);
|
|
2180
2472
|
} catch (err) {
|
|
2181
2473
|
if (err instanceof ChannelAuthError) throw err;
|
|
2182
2474
|
this.dispatched.delete(message.id);
|
|
2183
|
-
|
|
2475
|
+
const exists = await sessionExists(this.port, sessionId);
|
|
2476
|
+
if (exists === false) {
|
|
2184
2477
|
this.sessions.delete(conv.id);
|
|
2185
2478
|
this.log({
|
|
2186
|
-
level: "
|
|
2479
|
+
level: "warn",
|
|
2187
2480
|
message: `Message ${message.id.slice(0, 8)} dispatch hit a session (${sessionId.slice(0, 8)}) that was deleted mid-dispatch (cleanup race) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick (recreated then, in order). Already-dispatched turns keep their watcher.`,
|
|
2188
2481
|
conversation_id: conv.id,
|
|
2189
2482
|
message_id: message.id
|
|
2190
2483
|
});
|
|
2191
2484
|
break;
|
|
2192
2485
|
}
|
|
2193
|
-
|
|
2486
|
+
if (exists === null) {
|
|
2487
|
+
this.log({
|
|
2488
|
+
level: "warn",
|
|
2489
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed and session (${sessionId.slice(0, 8)}) existence could not be confirmed (opencode momentarily unreachable) \u2014 deferring this and later messages for conversation ${conv.id.slice(0, 8)} to the next tick rather than treating it as a genuine failure.`,
|
|
2490
|
+
conversation_id: conv.id,
|
|
2491
|
+
message_id: message.id
|
|
2492
|
+
});
|
|
2493
|
+
break;
|
|
2494
|
+
}
|
|
2495
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2496
|
+
this.sessions.delete(conv.id);
|
|
2497
|
+
this.supersede(conv.id, sessionId);
|
|
2498
|
+
this.log({
|
|
2499
|
+
level: "warn",
|
|
2500
|
+
message: `Abandoning OpenCode session ${sessionId.slice(0, 8)} as the binding for conversation ${conv.id.slice(0, 8)} (it exists but failed to run a turn) \u2014 a fresh session is created on the next tick, whatever the persisted binding says by then.`,
|
|
2501
|
+
conversation_id: conv.id,
|
|
2502
|
+
message_id: message.id
|
|
2503
|
+
});
|
|
2504
|
+
await this.markFailed(conv.id, message.id, null, errorMessage).catch((markErr) => {
|
|
2505
|
+
this.log({
|
|
2506
|
+
level: "warn",
|
|
2507
|
+
message: `markFailed PATCH for message ${message.id.slice(0, 8)} (conversation ${conv.id.slice(0, 8)}) failed (best-effort, not retried): ${markErr instanceof Error ? markErr.message : String(markErr)}`,
|
|
2508
|
+
conversation_id: conv.id,
|
|
2509
|
+
message_id: message.id
|
|
2510
|
+
});
|
|
2194
2511
|
});
|
|
2195
2512
|
this.log({
|
|
2196
2513
|
level: "error",
|
|
2197
|
-
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${
|
|
2514
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${errorMessage}`,
|
|
2198
2515
|
conversation_id: conv.id,
|
|
2199
2516
|
message_id: message.id
|
|
2200
2517
|
});
|
|
2201
|
-
|
|
2518
|
+
break;
|
|
2202
2519
|
}
|
|
2203
2520
|
if (opencodeMessageId === null) {
|
|
2204
2521
|
this.log({
|
|
2205
|
-
level: "
|
|
2522
|
+
level: "warn",
|
|
2206
2523
|
message: `Message ${message.id.slice(0, 8)} dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next tick`,
|
|
2207
2524
|
conversation_id: conv.id,
|
|
2208
2525
|
message_id: message.id
|
|
@@ -2216,7 +2533,7 @@ var ChannelDriver = class {
|
|
|
2216
2533
|
}
|
|
2217
2534
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2218
2535
|
this.log({
|
|
2219
|
-
level: "
|
|
2536
|
+
level: "warn",
|
|
2220
2537
|
message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
|
|
2221
2538
|
conversation_id: conv.id
|
|
2222
2539
|
});
|
|
@@ -2224,23 +2541,57 @@ var ChannelDriver = class {
|
|
|
2224
2541
|
this.ensureWatcherRunning(sessionId);
|
|
2225
2542
|
return dispatched;
|
|
2226
2543
|
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Record that `sessionId` is no longer a valid binding for `conversationId`
|
|
2546
|
+
* (#553). Keyed by conversation and hard-capped, so it cannot grow with the
|
|
2547
|
+
* number of failures — see the `supersededSessions` field doc.
|
|
2548
|
+
*/
|
|
2549
|
+
supersede(conversationId, sessionId) {
|
|
2550
|
+
this.supersededSessions.delete(conversationId);
|
|
2551
|
+
this.supersededSessions.set(conversationId, sessionId);
|
|
2552
|
+
while (this.supersededSessions.size > MAX_SUPERSEDED_CONVERSATIONS) {
|
|
2553
|
+
const oldest = this.supersededSessions.keys().next().value;
|
|
2554
|
+
if (oldest === void 0) return;
|
|
2555
|
+
this.supersededSessions.delete(oldest);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
/** Whether `sessionId` is the session this conversation has abandoned (#553). */
|
|
2559
|
+
isSuperseded(conversationId, sessionId) {
|
|
2560
|
+
return this.supersededSessions.get(conversationId) === sessionId;
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Resolve the opencode session to run this conversation's turns in.
|
|
2564
|
+
*
|
|
2565
|
+
* `refusedSessionId` is set when the #553 guard fired — i.e. the persisted
|
|
2566
|
+
* binding was an id this runner had abandoned, so a resurrection genuinely
|
|
2567
|
+
* happened and a fresh session was bound instead. The caller reports it.
|
|
2568
|
+
*/
|
|
2227
2569
|
async ensureSession(conv) {
|
|
2228
2570
|
const bound = this.sessions.get(conv.id) ?? conv.opencode_session_id ?? null;
|
|
2571
|
+
if (bound && this.isSuperseded(conv.id, bound)) {
|
|
2572
|
+
this.log({
|
|
2573
|
+
level: "warn",
|
|
2574
|
+
message: `OpenCode session ${bound.slice(0, 8)} was abandoned for conversation ${conv.id.slice(0, 8)} after a failed dispatch but is still bound to it (the persisted id was written back by a turn already in flight) \u2014 ignoring it and binding a fresh session.`,
|
|
2575
|
+
conversation_id: conv.id
|
|
2576
|
+
});
|
|
2577
|
+
this.sessions.delete(conv.id);
|
|
2578
|
+
return { sessionId: await this.createAndBindSession(conv.id), refusedSessionId: bound };
|
|
2579
|
+
}
|
|
2229
2580
|
if (bound) {
|
|
2230
2581
|
const exists = await sessionExists(this.port, bound);
|
|
2231
2582
|
if (exists === false) {
|
|
2232
2583
|
this.log({
|
|
2233
|
-
level: "
|
|
2584
|
+
level: "debug",
|
|
2234
2585
|
message: `OpenCode session ${bound} for conversation ${conv.id.slice(0, 8)} no longer exists (deleted or DB reset) \u2014 creating a fresh session and rebinding.`,
|
|
2235
2586
|
conversation_id: conv.id
|
|
2236
2587
|
});
|
|
2237
2588
|
this.sessions.delete(conv.id);
|
|
2238
|
-
return this.createAndBindSession(conv.id);
|
|
2589
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2239
2590
|
}
|
|
2240
2591
|
this.sessions.set(conv.id, bound);
|
|
2241
|
-
return bound;
|
|
2592
|
+
return { sessionId: bound };
|
|
2242
2593
|
}
|
|
2243
|
-
return this.createAndBindSession(conv.id);
|
|
2594
|
+
return { sessionId: await this.createAndBindSession(conv.id) };
|
|
2244
2595
|
}
|
|
2245
2596
|
/**
|
|
2246
2597
|
* Create a fresh OpenCode session for a conversation, cache the binding, and
|
|
@@ -2265,15 +2616,13 @@ var ChannelDriver = class {
|
|
|
2265
2616
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2266
2617
|
if (!this.opencodeDirectory) {
|
|
2267
2618
|
this.log({
|
|
2268
|
-
level: "
|
|
2619
|
+
level: "warn",
|
|
2269
2620
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2270
2621
|
});
|
|
2271
2622
|
}
|
|
2272
2623
|
return this.opencodeDirectory;
|
|
2273
2624
|
}
|
|
2274
|
-
// -------------------------------------------------------------------------
|
|
2275
2625
|
// Per-session watcher (WI-3)
|
|
2276
|
-
// -------------------------------------------------------------------------
|
|
2277
2626
|
/**
|
|
2278
2627
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2279
2628
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2293,6 +2642,130 @@ var ChannelDriver = class {
|
|
|
2293
2642
|
);
|
|
2294
2643
|
return run2;
|
|
2295
2644
|
}
|
|
2645
|
+
// Inbound image attachments (#255, WI-8)
|
|
2646
|
+
/**
|
|
2647
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
2648
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
2649
|
+
*
|
|
2650
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
2651
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
2652
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
2653
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
2654
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
2655
|
+
* existing callback surface when any image was skipped/failed.
|
|
2656
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
2657
|
+
* reports outcomes back via `onOutcomes`.
|
|
2658
|
+
*/
|
|
2659
|
+
buildSendAttachments(conv, message) {
|
|
2660
|
+
const refs = message.attachments;
|
|
2661
|
+
if (!refs || refs.length === 0) return void 0;
|
|
2662
|
+
return {
|
|
2663
|
+
inputs: refs.map((a, index) => ({
|
|
2664
|
+
index,
|
|
2665
|
+
mime: a.mime,
|
|
2666
|
+
...a.filename ? { filename: a.filename } : {}
|
|
2667
|
+
})),
|
|
2668
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
2669
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
2674
|
+
* (`GET {apiUrl}/runners/{agentId}/attachments/{messageId}/{index}`) using the
|
|
2675
|
+
* existing authenticated fetch, and base64-encode into a
|
|
2676
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
2677
|
+
*
|
|
2678
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
2679
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
2680
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
2681
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
2682
|
+
* A 404 body carrying `{ reason: 'needs_reauth' }` (#547 — the server CONFIRMED
|
|
2683
|
+
* a Slack `files:read` scope problem via `files.info`) instead resolves the
|
|
2684
|
+
* `AttachmentFetchNeedsReauth` sentinel, so the in-thread note can steer the
|
|
2685
|
+
* user to reconnect Slack instead of a generic "unavailable". Failures are
|
|
2686
|
+
* logged with context (no silent swallow).
|
|
2687
|
+
*/
|
|
2688
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
2689
|
+
try {
|
|
2690
|
+
const res = await this.fetchImpl(
|
|
2691
|
+
`${this.apiUrl}/runners/${this.agentId}/attachments/${messageId}/${index}`,
|
|
2692
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2693
|
+
);
|
|
2694
|
+
if (!res.ok) {
|
|
2695
|
+
let reason;
|
|
2696
|
+
try {
|
|
2697
|
+
const body = await res.json();
|
|
2698
|
+
if (body && typeof body.reason === "string") reason = body.reason;
|
|
2699
|
+
} catch (parseErr) {
|
|
2700
|
+
this.log({
|
|
2701
|
+
level: "debug",
|
|
2702
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index}: error body was not JSON (${parseErr instanceof Error ? parseErr.message : String(parseErr)}) \u2014 treating as a plain failure`,
|
|
2703
|
+
message_id: messageId
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
if (reason === "needs_reauth") {
|
|
2707
|
+
this.log({
|
|
2708
|
+
level: "error",
|
|
2709
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 server confirmed a Slack reauth/scope problem \u2014 omitting this image (text turn proceeds)`,
|
|
2710
|
+
message_id: messageId
|
|
2711
|
+
});
|
|
2712
|
+
return { needsReauth: true };
|
|
2713
|
+
}
|
|
2714
|
+
this.log({
|
|
2715
|
+
level: "error",
|
|
2716
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
2717
|
+
message_id: messageId
|
|
2718
|
+
});
|
|
2719
|
+
return null;
|
|
2720
|
+
}
|
|
2721
|
+
const buf = await res.arrayBuffer();
|
|
2722
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
2723
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
2724
|
+
return `data:${dataMime};base64,${base64}`;
|
|
2725
|
+
} catch (err) {
|
|
2726
|
+
this.log({
|
|
2727
|
+
level: "error",
|
|
2728
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} failed \u2014 omitting this image (text turn proceeds): ${err instanceof Error ? err.message : String(err)}`,
|
|
2729
|
+
message_id: messageId
|
|
2730
|
+
});
|
|
2731
|
+
return null;
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
2736
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
2737
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
2738
|
+
*
|
|
2739
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
2740
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
2741
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
2742
|
+
* reaches the channel.
|
|
2743
|
+
*
|
|
2744
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
2745
|
+
*/
|
|
2746
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
2747
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
2748
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
2749
|
+
if (skipped === 0 && failed === 0) return;
|
|
2750
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
2751
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
2752
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
2753
|
+
const failedReason = outcomes.some(
|
|
2754
|
+
(o) => o.status === "failed" && o.reason === "needs_reauth"
|
|
2755
|
+
) ? "needs_reauth" : void 0;
|
|
2756
|
+
this.log({
|
|
2757
|
+
level: "info",
|
|
2758
|
+
message: `Message ${messageId.slice(0, 8)}: ${skipped} image(s) skipped (${capabilityUnknown ? "capability was unreadable \u2014 failed open to text-only" : "model not attachment-capable"}), ${failed} image(s) unavailable (deleted-at-source or fetch failure) \u2014 noting to Evident`,
|
|
2759
|
+
conversation_id: conversationId,
|
|
2760
|
+
message_id: messageId
|
|
2761
|
+
});
|
|
2762
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
2763
|
+
skipped,
|
|
2764
|
+
failed,
|
|
2765
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {},
|
|
2766
|
+
...failedReason ? { failed_reason: failedReason } : {}
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2296
2769
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2297
2770
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2298
2771
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2530,18 +3003,20 @@ var ChannelDriver = class {
|
|
|
2530
3003
|
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2531
3004
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
2532
3005
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
3006
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2533
3007
|
let claimed;
|
|
2534
3008
|
try {
|
|
2535
3009
|
claimed = await this.markProcessing(
|
|
2536
3010
|
conv.id,
|
|
2537
3011
|
inFlight.evidentMessageId,
|
|
2538
3012
|
sessionId,
|
|
2539
|
-
inFlight.opencodeMessageId
|
|
3013
|
+
inFlight.opencodeMessageId,
|
|
3014
|
+
title
|
|
2540
3015
|
);
|
|
2541
3016
|
} catch (err) {
|
|
2542
3017
|
if (err instanceof ChannelAuthError) throw err;
|
|
2543
3018
|
this.log({
|
|
2544
|
-
level: "
|
|
3019
|
+
level: "warn",
|
|
2545
3020
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2546
3021
|
conversation_id: conv.id,
|
|
2547
3022
|
message_id: inFlight.evidentMessageId
|
|
@@ -2551,7 +3026,7 @@ var ChannelDriver = class {
|
|
|
2551
3026
|
inFlight.started = true;
|
|
2552
3027
|
if (!claimed) {
|
|
2553
3028
|
this.log({
|
|
2554
|
-
level: "
|
|
3029
|
+
level: "debug",
|
|
2555
3030
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2556
3031
|
conversation_id: conv.id,
|
|
2557
3032
|
message_id: inFlight.evidentMessageId
|
|
@@ -2567,18 +3042,22 @@ var ChannelDriver = class {
|
|
|
2567
3042
|
conversation_id: conv.id,
|
|
2568
3043
|
message_id: inFlight.evidentMessageId
|
|
2569
3044
|
});
|
|
3045
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
3046
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2570
3047
|
try {
|
|
2571
3048
|
await this.markDone(
|
|
2572
3049
|
conv.id,
|
|
2573
3050
|
inFlight.evidentMessageId,
|
|
2574
3051
|
sessionId,
|
|
2575
|
-
inFlight.opencodeMessageId
|
|
3052
|
+
inFlight.opencodeMessageId,
|
|
3053
|
+
title,
|
|
3054
|
+
usage
|
|
2576
3055
|
);
|
|
2577
3056
|
} catch (err) {
|
|
2578
3057
|
if (err instanceof ChannelAuthError) throw err;
|
|
2579
3058
|
if (err instanceof ChannelTerminalError) {
|
|
2580
3059
|
this.log({
|
|
2581
|
-
level: "
|
|
3060
|
+
level: "warn",
|
|
2582
3061
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2583
3062
|
conversation_id: conv.id,
|
|
2584
3063
|
message_id: inFlight.evidentMessageId
|
|
@@ -2588,7 +3067,7 @@ var ChannelDriver = class {
|
|
|
2588
3067
|
}
|
|
2589
3068
|
if (this.now() >= inFlight.deadline) {
|
|
2590
3069
|
this.log({
|
|
2591
|
-
level: "
|
|
3070
|
+
level: "warn",
|
|
2592
3071
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
2593
3072
|
conversation_id: conv.id,
|
|
2594
3073
|
message_id: inFlight.evidentMessageId
|
|
@@ -2597,7 +3076,7 @@ var ChannelDriver = class {
|
|
|
2597
3076
|
return;
|
|
2598
3077
|
}
|
|
2599
3078
|
this.log({
|
|
2600
|
-
level: "
|
|
3079
|
+
level: "warn",
|
|
2601
3080
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2602
3081
|
conversation_id: conv.id,
|
|
2603
3082
|
message_id: inFlight.evidentMessageId
|
|
@@ -2619,13 +3098,14 @@ var ChannelDriver = class {
|
|
|
2619
3098
|
conversation_id: conv.id,
|
|
2620
3099
|
message_id: inFlight.evidentMessageId
|
|
2621
3100
|
});
|
|
3101
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2622
3102
|
try {
|
|
2623
|
-
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
|
|
3103
|
+
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
|
|
2624
3104
|
} catch (err) {
|
|
2625
3105
|
if (err instanceof ChannelAuthError) throw err;
|
|
2626
3106
|
if (err instanceof ChannelTerminalError) {
|
|
2627
3107
|
this.log({
|
|
2628
|
-
level: "
|
|
3108
|
+
level: "warn",
|
|
2629
3109
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
2630
3110
|
conversation_id: conv.id,
|
|
2631
3111
|
message_id: inFlight.evidentMessageId
|
|
@@ -2635,7 +3115,7 @@ var ChannelDriver = class {
|
|
|
2635
3115
|
}
|
|
2636
3116
|
if (this.now() >= inFlight.deadline) {
|
|
2637
3117
|
this.log({
|
|
2638
|
-
level: "
|
|
3118
|
+
level: "warn",
|
|
2639
3119
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
2640
3120
|
conversation_id: conv.id,
|
|
2641
3121
|
message_id: inFlight.evidentMessageId
|
|
@@ -2644,7 +3124,7 @@ var ChannelDriver = class {
|
|
|
2644
3124
|
return;
|
|
2645
3125
|
}
|
|
2646
3126
|
this.log({
|
|
2647
|
-
level: "
|
|
3127
|
+
level: "warn",
|
|
2648
3128
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2649
3129
|
conversation_id: conv.id,
|
|
2650
3130
|
message_id: inFlight.evidentMessageId
|
|
@@ -2667,7 +3147,7 @@ var ChannelDriver = class {
|
|
|
2667
3147
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2668
3148
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2669
3149
|
this.log({
|
|
2670
|
-
level: "
|
|
3150
|
+
level: "warn",
|
|
2671
3151
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} exceeded the absolute processing ceiling (${Math.round((this.now() - inFlight.processingAnchorMs) / 6e4)}min, session ${sessionId}) while still actively running \u2014 releasing so the cron can reclaim it`,
|
|
2672
3152
|
conversation_id: conv.id,
|
|
2673
3153
|
message_id: inFlight.evidentMessageId
|
|
@@ -2710,7 +3190,7 @@ var ChannelDriver = class {
|
|
|
2710
3190
|
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2711
3191
|
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2712
3192
|
this.log({
|
|
2713
|
-
level: "
|
|
3193
|
+
level: "debug",
|
|
2714
3194
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2715
3195
|
conversation_id: conv.id,
|
|
2716
3196
|
message_id: inFlight.evidentMessageId
|
|
@@ -2721,9 +3201,7 @@ var ChannelDriver = class {
|
|
|
2721
3201
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2722
3202
|
}
|
|
2723
3203
|
}
|
|
2724
|
-
// -------------------------------------------------------------------------
|
|
2725
3204
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2726
|
-
// -------------------------------------------------------------------------
|
|
2727
3205
|
/**
|
|
2728
3206
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2729
3207
|
*
|
|
@@ -2753,7 +3231,7 @@ var ChannelDriver = class {
|
|
|
2753
3231
|
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2754
3232
|
if (cleared || clearedUndeliverable) {
|
|
2755
3233
|
this.log({
|
|
2756
|
-
level: "
|
|
3234
|
+
level: "debug",
|
|
2757
3235
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2758
3236
|
message_id: id
|
|
2759
3237
|
});
|
|
@@ -2766,7 +3244,7 @@ var ChannelDriver = class {
|
|
|
2766
3244
|
for (const row of rows) {
|
|
2767
3245
|
if (!row.opencode_session_id) {
|
|
2768
3246
|
this.log({
|
|
2769
|
-
level: "
|
|
3247
|
+
level: "warn",
|
|
2770
3248
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2771
3249
|
conversation_id: row.conversation_id,
|
|
2772
3250
|
message_id: row.id
|
|
@@ -2783,7 +3261,7 @@ var ChannelDriver = class {
|
|
|
2783
3261
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2784
3262
|
if (!res.ok) {
|
|
2785
3263
|
this.log({
|
|
2786
|
-
level: "
|
|
3264
|
+
level: "warn",
|
|
2787
3265
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2788
3266
|
});
|
|
2789
3267
|
continue;
|
|
@@ -2791,7 +3269,7 @@ var ChannelDriver = class {
|
|
|
2791
3269
|
const body = await res.json();
|
|
2792
3270
|
if (!Array.isArray(body)) {
|
|
2793
3271
|
this.log({
|
|
2794
|
-
level: "
|
|
3272
|
+
level: "warn",
|
|
2795
3273
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2796
3274
|
});
|
|
2797
3275
|
continue;
|
|
@@ -2799,7 +3277,7 @@ var ChannelDriver = class {
|
|
|
2799
3277
|
messages = body;
|
|
2800
3278
|
} catch (err) {
|
|
2801
3279
|
this.log({
|
|
2802
|
-
level: "
|
|
3280
|
+
level: "warn",
|
|
2803
3281
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2804
3282
|
});
|
|
2805
3283
|
continue;
|
|
@@ -2832,7 +3310,7 @@ var ChannelDriver = class {
|
|
|
2832
3310
|
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2833
3311
|
if (this.isTracked(sessionId, row.id)) {
|
|
2834
3312
|
this.log({
|
|
2835
|
-
level: "
|
|
3313
|
+
level: "debug",
|
|
2836
3314
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2837
3315
|
conversation_id: row.conversation_id,
|
|
2838
3316
|
message_id: row.id
|
|
@@ -2844,7 +3322,7 @@ var ChannelDriver = class {
|
|
|
2844
3322
|
if (state === "done") {
|
|
2845
3323
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2846
3324
|
this.log({
|
|
2847
|
-
level: "
|
|
3325
|
+
level: "debug",
|
|
2848
3326
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2849
3327
|
conversation_id: row.conversation_id,
|
|
2850
3328
|
message_id: row.id
|
|
@@ -2858,13 +3336,15 @@ var ChannelDriver = class {
|
|
|
2858
3336
|
message_id: row.id
|
|
2859
3337
|
});
|
|
2860
3338
|
try {
|
|
2861
|
-
await this.
|
|
3339
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3340
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
3341
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
2862
3342
|
} catch (err) {
|
|
2863
3343
|
if (err instanceof ChannelAuthError) throw err;
|
|
2864
3344
|
if (err instanceof ChannelTerminalError) {
|
|
2865
3345
|
this.doneUndeliverable.add(row.id);
|
|
2866
3346
|
this.log({
|
|
2867
|
-
level: "
|
|
3347
|
+
level: "warn",
|
|
2868
3348
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
|
|
2869
3349
|
conversation_id: row.conversation_id,
|
|
2870
3350
|
message_id: row.id
|
|
@@ -2873,7 +3353,7 @@ var ChannelDriver = class {
|
|
|
2873
3353
|
return;
|
|
2874
3354
|
}
|
|
2875
3355
|
this.log({
|
|
2876
|
-
level: "
|
|
3356
|
+
level: "warn",
|
|
2877
3357
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2878
3358
|
conversation_id: row.conversation_id,
|
|
2879
3359
|
message_id: row.id
|
|
@@ -2886,6 +3366,7 @@ var ChannelDriver = class {
|
|
|
2886
3366
|
}
|
|
2887
3367
|
if (state === "failed") {
|
|
2888
3368
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
3369
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
2889
3370
|
this.log({
|
|
2890
3371
|
level: "error",
|
|
2891
3372
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -2893,13 +3374,13 @@ var ChannelDriver = class {
|
|
|
2893
3374
|
message_id: row.id
|
|
2894
3375
|
});
|
|
2895
3376
|
try {
|
|
2896
|
-
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
3377
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
|
|
2897
3378
|
} catch (err) {
|
|
2898
3379
|
if (err instanceof ChannelAuthError) throw err;
|
|
2899
3380
|
if (err instanceof ChannelTerminalError) {
|
|
2900
3381
|
this.doneUndeliverable.add(row.id);
|
|
2901
3382
|
this.log({
|
|
2902
|
-
level: "
|
|
3383
|
+
level: "warn",
|
|
2903
3384
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
|
|
2904
3385
|
conversation_id: row.conversation_id,
|
|
2905
3386
|
message_id: row.id
|
|
@@ -2908,7 +3389,7 @@ var ChannelDriver = class {
|
|
|
2908
3389
|
return;
|
|
2909
3390
|
}
|
|
2910
3391
|
this.log({
|
|
2911
|
-
level: "
|
|
3392
|
+
level: "warn",
|
|
2912
3393
|
message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} failed (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
2913
3394
|
conversation_id: row.conversation_id,
|
|
2914
3395
|
message_id: row.id
|
|
@@ -2921,7 +3402,7 @@ var ChannelDriver = class {
|
|
|
2921
3402
|
}
|
|
2922
3403
|
if (this.dontRedispatch.has(row.id)) {
|
|
2923
3404
|
this.log({
|
|
2924
|
-
level: "
|
|
3405
|
+
level: "debug",
|
|
2925
3406
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2926
3407
|
conversation_id: row.conversation_id,
|
|
2927
3408
|
message_id: row.id
|
|
@@ -2946,7 +3427,7 @@ var ChannelDriver = class {
|
|
|
2946
3427
|
}
|
|
2947
3428
|
if (ongoing === true) {
|
|
2948
3429
|
this.log({
|
|
2949
|
-
level: "
|
|
3430
|
+
level: "debug",
|
|
2950
3431
|
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} and session ${sessionId.slice(0, 8)} is ongoing per GET /session/status (busy/retry) \u2014 re-attaching watcher (no re-dispatch)`,
|
|
2951
3432
|
conversation_id: row.conversation_id,
|
|
2952
3433
|
message_id: row.id
|
|
@@ -2954,7 +3435,7 @@ var ChannelDriver = class {
|
|
|
2954
3435
|
} else {
|
|
2955
3436
|
if (shape === "b1") {
|
|
2956
3437
|
this.log({
|
|
2957
|
-
level: "
|
|
3438
|
+
level: "debug",
|
|
2958
3439
|
message: `Re-adopt: message ${row.id.slice(0, 8)} running/b1 but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 NOT latching a b1 row on a transient status blip; leaving it un-tracked to re-evaluate on the next drain`,
|
|
2959
3440
|
conversation_id: row.conversation_id,
|
|
2960
3441
|
message_id: row.id
|
|
@@ -2966,7 +3447,7 @@ var ChannelDriver = class {
|
|
|
2966
3447
|
return;
|
|
2967
3448
|
}
|
|
2968
3449
|
this.log({
|
|
2969
|
-
level: "
|
|
3450
|
+
level: "debug",
|
|
2970
3451
|
message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but GET /session/status was unreadable (null) for session ${sessionId.slice(0, 8)} \u2014 falling back to the #253 preamble + descendant cross-check`,
|
|
2971
3452
|
conversation_id: row.conversation_id,
|
|
2972
3453
|
message_id: row.id
|
|
@@ -2977,7 +3458,7 @@ var ChannelDriver = class {
|
|
|
2977
3458
|
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2978
3459
|
if (descendantAlive === true) {
|
|
2979
3460
|
this.log({
|
|
2980
|
-
level: "
|
|
3461
|
+
level: "debug",
|
|
2981
3462
|
message: `Re-adopt: message ${row.id.slice(0, 8)} preamble-pinned running (root ${sessionId.slice(0, 8)}) but a live descendant sub-agent session was found \u2014 treating as still running, re-attaching watcher (no re-dispatch)`,
|
|
2982
3463
|
conversation_id: row.conversation_id,
|
|
2983
3464
|
message_id: row.id
|
|
@@ -3001,7 +3482,7 @@ var ChannelDriver = class {
|
|
|
3001
3482
|
this.readopted.add(row.id);
|
|
3002
3483
|
this.ensureWatcherRunning(sessionId);
|
|
3003
3484
|
this.log({
|
|
3004
|
-
level: "
|
|
3485
|
+
level: "debug",
|
|
3005
3486
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
3006
3487
|
conversation_id: row.conversation_id,
|
|
3007
3488
|
message_id: row.id
|
|
@@ -3035,7 +3516,7 @@ var ChannelDriver = class {
|
|
|
3035
3516
|
async forceReadoptRun(sessionId, row) {
|
|
3036
3517
|
if (this.stopped) {
|
|
3037
3518
|
this.log({
|
|
3038
|
-
level: "
|
|
3519
|
+
level: "debug",
|
|
3039
3520
|
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but the runner is stopping \u2014 not starting a fresh turn; leaving for restart recovery`,
|
|
3040
3521
|
conversation_id: row.conversation_id,
|
|
3041
3522
|
message_id: row.id
|
|
@@ -3044,7 +3525,7 @@ var ChannelDriver = class {
|
|
|
3044
3525
|
}
|
|
3045
3526
|
if (this.awaitingReadopt.has(row.id)) {
|
|
3046
3527
|
this.log({
|
|
3047
|
-
level: "
|
|
3528
|
+
level: "debug",
|
|
3048
3529
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
3049
3530
|
conversation_id: row.conversation_id,
|
|
3050
3531
|
message_id: row.id
|
|
@@ -3054,7 +3535,7 @@ var ChannelDriver = class {
|
|
|
3054
3535
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
3055
3536
|
this.dontRedispatch.add(row.id);
|
|
3056
3537
|
this.log({
|
|
3057
|
-
level: "
|
|
3538
|
+
level: "debug",
|
|
3058
3539
|
message: `Re-adopt: message ${row.id.slice(0, 8)} orphaned but its re-adopt window has already elapsed \u2014 not dispatching an unwatchable turn; parking until it leaves processing (cron will reset it)`,
|
|
3059
3540
|
conversation_id: row.conversation_id,
|
|
3060
3541
|
message_id: row.id
|
|
@@ -3073,17 +3554,20 @@ var ChannelDriver = class {
|
|
|
3073
3554
|
message_id: row.id
|
|
3074
3555
|
});
|
|
3075
3556
|
this.awaitingReadopt.add(row.id);
|
|
3557
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
3558
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
3559
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
3076
3560
|
let ocId;
|
|
3077
3561
|
try {
|
|
3078
3562
|
ocId = await this.dispatchLocked(
|
|
3079
3563
|
sessionId,
|
|
3080
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3564
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
3081
3565
|
);
|
|
3082
3566
|
} catch (err) {
|
|
3083
3567
|
this.awaitingReadopt.delete(row.id);
|
|
3084
3568
|
if (err instanceof ChannelAuthError) throw err;
|
|
3085
3569
|
this.log({
|
|
3086
|
-
level: "
|
|
3570
|
+
level: "warn",
|
|
3087
3571
|
message: `Re-adopt: re-dispatch failed for message ${row.id.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
|
|
3088
3572
|
conversation_id: row.conversation_id,
|
|
3089
3573
|
message_id: row.id
|
|
@@ -3094,7 +3578,7 @@ var ChannelDriver = class {
|
|
|
3094
3578
|
if (ocId === null) {
|
|
3095
3579
|
this.awaitingReadopt.delete(row.id);
|
|
3096
3580
|
this.log({
|
|
3097
|
-
level: "
|
|
3581
|
+
level: "warn",
|
|
3098
3582
|
message: `Re-adopt: message ${row.id.slice(0, 8)} re-dispatched but its opencode id could not be read back \u2014 leaving un-tracked to retry next drain`,
|
|
3099
3583
|
conversation_id: row.conversation_id,
|
|
3100
3584
|
message_id: row.id
|
|
@@ -3102,9 +3586,7 @@ var ChannelDriver = class {
|
|
|
3102
3586
|
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
3103
3587
|
return;
|
|
3104
3588
|
}
|
|
3105
|
-
|
|
3106
|
-
const message = this.queuedMessageForRow(row);
|
|
3107
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3589
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
3108
3590
|
this.dispatched.add(row.id);
|
|
3109
3591
|
this.readopted.add(row.id);
|
|
3110
3592
|
this.awaitingReadopt.delete(row.id);
|
|
@@ -3158,7 +3640,8 @@ var ChannelDriver = class {
|
|
|
3158
3640
|
opencode_agent: row.opencode_agent,
|
|
3159
3641
|
opencode_model: row.opencode_model,
|
|
3160
3642
|
source_message_id: row.source_message_id,
|
|
3161
|
-
slack_user_id: row.slack_user_id
|
|
3643
|
+
slack_user_id: row.slack_user_id,
|
|
3644
|
+
attachments: row.attachments ?? null
|
|
3162
3645
|
};
|
|
3163
3646
|
}
|
|
3164
3647
|
/**
|
|
@@ -3179,7 +3662,7 @@ var ChannelDriver = class {
|
|
|
3179
3662
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
3180
3663
|
this.dontRedispatch.add(evidentMessageId);
|
|
3181
3664
|
this.log({
|
|
3182
|
-
level: "
|
|
3665
|
+
level: "debug",
|
|
3183
3666
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
3184
3667
|
conversation_id: watcher.conv.id,
|
|
3185
3668
|
message_id: evidentMessageId
|
|
@@ -3319,6 +3802,68 @@ var ChannelDriver = class {
|
|
|
3319
3802
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3320
3803
|
return parent;
|
|
3321
3804
|
}
|
|
3805
|
+
/**
|
|
3806
|
+
* OpenCode's synchronous default session title (e.g.
|
|
3807
|
+
* `"New session - 1737800000000"`), assigned immediately when a session is
|
|
3808
|
+
* created — before OpenCode's async LLM-based auto-titling later renames it
|
|
3809
|
+
* mid-turn (#549). Matched by this literal, case-sensitive prefix only; the
|
|
3810
|
+
* timestamp suffix's exact format is deliberately NOT matched, since the prefix
|
|
3811
|
+
* alone is the stable, cheap signal and over-anchoring on the timestamp
|
|
3812
|
+
* representation risks silently breaking if OpenCode ever changes it. Accepted
|
|
3813
|
+
* trade-off: a genuine LLM-assigned title that happens to literally start with
|
|
3814
|
+
* this prefix would also fail to latch (see `resolveSessionTitle`) —
|
|
3815
|
+
* vanishingly unlikely in practice, and deliberately not engineered around.
|
|
3816
|
+
*/
|
|
3817
|
+
static OPENCODE_DEFAULT_TITLE_PREFIX = /^New session - /;
|
|
3818
|
+
/**
|
|
3819
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3820
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3821
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3822
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3823
|
+
* Best-effort:
|
|
3824
|
+
* - a resolved NON-EMPTY title that does NOT match
|
|
3825
|
+
* `OPENCODE_DEFAULT_TITLE_PREFIX` is cached and terminal (a real session name
|
|
3826
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3827
|
+
* - while the title is still absent, empty, or matches the OpenCode
|
|
3828
|
+
* placeholder prefix (#549) we do NOT latch it — OpenCode names sessions
|
|
3829
|
+
* asynchronously mid-turn, so an early call (e.g. at `processing`) must leave
|
|
3830
|
+
* the cache unresolved and re-fetch on the next need so a later call (e.g. at
|
|
3831
|
+
* `done`) picks up the name assigned in the meantime. Such a call returns
|
|
3832
|
+
* `null` (omit the title on THIS PATCH) without caching. If a session is
|
|
3833
|
+
* never renamed, the title is omitted forever rather than ever persisting
|
|
3834
|
+
* the placeholder as a last resort;
|
|
3835
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3836
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
3837
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
3838
|
+
*/
|
|
3839
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
3840
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
3841
|
+
if (cached != null) return cached;
|
|
3842
|
+
try {
|
|
3843
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3844
|
+
if (res.ok) {
|
|
3845
|
+
const body = await res.json();
|
|
3846
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3847
|
+
if (title.length > 0 && !_ChannelDriver.OPENCODE_DEFAULT_TITLE_PREFIX.test(title)) {
|
|
3848
|
+
this.sessionTitles.set(sessionId, title);
|
|
3849
|
+
return title;
|
|
3850
|
+
}
|
|
3851
|
+
return null;
|
|
3852
|
+
}
|
|
3853
|
+
this.log({
|
|
3854
|
+
level: "debug",
|
|
3855
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
3856
|
+
conversation_id: conversationId
|
|
3857
|
+
});
|
|
3858
|
+
} catch (err) {
|
|
3859
|
+
this.log({
|
|
3860
|
+
level: "debug",
|
|
3861
|
+
message: `Best-effort session title fetch failed for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) \u2014 omitting title: ${err instanceof Error ? err.message : String(err)}`,
|
|
3862
|
+
conversation_id: conversationId
|
|
3863
|
+
});
|
|
3864
|
+
}
|
|
3865
|
+
return null;
|
|
3866
|
+
}
|
|
3322
3867
|
/**
|
|
3323
3868
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3324
3869
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -3362,7 +3907,7 @@ var ChannelDriver = class {
|
|
|
3362
3907
|
const sessions = await listSessions(this.port);
|
|
3363
3908
|
if (!sessions) {
|
|
3364
3909
|
this.log({
|
|
3365
|
-
level: "
|
|
3910
|
+
level: "warn",
|
|
3366
3911
|
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3367
3912
|
});
|
|
3368
3913
|
return null;
|
|
@@ -3446,12 +3991,10 @@ var ChannelDriver = class {
|
|
|
3446
3991
|
}
|
|
3447
3992
|
return inFlight.sort(byOldest)[0];
|
|
3448
3993
|
}
|
|
3449
|
-
// -------------------------------------------------------------------------
|
|
3450
3994
|
// Evident API calls (combinedAuth thread routes)
|
|
3451
|
-
// -------------------------------------------------------------------------
|
|
3452
3995
|
async getPendingConversations() {
|
|
3453
3996
|
const res = await this.fetchImpl(
|
|
3454
|
-
`${this.apiUrl}/
|
|
3997
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/pending`,
|
|
3455
3998
|
{
|
|
3456
3999
|
headers: { Authorization: this.getAuthHeader() }
|
|
3457
4000
|
}
|
|
@@ -3469,7 +4012,7 @@ var ChannelDriver = class {
|
|
|
3469
4012
|
}
|
|
3470
4013
|
async getPendingMessages(conversationId) {
|
|
3471
4014
|
const res = await this.fetchImpl(
|
|
3472
|
-
`${this.apiUrl}/
|
|
4015
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages?status=pending`,
|
|
3473
4016
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3474
4017
|
);
|
|
3475
4018
|
this.assertAuth(res, "fetching pending messages");
|
|
@@ -3493,7 +4036,7 @@ var ChannelDriver = class {
|
|
|
3493
4036
|
*/
|
|
3494
4037
|
async getProcessingMessages() {
|
|
3495
4038
|
const res = await this.fetchImpl(
|
|
3496
|
-
`${this.apiUrl}/
|
|
4039
|
+
`${this.apiUrl}/runners/${this.agentId}/conversations/processing`,
|
|
3497
4040
|
{ headers: { Authorization: this.getAuthHeader() } }
|
|
3498
4041
|
);
|
|
3499
4042
|
this.assertAuth(res, "fetching processing messages");
|
|
@@ -3507,6 +4050,32 @@ var ChannelDriver = class {
|
|
|
3507
4050
|
}
|
|
3508
4051
|
return messages;
|
|
3509
4052
|
}
|
|
4053
|
+
/**
|
|
4054
|
+
* The `opencode_session_id` fragment of a status PATCH body — `{}` when this
|
|
4055
|
+
* conversation has ABANDONED that session (#553). The field is optional
|
|
4056
|
+
* server-side and an absent one leaves the persisted binding untouched, so
|
|
4057
|
+
* omitting it is how a routine status write stops resurrecting it.
|
|
4058
|
+
*
|
|
4059
|
+
* ONLY for writes whose sole cost is a lost deep link. The `processing` notice
|
|
4060
|
+
* degrades to no "View in Evident" link (the reaction swap still fires) and the
|
|
4061
|
+
* turn-failure notice is built from the PATCH's own `error` text with a link off
|
|
4062
|
+
* the persisted row — neither loses content the user came for. `markDone`
|
|
4063
|
+
* deliberately does NOT use this helper: the server fetches the reply text
|
|
4064
|
+
* THROUGH the session id it is given, so suppressing there would replace the
|
|
4065
|
+
* agent's answer with a bare "✅ Done!" (the #183/#187 failure). The
|
|
4066
|
+
* `ensureSession` guard, not this suppression, is what makes the self-heal
|
|
4067
|
+
* stick.
|
|
4068
|
+
*/
|
|
4069
|
+
sessionIdBody(sessionId, conversationId, messageId, status) {
|
|
4070
|
+
if (!this.isSuperseded(conversationId, sessionId)) return { opencode_session_id: sessionId };
|
|
4071
|
+
this.log({
|
|
4072
|
+
level: "debug",
|
|
4073
|
+
message: `Omitting the abandoned OpenCode session ${sessionId.slice(0, 8)} from the '${status}' update for message ${messageId.slice(0, 8)} so it is not re-bound to conversation ${conversationId.slice(0, 8)}`,
|
|
4074
|
+
conversation_id: conversationId,
|
|
4075
|
+
message_id: messageId
|
|
4076
|
+
});
|
|
4077
|
+
return {};
|
|
4078
|
+
}
|
|
3510
4079
|
/**
|
|
3511
4080
|
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
3512
4081
|
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
@@ -3528,16 +4097,17 @@ var ChannelDriver = class {
|
|
|
3528
4097
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3529
4098
|
* retry vehicle for the swap-to-running.
|
|
3530
4099
|
*/
|
|
3531
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
4100
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3532
4101
|
const res = await this.fetchImpl(
|
|
3533
|
-
`${this.apiUrl}/
|
|
4102
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3534
4103
|
{
|
|
3535
4104
|
method: "PATCH",
|
|
3536
4105
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3537
4106
|
body: JSON.stringify({
|
|
3538
4107
|
status: "processing",
|
|
3539
|
-
|
|
3540
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
4108
|
+
...this.sessionIdBody(sessionId, conversationId, messageId, "processing"),
|
|
4109
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
4110
|
+
...title ? { title } : {}
|
|
3541
4111
|
})
|
|
3542
4112
|
}
|
|
3543
4113
|
);
|
|
@@ -3576,16 +4146,23 @@ var ChannelDriver = class {
|
|
|
3576
4146
|
* watcher retries next tick within the
|
|
3577
4147
|
* deadline, Finding 4).
|
|
3578
4148
|
*/
|
|
3579
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
4149
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
|
|
3580
4150
|
const res = await this.fetchImpl(
|
|
3581
|
-
`${this.apiUrl}/
|
|
4151
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3582
4152
|
{
|
|
3583
4153
|
method: "PATCH",
|
|
3584
4154
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
3585
4155
|
body: JSON.stringify({
|
|
3586
4156
|
status: "done",
|
|
4157
|
+
// ALWAYS sent, even for a session this conversation has abandoned
|
|
4158
|
+
// (#553): the server reads the reply text back out of THIS session id
|
|
4159
|
+
// to deliver it. Omitting it would leave the user with "✅ Done!"
|
|
4160
|
+
// instead of the answer — a worse regression than the resurrection it
|
|
4161
|
+
// would prevent, which `ensureSession`'s guard handles anyway.
|
|
3587
4162
|
opencode_session_id: sessionId,
|
|
3588
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
4163
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
4164
|
+
...title ? { title } : {},
|
|
4165
|
+
...usage ? usage : {}
|
|
3589
4166
|
})
|
|
3590
4167
|
}
|
|
3591
4168
|
);
|
|
@@ -3598,19 +4175,29 @@ var ChannelDriver = class {
|
|
|
3598
4175
|
}
|
|
3599
4176
|
/**
|
|
3600
4177
|
* Mark a message `failed`. `sessionId` / `error` are threaded to the API ONLY
|
|
3601
|
-
* when provided (issue #182)
|
|
3602
|
-
* `
|
|
3603
|
-
*
|
|
3604
|
-
*
|
|
4178
|
+
* when provided (issue #182). Three states for `sessionId`:
|
|
4179
|
+
* - omitted (`undefined`) → don't send the field, leave the persisted
|
|
4180
|
+
* session untouched (unused today; kept for API symmetry).
|
|
4181
|
+
* - a real id (`string`) → send it, update the persisted session (the
|
|
4182
|
+
* turn-failure call sites: an errored OpenCode turn).
|
|
4183
|
+
* - explicit `null` → send it, CLEAR the persisted session (issue
|
|
4184
|
+
* #485's dispatch-handoff-failure call site: the session id still
|
|
4185
|
+
* exists but is wedged, so the next attempt must get a fresh one
|
|
4186
|
+
* instead of reusing it — see WI-1's server-side null-clearing PATCH).
|
|
3605
4187
|
*/
|
|
3606
|
-
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
4188
|
+
async markFailed(conversationId, messageId, sessionId, error2, usage) {
|
|
3607
4189
|
const body = { status: "failed" };
|
|
3608
|
-
if (sessionId
|
|
4190
|
+
if (sessionId === null) {
|
|
4191
|
+
body.opencode_session_id = null;
|
|
4192
|
+
} else if (sessionId !== void 0) {
|
|
4193
|
+
Object.assign(body, this.sessionIdBody(sessionId, conversationId, messageId, "failed"));
|
|
4194
|
+
}
|
|
3609
4195
|
if (error2 !== void 0) body.error = error2;
|
|
4196
|
+
if (usage) Object.assign(body, usage);
|
|
3610
4197
|
await this.callWithRetry(
|
|
3611
4198
|
"marking message as failed",
|
|
3612
4199
|
() => this.fetchImpl(
|
|
3613
|
-
`${this.apiUrl}/
|
|
4200
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3614
4201
|
{
|
|
3615
4202
|
method: "PATCH",
|
|
3616
4203
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3637,7 +4224,7 @@ var ChannelDriver = class {
|
|
|
3637
4224
|
async postSignal(conversationId, messageId, signal, extra) {
|
|
3638
4225
|
try {
|
|
3639
4226
|
const res = await this.fetchImpl(
|
|
3640
|
-
`${this.apiUrl}/
|
|
4227
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
|
|
3641
4228
|
{
|
|
3642
4229
|
method: "POST",
|
|
3643
4230
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3646,7 +4233,7 @@ var ChannelDriver = class {
|
|
|
3646
4233
|
);
|
|
3647
4234
|
if (!res.ok) {
|
|
3648
4235
|
this.log({
|
|
3649
|
-
level: "
|
|
4236
|
+
level: "warn",
|
|
3650
4237
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3651
4238
|
conversation_id: conversationId,
|
|
3652
4239
|
message_id: messageId
|
|
@@ -3656,7 +4243,7 @@ var ChannelDriver = class {
|
|
|
3656
4243
|
return true;
|
|
3657
4244
|
} catch (err) {
|
|
3658
4245
|
this.log({
|
|
3659
|
-
level: "
|
|
4246
|
+
level: "warn",
|
|
3660
4247
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3661
4248
|
conversation_id: conversationId,
|
|
3662
4249
|
message_id: messageId
|
|
@@ -3666,7 +4253,7 @@ var ChannelDriver = class {
|
|
|
3666
4253
|
}
|
|
3667
4254
|
async persistSession(conversationId, sessionId) {
|
|
3668
4255
|
const res = await this.fetchImpl(
|
|
3669
|
-
`${this.apiUrl}/
|
|
4256
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}`,
|
|
3670
4257
|
{
|
|
3671
4258
|
method: "PATCH",
|
|
3672
4259
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3692,7 +4279,7 @@ var ChannelDriver = class {
|
|
|
3692
4279
|
await this.callWithRetry(
|
|
3693
4280
|
"reporting interactive event",
|
|
3694
4281
|
() => this.fetchImpl(
|
|
3695
|
-
`${this.apiUrl}/
|
|
4282
|
+
`${this.apiUrl}/runners/${this.agentId}/threads/${conversationId}/interactive-event`,
|
|
3696
4283
|
{
|
|
3697
4284
|
method: "POST",
|
|
3698
4285
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
@@ -3718,9 +4305,7 @@ var ChannelDriver = class {
|
|
|
3718
4305
|
return false;
|
|
3719
4306
|
}
|
|
3720
4307
|
}
|
|
3721
|
-
// -------------------------------------------------------------------------
|
|
3722
4308
|
// Retry wrapper
|
|
3723
|
-
// -------------------------------------------------------------------------
|
|
3724
4309
|
/**
|
|
3725
4310
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3726
4311
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3919,7 +4504,7 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3919
4504
|
if (!response.ok) {
|
|
3920
4505
|
const serverMessage = await readErrorMessage(response);
|
|
3921
4506
|
return {
|
|
3922
|
-
error: `Failed to resolve
|
|
4507
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3923
4508
|
};
|
|
3924
4509
|
}
|
|
3925
4510
|
const data = await response.json();
|
|
@@ -3927,17 +4512,17 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3927
4512
|
return { agent_id: data.agent_id };
|
|
3928
4513
|
}
|
|
3929
4514
|
return {
|
|
3930
|
-
error: "Cannot resolve
|
|
4515
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
3931
4516
|
};
|
|
3932
4517
|
} catch (error2) {
|
|
3933
4518
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3934
|
-
return { error: `Failed to resolve
|
|
4519
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
3935
4520
|
}
|
|
3936
4521
|
}
|
|
3937
4522
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
3938
4523
|
const apiUrl = getApiUrlConfig();
|
|
3939
4524
|
try {
|
|
3940
|
-
const response = await fetch(`${apiUrl}/
|
|
4525
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
|
|
3941
4526
|
method: "POST",
|
|
3942
4527
|
headers: { Authorization: authHeader }
|
|
3943
4528
|
});
|
|
@@ -3956,7 +4541,7 @@ async function notifyAgentDisconnected(agentId, authHeader) {
|
|
|
3956
4541
|
async function getAgentInfo(agentId, authHeader) {
|
|
3957
4542
|
const apiUrl = getApiUrlConfig();
|
|
3958
4543
|
try {
|
|
3959
|
-
const response = await fetch(`${apiUrl}/
|
|
4544
|
+
const response = await fetch(`${apiUrl}/runners/${agentId}`, {
|
|
3960
4545
|
headers: { Authorization: authHeader }
|
|
3961
4546
|
});
|
|
3962
4547
|
if (response.status === 401) {
|
|
@@ -3967,12 +4552,12 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3967
4552
|
const serverMessage = await readErrorMessage(response);
|
|
3968
4553
|
return {
|
|
3969
4554
|
valid: false,
|
|
3970
|
-
error: serverMessage ?? "You do not have access to this
|
|
4555
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
3971
4556
|
};
|
|
3972
4557
|
}
|
|
3973
4558
|
if (response.status === 404) {
|
|
3974
4559
|
const serverMessage = await readErrorMessage(response);
|
|
3975
|
-
return { valid: false, error: serverMessage ?? `
|
|
4560
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
3976
4561
|
}
|
|
3977
4562
|
if (!response.ok) {
|
|
3978
4563
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3985,13 +4570,13 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3985
4570
|
if (agent.agent_type !== "local") {
|
|
3986
4571
|
return {
|
|
3987
4572
|
valid: false,
|
|
3988
|
-
error: `
|
|
4573
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
3989
4574
|
};
|
|
3990
4575
|
}
|
|
3991
4576
|
return { valid: true, agent };
|
|
3992
4577
|
} catch (error2) {
|
|
3993
4578
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3994
|
-
return { valid: false, error: `Failed to validate
|
|
4579
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
3995
4580
|
}
|
|
3996
4581
|
}
|
|
3997
4582
|
|
|
@@ -4000,23 +4585,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
4000
4585
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
4001
4586
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
4002
4587
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
4003
|
-
function
|
|
4588
|
+
function resolveLogLevel(options) {
|
|
4589
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
4590
|
+
const validate = (value, source) => {
|
|
4591
|
+
const normalized = value.trim().toLowerCase();
|
|
4592
|
+
if (!accepted.includes(normalized)) {
|
|
4593
|
+
throw new Error(
|
|
4594
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
4595
|
+
);
|
|
4596
|
+
}
|
|
4597
|
+
return normalized;
|
|
4598
|
+
};
|
|
4599
|
+
if (options.logLevel !== void 0) {
|
|
4600
|
+
return validate(options.logLevel, " (--log-level)");
|
|
4601
|
+
}
|
|
4602
|
+
if (options.verbose) {
|
|
4603
|
+
return "debug";
|
|
4604
|
+
}
|
|
4605
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
4606
|
+
if (env !== void 0 && env !== "") {
|
|
4607
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
4608
|
+
}
|
|
4609
|
+
return "info";
|
|
4610
|
+
}
|
|
4611
|
+
function meetsThreshold(state, level) {
|
|
4612
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4613
|
+
}
|
|
4614
|
+
function log2(state, message, level = "info") {
|
|
4615
|
+
if (!meetsThreshold(state, level)) return;
|
|
4004
4616
|
if (state.json) {
|
|
4005
4617
|
console.log(
|
|
4006
4618
|
JSON.stringify({
|
|
4007
4619
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4008
|
-
level
|
|
4620
|
+
level,
|
|
4009
4621
|
message
|
|
4010
4622
|
})
|
|
4011
4623
|
);
|
|
4012
4624
|
} else if (!state.interactive) {
|
|
4013
|
-
const prefix =
|
|
4625
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
4014
4626
|
console.log(`${prefix} ${message}`);
|
|
4015
4627
|
}
|
|
4016
4628
|
}
|
|
4017
4629
|
function logActivity(state, entry) {
|
|
4630
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4631
|
+
if (!meetsThreshold(state, level)) return;
|
|
4018
4632
|
const fullEntry = {
|
|
4019
4633
|
...entry,
|
|
4634
|
+
level,
|
|
4020
4635
|
timestamp: /* @__PURE__ */ new Date()
|
|
4021
4636
|
};
|
|
4022
4637
|
state.activityLog.push(fullEntry);
|
|
@@ -4025,9 +4640,9 @@ function logActivity(state, entry) {
|
|
|
4025
4640
|
}
|
|
4026
4641
|
if (!state.interactive) {
|
|
4027
4642
|
if (entry.type === "error") {
|
|
4028
|
-
log2(state, entry.error ?? "Unknown error",
|
|
4029
|
-
} else if (entry.
|
|
4030
|
-
log2(state, entry.message);
|
|
4643
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4644
|
+
} else if (entry.message) {
|
|
4645
|
+
log2(state, entry.message, level);
|
|
4031
4646
|
}
|
|
4032
4647
|
}
|
|
4033
4648
|
}
|
|
@@ -4226,7 +4841,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
4226
4841
|
process.env
|
|
4227
4842
|
);
|
|
4228
4843
|
for (const warning2 of config2.warnings) {
|
|
4229
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
4844
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4230
4845
|
}
|
|
4231
4846
|
if (!config2.enabled) return;
|
|
4232
4847
|
logActivity(state, {
|
|
@@ -4248,7 +4863,7 @@ async function notifyOffline(state) {
|
|
|
4248
4863
|
}
|
|
4249
4864
|
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
4250
4865
|
if (result.ok) {
|
|
4251
|
-
log2(state, "Notified Evident the
|
|
4866
|
+
log2(state, "Notified Evident the runner is going offline");
|
|
4252
4867
|
} else {
|
|
4253
4868
|
logActivity(state, {
|
|
4254
4869
|
type: "error",
|
|
@@ -4298,14 +4913,29 @@ async function cleanup(state, opts = {}) {
|
|
|
4298
4913
|
}
|
|
4299
4914
|
async function run(options) {
|
|
4300
4915
|
const interactive = isInteractive(options.json);
|
|
4916
|
+
let logLevel;
|
|
4917
|
+
try {
|
|
4918
|
+
logLevel = resolveLogLevel(options);
|
|
4919
|
+
} catch (error2) {
|
|
4920
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4921
|
+
if (options.json) {
|
|
4922
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
4923
|
+
} else {
|
|
4924
|
+
printError(message);
|
|
4925
|
+
}
|
|
4926
|
+
await shutdownTelemetry();
|
|
4927
|
+
process.exit(1);
|
|
4928
|
+
return;
|
|
4929
|
+
}
|
|
4301
4930
|
const state = {
|
|
4302
|
-
agentId: options.agent || "",
|
|
4931
|
+
agentId: options.runner || options.agent || "",
|
|
4303
4932
|
agentName: null,
|
|
4304
4933
|
port: options.port ?? 4096,
|
|
4305
4934
|
conversationFilter: options.conversation ?? null,
|
|
4306
4935
|
idleTimeout: options.idleTimeout ?? null,
|
|
4307
4936
|
json: options.json ?? false,
|
|
4308
4937
|
interactive,
|
|
4938
|
+
logLevel,
|
|
4309
4939
|
connected: false,
|
|
4310
4940
|
opencodeConnected: false,
|
|
4311
4941
|
opencodeVersion: null,
|
|
@@ -4320,11 +4950,24 @@ async function run(options) {
|
|
|
4320
4950
|
sessionCleanupTimers: [],
|
|
4321
4951
|
authHeader: ""
|
|
4322
4952
|
};
|
|
4953
|
+
if (!options.runner && options.agent) {
|
|
4954
|
+
telemetry.info(
|
|
4955
|
+
EventTypes.DEPRECATED_AGENT_FLAG_USED,
|
|
4956
|
+
"Deprecated --agent flag used instead of --runner",
|
|
4957
|
+
{ command: "run" },
|
|
4958
|
+
state.agentId
|
|
4959
|
+
);
|
|
4960
|
+
const agentFlagNotice = "--agent is deprecated, use --runner instead; will be removed in a future release.";
|
|
4961
|
+
log2(state, agentFlagNotice, "warn");
|
|
4962
|
+
if (state.interactive && !state.json) {
|
|
4963
|
+
logActivity(state, { type: "info", level: "warn", message: agentFlagNotice });
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4323
4966
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4324
4967
|
log2(
|
|
4325
4968
|
state,
|
|
4326
|
-
"
|
|
4327
|
-
|
|
4969
|
+
"No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
|
|
4970
|
+
"warn"
|
|
4328
4971
|
);
|
|
4329
4972
|
}
|
|
4330
4973
|
const handleSignal = async () => {
|
|
@@ -4348,7 +4991,9 @@ async function run(options) {
|
|
|
4348
4991
|
if (!interactive) {
|
|
4349
4992
|
printError("Authentication required");
|
|
4350
4993
|
blank();
|
|
4351
|
-
console.log(
|
|
4994
|
+
console.log(
|
|
4995
|
+
chalk6.dim("Set EVIDENT_RUNNER_KEY (or EVIDENT_AGENT_KEY) environment variable for CI")
|
|
4996
|
+
);
|
|
4352
4997
|
console.log(chalk6.dim("Or run `evident login` for interactive authentication"));
|
|
4353
4998
|
blank();
|
|
4354
4999
|
process.exit(1);
|
|
@@ -4362,26 +5007,51 @@ async function run(options) {
|
|
|
4362
5007
|
);
|
|
4363
5008
|
}
|
|
4364
5009
|
state.authHeader = getAuthHeader(credentials2);
|
|
5010
|
+
if (credentials2.notice) {
|
|
5011
|
+
log2(state, credentials2.notice, "warn");
|
|
5012
|
+
if (state.interactive && !state.json) {
|
|
5013
|
+
logActivity(state, { type: "info", level: "warn", message: credentials2.notice });
|
|
5014
|
+
}
|
|
5015
|
+
}
|
|
5016
|
+
if (credentials2.keySource === "agent_key") {
|
|
5017
|
+
telemetry.info(
|
|
5018
|
+
EventTypes.DEPRECATED_AGENT_KEY_ENV_USED,
|
|
5019
|
+
"Deprecated EVIDENT_AGENT_KEY env var used instead of EVIDENT_RUNNER_KEY",
|
|
5020
|
+
{ command: "run" },
|
|
5021
|
+
state.agentId
|
|
5022
|
+
);
|
|
5023
|
+
const agentKeyNotice = "EVIDENT_AGENT_KEY is deprecated, use EVIDENT_RUNNER_KEY instead; will be removed in a future release.";
|
|
5024
|
+
log2(state, agentKeyNotice, "warn");
|
|
5025
|
+
if (state.interactive && !state.json) {
|
|
5026
|
+
logActivity(state, { type: "info", level: "warn", message: agentKeyNotice });
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
4365
5029
|
if (!state.agentId) {
|
|
4366
5030
|
if (credentials2.authType === "agent_key") {
|
|
4367
5031
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
4368
5032
|
if (resolved.agent_id) {
|
|
4369
5033
|
state.agentId = resolved.agent_id;
|
|
4370
|
-
log2(state, `Resolved
|
|
5034
|
+
log2(state, `Resolved runner ID from key: ${state.agentId}`);
|
|
4371
5035
|
if (state.interactive && !state.json) {
|
|
4372
5036
|
logActivity(state, {
|
|
4373
5037
|
type: "info",
|
|
4374
|
-
message: `
|
|
5038
|
+
message: `Runner ID resolved from key: ${state.agentId}`
|
|
4375
5039
|
});
|
|
4376
5040
|
}
|
|
4377
5041
|
} else {
|
|
4378
|
-
printError(resolved.error || "Failed to resolve
|
|
5042
|
+
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
4379
5043
|
process.exit(1);
|
|
4380
5044
|
}
|
|
4381
5045
|
} else {
|
|
4382
|
-
printError(
|
|
5046
|
+
printError(
|
|
5047
|
+
"--runner (or --agent) is required when not using EVIDENT_RUNNER_KEY or EVIDENT_AGENT_KEY"
|
|
5048
|
+
);
|
|
4383
5049
|
blank();
|
|
4384
|
-
console.log(
|
|
5050
|
+
console.log(
|
|
5051
|
+
chalk6.dim(
|
|
5052
|
+
"Either provide --runner/--agent <id> or set EVIDENT_RUNNER_KEY/EVIDENT_AGENT_KEY"
|
|
5053
|
+
)
|
|
5054
|
+
);
|
|
4385
5055
|
blank();
|
|
4386
5056
|
process.exit(1);
|
|
4387
5057
|
}
|
|
@@ -4403,7 +5073,7 @@ async function run(options) {
|
|
|
4403
5073
|
console.log(chalk6.bold("Evident Run"));
|
|
4404
5074
|
console.log(chalk6.dim("-".repeat(40)));
|
|
4405
5075
|
}
|
|
4406
|
-
const spinner = interactive && !state.json ? ora3("Validating
|
|
5076
|
+
const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
|
|
4407
5077
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4408
5078
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
4409
5079
|
spinner?.fail("Authentication failed");
|
|
@@ -4415,14 +5085,14 @@ async function run(options) {
|
|
|
4415
5085
|
"Login successful! Retrying..."
|
|
4416
5086
|
);
|
|
4417
5087
|
state.authHeader = getAuthHeader(credentials2);
|
|
4418
|
-
spinner?.start("Validating
|
|
5088
|
+
spinner?.start("Validating runner...");
|
|
4419
5089
|
validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4420
5090
|
}
|
|
4421
5091
|
if (!validation.valid) {
|
|
4422
|
-
spinner?.fail(`
|
|
5092
|
+
spinner?.fail(`Runner validation failed: ${validation.error}`);
|
|
4423
5093
|
throw new Error(validation.error);
|
|
4424
5094
|
}
|
|
4425
|
-
spinner?.succeed(`
|
|
5095
|
+
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
4426
5096
|
state.agentName = validation.agent.name;
|
|
4427
5097
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
4428
5098
|
try {
|
|
@@ -4440,9 +5110,24 @@ async function run(options) {
|
|
|
4440
5110
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4441
5111
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4442
5112
|
if (versionWarning) {
|
|
4443
|
-
log2(state, versionWarning,
|
|
5113
|
+
log2(state, versionWarning, "warn");
|
|
5114
|
+
if (state.interactive && !state.json) {
|
|
5115
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
5116
|
+
}
|
|
5117
|
+
}
|
|
5118
|
+
const noProviderWarning = buildNoProviderWarning(await hasAnyConfiguredProvider(state.port));
|
|
5119
|
+
if (noProviderWarning) {
|
|
5120
|
+
log2(state, noProviderWarning, "warn");
|
|
4444
5121
|
if (state.interactive && !state.json) {
|
|
4445
|
-
logActivity(state, { type: "info", message:
|
|
5122
|
+
logActivity(state, { type: "info", level: "warn", message: noProviderWarning });
|
|
5123
|
+
blank();
|
|
5124
|
+
console.log(chalk6.yellow("\u26A0 No OpenCode model provider is configured."));
|
|
5125
|
+
console.log(
|
|
5126
|
+
chalk6.dim(
|
|
5127
|
+
`Run ${chalk6.cyan("opencode auth login")} to set one up \u2014 messages will fail until then.`
|
|
5128
|
+
)
|
|
5129
|
+
);
|
|
5130
|
+
blank();
|
|
4446
5131
|
}
|
|
4447
5132
|
}
|
|
4448
5133
|
} catch (error2) {
|
|
@@ -4457,11 +5142,17 @@ async function run(options) {
|
|
|
4457
5142
|
getAuthHeader: () => state.authHeader,
|
|
4458
5143
|
conversationFilter: state.conversationFilter,
|
|
4459
5144
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4460
|
-
log: (entry) =>
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
5145
|
+
log: (entry) => (
|
|
5146
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
5147
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
5148
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
5149
|
+
logActivity(state, {
|
|
5150
|
+
type: entry.level === "error" ? "error" : "info",
|
|
5151
|
+
level: entry.level,
|
|
5152
|
+
message: entry.message,
|
|
5153
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
5154
|
+
})
|
|
5155
|
+
)
|
|
4465
5156
|
});
|
|
4466
5157
|
state.channelDriver = channelDriver;
|
|
4467
5158
|
const connection = new RunnerConnection({
|
|
@@ -4475,7 +5166,7 @@ async function run(options) {
|
|
|
4475
5166
|
state.agentId = agentId;
|
|
4476
5167
|
logActivity(state, {
|
|
4477
5168
|
type: "info",
|
|
4478
|
-
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (
|
|
5169
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
4479
5170
|
});
|
|
4480
5171
|
emitAgentConnected(state.agentId, {
|
|
4481
5172
|
port: state.port,
|
|
@@ -4590,7 +5281,7 @@ async function run(options) {
|
|
|
4590
5281
|
}
|
|
4591
5282
|
telemetry.error(EventTypes.CLI_ERROR, `Run command failed: ${message}`, {
|
|
4592
5283
|
command: "run",
|
|
4593
|
-
agentId: options.agent
|
|
5284
|
+
agentId: options.runner || options.agent
|
|
4594
5285
|
});
|
|
4595
5286
|
await shutdownTelemetry();
|
|
4596
5287
|
process.exit(1);
|
|
@@ -4615,7 +5306,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4615
5306
|
program.command("login").description("Authenticate with Evident").option("--token", "Use token-based authentication (for CI/CD)").option("--no-browser", "Do not open the browser automatically").action(login);
|
|
4616
5307
|
program.command("logout").description("Remove stored credentials for the current endpoint").option("--all", "Remove stored credentials for all endpoints").action((options) => logout({ all: options.all }));
|
|
4617
5308
|
program.command("whoami").description("Show the currently logged in user").action(whoami);
|
|
4618
|
-
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "
|
|
5309
|
+
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Runner ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("--runner [id]", "Alias for --agent (preferred name; wins if both are given)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
5310
|
+
"--log-level <level>",
|
|
5311
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
5312
|
+
).option("-v, --verbose", "Alias for --log-level debug (ignored if --log-level is set)").option("-c, --conversation <id>", "Process only this specific conversation").option("--idle-timeout <seconds>", "Exit after N seconds idle").option("--json", "Output in JSON format").option(
|
|
4619
5313
|
"--session-cleanup-max-age <duration>",
|
|
4620
5314
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4621
5315
|
).option(
|
|
@@ -4628,7 +5322,11 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4628
5322
|
(options) => {
|
|
4629
5323
|
run({
|
|
4630
5324
|
agent: options.agent,
|
|
5325
|
+
runner: options.runner,
|
|
4631
5326
|
port: parseInt(options.port, 10),
|
|
5327
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
5328
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
5329
|
+
logLevel: options.logLevel,
|
|
4632
5330
|
verbose: options.verbose,
|
|
4633
5331
|
conversation: options.conversation,
|
|
4634
5332
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|