@evident-ai/cli 3.0.1-dev.ff1c4ac → 3.1.1-dev.14c6359
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 +17 -15
- package/dist/index.js +504 -106
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1173,17 +1173,113 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1173
1173
|
const data = await response.json();
|
|
1174
1174
|
return data.id;
|
|
1175
1175
|
}
|
|
1176
|
+
async function getModelAttachmentCapability(port, model) {
|
|
1177
|
+
try {
|
|
1178
|
+
const res = await fetch(`${opencodeBase(port)}/config/providers`);
|
|
1179
|
+
if (!res.ok) {
|
|
1180
|
+
console.error(
|
|
1181
|
+
`[getModelAttachmentCapability] GET /config/providers returned HTTP ${res.status} (port ${port})`
|
|
1182
|
+
);
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
const body = await res.json();
|
|
1186
|
+
const providers = Array.isArray(body?.providers) ? body.providers : null;
|
|
1187
|
+
if (!providers) {
|
|
1188
|
+
console.error(
|
|
1189
|
+
`[getModelAttachmentCapability] GET /config/providers body had no providers array (port ${port})`
|
|
1190
|
+
);
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
const slash = model ? model.indexOf("/") : -1;
|
|
1194
|
+
const providerId = slash > 0 ? model.slice(0, slash) : void 0;
|
|
1195
|
+
let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
|
|
1196
|
+
const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
|
|
1197
|
+
let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
|
|
1198
|
+
if (!provider && !providerId) {
|
|
1199
|
+
const defaultProviderIds = defaults2 ? Object.keys(defaults2) : [];
|
|
1200
|
+
if (defaultProviderIds.length === 1) {
|
|
1201
|
+
provider = providers.find((p) => p?.id === defaultProviderIds[0]);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (!provider || !provider.models) return null;
|
|
1205
|
+
if (!modelId && defaults2 && typeof provider.id === "string") {
|
|
1206
|
+
const def = defaults2[provider.id];
|
|
1207
|
+
if (typeof def === "string") modelId = def;
|
|
1208
|
+
}
|
|
1209
|
+
if (!modelId) {
|
|
1210
|
+
if (providerId) {
|
|
1211
|
+
const keys = Object.keys(provider.models);
|
|
1212
|
+
if (keys.length === 1) modelId = keys[0];
|
|
1213
|
+
}
|
|
1214
|
+
if (!modelId) return null;
|
|
1215
|
+
}
|
|
1216
|
+
const entry = provider.models[modelId];
|
|
1217
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1218
|
+
return typeof entry.attachment === "boolean" ? entry.attachment : null;
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
console.error(
|
|
1221
|
+
`[getModelAttachmentCapability] GET /config/providers failed (port ${port}): ${err instanceof Error ? err.message : String(err)}`
|
|
1222
|
+
);
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
async function buildFileParts(attachments, capable) {
|
|
1227
|
+
const outcomes = [];
|
|
1228
|
+
const parts = [];
|
|
1229
|
+
const capabilityUnknown = capable === null;
|
|
1230
|
+
if (capable !== true) {
|
|
1231
|
+
for (const a of attachments.inputs) {
|
|
1232
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "skipped" });
|
|
1233
|
+
}
|
|
1234
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1235
|
+
}
|
|
1236
|
+
for (const a of attachments.inputs) {
|
|
1237
|
+
let dataUrl = null;
|
|
1238
|
+
try {
|
|
1239
|
+
dataUrl = await attachments.fetchDataUrl(a.index);
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
console.error(
|
|
1242
|
+
`[buildFileParts] attachment ${a.index} (${a.mime}) fetch threw \u2014 omitting: ${err instanceof Error ? err.message : String(err)}`
|
|
1243
|
+
);
|
|
1244
|
+
dataUrl = null;
|
|
1245
|
+
}
|
|
1246
|
+
if (dataUrl == null) {
|
|
1247
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "failed" });
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
parts.push({
|
|
1251
|
+
type: "file",
|
|
1252
|
+
mime: a.mime,
|
|
1253
|
+
url: dataUrl,
|
|
1254
|
+
...a.filename ? { filename: a.filename } : {}
|
|
1255
|
+
});
|
|
1256
|
+
outcomes.push({ index: a.index, mime: a.mime, filename: a.filename, status: "sent" });
|
|
1257
|
+
}
|
|
1258
|
+
return { parts, outcomes, capabilityUnknown };
|
|
1259
|
+
}
|
|
1176
1260
|
function messageText(m) {
|
|
1177
1261
|
if (!m || !Array.isArray(m.parts)) return "";
|
|
1178
1262
|
return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
1179
1263
|
}
|
|
1180
|
-
async function sendPromptAsync(port, sessionId, content, options) {
|
|
1264
|
+
async function sendPromptAsync(port, sessionId, content, options, attachments) {
|
|
1181
1265
|
const before = await getSessionMessages(port, sessionId);
|
|
1182
1266
|
const knownUserIds = new Set(
|
|
1183
1267
|
(before ?? []).filter((m) => roleOf(m) === "user").map((m) => idOf(m)).filter((id) => typeof id === "string")
|
|
1184
1268
|
);
|
|
1269
|
+
const parts = [{ type: "text", text: content }];
|
|
1270
|
+
let pendingOutcomes = null;
|
|
1271
|
+
if (attachments && attachments.inputs.length > 0) {
|
|
1272
|
+
const capable = await getModelAttachmentCapability(port, options?.model);
|
|
1273
|
+
const {
|
|
1274
|
+
parts: fileParts,
|
|
1275
|
+
outcomes,
|
|
1276
|
+
capabilityUnknown
|
|
1277
|
+
} = await buildFileParts(attachments, capable);
|
|
1278
|
+
parts.push(...fileParts);
|
|
1279
|
+
if (attachments.onOutcomes) pendingOutcomes = { outcomes, capabilityUnknown };
|
|
1280
|
+
}
|
|
1185
1281
|
const body = {
|
|
1186
|
-
parts
|
|
1282
|
+
parts
|
|
1187
1283
|
};
|
|
1188
1284
|
if (options?.agent) {
|
|
1189
1285
|
body.agent = options.agent;
|
|
@@ -1222,7 +1318,10 @@ async function sendPromptAsync(port, sessionId, content, options) {
|
|
|
1222
1318
|
best = { id, created };
|
|
1223
1319
|
}
|
|
1224
1320
|
}
|
|
1225
|
-
if (best)
|
|
1321
|
+
if (best) {
|
|
1322
|
+
if (pendingOutcomes && attachments?.onOutcomes) attachments.onOutcomes(pendingOutcomes);
|
|
1323
|
+
return best.id;
|
|
1324
|
+
}
|
|
1226
1325
|
}
|
|
1227
1326
|
if (attempt < READ_BACK_ATTEMPTS - 1) {
|
|
1228
1327
|
await new Promise((resolve2) => setTimeout(resolve2, READ_BACK_DELAY_MS));
|
|
@@ -1271,6 +1370,72 @@ function findLastAssistantReplyFor(messages, userMessageId) {
|
|
|
1271
1370
|
}
|
|
1272
1371
|
return lastOk ?? last;
|
|
1273
1372
|
}
|
|
1373
|
+
function messageUsage(messages, userMessageId) {
|
|
1374
|
+
if (!messages || messages.length === 0) return null;
|
|
1375
|
+
const byParentAll = messages.filter(
|
|
1376
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1377
|
+
);
|
|
1378
|
+
const byParentNonErrored = byParentAll.filter((m) => errorOf(m) == null);
|
|
1379
|
+
const byParent = byParentNonErrored.length > 0 ? byParentNonErrored : byParentAll;
|
|
1380
|
+
let correlated;
|
|
1381
|
+
if (byParent.length > 0) {
|
|
1382
|
+
correlated = byParent;
|
|
1383
|
+
} else {
|
|
1384
|
+
const reply = findAssistantReplyAfter(messages, userMessageId);
|
|
1385
|
+
correlated = reply ? [reply] : [];
|
|
1386
|
+
}
|
|
1387
|
+
if (correlated.length === 0) return null;
|
|
1388
|
+
let sawAnyUsage = false;
|
|
1389
|
+
let inputSum = 0;
|
|
1390
|
+
let outputSum = 0;
|
|
1391
|
+
let reasoningSum = 0;
|
|
1392
|
+
let cacheReadSum = 0;
|
|
1393
|
+
let cacheWriteSum = 0;
|
|
1394
|
+
let costSum = 0;
|
|
1395
|
+
let sawCost = false;
|
|
1396
|
+
let modelId = null;
|
|
1397
|
+
let providerId = null;
|
|
1398
|
+
for (const m of correlated) {
|
|
1399
|
+
const info = m.info;
|
|
1400
|
+
if (!info) continue;
|
|
1401
|
+
const tokens = info.tokens;
|
|
1402
|
+
if (tokens) {
|
|
1403
|
+
sawAnyUsage = true;
|
|
1404
|
+
inputSum += tokens.input ?? 0;
|
|
1405
|
+
outputSum += tokens.output ?? 0;
|
|
1406
|
+
reasoningSum += tokens.reasoning ?? 0;
|
|
1407
|
+
cacheReadSum += tokens.cache?.read ?? 0;
|
|
1408
|
+
cacheWriteSum += tokens.cache?.write ?? 0;
|
|
1409
|
+
}
|
|
1410
|
+
if (typeof info.cost === "number") {
|
|
1411
|
+
sawAnyUsage = true;
|
|
1412
|
+
sawCost = true;
|
|
1413
|
+
costSum += info.cost;
|
|
1414
|
+
}
|
|
1415
|
+
if (typeof info.modelID === "string") {
|
|
1416
|
+
sawAnyUsage = true;
|
|
1417
|
+
modelId = info.modelID;
|
|
1418
|
+
}
|
|
1419
|
+
if (typeof info.providerID === "string") {
|
|
1420
|
+
sawAnyUsage = true;
|
|
1421
|
+
providerId = info.providerID;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (!sawAnyUsage) return null;
|
|
1425
|
+
return {
|
|
1426
|
+
usage_provider_id: providerId,
|
|
1427
|
+
usage_model_id: modelId,
|
|
1428
|
+
usage_tokens_input: inputSum,
|
|
1429
|
+
usage_tokens_output: outputSum,
|
|
1430
|
+
usage_tokens_reasoning: reasoningSum,
|
|
1431
|
+
usage_tokens_cache_read: cacheReadSum,
|
|
1432
|
+
usage_tokens_cache_write: cacheWriteSum,
|
|
1433
|
+
// NULL means "OpenCode never reported a cost" (never inferred from
|
|
1434
|
+
// tokens) — distinct from a genuine 0-cost turn, which would set
|
|
1435
|
+
// `sawCost` true with `costSum === 0`.
|
|
1436
|
+
usage_cost_usd: sawCost ? costSum : null
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1274
1439
|
function messageRunState(messages, userMessageId) {
|
|
1275
1440
|
if (!messages || messages.length === 0) return "unknown";
|
|
1276
1441
|
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
@@ -1811,6 +1976,17 @@ function messageIdOf(m) {
|
|
|
1811
1976
|
const infoId = m.info?.id;
|
|
1812
1977
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1813
1978
|
}
|
|
1979
|
+
function cleanImageMime(contentType) {
|
|
1980
|
+
if (!contentType) return null;
|
|
1981
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
1982
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
1983
|
+
}
|
|
1984
|
+
var LOG_LEVELS = {
|
|
1985
|
+
debug: 0,
|
|
1986
|
+
info: 1,
|
|
1987
|
+
warn: 2,
|
|
1988
|
+
error: 3
|
|
1989
|
+
};
|
|
1814
1990
|
var DEFAULT_RETRY_POLICY = {
|
|
1815
1991
|
maxAttempts: 6,
|
|
1816
1992
|
baseDelayMs: 500,
|
|
@@ -1941,6 +2117,15 @@ var ChannelDriver = class {
|
|
|
1941
2117
|
* so the NEXT tick may retry exactly once more).
|
|
1942
2118
|
*/
|
|
1943
2119
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2120
|
+
/**
|
|
2121
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2122
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2123
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2124
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2125
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2126
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2127
|
+
*/
|
|
2128
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1944
2129
|
/**
|
|
1945
2130
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1946
2131
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1958,6 +2143,16 @@ var ChannelDriver = class {
|
|
|
1958
2143
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1959
2144
|
*/
|
|
1960
2145
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2146
|
+
/**
|
|
2147
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2148
|
+
* NON-EMPTY name is stored (terminal — a real session name won't later un-name),
|
|
2149
|
+
* so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
|
|
2150
|
+
* resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
|
|
2151
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2152
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2153
|
+
* no watcher) can resolve the title.
|
|
2154
|
+
*/
|
|
2155
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1961
2156
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1962
2157
|
draining = false;
|
|
1963
2158
|
/**
|
|
@@ -1995,9 +2190,6 @@ var ChannelDriver = class {
|
|
|
1995
2190
|
get opencodeBase() {
|
|
1996
2191
|
return `http://127.0.0.1:${this.port}`;
|
|
1997
2192
|
}
|
|
1998
|
-
// -------------------------------------------------------------------------
|
|
1999
|
-
// Public API
|
|
2000
|
-
// -------------------------------------------------------------------------
|
|
2001
2193
|
/**
|
|
2002
2194
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
2003
2195
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2139,9 +2331,7 @@ var ChannelDriver = class {
|
|
|
2139
2331
|
if (!stillLive) return;
|
|
2140
2332
|
}
|
|
2141
2333
|
}
|
|
2142
|
-
// -------------------------------------------------------------------------
|
|
2143
2334
|
// Conversation processing (WI-3 — async dispatch)
|
|
2144
|
-
// -------------------------------------------------------------------------
|
|
2145
2335
|
/**
|
|
2146
2336
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2147
2337
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2173,9 +2363,10 @@ var ChannelDriver = class {
|
|
|
2173
2363
|
conversation_id: conv.id,
|
|
2174
2364
|
message_id: message.id
|
|
2175
2365
|
});
|
|
2366
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2176
2367
|
opencodeMessageId = await this.dispatchLocked(
|
|
2177
2368
|
sessionId,
|
|
2178
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2369
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2179
2370
|
);
|
|
2180
2371
|
} catch (err) {
|
|
2181
2372
|
if (err instanceof ChannelAuthError) throw err;
|
|
@@ -2183,7 +2374,7 @@ var ChannelDriver = class {
|
|
|
2183
2374
|
if (await sessionExists(this.port, sessionId) === false) {
|
|
2184
2375
|
this.sessions.delete(conv.id);
|
|
2185
2376
|
this.log({
|
|
2186
|
-
level: "
|
|
2377
|
+
level: "warn",
|
|
2187
2378
|
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
2379
|
conversation_id: conv.id,
|
|
2189
2380
|
message_id: message.id
|
|
@@ -2202,7 +2393,7 @@ var ChannelDriver = class {
|
|
|
2202
2393
|
}
|
|
2203
2394
|
if (opencodeMessageId === null) {
|
|
2204
2395
|
this.log({
|
|
2205
|
-
level: "
|
|
2396
|
+
level: "warn",
|
|
2206
2397
|
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
2398
|
conversation_id: conv.id,
|
|
2208
2399
|
message_id: message.id
|
|
@@ -2216,7 +2407,7 @@ var ChannelDriver = class {
|
|
|
2216
2407
|
}
|
|
2217
2408
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2218
2409
|
this.log({
|
|
2219
|
-
level: "
|
|
2410
|
+
level: "warn",
|
|
2220
2411
|
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
2412
|
conversation_id: conv.id
|
|
2222
2413
|
});
|
|
@@ -2230,7 +2421,7 @@ var ChannelDriver = class {
|
|
|
2230
2421
|
const exists = await sessionExists(this.port, bound);
|
|
2231
2422
|
if (exists === false) {
|
|
2232
2423
|
this.log({
|
|
2233
|
-
level: "
|
|
2424
|
+
level: "debug",
|
|
2234
2425
|
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
2426
|
conversation_id: conv.id
|
|
2236
2427
|
});
|
|
@@ -2265,15 +2456,13 @@ var ChannelDriver = class {
|
|
|
2265
2456
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2266
2457
|
if (!this.opencodeDirectory) {
|
|
2267
2458
|
this.log({
|
|
2268
|
-
level: "
|
|
2459
|
+
level: "warn",
|
|
2269
2460
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2270
2461
|
});
|
|
2271
2462
|
}
|
|
2272
2463
|
return this.opencodeDirectory;
|
|
2273
2464
|
}
|
|
2274
|
-
// -------------------------------------------------------------------------
|
|
2275
2465
|
// Per-session watcher (WI-3)
|
|
2276
|
-
// -------------------------------------------------------------------------
|
|
2277
2466
|
/**
|
|
2278
2467
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2279
2468
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2293,6 +2482,103 @@ var ChannelDriver = class {
|
|
|
2293
2482
|
);
|
|
2294
2483
|
return run2;
|
|
2295
2484
|
}
|
|
2485
|
+
// Inbound image attachments (#255, WI-8)
|
|
2486
|
+
/**
|
|
2487
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
2488
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
2489
|
+
*
|
|
2490
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
2491
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
2492
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
2493
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
2494
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
2495
|
+
* existing callback surface when any image was skipped/failed.
|
|
2496
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
2497
|
+
* reports outcomes back via `onOutcomes`.
|
|
2498
|
+
*/
|
|
2499
|
+
buildSendAttachments(conv, message) {
|
|
2500
|
+
const refs = message.attachments;
|
|
2501
|
+
if (!refs || refs.length === 0) return void 0;
|
|
2502
|
+
return {
|
|
2503
|
+
inputs: refs.map((a, index) => ({
|
|
2504
|
+
index,
|
|
2505
|
+
mime: a.mime,
|
|
2506
|
+
...a.filename ? { filename: a.filename } : {}
|
|
2507
|
+
})),
|
|
2508
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
2509
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
2514
|
+
* (`GET {apiUrl}/agents/{agentId}/attachments/{messageId}/{index}`) using the
|
|
2515
|
+
* existing authenticated fetch, and base64-encode into a
|
|
2516
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
2517
|
+
*
|
|
2518
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
2519
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
2520
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
2521
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
2522
|
+
* Failures are logged with context (no silent swallow).
|
|
2523
|
+
*/
|
|
2524
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
2525
|
+
try {
|
|
2526
|
+
const res = await this.fetchImpl(
|
|
2527
|
+
`${this.apiUrl}/agents/${this.agentId}/attachments/${messageId}/${index}`,
|
|
2528
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2529
|
+
);
|
|
2530
|
+
if (!res.ok) {
|
|
2531
|
+
this.log({
|
|
2532
|
+
level: "error",
|
|
2533
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
2534
|
+
message_id: messageId
|
|
2535
|
+
});
|
|
2536
|
+
return null;
|
|
2537
|
+
}
|
|
2538
|
+
const buf = await res.arrayBuffer();
|
|
2539
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
2540
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
2541
|
+
return `data:${dataMime};base64,${base64}`;
|
|
2542
|
+
} catch (err) {
|
|
2543
|
+
this.log({
|
|
2544
|
+
level: "error",
|
|
2545
|
+
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)}`,
|
|
2546
|
+
message_id: messageId
|
|
2547
|
+
});
|
|
2548
|
+
return null;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
2553
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
2554
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
2555
|
+
*
|
|
2556
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
2557
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
2558
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
2559
|
+
* reaches the channel.
|
|
2560
|
+
*
|
|
2561
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
2562
|
+
*/
|
|
2563
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
2564
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
2565
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
2566
|
+
if (skipped === 0 && failed === 0) return;
|
|
2567
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
2568
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
2569
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
2570
|
+
this.log({
|
|
2571
|
+
level: "info",
|
|
2572
|
+
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`,
|
|
2573
|
+
conversation_id: conversationId,
|
|
2574
|
+
message_id: messageId
|
|
2575
|
+
});
|
|
2576
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
2577
|
+
skipped,
|
|
2578
|
+
failed,
|
|
2579
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {}
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2296
2582
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2297
2583
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2298
2584
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2530,18 +2816,20 @@ var ChannelDriver = class {
|
|
|
2530
2816
|
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2531
2817
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
2532
2818
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2819
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2533
2820
|
let claimed;
|
|
2534
2821
|
try {
|
|
2535
2822
|
claimed = await this.markProcessing(
|
|
2536
2823
|
conv.id,
|
|
2537
2824
|
inFlight.evidentMessageId,
|
|
2538
2825
|
sessionId,
|
|
2539
|
-
inFlight.opencodeMessageId
|
|
2826
|
+
inFlight.opencodeMessageId,
|
|
2827
|
+
title
|
|
2540
2828
|
);
|
|
2541
2829
|
} catch (err) {
|
|
2542
2830
|
if (err instanceof ChannelAuthError) throw err;
|
|
2543
2831
|
this.log({
|
|
2544
|
-
level: "
|
|
2832
|
+
level: "warn",
|
|
2545
2833
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2546
2834
|
conversation_id: conv.id,
|
|
2547
2835
|
message_id: inFlight.evidentMessageId
|
|
@@ -2551,7 +2839,7 @@ var ChannelDriver = class {
|
|
|
2551
2839
|
inFlight.started = true;
|
|
2552
2840
|
if (!claimed) {
|
|
2553
2841
|
this.log({
|
|
2554
|
-
level: "
|
|
2842
|
+
level: "debug",
|
|
2555
2843
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2556
2844
|
conversation_id: conv.id,
|
|
2557
2845
|
message_id: inFlight.evidentMessageId
|
|
@@ -2567,18 +2855,22 @@ var ChannelDriver = class {
|
|
|
2567
2855
|
conversation_id: conv.id,
|
|
2568
2856
|
message_id: inFlight.evidentMessageId
|
|
2569
2857
|
});
|
|
2858
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2859
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2570
2860
|
try {
|
|
2571
2861
|
await this.markDone(
|
|
2572
2862
|
conv.id,
|
|
2573
2863
|
inFlight.evidentMessageId,
|
|
2574
2864
|
sessionId,
|
|
2575
|
-
inFlight.opencodeMessageId
|
|
2865
|
+
inFlight.opencodeMessageId,
|
|
2866
|
+
title,
|
|
2867
|
+
usage
|
|
2576
2868
|
);
|
|
2577
2869
|
} catch (err) {
|
|
2578
2870
|
if (err instanceof ChannelAuthError) throw err;
|
|
2579
2871
|
if (err instanceof ChannelTerminalError) {
|
|
2580
2872
|
this.log({
|
|
2581
|
-
level: "
|
|
2873
|
+
level: "warn",
|
|
2582
2874
|
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
2875
|
conversation_id: conv.id,
|
|
2584
2876
|
message_id: inFlight.evidentMessageId
|
|
@@ -2588,7 +2880,7 @@ var ChannelDriver = class {
|
|
|
2588
2880
|
}
|
|
2589
2881
|
if (this.now() >= inFlight.deadline) {
|
|
2590
2882
|
this.log({
|
|
2591
|
-
level: "
|
|
2883
|
+
level: "warn",
|
|
2592
2884
|
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
2885
|
conversation_id: conv.id,
|
|
2594
2886
|
message_id: inFlight.evidentMessageId
|
|
@@ -2597,7 +2889,7 @@ var ChannelDriver = class {
|
|
|
2597
2889
|
return;
|
|
2598
2890
|
}
|
|
2599
2891
|
this.log({
|
|
2600
|
-
level: "
|
|
2892
|
+
level: "warn",
|
|
2601
2893
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2602
2894
|
conversation_id: conv.id,
|
|
2603
2895
|
message_id: inFlight.evidentMessageId
|
|
@@ -2619,13 +2911,14 @@ var ChannelDriver = class {
|
|
|
2619
2911
|
conversation_id: conv.id,
|
|
2620
2912
|
message_id: inFlight.evidentMessageId
|
|
2621
2913
|
});
|
|
2914
|
+
const usage = messageUsage(messages, inFlight.opencodeMessageId);
|
|
2622
2915
|
try {
|
|
2623
|
-
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2);
|
|
2916
|
+
await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
|
|
2624
2917
|
} catch (err) {
|
|
2625
2918
|
if (err instanceof ChannelAuthError) throw err;
|
|
2626
2919
|
if (err instanceof ChannelTerminalError) {
|
|
2627
2920
|
this.log({
|
|
2628
|
-
level: "
|
|
2921
|
+
level: "warn",
|
|
2629
2922
|
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
2923
|
conversation_id: conv.id,
|
|
2631
2924
|
message_id: inFlight.evidentMessageId
|
|
@@ -2635,7 +2928,7 @@ var ChannelDriver = class {
|
|
|
2635
2928
|
}
|
|
2636
2929
|
if (this.now() >= inFlight.deadline) {
|
|
2637
2930
|
this.log({
|
|
2638
|
-
level: "
|
|
2931
|
+
level: "warn",
|
|
2639
2932
|
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
2933
|
conversation_id: conv.id,
|
|
2641
2934
|
message_id: inFlight.evidentMessageId
|
|
@@ -2644,7 +2937,7 @@ var ChannelDriver = class {
|
|
|
2644
2937
|
return;
|
|
2645
2938
|
}
|
|
2646
2939
|
this.log({
|
|
2647
|
-
level: "
|
|
2940
|
+
level: "warn",
|
|
2648
2941
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2649
2942
|
conversation_id: conv.id,
|
|
2650
2943
|
message_id: inFlight.evidentMessageId
|
|
@@ -2667,7 +2960,7 @@ var ChannelDriver = class {
|
|
|
2667
2960
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2668
2961
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2669
2962
|
this.log({
|
|
2670
|
-
level: "
|
|
2963
|
+
level: "warn",
|
|
2671
2964
|
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
2965
|
conversation_id: conv.id,
|
|
2673
2966
|
message_id: inFlight.evidentMessageId
|
|
@@ -2710,7 +3003,7 @@ var ChannelDriver = class {
|
|
|
2710
3003
|
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2711
3004
|
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2712
3005
|
this.log({
|
|
2713
|
-
level: "
|
|
3006
|
+
level: "debug",
|
|
2714
3007
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2715
3008
|
conversation_id: conv.id,
|
|
2716
3009
|
message_id: inFlight.evidentMessageId
|
|
@@ -2721,9 +3014,7 @@ var ChannelDriver = class {
|
|
|
2721
3014
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2722
3015
|
}
|
|
2723
3016
|
}
|
|
2724
|
-
// -------------------------------------------------------------------------
|
|
2725
3017
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2726
|
-
// -------------------------------------------------------------------------
|
|
2727
3018
|
/**
|
|
2728
3019
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2729
3020
|
*
|
|
@@ -2753,7 +3044,7 @@ var ChannelDriver = class {
|
|
|
2753
3044
|
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2754
3045
|
if (cleared || clearedUndeliverable) {
|
|
2755
3046
|
this.log({
|
|
2756
|
-
level: "
|
|
3047
|
+
level: "debug",
|
|
2757
3048
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2758
3049
|
message_id: id
|
|
2759
3050
|
});
|
|
@@ -2766,7 +3057,7 @@ var ChannelDriver = class {
|
|
|
2766
3057
|
for (const row of rows) {
|
|
2767
3058
|
if (!row.opencode_session_id) {
|
|
2768
3059
|
this.log({
|
|
2769
|
-
level: "
|
|
3060
|
+
level: "warn",
|
|
2770
3061
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2771
3062
|
conversation_id: row.conversation_id,
|
|
2772
3063
|
message_id: row.id
|
|
@@ -2783,7 +3074,7 @@ var ChannelDriver = class {
|
|
|
2783
3074
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2784
3075
|
if (!res.ok) {
|
|
2785
3076
|
this.log({
|
|
2786
|
-
level: "
|
|
3077
|
+
level: "warn",
|
|
2787
3078
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2788
3079
|
});
|
|
2789
3080
|
continue;
|
|
@@ -2791,7 +3082,7 @@ var ChannelDriver = class {
|
|
|
2791
3082
|
const body = await res.json();
|
|
2792
3083
|
if (!Array.isArray(body)) {
|
|
2793
3084
|
this.log({
|
|
2794
|
-
level: "
|
|
3085
|
+
level: "warn",
|
|
2795
3086
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2796
3087
|
});
|
|
2797
3088
|
continue;
|
|
@@ -2799,7 +3090,7 @@ var ChannelDriver = class {
|
|
|
2799
3090
|
messages = body;
|
|
2800
3091
|
} catch (err) {
|
|
2801
3092
|
this.log({
|
|
2802
|
-
level: "
|
|
3093
|
+
level: "warn",
|
|
2803
3094
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2804
3095
|
});
|
|
2805
3096
|
continue;
|
|
@@ -2832,7 +3123,7 @@ var ChannelDriver = class {
|
|
|
2832
3123
|
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2833
3124
|
if (this.isTracked(sessionId, row.id)) {
|
|
2834
3125
|
this.log({
|
|
2835
|
-
level: "
|
|
3126
|
+
level: "debug",
|
|
2836
3127
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2837
3128
|
conversation_id: row.conversation_id,
|
|
2838
3129
|
message_id: row.id
|
|
@@ -2844,7 +3135,7 @@ var ChannelDriver = class {
|
|
|
2844
3135
|
if (state === "done") {
|
|
2845
3136
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2846
3137
|
this.log({
|
|
2847
|
-
level: "
|
|
3138
|
+
level: "debug",
|
|
2848
3139
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2849
3140
|
conversation_id: row.conversation_id,
|
|
2850
3141
|
message_id: row.id
|
|
@@ -2858,13 +3149,15 @@ var ChannelDriver = class {
|
|
|
2858
3149
|
message_id: row.id
|
|
2859
3150
|
});
|
|
2860
3151
|
try {
|
|
2861
|
-
await this.
|
|
3152
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3153
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
3154
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
|
|
2862
3155
|
} catch (err) {
|
|
2863
3156
|
if (err instanceof ChannelAuthError) throw err;
|
|
2864
3157
|
if (err instanceof ChannelTerminalError) {
|
|
2865
3158
|
this.doneUndeliverable.add(row.id);
|
|
2866
3159
|
this.log({
|
|
2867
|
-
level: "
|
|
3160
|
+
level: "warn",
|
|
2868
3161
|
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
3162
|
conversation_id: row.conversation_id,
|
|
2870
3163
|
message_id: row.id
|
|
@@ -2873,7 +3166,7 @@ var ChannelDriver = class {
|
|
|
2873
3166
|
return;
|
|
2874
3167
|
}
|
|
2875
3168
|
this.log({
|
|
2876
|
-
level: "
|
|
3169
|
+
level: "warn",
|
|
2877
3170
|
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
3171
|
conversation_id: row.conversation_id,
|
|
2879
3172
|
message_id: row.id
|
|
@@ -2886,6 +3179,7 @@ var ChannelDriver = class {
|
|
|
2886
3179
|
}
|
|
2887
3180
|
if (state === "failed") {
|
|
2888
3181
|
const error2 = messageError(messages, ocId ?? "") ?? void 0;
|
|
3182
|
+
const usage = messageUsage(messages, ocId ?? "");
|
|
2889
3183
|
this.log({
|
|
2890
3184
|
level: "error",
|
|
2891
3185
|
message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
|
|
@@ -2893,13 +3187,13 @@ var ChannelDriver = class {
|
|
|
2893
3187
|
message_id: row.id
|
|
2894
3188
|
});
|
|
2895
3189
|
try {
|
|
2896
|
-
await this.markFailed(row.conversation_id, row.id, sessionId, error2);
|
|
3190
|
+
await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
|
|
2897
3191
|
} catch (err) {
|
|
2898
3192
|
if (err instanceof ChannelAuthError) throw err;
|
|
2899
3193
|
if (err instanceof ChannelTerminalError) {
|
|
2900
3194
|
this.doneUndeliverable.add(row.id);
|
|
2901
3195
|
this.log({
|
|
2902
|
-
level: "
|
|
3196
|
+
level: "warn",
|
|
2903
3197
|
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
3198
|
conversation_id: row.conversation_id,
|
|
2905
3199
|
message_id: row.id
|
|
@@ -2908,7 +3202,7 @@ var ChannelDriver = class {
|
|
|
2908
3202
|
return;
|
|
2909
3203
|
}
|
|
2910
3204
|
this.log({
|
|
2911
|
-
level: "
|
|
3205
|
+
level: "warn",
|
|
2912
3206
|
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
3207
|
conversation_id: row.conversation_id,
|
|
2914
3208
|
message_id: row.id
|
|
@@ -2921,7 +3215,7 @@ var ChannelDriver = class {
|
|
|
2921
3215
|
}
|
|
2922
3216
|
if (this.dontRedispatch.has(row.id)) {
|
|
2923
3217
|
this.log({
|
|
2924
|
-
level: "
|
|
3218
|
+
level: "debug",
|
|
2925
3219
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2926
3220
|
conversation_id: row.conversation_id,
|
|
2927
3221
|
message_id: row.id
|
|
@@ -2946,7 +3240,7 @@ var ChannelDriver = class {
|
|
|
2946
3240
|
}
|
|
2947
3241
|
if (ongoing === true) {
|
|
2948
3242
|
this.log({
|
|
2949
|
-
level: "
|
|
3243
|
+
level: "debug",
|
|
2950
3244
|
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
3245
|
conversation_id: row.conversation_id,
|
|
2952
3246
|
message_id: row.id
|
|
@@ -2954,7 +3248,7 @@ var ChannelDriver = class {
|
|
|
2954
3248
|
} else {
|
|
2955
3249
|
if (shape === "b1") {
|
|
2956
3250
|
this.log({
|
|
2957
|
-
level: "
|
|
3251
|
+
level: "debug",
|
|
2958
3252
|
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
3253
|
conversation_id: row.conversation_id,
|
|
2960
3254
|
message_id: row.id
|
|
@@ -2966,7 +3260,7 @@ var ChannelDriver = class {
|
|
|
2966
3260
|
return;
|
|
2967
3261
|
}
|
|
2968
3262
|
this.log({
|
|
2969
|
-
level: "
|
|
3263
|
+
level: "debug",
|
|
2970
3264
|
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
3265
|
conversation_id: row.conversation_id,
|
|
2972
3266
|
message_id: row.id
|
|
@@ -2977,7 +3271,7 @@ var ChannelDriver = class {
|
|
|
2977
3271
|
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2978
3272
|
if (descendantAlive === true) {
|
|
2979
3273
|
this.log({
|
|
2980
|
-
level: "
|
|
3274
|
+
level: "debug",
|
|
2981
3275
|
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
3276
|
conversation_id: row.conversation_id,
|
|
2983
3277
|
message_id: row.id
|
|
@@ -3001,7 +3295,7 @@ var ChannelDriver = class {
|
|
|
3001
3295
|
this.readopted.add(row.id);
|
|
3002
3296
|
this.ensureWatcherRunning(sessionId);
|
|
3003
3297
|
this.log({
|
|
3004
|
-
level: "
|
|
3298
|
+
level: "debug",
|
|
3005
3299
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
3006
3300
|
conversation_id: row.conversation_id,
|
|
3007
3301
|
message_id: row.id
|
|
@@ -3035,7 +3329,7 @@ var ChannelDriver = class {
|
|
|
3035
3329
|
async forceReadoptRun(sessionId, row) {
|
|
3036
3330
|
if (this.stopped) {
|
|
3037
3331
|
this.log({
|
|
3038
|
-
level: "
|
|
3332
|
+
level: "debug",
|
|
3039
3333
|
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
3334
|
conversation_id: row.conversation_id,
|
|
3041
3335
|
message_id: row.id
|
|
@@ -3044,7 +3338,7 @@ var ChannelDriver = class {
|
|
|
3044
3338
|
}
|
|
3045
3339
|
if (this.awaitingReadopt.has(row.id)) {
|
|
3046
3340
|
this.log({
|
|
3047
|
-
level: "
|
|
3341
|
+
level: "debug",
|
|
3048
3342
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
3049
3343
|
conversation_id: row.conversation_id,
|
|
3050
3344
|
message_id: row.id
|
|
@@ -3054,7 +3348,7 @@ var ChannelDriver = class {
|
|
|
3054
3348
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
3055
3349
|
this.dontRedispatch.add(row.id);
|
|
3056
3350
|
this.log({
|
|
3057
|
-
level: "
|
|
3351
|
+
level: "debug",
|
|
3058
3352
|
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
3353
|
conversation_id: row.conversation_id,
|
|
3060
3354
|
message_id: row.id
|
|
@@ -3073,17 +3367,20 @@ var ChannelDriver = class {
|
|
|
3073
3367
|
message_id: row.id
|
|
3074
3368
|
});
|
|
3075
3369
|
this.awaitingReadopt.add(row.id);
|
|
3370
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
3371
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
3372
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
3076
3373
|
let ocId;
|
|
3077
3374
|
try {
|
|
3078
3375
|
ocId = await this.dispatchLocked(
|
|
3079
3376
|
sessionId,
|
|
3080
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3377
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
3081
3378
|
);
|
|
3082
3379
|
} catch (err) {
|
|
3083
3380
|
this.awaitingReadopt.delete(row.id);
|
|
3084
3381
|
if (err instanceof ChannelAuthError) throw err;
|
|
3085
3382
|
this.log({
|
|
3086
|
-
level: "
|
|
3383
|
+
level: "warn",
|
|
3087
3384
|
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
3385
|
conversation_id: row.conversation_id,
|
|
3089
3386
|
message_id: row.id
|
|
@@ -3094,7 +3391,7 @@ var ChannelDriver = class {
|
|
|
3094
3391
|
if (ocId === null) {
|
|
3095
3392
|
this.awaitingReadopt.delete(row.id);
|
|
3096
3393
|
this.log({
|
|
3097
|
-
level: "
|
|
3394
|
+
level: "warn",
|
|
3098
3395
|
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
3396
|
conversation_id: row.conversation_id,
|
|
3100
3397
|
message_id: row.id
|
|
@@ -3102,9 +3399,7 @@ var ChannelDriver = class {
|
|
|
3102
3399
|
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
3103
3400
|
return;
|
|
3104
3401
|
}
|
|
3105
|
-
|
|
3106
|
-
const message = this.queuedMessageForRow(row);
|
|
3107
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3402
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
3108
3403
|
this.dispatched.add(row.id);
|
|
3109
3404
|
this.readopted.add(row.id);
|
|
3110
3405
|
this.awaitingReadopt.delete(row.id);
|
|
@@ -3158,7 +3453,8 @@ var ChannelDriver = class {
|
|
|
3158
3453
|
opencode_agent: row.opencode_agent,
|
|
3159
3454
|
opencode_model: row.opencode_model,
|
|
3160
3455
|
source_message_id: row.source_message_id,
|
|
3161
|
-
slack_user_id: row.slack_user_id
|
|
3456
|
+
slack_user_id: row.slack_user_id,
|
|
3457
|
+
attachments: row.attachments ?? null
|
|
3162
3458
|
};
|
|
3163
3459
|
}
|
|
3164
3460
|
/**
|
|
@@ -3179,7 +3475,7 @@ var ChannelDriver = class {
|
|
|
3179
3475
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
3180
3476
|
this.dontRedispatch.add(evidentMessageId);
|
|
3181
3477
|
this.log({
|
|
3182
|
-
level: "
|
|
3478
|
+
level: "debug",
|
|
3183
3479
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
3184
3480
|
conversation_id: watcher.conv.id,
|
|
3185
3481
|
message_id: evidentMessageId
|
|
@@ -3319,6 +3615,51 @@ var ChannelDriver = class {
|
|
|
3319
3615
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3320
3616
|
return parent;
|
|
3321
3617
|
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3620
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3621
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3622
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3623
|
+
* Best-effort:
|
|
3624
|
+
* - a resolved NON-EMPTY title is cached and terminal (a real session name
|
|
3625
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3626
|
+
* - while the title is still absent/empty we do NOT latch it — OpenCode names
|
|
3627
|
+
* sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
|
|
3628
|
+
* must leave the cache unresolved and re-fetch on the next need so a later
|
|
3629
|
+
* call (e.g. at `done`) picks up the name assigned in the meantime. Such a
|
|
3630
|
+
* call returns `null` (omit the title on THIS PATCH) without caching;
|
|
3631
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3632
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
3633
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
3634
|
+
*/
|
|
3635
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
3636
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
3637
|
+
if (cached != null) return cached;
|
|
3638
|
+
try {
|
|
3639
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3640
|
+
if (res.ok) {
|
|
3641
|
+
const body = await res.json();
|
|
3642
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3643
|
+
if (title.length > 0) {
|
|
3644
|
+
this.sessionTitles.set(sessionId, title);
|
|
3645
|
+
return title;
|
|
3646
|
+
}
|
|
3647
|
+
return null;
|
|
3648
|
+
}
|
|
3649
|
+
this.log({
|
|
3650
|
+
level: "debug",
|
|
3651
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
3652
|
+
conversation_id: conversationId
|
|
3653
|
+
});
|
|
3654
|
+
} catch (err) {
|
|
3655
|
+
this.log({
|
|
3656
|
+
level: "debug",
|
|
3657
|
+
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)}`,
|
|
3658
|
+
conversation_id: conversationId
|
|
3659
|
+
});
|
|
3660
|
+
}
|
|
3661
|
+
return null;
|
|
3662
|
+
}
|
|
3322
3663
|
/**
|
|
3323
3664
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3324
3665
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -3362,7 +3703,7 @@ var ChannelDriver = class {
|
|
|
3362
3703
|
const sessions = await listSessions(this.port);
|
|
3363
3704
|
if (!sessions) {
|
|
3364
3705
|
this.log({
|
|
3365
|
-
level: "
|
|
3706
|
+
level: "warn",
|
|
3366
3707
|
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3367
3708
|
});
|
|
3368
3709
|
return null;
|
|
@@ -3446,9 +3787,7 @@ var ChannelDriver = class {
|
|
|
3446
3787
|
}
|
|
3447
3788
|
return inFlight.sort(byOldest)[0];
|
|
3448
3789
|
}
|
|
3449
|
-
// -------------------------------------------------------------------------
|
|
3450
3790
|
// Evident API calls (combinedAuth thread routes)
|
|
3451
|
-
// -------------------------------------------------------------------------
|
|
3452
3791
|
async getPendingConversations() {
|
|
3453
3792
|
const res = await this.fetchImpl(
|
|
3454
3793
|
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
@@ -3528,7 +3867,7 @@ var ChannelDriver = class {
|
|
|
3528
3867
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3529
3868
|
* retry vehicle for the swap-to-running.
|
|
3530
3869
|
*/
|
|
3531
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3870
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3532
3871
|
const res = await this.fetchImpl(
|
|
3533
3872
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3534
3873
|
{
|
|
@@ -3537,7 +3876,8 @@ var ChannelDriver = class {
|
|
|
3537
3876
|
body: JSON.stringify({
|
|
3538
3877
|
status: "processing",
|
|
3539
3878
|
opencode_session_id: sessionId,
|
|
3540
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3879
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3880
|
+
...title ? { title } : {}
|
|
3541
3881
|
})
|
|
3542
3882
|
}
|
|
3543
3883
|
);
|
|
@@ -3576,7 +3916,7 @@ var ChannelDriver = class {
|
|
|
3576
3916
|
* watcher retries next tick within the
|
|
3577
3917
|
* deadline, Finding 4).
|
|
3578
3918
|
*/
|
|
3579
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3919
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title, usage) {
|
|
3580
3920
|
const res = await this.fetchImpl(
|
|
3581
3921
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3582
3922
|
{
|
|
@@ -3585,7 +3925,9 @@ var ChannelDriver = class {
|
|
|
3585
3925
|
body: JSON.stringify({
|
|
3586
3926
|
status: "done",
|
|
3587
3927
|
opencode_session_id: sessionId,
|
|
3588
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3928
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3929
|
+
...title ? { title } : {},
|
|
3930
|
+
...usage ? usage : {}
|
|
3589
3931
|
})
|
|
3590
3932
|
}
|
|
3591
3933
|
);
|
|
@@ -3603,10 +3945,11 @@ var ChannelDriver = class {
|
|
|
3603
3945
|
* OpenCode turn sends `{status:'failed', opencode_session_id, error}` so the
|
|
3604
3946
|
* failure reason reaches the channel.
|
|
3605
3947
|
*/
|
|
3606
|
-
async markFailed(conversationId, messageId, sessionId, error2) {
|
|
3948
|
+
async markFailed(conversationId, messageId, sessionId, error2, usage) {
|
|
3607
3949
|
const body = { status: "failed" };
|
|
3608
3950
|
if (sessionId !== void 0) body.opencode_session_id = sessionId;
|
|
3609
3951
|
if (error2 !== void 0) body.error = error2;
|
|
3952
|
+
if (usage) Object.assign(body, usage);
|
|
3610
3953
|
await this.callWithRetry(
|
|
3611
3954
|
"marking message as failed",
|
|
3612
3955
|
() => this.fetchImpl(
|
|
@@ -3646,7 +3989,7 @@ var ChannelDriver = class {
|
|
|
3646
3989
|
);
|
|
3647
3990
|
if (!res.ok) {
|
|
3648
3991
|
this.log({
|
|
3649
|
-
level: "
|
|
3992
|
+
level: "warn",
|
|
3650
3993
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3651
3994
|
conversation_id: conversationId,
|
|
3652
3995
|
message_id: messageId
|
|
@@ -3656,7 +3999,7 @@ var ChannelDriver = class {
|
|
|
3656
3999
|
return true;
|
|
3657
4000
|
} catch (err) {
|
|
3658
4001
|
this.log({
|
|
3659
|
-
level: "
|
|
4002
|
+
level: "warn",
|
|
3660
4003
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3661
4004
|
conversation_id: conversationId,
|
|
3662
4005
|
message_id: messageId
|
|
@@ -3718,9 +4061,7 @@ var ChannelDriver = class {
|
|
|
3718
4061
|
return false;
|
|
3719
4062
|
}
|
|
3720
4063
|
}
|
|
3721
|
-
// -------------------------------------------------------------------------
|
|
3722
4064
|
// Retry wrapper
|
|
3723
|
-
// -------------------------------------------------------------------------
|
|
3724
4065
|
/**
|
|
3725
4066
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3726
4067
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -3919,7 +4260,7 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3919
4260
|
if (!response.ok) {
|
|
3920
4261
|
const serverMessage = await readErrorMessage(response);
|
|
3921
4262
|
return {
|
|
3922
|
-
error: `Failed to resolve
|
|
4263
|
+
error: `Failed to resolve runner from key (HTTP ${response.status})${serverMessage ? `: ${serverMessage}` : ""}`
|
|
3923
4264
|
};
|
|
3924
4265
|
}
|
|
3925
4266
|
const data = await response.json();
|
|
@@ -3927,11 +4268,11 @@ async function resolveAgentIdFromKey(authHeader) {
|
|
|
3927
4268
|
return { agent_id: data.agent_id };
|
|
3928
4269
|
}
|
|
3929
4270
|
return {
|
|
3930
|
-
error: "Cannot resolve
|
|
4271
|
+
error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
|
|
3931
4272
|
};
|
|
3932
4273
|
} catch (error2) {
|
|
3933
4274
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3934
|
-
return { error: `Failed to resolve
|
|
4275
|
+
return { error: `Failed to resolve runner from key: ${message}` };
|
|
3935
4276
|
}
|
|
3936
4277
|
}
|
|
3937
4278
|
async function notifyAgentDisconnected(agentId, authHeader) {
|
|
@@ -3967,12 +4308,12 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3967
4308
|
const serverMessage = await readErrorMessage(response);
|
|
3968
4309
|
return {
|
|
3969
4310
|
valid: false,
|
|
3970
|
-
error: serverMessage ?? "You do not have access to this
|
|
4311
|
+
error: serverMessage ?? "You do not have access to this runner (it may belong to a different team or organization)."
|
|
3971
4312
|
};
|
|
3972
4313
|
}
|
|
3973
4314
|
if (response.status === 404) {
|
|
3974
4315
|
const serverMessage = await readErrorMessage(response);
|
|
3975
|
-
return { valid: false, error: serverMessage ?? `
|
|
4316
|
+
return { valid: false, error: serverMessage ?? `Runner ${agentId} not found` };
|
|
3976
4317
|
}
|
|
3977
4318
|
if (!response.ok) {
|
|
3978
4319
|
const serverMessage = await readErrorMessage(response);
|
|
@@ -3985,13 +4326,13 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
3985
4326
|
if (agent.agent_type !== "local") {
|
|
3986
4327
|
return {
|
|
3987
4328
|
valid: false,
|
|
3988
|
-
error: `
|
|
4329
|
+
error: `Runner is type '${agent.agent_type}', must be 'local' for CLI connection`
|
|
3989
4330
|
};
|
|
3990
4331
|
}
|
|
3991
4332
|
return { valid: true, agent };
|
|
3992
4333
|
} catch (error2) {
|
|
3993
4334
|
const message = error2 instanceof Error ? error2.message : "Unknown error";
|
|
3994
|
-
return { valid: false, error: `Failed to validate
|
|
4335
|
+
return { valid: false, error: `Failed to validate runner: ${message}` };
|
|
3995
4336
|
}
|
|
3996
4337
|
}
|
|
3997
4338
|
|
|
@@ -4000,23 +4341,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
4000
4341
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
4001
4342
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
4002
4343
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
4003
|
-
function
|
|
4344
|
+
function resolveLogLevel(options) {
|
|
4345
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
4346
|
+
const validate = (value, source) => {
|
|
4347
|
+
const normalized = value.trim().toLowerCase();
|
|
4348
|
+
if (!accepted.includes(normalized)) {
|
|
4349
|
+
throw new Error(
|
|
4350
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
4351
|
+
);
|
|
4352
|
+
}
|
|
4353
|
+
return normalized;
|
|
4354
|
+
};
|
|
4355
|
+
if (options.logLevel !== void 0) {
|
|
4356
|
+
return validate(options.logLevel, " (--log-level)");
|
|
4357
|
+
}
|
|
4358
|
+
if (options.verbose) {
|
|
4359
|
+
return "debug";
|
|
4360
|
+
}
|
|
4361
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
4362
|
+
if (env !== void 0 && env !== "") {
|
|
4363
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
4364
|
+
}
|
|
4365
|
+
return "info";
|
|
4366
|
+
}
|
|
4367
|
+
function meetsThreshold(state, level) {
|
|
4368
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4369
|
+
}
|
|
4370
|
+
function log2(state, message, level = "info") {
|
|
4371
|
+
if (!meetsThreshold(state, level)) return;
|
|
4004
4372
|
if (state.json) {
|
|
4005
4373
|
console.log(
|
|
4006
4374
|
JSON.stringify({
|
|
4007
4375
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4008
|
-
level
|
|
4376
|
+
level,
|
|
4009
4377
|
message
|
|
4010
4378
|
})
|
|
4011
4379
|
);
|
|
4012
4380
|
} else if (!state.interactive) {
|
|
4013
|
-
const prefix =
|
|
4381
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
4014
4382
|
console.log(`${prefix} ${message}`);
|
|
4015
4383
|
}
|
|
4016
4384
|
}
|
|
4017
4385
|
function logActivity(state, entry) {
|
|
4386
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4387
|
+
if (!meetsThreshold(state, level)) return;
|
|
4018
4388
|
const fullEntry = {
|
|
4019
4389
|
...entry,
|
|
4390
|
+
level,
|
|
4020
4391
|
timestamp: /* @__PURE__ */ new Date()
|
|
4021
4392
|
};
|
|
4022
4393
|
state.activityLog.push(fullEntry);
|
|
@@ -4025,9 +4396,9 @@ function logActivity(state, entry) {
|
|
|
4025
4396
|
}
|
|
4026
4397
|
if (!state.interactive) {
|
|
4027
4398
|
if (entry.type === "error") {
|
|
4028
|
-
log2(state, entry.error ?? "Unknown error",
|
|
4029
|
-
} else if (entry.
|
|
4030
|
-
log2(state, entry.message);
|
|
4399
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4400
|
+
} else if (entry.message) {
|
|
4401
|
+
log2(state, entry.message, level);
|
|
4031
4402
|
}
|
|
4032
4403
|
}
|
|
4033
4404
|
}
|
|
@@ -4226,7 +4597,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
4226
4597
|
process.env
|
|
4227
4598
|
);
|
|
4228
4599
|
for (const warning2 of config2.warnings) {
|
|
4229
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
4600
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4230
4601
|
}
|
|
4231
4602
|
if (!config2.enabled) return;
|
|
4232
4603
|
logActivity(state, {
|
|
@@ -4248,7 +4619,7 @@ async function notifyOffline(state) {
|
|
|
4248
4619
|
}
|
|
4249
4620
|
const result = await notifyAgentDisconnected(state.agentId, state.authHeader);
|
|
4250
4621
|
if (result.ok) {
|
|
4251
|
-
log2(state, "Notified Evident the
|
|
4622
|
+
log2(state, "Notified Evident the runner is going offline");
|
|
4252
4623
|
} else {
|
|
4253
4624
|
logActivity(state, {
|
|
4254
4625
|
type: "error",
|
|
@@ -4298,6 +4669,20 @@ async function cleanup(state, opts = {}) {
|
|
|
4298
4669
|
}
|
|
4299
4670
|
async function run(options) {
|
|
4300
4671
|
const interactive = isInteractive(options.json);
|
|
4672
|
+
let logLevel;
|
|
4673
|
+
try {
|
|
4674
|
+
logLevel = resolveLogLevel(options);
|
|
4675
|
+
} catch (error2) {
|
|
4676
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4677
|
+
if (options.json) {
|
|
4678
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
4679
|
+
} else {
|
|
4680
|
+
printError(message);
|
|
4681
|
+
}
|
|
4682
|
+
await shutdownTelemetry();
|
|
4683
|
+
process.exit(1);
|
|
4684
|
+
return;
|
|
4685
|
+
}
|
|
4301
4686
|
const state = {
|
|
4302
4687
|
agentId: options.agent || "",
|
|
4303
4688
|
agentName: null,
|
|
@@ -4306,6 +4691,7 @@ async function run(options) {
|
|
|
4306
4691
|
idleTimeout: options.idleTimeout ?? null,
|
|
4307
4692
|
json: options.json ?? false,
|
|
4308
4693
|
interactive,
|
|
4694
|
+
logLevel,
|
|
4309
4695
|
connected: false,
|
|
4310
4696
|
opencodeConnected: false,
|
|
4311
4697
|
opencodeVersion: null,
|
|
@@ -4323,8 +4709,8 @@ async function run(options) {
|
|
|
4323
4709
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4324
4710
|
log2(
|
|
4325
4711
|
state,
|
|
4326
|
-
"
|
|
4327
|
-
|
|
4712
|
+
"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.",
|
|
4713
|
+
"warn"
|
|
4328
4714
|
);
|
|
4329
4715
|
}
|
|
4330
4716
|
const handleSignal = async () => {
|
|
@@ -4367,15 +4753,15 @@ async function run(options) {
|
|
|
4367
4753
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
4368
4754
|
if (resolved.agent_id) {
|
|
4369
4755
|
state.agentId = resolved.agent_id;
|
|
4370
|
-
log2(state, `Resolved
|
|
4756
|
+
log2(state, `Resolved runner ID from key: ${state.agentId}`);
|
|
4371
4757
|
if (state.interactive && !state.json) {
|
|
4372
4758
|
logActivity(state, {
|
|
4373
4759
|
type: "info",
|
|
4374
|
-
message: `
|
|
4760
|
+
message: `Runner ID resolved from key: ${state.agentId}`
|
|
4375
4761
|
});
|
|
4376
4762
|
}
|
|
4377
4763
|
} else {
|
|
4378
|
-
printError(resolved.error || "Failed to resolve
|
|
4764
|
+
printError(resolved.error || "Failed to resolve runner ID from key");
|
|
4379
4765
|
process.exit(1);
|
|
4380
4766
|
}
|
|
4381
4767
|
} else {
|
|
@@ -4403,7 +4789,7 @@ async function run(options) {
|
|
|
4403
4789
|
console.log(chalk6.bold("Evident Run"));
|
|
4404
4790
|
console.log(chalk6.dim("-".repeat(40)));
|
|
4405
4791
|
}
|
|
4406
|
-
const spinner = interactive && !state.json ? ora3("Validating
|
|
4792
|
+
const spinner = interactive && !state.json ? ora3("Validating runner...").start() : null;
|
|
4407
4793
|
let validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4408
4794
|
if (!validation.valid && validation.authFailed && interactive) {
|
|
4409
4795
|
spinner?.fail("Authentication failed");
|
|
@@ -4415,14 +4801,14 @@ async function run(options) {
|
|
|
4415
4801
|
"Login successful! Retrying..."
|
|
4416
4802
|
);
|
|
4417
4803
|
state.authHeader = getAuthHeader(credentials2);
|
|
4418
|
-
spinner?.start("Validating
|
|
4804
|
+
spinner?.start("Validating runner...");
|
|
4419
4805
|
validation = await getAgentInfo(state.agentId, state.authHeader);
|
|
4420
4806
|
}
|
|
4421
4807
|
if (!validation.valid) {
|
|
4422
|
-
spinner?.fail(`
|
|
4808
|
+
spinner?.fail(`Runner validation failed: ${validation.error}`);
|
|
4423
4809
|
throw new Error(validation.error);
|
|
4424
4810
|
}
|
|
4425
|
-
spinner?.succeed(`
|
|
4811
|
+
spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
|
|
4426
4812
|
state.agentName = validation.agent.name;
|
|
4427
4813
|
const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
|
|
4428
4814
|
try {
|
|
@@ -4440,9 +4826,9 @@ async function run(options) {
|
|
|
4440
4826
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4441
4827
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4442
4828
|
if (versionWarning) {
|
|
4443
|
-
log2(state, versionWarning,
|
|
4829
|
+
log2(state, versionWarning, "warn");
|
|
4444
4830
|
if (state.interactive && !state.json) {
|
|
4445
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4831
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
4446
4832
|
}
|
|
4447
4833
|
}
|
|
4448
4834
|
} catch (error2) {
|
|
@@ -4457,11 +4843,17 @@ async function run(options) {
|
|
|
4457
4843
|
getAuthHeader: () => state.authHeader,
|
|
4458
4844
|
conversationFilter: state.conversationFilter,
|
|
4459
4845
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4460
|
-
log: (entry) =>
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4846
|
+
log: (entry) => (
|
|
4847
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
4848
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
4849
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
4850
|
+
logActivity(state, {
|
|
4851
|
+
type: entry.level === "error" ? "error" : "info",
|
|
4852
|
+
level: entry.level,
|
|
4853
|
+
message: entry.message,
|
|
4854
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
4855
|
+
})
|
|
4856
|
+
)
|
|
4465
4857
|
});
|
|
4466
4858
|
state.channelDriver = channelDriver;
|
|
4467
4859
|
const connection = new RunnerConnection({
|
|
@@ -4475,7 +4867,7 @@ async function run(options) {
|
|
|
4475
4867
|
state.agentId = agentId;
|
|
4476
4868
|
logActivity(state, {
|
|
4477
4869
|
type: "info",
|
|
4478
|
-
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (
|
|
4870
|
+
message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
|
|
4479
4871
|
});
|
|
4480
4872
|
emitAgentConnected(state.agentId, {
|
|
4481
4873
|
port: state.port,
|
|
@@ -4615,7 +5007,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4615
5007
|
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
5008
|
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
5009
|
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]", "
|
|
5010
|
+
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("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
5011
|
+
"--log-level <level>",
|
|
5012
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
5013
|
+
).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
5014
|
"--session-cleanup-max-age <duration>",
|
|
4620
5015
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4621
5016
|
).option(
|
|
@@ -4629,6 +5024,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4629
5024
|
run({
|
|
4630
5025
|
agent: options.agent,
|
|
4631
5026
|
port: parseInt(options.port, 10),
|
|
5027
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
5028
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
5029
|
+
logLevel: options.logLevel,
|
|
4632
5030
|
verbose: options.verbose,
|
|
4633
5031
|
conversation: options.conversation,
|
|
4634
5032
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|