@evident-ai/cli 3.0.1-dev.9cb8b53 → 3.0.1-dev.a24d876
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 +3 -1
- package/dist/index.js +412 -87
- 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));
|
|
@@ -1811,6 +1910,17 @@ function messageIdOf(m) {
|
|
|
1811
1910
|
const infoId = m.info?.id;
|
|
1812
1911
|
return typeof infoId === "string" ? infoId : void 0;
|
|
1813
1912
|
}
|
|
1913
|
+
function cleanImageMime(contentType) {
|
|
1914
|
+
if (!contentType) return null;
|
|
1915
|
+
const media = contentType.split(";")[0].trim().toLowerCase();
|
|
1916
|
+
return /^image\/[a-z0-9.+-]+$/.test(media) ? media : null;
|
|
1917
|
+
}
|
|
1918
|
+
var LOG_LEVELS = {
|
|
1919
|
+
debug: 0,
|
|
1920
|
+
info: 1,
|
|
1921
|
+
warn: 2,
|
|
1922
|
+
error: 3
|
|
1923
|
+
};
|
|
1814
1924
|
var DEFAULT_RETRY_POLICY = {
|
|
1815
1925
|
maxAttempts: 6,
|
|
1816
1926
|
baseDelayMs: 500,
|
|
@@ -1941,6 +2051,15 @@ var ChannelDriver = class {
|
|
|
1941
2051
|
* so the NEXT tick may retry exactly once more).
|
|
1942
2052
|
*/
|
|
1943
2053
|
awaitingReadopt = /* @__PURE__ */ new Set();
|
|
2054
|
+
/**
|
|
2055
|
+
* "Already signalled `attachments_skipped` for this Evident message id" (#376).
|
|
2056
|
+
* The in-thread skip note is an OUTCOME, so it must fire AT MOST ONCE per message
|
|
2057
|
+
* — never re-post on a re-dispatch of the same row (`forceReadoptRun` or the
|
|
2058
|
+
* next-tick null-id retry both re-run `sendPromptAsync`, which re-fires
|
|
2059
|
+
* `onOutcomes`). Mirrors `readoptPollUnresolvedSignalled`: a local dedup on the
|
|
2060
|
+
* outcome, not the dispatch. Not cleared (a message is signalled once for life).
|
|
2061
|
+
*/
|
|
2062
|
+
attachmentsSkippedSignalled = /* @__PURE__ */ new Set();
|
|
1944
2063
|
/**
|
|
1945
2064
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1946
2065
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1958,6 +2077,16 @@ var ChannelDriver = class {
|
|
|
1958
2077
|
* entry = not yet resolved; `null` = resolved root (stop walking).
|
|
1959
2078
|
*/
|
|
1960
2079
|
sessionParents = /* @__PURE__ */ new Map();
|
|
2080
|
+
/**
|
|
2081
|
+
* Per-session OpenCode title cache (#310), keyed by sessionId. Only a resolved
|
|
2082
|
+
* NON-EMPTY name is stored (terminal — a real session name won't later un-name),
|
|
2083
|
+
* so we do NOT re-GET `/session/:id` every tick. A missing entry = not yet
|
|
2084
|
+
* resolved OR resolved-but-still-empty → re-fetch on next need, since OpenCode
|
|
2085
|
+
* names sessions asynchronously mid-turn. Driver-level (not per-watcher) so both
|
|
2086
|
+
* the watcher completion path AND the restart-recovery re-adopt path (which has
|
|
2087
|
+
* no watcher) can resolve the title.
|
|
2088
|
+
*/
|
|
2089
|
+
sessionTitles = /* @__PURE__ */ new Map();
|
|
1961
2090
|
/** Serialises drains so a reconnect during a drain doesn't double-process. */
|
|
1962
2091
|
draining = false;
|
|
1963
2092
|
/**
|
|
@@ -1995,9 +2124,6 @@ var ChannelDriver = class {
|
|
|
1995
2124
|
get opencodeBase() {
|
|
1996
2125
|
return `http://127.0.0.1:${this.port}`;
|
|
1997
2126
|
}
|
|
1998
|
-
// -------------------------------------------------------------------------
|
|
1999
|
-
// Public API
|
|
2000
|
-
// -------------------------------------------------------------------------
|
|
2001
2127
|
/**
|
|
2002
2128
|
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
2003
2129
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
@@ -2139,9 +2265,7 @@ var ChannelDriver = class {
|
|
|
2139
2265
|
if (!stillLive) return;
|
|
2140
2266
|
}
|
|
2141
2267
|
}
|
|
2142
|
-
// -------------------------------------------------------------------------
|
|
2143
2268
|
// Conversation processing (WI-3 — async dispatch)
|
|
2144
|
-
// -------------------------------------------------------------------------
|
|
2145
2269
|
/**
|
|
2146
2270
|
* Dispatch each pending message for a conversation to opencode's native queue
|
|
2147
2271
|
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
@@ -2173,9 +2297,10 @@ var ChannelDriver = class {
|
|
|
2173
2297
|
conversation_id: conv.id,
|
|
2174
2298
|
message_id: message.id
|
|
2175
2299
|
});
|
|
2300
|
+
const sendAttachments = this.buildSendAttachments(conv, message);
|
|
2176
2301
|
opencodeMessageId = await this.dispatchLocked(
|
|
2177
2302
|
sessionId,
|
|
2178
|
-
() => sendPromptAsync(this.port, sessionId, message.content, options)
|
|
2303
|
+
() => sendPromptAsync(this.port, sessionId, message.content, options, sendAttachments)
|
|
2179
2304
|
);
|
|
2180
2305
|
} catch (err) {
|
|
2181
2306
|
if (err instanceof ChannelAuthError) throw err;
|
|
@@ -2183,7 +2308,7 @@ var ChannelDriver = class {
|
|
|
2183
2308
|
if (await sessionExists(this.port, sessionId) === false) {
|
|
2184
2309
|
this.sessions.delete(conv.id);
|
|
2185
2310
|
this.log({
|
|
2186
|
-
level: "
|
|
2311
|
+
level: "warn",
|
|
2187
2312
|
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
2313
|
conversation_id: conv.id,
|
|
2189
2314
|
message_id: message.id
|
|
@@ -2202,7 +2327,7 @@ var ChannelDriver = class {
|
|
|
2202
2327
|
}
|
|
2203
2328
|
if (opencodeMessageId === null) {
|
|
2204
2329
|
this.log({
|
|
2205
|
-
level: "
|
|
2330
|
+
level: "warn",
|
|
2206
2331
|
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
2332
|
conversation_id: conv.id,
|
|
2208
2333
|
message_id: message.id
|
|
@@ -2216,7 +2341,7 @@ var ChannelDriver = class {
|
|
|
2216
2341
|
}
|
|
2217
2342
|
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
2218
2343
|
this.log({
|
|
2219
|
-
level: "
|
|
2344
|
+
level: "warn",
|
|
2220
2345
|
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
2346
|
conversation_id: conv.id
|
|
2222
2347
|
});
|
|
@@ -2230,7 +2355,7 @@ var ChannelDriver = class {
|
|
|
2230
2355
|
const exists = await sessionExists(this.port, bound);
|
|
2231
2356
|
if (exists === false) {
|
|
2232
2357
|
this.log({
|
|
2233
|
-
level: "
|
|
2358
|
+
level: "debug",
|
|
2234
2359
|
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
2360
|
conversation_id: conv.id
|
|
2236
2361
|
});
|
|
@@ -2265,15 +2390,13 @@ var ChannelDriver = class {
|
|
|
2265
2390
|
this.opencodeDirectory = await getOpenCodeDirectory(this.port);
|
|
2266
2391
|
if (!this.opencodeDirectory) {
|
|
2267
2392
|
this.log({
|
|
2268
|
-
level: "
|
|
2393
|
+
level: "warn",
|
|
2269
2394
|
message: "Could not determine opencode directory (GET /path) \u2014 new sessions may not appear in opencode web"
|
|
2270
2395
|
});
|
|
2271
2396
|
}
|
|
2272
2397
|
return this.opencodeDirectory;
|
|
2273
2398
|
}
|
|
2274
|
-
// -------------------------------------------------------------------------
|
|
2275
2399
|
// Per-session watcher (WI-3)
|
|
2276
|
-
// -------------------------------------------------------------------------
|
|
2277
2400
|
/**
|
|
2278
2401
|
* Run one dispatch (`sendPromptAsync` snapshot→POST→read-back) serialized per
|
|
2279
2402
|
* opencode session (Task 2.1a), so two dispatches into the SAME session can
|
|
@@ -2293,6 +2416,103 @@ var ChannelDriver = class {
|
|
|
2293
2416
|
);
|
|
2294
2417
|
return run2;
|
|
2295
2418
|
}
|
|
2419
|
+
// Inbound image attachments (#255, WI-8)
|
|
2420
|
+
/**
|
|
2421
|
+
* Build the `SendAttachmentsInput` for a message's inbound images, or
|
|
2422
|
+
* `undefined` when the message has none (so a text-only turn is unchanged).
|
|
2423
|
+
*
|
|
2424
|
+
* The driver OWNS the two channel-facing concerns the session module cannot:
|
|
2425
|
+
* - the AUTHENTICATED byte fetch through Evident's WI-6 endpoint
|
|
2426
|
+
* (`fetchAttachmentDataUrl`), using the SAME `getAuthHeader()` as every
|
|
2427
|
+
* other combinedAuth callback — the CLI NEVER talks to Slack directly;
|
|
2428
|
+
* - the in-thread SKIP NOTE (`signalAttachmentsSkipped`) posted over the
|
|
2429
|
+
* existing callback surface when any image was skipped/failed.
|
|
2430
|
+
* `sendPromptAsync` applies the capability gate + appends the `file` parts and
|
|
2431
|
+
* reports outcomes back via `onOutcomes`.
|
|
2432
|
+
*/
|
|
2433
|
+
buildSendAttachments(conv, message) {
|
|
2434
|
+
const refs = message.attachments;
|
|
2435
|
+
if (!refs || refs.length === 0) return void 0;
|
|
2436
|
+
return {
|
|
2437
|
+
inputs: refs.map((a, index) => ({
|
|
2438
|
+
index,
|
|
2439
|
+
mime: a.mime,
|
|
2440
|
+
...a.filename ? { filename: a.filename } : {}
|
|
2441
|
+
})),
|
|
2442
|
+
fetchDataUrl: (index) => this.fetchAttachmentDataUrl(message.id, index, refs[index].mime),
|
|
2443
|
+
onOutcomes: ({ outcomes, capabilityUnknown }) => this.signalAttachmentsSkipped(conv.id, message.id, outcomes, capabilityUnknown)
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Fetch ONE inbound image's bytes through Evident's WI-6 endpoint
|
|
2448
|
+
* (`GET {apiUrl}/agents/{agentId}/attachments/{messageId}/{index}`) using the
|
|
2449
|
+
* existing authenticated fetch, and base64-encode into a
|
|
2450
|
+
* `data:<mime>;base64,<…>` URL for the opencode `file` part's `url`.
|
|
2451
|
+
*
|
|
2452
|
+
* The endpoint streams the source bytes verbatim (200), or returns 404
|
|
2453
|
+
* (not-owned / out-of-range / deleted-at-source / workspace gone) / 413
|
|
2454
|
+
* (over-cap). On ANY non-2xx or thrown failure we return `null` so the caller
|
|
2455
|
+
* OMITS that one image and the text turn still sends — NEVER throws the turn.
|
|
2456
|
+
* Failures are logged with context (no silent swallow).
|
|
2457
|
+
*/
|
|
2458
|
+
async fetchAttachmentDataUrl(messageId, index, mime) {
|
|
2459
|
+
try {
|
|
2460
|
+
const res = await this.fetchImpl(
|
|
2461
|
+
`${this.apiUrl}/agents/${this.agentId}/attachments/${messageId}/${index}`,
|
|
2462
|
+
{ headers: { Authorization: this.getAuthHeader() } }
|
|
2463
|
+
);
|
|
2464
|
+
if (!res.ok) {
|
|
2465
|
+
this.log({
|
|
2466
|
+
level: "error",
|
|
2467
|
+
message: `Attachment fetch for message ${messageId.slice(0, 8)} index ${index} returned HTTP ${res.status} \u2014 omitting this image (text turn proceeds)`,
|
|
2468
|
+
message_id: messageId
|
|
2469
|
+
});
|
|
2470
|
+
return null;
|
|
2471
|
+
}
|
|
2472
|
+
const buf = await res.arrayBuffer();
|
|
2473
|
+
const base64 = Buffer.from(buf).toString("base64");
|
|
2474
|
+
const dataMime = cleanImageMime(res.headers.get("content-type")) || mime;
|
|
2475
|
+
return `data:${dataMime};base64,${base64}`;
|
|
2476
|
+
} catch (err) {
|
|
2477
|
+
this.log({
|
|
2478
|
+
level: "error",
|
|
2479
|
+
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)}`,
|
|
2480
|
+
message_id: messageId
|
|
2481
|
+
});
|
|
2482
|
+
return null;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* On any skipped/failed image, post an in-thread note to Evident over the
|
|
2487
|
+
* EXISTING combinedAuth callback surface — the CLI NEVER posts to Slack directly.
|
|
2488
|
+
* Evident routes the note to source via `conversation.deliver`.
|
|
2489
|
+
*
|
|
2490
|
+
* The `POST .../messages/:id/signal` route accepts `attachments_skipped` (in
|
|
2491
|
+
* `messageSignalSchema`) and turns it into an in-thread note delivered through
|
|
2492
|
+
* `conversation.deliver` (e.g. "N image(s) couldn't be forwarded"), so the note
|
|
2493
|
+
* reaches the channel.
|
|
2494
|
+
*
|
|
2495
|
+
* Fire-and-forget: never throws into the send/tick (logs its own failure).
|
|
2496
|
+
*/
|
|
2497
|
+
signalAttachmentsSkipped(conversationId, messageId, outcomes, capabilityUnknown) {
|
|
2498
|
+
const skipped = outcomes.filter((o) => o.status === "skipped").length;
|
|
2499
|
+
const failed = outcomes.filter((o) => o.status === "failed").length;
|
|
2500
|
+
if (skipped === 0 && failed === 0) return;
|
|
2501
|
+
if (this.attachmentsSkippedSignalled.has(messageId)) return;
|
|
2502
|
+
this.attachmentsSkippedSignalled.add(messageId);
|
|
2503
|
+
const skippedReason = capabilityUnknown ? "unknown" : "unsupported";
|
|
2504
|
+
this.log({
|
|
2505
|
+
level: "info",
|
|
2506
|
+
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`,
|
|
2507
|
+
conversation_id: conversationId,
|
|
2508
|
+
message_id: messageId
|
|
2509
|
+
});
|
|
2510
|
+
void this.postSignal(conversationId, messageId, "attachments_skipped", {
|
|
2511
|
+
skipped,
|
|
2512
|
+
failed,
|
|
2513
|
+
...skipped > 0 ? { skipped_reason: skippedReason } : {}
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2296
2516
|
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
2297
2517
|
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
2298
2518
|
let watcher = this.watchers.get(sessionId);
|
|
@@ -2530,18 +2750,20 @@ var ChannelDriver = class {
|
|
|
2530
2750
|
const latchedPaused = inFlight.pausedOnQuestion || inFlight.pausedOnPermission;
|
|
2531
2751
|
const awaitingHuman = observedOpen || latchedPaused;
|
|
2532
2752
|
if ((state === "running" || state === "done" || state === "failed") && !inFlight.started) {
|
|
2753
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2533
2754
|
let claimed;
|
|
2534
2755
|
try {
|
|
2535
2756
|
claimed = await this.markProcessing(
|
|
2536
2757
|
conv.id,
|
|
2537
2758
|
inFlight.evidentMessageId,
|
|
2538
2759
|
sessionId,
|
|
2539
|
-
inFlight.opencodeMessageId
|
|
2760
|
+
inFlight.opencodeMessageId,
|
|
2761
|
+
title
|
|
2540
2762
|
);
|
|
2541
2763
|
} catch (err) {
|
|
2542
2764
|
if (err instanceof ChannelAuthError) throw err;
|
|
2543
2765
|
this.log({
|
|
2544
|
-
level: "
|
|
2766
|
+
level: "warn",
|
|
2545
2767
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2546
2768
|
conversation_id: conv.id,
|
|
2547
2769
|
message_id: inFlight.evidentMessageId
|
|
@@ -2551,7 +2773,7 @@ var ChannelDriver = class {
|
|
|
2551
2773
|
inFlight.started = true;
|
|
2552
2774
|
if (!claimed) {
|
|
2553
2775
|
this.log({
|
|
2554
|
-
level: "
|
|
2776
|
+
level: "debug",
|
|
2555
2777
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
2556
2778
|
conversation_id: conv.id,
|
|
2557
2779
|
message_id: inFlight.evidentMessageId
|
|
@@ -2567,18 +2789,20 @@ var ChannelDriver = class {
|
|
|
2567
2789
|
conversation_id: conv.id,
|
|
2568
2790
|
message_id: inFlight.evidentMessageId
|
|
2569
2791
|
});
|
|
2792
|
+
const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
|
|
2570
2793
|
try {
|
|
2571
2794
|
await this.markDone(
|
|
2572
2795
|
conv.id,
|
|
2573
2796
|
inFlight.evidentMessageId,
|
|
2574
2797
|
sessionId,
|
|
2575
|
-
inFlight.opencodeMessageId
|
|
2798
|
+
inFlight.opencodeMessageId,
|
|
2799
|
+
title
|
|
2576
2800
|
);
|
|
2577
2801
|
} catch (err) {
|
|
2578
2802
|
if (err instanceof ChannelAuthError) throw err;
|
|
2579
2803
|
if (err instanceof ChannelTerminalError) {
|
|
2580
2804
|
this.log({
|
|
2581
|
-
level: "
|
|
2805
|
+
level: "warn",
|
|
2582
2806
|
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
2807
|
conversation_id: conv.id,
|
|
2584
2808
|
message_id: inFlight.evidentMessageId
|
|
@@ -2588,7 +2812,7 @@ var ChannelDriver = class {
|
|
|
2588
2812
|
}
|
|
2589
2813
|
if (this.now() >= inFlight.deadline) {
|
|
2590
2814
|
this.log({
|
|
2591
|
-
level: "
|
|
2815
|
+
level: "warn",
|
|
2592
2816
|
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
2817
|
conversation_id: conv.id,
|
|
2594
2818
|
message_id: inFlight.evidentMessageId
|
|
@@ -2597,7 +2821,7 @@ var ChannelDriver = class {
|
|
|
2597
2821
|
return;
|
|
2598
2822
|
}
|
|
2599
2823
|
this.log({
|
|
2600
|
-
level: "
|
|
2824
|
+
level: "warn",
|
|
2601
2825
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2602
2826
|
conversation_id: conv.id,
|
|
2603
2827
|
message_id: inFlight.evidentMessageId
|
|
@@ -2625,7 +2849,7 @@ var ChannelDriver = class {
|
|
|
2625
2849
|
if (err instanceof ChannelAuthError) throw err;
|
|
2626
2850
|
if (err instanceof ChannelTerminalError) {
|
|
2627
2851
|
this.log({
|
|
2628
|
-
level: "
|
|
2852
|
+
level: "warn",
|
|
2629
2853
|
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
2854
|
conversation_id: conv.id,
|
|
2631
2855
|
message_id: inFlight.evidentMessageId
|
|
@@ -2635,7 +2859,7 @@ var ChannelDriver = class {
|
|
|
2635
2859
|
}
|
|
2636
2860
|
if (this.now() >= inFlight.deadline) {
|
|
2637
2861
|
this.log({
|
|
2638
|
-
level: "
|
|
2862
|
+
level: "warn",
|
|
2639
2863
|
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
2864
|
conversation_id: conv.id,
|
|
2641
2865
|
message_id: inFlight.evidentMessageId
|
|
@@ -2644,7 +2868,7 @@ var ChannelDriver = class {
|
|
|
2644
2868
|
return;
|
|
2645
2869
|
}
|
|
2646
2870
|
this.log({
|
|
2647
|
-
level: "
|
|
2871
|
+
level: "warn",
|
|
2648
2872
|
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} failed (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
2649
2873
|
conversation_id: conv.id,
|
|
2650
2874
|
message_id: inFlight.evidentMessageId
|
|
@@ -2667,7 +2891,7 @@ var ChannelDriver = class {
|
|
|
2667
2891
|
const activelyRunning = state === "running" && !awaitingHuman;
|
|
2668
2892
|
if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
|
|
2669
2893
|
this.log({
|
|
2670
|
-
level: "
|
|
2894
|
+
level: "warn",
|
|
2671
2895
|
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
2896
|
conversation_id: conv.id,
|
|
2673
2897
|
message_id: inFlight.evidentMessageId
|
|
@@ -2710,7 +2934,7 @@ var ChannelDriver = class {
|
|
|
2710
2934
|
const queuedBehindRunningSibling = state === "queued" && hasActivelyRunningSibling;
|
|
2711
2935
|
if (!activelyRunning && !queuedBehindRunningSibling && this.now() >= inFlight.deadline) {
|
|
2712
2936
|
this.log({
|
|
2713
|
-
level: "
|
|
2937
|
+
level: "debug",
|
|
2714
2938
|
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
2715
2939
|
conversation_id: conv.id,
|
|
2716
2940
|
message_id: inFlight.evidentMessageId
|
|
@@ -2721,9 +2945,7 @@ var ChannelDriver = class {
|
|
|
2721
2945
|
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2722
2946
|
}
|
|
2723
2947
|
}
|
|
2724
|
-
// -------------------------------------------------------------------------
|
|
2725
2948
|
// Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
|
|
2726
|
-
// -------------------------------------------------------------------------
|
|
2727
2949
|
/**
|
|
2728
2950
|
* Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
|
|
2729
2951
|
*
|
|
@@ -2753,7 +2975,7 @@ var ChannelDriver = class {
|
|
|
2753
2975
|
this.readoptPollUnresolvedSignalled.delete(id);
|
|
2754
2976
|
if (cleared || clearedUndeliverable) {
|
|
2755
2977
|
this.log({
|
|
2756
|
-
level: "
|
|
2978
|
+
level: "debug",
|
|
2757
2979
|
message: `Re-adopt: message ${id.slice(0, 8)} left the processing list (cron reset) \u2014 cleared gave-up marker`,
|
|
2758
2980
|
message_id: id
|
|
2759
2981
|
});
|
|
@@ -2766,7 +2988,7 @@ var ChannelDriver = class {
|
|
|
2766
2988
|
for (const row of rows) {
|
|
2767
2989
|
if (!row.opencode_session_id) {
|
|
2768
2990
|
this.log({
|
|
2769
|
-
level: "
|
|
2991
|
+
level: "warn",
|
|
2770
2992
|
message: `Cannot re-adopt processing message ${row.id.slice(0, 8)} \u2014 no opencode session id; leaving for the cron safety net`,
|
|
2771
2993
|
conversation_id: row.conversation_id,
|
|
2772
2994
|
message_id: row.id
|
|
@@ -2783,7 +3005,7 @@ var ChannelDriver = class {
|
|
|
2783
3005
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
2784
3006
|
if (!res.ok) {
|
|
2785
3007
|
this.log({
|
|
2786
|
-
level: "
|
|
3008
|
+
level: "warn",
|
|
2787
3009
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned HTTP ${res.status} \u2014 skipping this session this tick`
|
|
2788
3010
|
});
|
|
2789
3011
|
continue;
|
|
@@ -2791,7 +3013,7 @@ var ChannelDriver = class {
|
|
|
2791
3013
|
const body = await res.json();
|
|
2792
3014
|
if (!Array.isArray(body)) {
|
|
2793
3015
|
this.log({
|
|
2794
|
-
level: "
|
|
3016
|
+
level: "warn",
|
|
2795
3017
|
message: `Re-adopt: polling session ${sessionId.slice(0, 8)} returned a non-array message body \u2014 skipping this session this tick`
|
|
2796
3018
|
});
|
|
2797
3019
|
continue;
|
|
@@ -2799,7 +3021,7 @@ var ChannelDriver = class {
|
|
|
2799
3021
|
messages = body;
|
|
2800
3022
|
} catch (err) {
|
|
2801
3023
|
this.log({
|
|
2802
|
-
level: "
|
|
3024
|
+
level: "warn",
|
|
2803
3025
|
message: `Re-adopt: failed to poll session ${sessionId.slice(0, 8)} (will retry next drain): ${err instanceof Error ? err.message : String(err)}`
|
|
2804
3026
|
});
|
|
2805
3027
|
continue;
|
|
@@ -2832,7 +3054,7 @@ var ChannelDriver = class {
|
|
|
2832
3054
|
async readoptOne(sessionId, row, messages, sessionOngoing) {
|
|
2833
3055
|
if (this.isTracked(sessionId, row.id)) {
|
|
2834
3056
|
this.log({
|
|
2835
|
-
level: "
|
|
3057
|
+
level: "debug",
|
|
2836
3058
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already tracked in-flight \u2014 skipping (owned by the watcher loop)`,
|
|
2837
3059
|
conversation_id: row.conversation_id,
|
|
2838
3060
|
message_id: row.id
|
|
@@ -2844,7 +3066,7 @@ var ChannelDriver = class {
|
|
|
2844
3066
|
if (state === "done") {
|
|
2845
3067
|
if (this.doneUndeliverable.has(row.id)) {
|
|
2846
3068
|
this.log({
|
|
2847
|
-
level: "
|
|
3069
|
+
level: "debug",
|
|
2848
3070
|
message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
|
|
2849
3071
|
conversation_id: row.conversation_id,
|
|
2850
3072
|
message_id: row.id
|
|
@@ -2858,13 +3080,14 @@ var ChannelDriver = class {
|
|
|
2858
3080
|
message_id: row.id
|
|
2859
3081
|
});
|
|
2860
3082
|
try {
|
|
2861
|
-
await this.
|
|
3083
|
+
const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
|
|
3084
|
+
await this.markDone(row.conversation_id, row.id, sessionId, ocId, title);
|
|
2862
3085
|
} catch (err) {
|
|
2863
3086
|
if (err instanceof ChannelAuthError) throw err;
|
|
2864
3087
|
if (err instanceof ChannelTerminalError) {
|
|
2865
3088
|
this.doneUndeliverable.add(row.id);
|
|
2866
3089
|
this.log({
|
|
2867
|
-
level: "
|
|
3090
|
+
level: "warn",
|
|
2868
3091
|
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
3092
|
conversation_id: row.conversation_id,
|
|
2870
3093
|
message_id: row.id
|
|
@@ -2873,7 +3096,7 @@ var ChannelDriver = class {
|
|
|
2873
3096
|
return;
|
|
2874
3097
|
}
|
|
2875
3098
|
this.log({
|
|
2876
|
-
level: "
|
|
3099
|
+
level: "warn",
|
|
2877
3100
|
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
3101
|
conversation_id: row.conversation_id,
|
|
2879
3102
|
message_id: row.id
|
|
@@ -2899,7 +3122,7 @@ var ChannelDriver = class {
|
|
|
2899
3122
|
if (err instanceof ChannelTerminalError) {
|
|
2900
3123
|
this.doneUndeliverable.add(row.id);
|
|
2901
3124
|
this.log({
|
|
2902
|
-
level: "
|
|
3125
|
+
level: "warn",
|
|
2903
3126
|
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
3127
|
conversation_id: row.conversation_id,
|
|
2905
3128
|
message_id: row.id
|
|
@@ -2908,7 +3131,7 @@ var ChannelDriver = class {
|
|
|
2908
3131
|
return;
|
|
2909
3132
|
}
|
|
2910
3133
|
this.log({
|
|
2911
|
-
level: "
|
|
3134
|
+
level: "warn",
|
|
2912
3135
|
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
3136
|
conversation_id: row.conversation_id,
|
|
2914
3137
|
message_id: row.id
|
|
@@ -2921,7 +3144,7 @@ var ChannelDriver = class {
|
|
|
2921
3144
|
}
|
|
2922
3145
|
if (this.dontRedispatch.has(row.id)) {
|
|
2923
3146
|
this.log({
|
|
2924
|
-
level: "
|
|
3147
|
+
level: "debug",
|
|
2925
3148
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already gave up \u2014 left to the cron; skipping until it leaves processing`,
|
|
2926
3149
|
conversation_id: row.conversation_id,
|
|
2927
3150
|
message_id: row.id
|
|
@@ -2946,7 +3169,7 @@ var ChannelDriver = class {
|
|
|
2946
3169
|
}
|
|
2947
3170
|
if (ongoing === true) {
|
|
2948
3171
|
this.log({
|
|
2949
|
-
level: "
|
|
3172
|
+
level: "debug",
|
|
2950
3173
|
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
3174
|
conversation_id: row.conversation_id,
|
|
2952
3175
|
message_id: row.id
|
|
@@ -2954,7 +3177,7 @@ var ChannelDriver = class {
|
|
|
2954
3177
|
} else {
|
|
2955
3178
|
if (shape === "b1") {
|
|
2956
3179
|
this.log({
|
|
2957
|
-
level: "
|
|
3180
|
+
level: "debug",
|
|
2958
3181
|
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
3182
|
conversation_id: row.conversation_id,
|
|
2960
3183
|
message_id: row.id
|
|
@@ -2966,7 +3189,7 @@ var ChannelDriver = class {
|
|
|
2966
3189
|
return;
|
|
2967
3190
|
}
|
|
2968
3191
|
this.log({
|
|
2969
|
-
level: "
|
|
3192
|
+
level: "debug",
|
|
2970
3193
|
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
3194
|
conversation_id: row.conversation_id,
|
|
2972
3195
|
message_id: row.id
|
|
@@ -2977,7 +3200,7 @@ var ChannelDriver = class {
|
|
|
2977
3200
|
const descendantAlive = await this.isAnyDescendantSessionAlive(sessionId);
|
|
2978
3201
|
if (descendantAlive === true) {
|
|
2979
3202
|
this.log({
|
|
2980
|
-
level: "
|
|
3203
|
+
level: "debug",
|
|
2981
3204
|
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
3205
|
conversation_id: row.conversation_id,
|
|
2983
3206
|
message_id: row.id
|
|
@@ -3001,7 +3224,7 @@ var ChannelDriver = class {
|
|
|
3001
3224
|
this.readopted.add(row.id);
|
|
3002
3225
|
this.ensureWatcherRunning(sessionId);
|
|
3003
3226
|
this.log({
|
|
3004
|
-
level: "
|
|
3227
|
+
level: "debug",
|
|
3005
3228
|
message: `Re-adopt: message ${row.id.slice(0, 8)} ${state} \u2014 re-attached watcher (stored id, no re-dispatch)`,
|
|
3006
3229
|
conversation_id: row.conversation_id,
|
|
3007
3230
|
message_id: row.id
|
|
@@ -3035,7 +3258,7 @@ var ChannelDriver = class {
|
|
|
3035
3258
|
async forceReadoptRun(sessionId, row) {
|
|
3036
3259
|
if (this.stopped) {
|
|
3037
3260
|
this.log({
|
|
3038
|
-
level: "
|
|
3261
|
+
level: "debug",
|
|
3039
3262
|
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
3263
|
conversation_id: row.conversation_id,
|
|
3041
3264
|
message_id: row.id
|
|
@@ -3044,7 +3267,7 @@ var ChannelDriver = class {
|
|
|
3044
3267
|
}
|
|
3045
3268
|
if (this.awaitingReadopt.has(row.id)) {
|
|
3046
3269
|
this.log({
|
|
3047
|
-
level: "
|
|
3270
|
+
level: "debug",
|
|
3048
3271
|
message: `Re-adopt: message ${row.id.slice(0, 8)} already has a re-dispatch awaiting read-back \u2014 skipping (at most once)`,
|
|
3049
3272
|
conversation_id: row.conversation_id,
|
|
3050
3273
|
message_id: row.id
|
|
@@ -3054,7 +3277,7 @@ var ChannelDriver = class {
|
|
|
3054
3277
|
if (this.processedAtMs(row) + this.pausedMaxWaitMs <= this.now()) {
|
|
3055
3278
|
this.dontRedispatch.add(row.id);
|
|
3056
3279
|
this.log({
|
|
3057
|
-
level: "
|
|
3280
|
+
level: "debug",
|
|
3058
3281
|
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
3282
|
conversation_id: row.conversation_id,
|
|
3060
3283
|
message_id: row.id
|
|
@@ -3073,17 +3296,20 @@ var ChannelDriver = class {
|
|
|
3073
3296
|
message_id: row.id
|
|
3074
3297
|
});
|
|
3075
3298
|
this.awaitingReadopt.add(row.id);
|
|
3299
|
+
const readoptConv = this.convForRow(sessionId, row);
|
|
3300
|
+
const readoptMessage = this.queuedMessageForRow(row);
|
|
3301
|
+
const sendAttachments = this.buildSendAttachments(readoptConv, readoptMessage);
|
|
3076
3302
|
let ocId;
|
|
3077
3303
|
try {
|
|
3078
3304
|
ocId = await this.dispatchLocked(
|
|
3079
3305
|
sessionId,
|
|
3080
|
-
() => sendPromptAsync(this.port, sessionId, row.content, options)
|
|
3306
|
+
() => sendPromptAsync(this.port, sessionId, row.content, options, sendAttachments)
|
|
3081
3307
|
);
|
|
3082
3308
|
} catch (err) {
|
|
3083
3309
|
this.awaitingReadopt.delete(row.id);
|
|
3084
3310
|
if (err instanceof ChannelAuthError) throw err;
|
|
3085
3311
|
this.log({
|
|
3086
|
-
level: "
|
|
3312
|
+
level: "warn",
|
|
3087
3313
|
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
3314
|
conversation_id: row.conversation_id,
|
|
3089
3315
|
message_id: row.id
|
|
@@ -3094,7 +3320,7 @@ var ChannelDriver = class {
|
|
|
3094
3320
|
if (ocId === null) {
|
|
3095
3321
|
this.awaitingReadopt.delete(row.id);
|
|
3096
3322
|
this.log({
|
|
3097
|
-
level: "
|
|
3323
|
+
level: "warn",
|
|
3098
3324
|
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
3325
|
conversation_id: row.conversation_id,
|
|
3100
3326
|
message_id: row.id
|
|
@@ -3102,9 +3328,7 @@ var ChannelDriver = class {
|
|
|
3102
3328
|
void this.postSignal(row.conversation_id, row.id, "readopt_orphan_unsent");
|
|
3103
3329
|
return;
|
|
3104
3330
|
}
|
|
3105
|
-
|
|
3106
|
-
const message = this.queuedMessageForRow(row);
|
|
3107
|
-
this.registerReadopted(conv, sessionId, message, ocId, this.processedAtMs(row));
|
|
3331
|
+
this.registerReadopted(readoptConv, sessionId, readoptMessage, ocId, this.processedAtMs(row));
|
|
3108
3332
|
this.dispatched.add(row.id);
|
|
3109
3333
|
this.readopted.add(row.id);
|
|
3110
3334
|
this.awaitingReadopt.delete(row.id);
|
|
@@ -3158,7 +3382,8 @@ var ChannelDriver = class {
|
|
|
3158
3382
|
opencode_agent: row.opencode_agent,
|
|
3159
3383
|
opencode_model: row.opencode_model,
|
|
3160
3384
|
source_message_id: row.source_message_id,
|
|
3161
|
-
slack_user_id: row.slack_user_id
|
|
3385
|
+
slack_user_id: row.slack_user_id,
|
|
3386
|
+
attachments: row.attachments ?? null
|
|
3162
3387
|
};
|
|
3163
3388
|
}
|
|
3164
3389
|
/**
|
|
@@ -3179,7 +3404,7 @@ var ChannelDriver = class {
|
|
|
3179
3404
|
if (this.readopted.delete(evidentMessageId) && inFlight && !inFlight.done) {
|
|
3180
3405
|
this.dontRedispatch.add(evidentMessageId);
|
|
3181
3406
|
this.log({
|
|
3182
|
-
level: "
|
|
3407
|
+
level: "debug",
|
|
3183
3408
|
message: `Re-adopt: message ${evidentMessageId.slice(0, 8)} gave up \u2014 parking until it leaves the processing list (cron reset)`,
|
|
3184
3409
|
conversation_id: watcher.conv.id,
|
|
3185
3410
|
message_id: evidentMessageId
|
|
@@ -3319,6 +3544,51 @@ var ChannelDriver = class {
|
|
|
3319
3544
|
if (parent !== void 0) this.sessionParents.set(sessionId, parent);
|
|
3320
3545
|
return parent;
|
|
3321
3546
|
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Resolve (and cache in `sessionTitles`) the OpenCode session TITLE (#310) so the
|
|
3549
|
+
* status PATCH can carry it into the "Live sessions" list. Driver-level cache so
|
|
3550
|
+
* BOTH the watcher completion path and the restart-recovery re-adopt path (which
|
|
3551
|
+
* has no watcher) can use it. `conversationId` is passed only for log context.
|
|
3552
|
+
* Best-effort:
|
|
3553
|
+
* - a resolved NON-EMPTY title is cached and terminal (a real session name
|
|
3554
|
+
* won't later un-name), so we do NOT re-GET `/session/:id` every tick;
|
|
3555
|
+
* - while the title is still absent/empty we do NOT latch it — OpenCode names
|
|
3556
|
+
* sessions asynchronously mid-turn, so an early call (e.g. at `processing`)
|
|
3557
|
+
* must leave the cache unresolved and re-fetch on the next need so a later
|
|
3558
|
+
* call (e.g. at `done`) picks up the name assigned in the meantime. Such a
|
|
3559
|
+
* call returns `null` (omit the title on THIS PATCH) without caching;
|
|
3560
|
+
* - a failed request likewise leaves the cache unresolved (retry next need)
|
|
3561
|
+
* and returns `null` — it must NEVER throw or block completion.
|
|
3562
|
+
* A failure is logged with agent/session context (no silent catch).
|
|
3563
|
+
*/
|
|
3564
|
+
async resolveSessionTitle(sessionId, conversationId) {
|
|
3565
|
+
const cached = this.sessionTitles.get(sessionId);
|
|
3566
|
+
if (cached != null) return cached;
|
|
3567
|
+
try {
|
|
3568
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}`);
|
|
3569
|
+
if (res.ok) {
|
|
3570
|
+
const body = await res.json();
|
|
3571
|
+
const title = body && typeof body.title === "string" ? body.title.trim() : "";
|
|
3572
|
+
if (title.length > 0) {
|
|
3573
|
+
this.sessionTitles.set(sessionId, title);
|
|
3574
|
+
return title;
|
|
3575
|
+
}
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
this.log({
|
|
3579
|
+
level: "debug",
|
|
3580
|
+
message: `Session title fetch for session ${sessionId.slice(0, 8)} (agent ${this.agentId.slice(0, 8)}) returned HTTP ${res.status} \u2014 omitting title`,
|
|
3581
|
+
conversation_id: conversationId
|
|
3582
|
+
});
|
|
3583
|
+
} catch (err) {
|
|
3584
|
+
this.log({
|
|
3585
|
+
level: "debug",
|
|
3586
|
+
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)}`,
|
|
3587
|
+
conversation_id: conversationId
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
return null;
|
|
3591
|
+
}
|
|
3322
3592
|
/**
|
|
3323
3593
|
* DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
|
|
3324
3594
|
* (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
|
|
@@ -3362,7 +3632,7 @@ var ChannelDriver = class {
|
|
|
3362
3632
|
const sessions = await listSessions(this.port);
|
|
3363
3633
|
if (!sessions) {
|
|
3364
3634
|
this.log({
|
|
3365
|
-
level: "
|
|
3635
|
+
level: "warn",
|
|
3366
3636
|
message: `Re-adopt: could not enumerate sessions to cross-check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating child liveness as indeterminate`
|
|
3367
3637
|
});
|
|
3368
3638
|
return null;
|
|
@@ -3446,9 +3716,7 @@ var ChannelDriver = class {
|
|
|
3446
3716
|
}
|
|
3447
3717
|
return inFlight.sort(byOldest)[0];
|
|
3448
3718
|
}
|
|
3449
|
-
// -------------------------------------------------------------------------
|
|
3450
3719
|
// Evident API calls (combinedAuth thread routes)
|
|
3451
|
-
// -------------------------------------------------------------------------
|
|
3452
3720
|
async getPendingConversations() {
|
|
3453
3721
|
const res = await this.fetchImpl(
|
|
3454
3722
|
`${this.apiUrl}/agents/${this.agentId}/conversations/pending`,
|
|
@@ -3528,7 +3796,7 @@ var ChannelDriver = class {
|
|
|
3528
3796
|
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
3529
3797
|
* retry vehicle for the swap-to-running.
|
|
3530
3798
|
*/
|
|
3531
|
-
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3799
|
+
async markProcessing(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3532
3800
|
const res = await this.fetchImpl(
|
|
3533
3801
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3534
3802
|
{
|
|
@@ -3537,7 +3805,8 @@ var ChannelDriver = class {
|
|
|
3537
3805
|
body: JSON.stringify({
|
|
3538
3806
|
status: "processing",
|
|
3539
3807
|
opencode_session_id: sessionId,
|
|
3540
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3808
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3809
|
+
...title ? { title } : {}
|
|
3541
3810
|
})
|
|
3542
3811
|
}
|
|
3543
3812
|
);
|
|
@@ -3576,7 +3845,7 @@ var ChannelDriver = class {
|
|
|
3576
3845
|
* watcher retries next tick within the
|
|
3577
3846
|
* deadline, Finding 4).
|
|
3578
3847
|
*/
|
|
3579
|
-
async markDone(conversationId, messageId, sessionId, opencodeMessageId) {
|
|
3848
|
+
async markDone(conversationId, messageId, sessionId, opencodeMessageId, title) {
|
|
3580
3849
|
const res = await this.fetchImpl(
|
|
3581
3850
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
3582
3851
|
{
|
|
@@ -3585,7 +3854,8 @@ var ChannelDriver = class {
|
|
|
3585
3854
|
body: JSON.stringify({
|
|
3586
3855
|
status: "done",
|
|
3587
3856
|
opencode_session_id: sessionId,
|
|
3588
|
-
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {}
|
|
3857
|
+
...opencodeMessageId ? { opencode_message_id: opencodeMessageId } : {},
|
|
3858
|
+
...title ? { title } : {}
|
|
3589
3859
|
})
|
|
3590
3860
|
}
|
|
3591
3861
|
);
|
|
@@ -3646,7 +3916,7 @@ var ChannelDriver = class {
|
|
|
3646
3916
|
);
|
|
3647
3917
|
if (!res.ok) {
|
|
3648
3918
|
this.log({
|
|
3649
|
-
level: "
|
|
3919
|
+
level: "warn",
|
|
3650
3920
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
3651
3921
|
conversation_id: conversationId,
|
|
3652
3922
|
message_id: messageId
|
|
@@ -3656,7 +3926,7 @@ var ChannelDriver = class {
|
|
|
3656
3926
|
return true;
|
|
3657
3927
|
} catch (err) {
|
|
3658
3928
|
this.log({
|
|
3659
|
-
level: "
|
|
3929
|
+
level: "warn",
|
|
3660
3930
|
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
3661
3931
|
conversation_id: conversationId,
|
|
3662
3932
|
message_id: messageId
|
|
@@ -3718,9 +3988,7 @@ var ChannelDriver = class {
|
|
|
3718
3988
|
return false;
|
|
3719
3989
|
}
|
|
3720
3990
|
}
|
|
3721
|
-
// -------------------------------------------------------------------------
|
|
3722
3991
|
// Retry wrapper
|
|
3723
|
-
// -------------------------------------------------------------------------
|
|
3724
3992
|
/**
|
|
3725
3993
|
* Invoke an Evident API call, retrying on transient failures (5xx / 429 /
|
|
3726
3994
|
* network errors) with exponential backoff + jitter (capped). Auth failures
|
|
@@ -4000,23 +4268,53 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
|
4000
4268
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
4001
4269
|
var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
|
|
4002
4270
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
|
|
4003
|
-
function
|
|
4271
|
+
function resolveLogLevel(options) {
|
|
4272
|
+
const accepted = Object.keys(LOG_LEVELS);
|
|
4273
|
+
const validate = (value, source) => {
|
|
4274
|
+
const normalized = value.trim().toLowerCase();
|
|
4275
|
+
if (!accepted.includes(normalized)) {
|
|
4276
|
+
throw new Error(
|
|
4277
|
+
`Invalid log level "${value}"${source}; expected one of ${accepted.join(", ")}`
|
|
4278
|
+
);
|
|
4279
|
+
}
|
|
4280
|
+
return normalized;
|
|
4281
|
+
};
|
|
4282
|
+
if (options.logLevel !== void 0) {
|
|
4283
|
+
return validate(options.logLevel, " (--log-level)");
|
|
4284
|
+
}
|
|
4285
|
+
if (options.verbose) {
|
|
4286
|
+
return "debug";
|
|
4287
|
+
}
|
|
4288
|
+
const env = process.env.EVIDENT_LOG_LEVEL;
|
|
4289
|
+
if (env !== void 0 && env !== "") {
|
|
4290
|
+
return validate(env, " (EVIDENT_LOG_LEVEL)");
|
|
4291
|
+
}
|
|
4292
|
+
return "info";
|
|
4293
|
+
}
|
|
4294
|
+
function meetsThreshold(state, level) {
|
|
4295
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[state.logLevel];
|
|
4296
|
+
}
|
|
4297
|
+
function log2(state, message, level = "info") {
|
|
4298
|
+
if (!meetsThreshold(state, level)) return;
|
|
4004
4299
|
if (state.json) {
|
|
4005
4300
|
console.log(
|
|
4006
4301
|
JSON.stringify({
|
|
4007
4302
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4008
|
-
level
|
|
4303
|
+
level,
|
|
4009
4304
|
message
|
|
4010
4305
|
})
|
|
4011
4306
|
);
|
|
4012
4307
|
} else if (!state.interactive) {
|
|
4013
|
-
const prefix =
|
|
4308
|
+
const prefix = level === "error" ? chalk6.red("\u2717") : level === "warn" ? chalk6.yellow("!") : level === "debug" ? chalk6.dim("\xB7") : chalk6.green("\u2022");
|
|
4014
4309
|
console.log(`${prefix} ${message}`);
|
|
4015
4310
|
}
|
|
4016
4311
|
}
|
|
4017
4312
|
function logActivity(state, entry) {
|
|
4313
|
+
const level = entry.level ?? (entry.type === "error" ? "error" : "info");
|
|
4314
|
+
if (!meetsThreshold(state, level)) return;
|
|
4018
4315
|
const fullEntry = {
|
|
4019
4316
|
...entry,
|
|
4317
|
+
level,
|
|
4020
4318
|
timestamp: /* @__PURE__ */ new Date()
|
|
4021
4319
|
};
|
|
4022
4320
|
state.activityLog.push(fullEntry);
|
|
@@ -4025,9 +4323,9 @@ function logActivity(state, entry) {
|
|
|
4025
4323
|
}
|
|
4026
4324
|
if (!state.interactive) {
|
|
4027
4325
|
if (entry.type === "error") {
|
|
4028
|
-
log2(state, entry.error ?? "Unknown error",
|
|
4029
|
-
} else if (entry.
|
|
4030
|
-
log2(state, entry.message);
|
|
4326
|
+
log2(state, entry.error ?? "Unknown error", level);
|
|
4327
|
+
} else if (entry.message) {
|
|
4328
|
+
log2(state, entry.message, level);
|
|
4031
4329
|
}
|
|
4032
4330
|
}
|
|
4033
4331
|
}
|
|
@@ -4226,7 +4524,7 @@ function scheduleSessionCleanup(state, driver, options) {
|
|
|
4226
4524
|
process.env
|
|
4227
4525
|
);
|
|
4228
4526
|
for (const warning2 of config2.warnings) {
|
|
4229
|
-
logActivity(state, { type: "info", message: `Session cleanup: ${warning2}` });
|
|
4527
|
+
logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
|
|
4230
4528
|
}
|
|
4231
4529
|
if (!config2.enabled) return;
|
|
4232
4530
|
logActivity(state, {
|
|
@@ -4298,6 +4596,20 @@ async function cleanup(state, opts = {}) {
|
|
|
4298
4596
|
}
|
|
4299
4597
|
async function run(options) {
|
|
4300
4598
|
const interactive = isInteractive(options.json);
|
|
4599
|
+
let logLevel;
|
|
4600
|
+
try {
|
|
4601
|
+
logLevel = resolveLogLevel(options);
|
|
4602
|
+
} catch (error2) {
|
|
4603
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
4604
|
+
if (options.json) {
|
|
4605
|
+
console.log(JSON.stringify({ status: "error", error: message }));
|
|
4606
|
+
} else {
|
|
4607
|
+
printError(message);
|
|
4608
|
+
}
|
|
4609
|
+
await shutdownTelemetry();
|
|
4610
|
+
process.exit(1);
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
4301
4613
|
const state = {
|
|
4302
4614
|
agentId: options.agent || "",
|
|
4303
4615
|
agentName: null,
|
|
@@ -4306,6 +4618,7 @@ async function run(options) {
|
|
|
4306
4618
|
idleTimeout: options.idleTimeout ?? null,
|
|
4307
4619
|
json: options.json ?? false,
|
|
4308
4620
|
interactive,
|
|
4621
|
+
logLevel,
|
|
4309
4622
|
connected: false,
|
|
4310
4623
|
opencodeConnected: false,
|
|
4311
4624
|
opencodeVersion: null,
|
|
@@ -4323,8 +4636,8 @@ async function run(options) {
|
|
|
4323
4636
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
4324
4637
|
log2(
|
|
4325
4638
|
state,
|
|
4326
|
-
"
|
|
4327
|
-
|
|
4639
|
+
"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.",
|
|
4640
|
+
"warn"
|
|
4328
4641
|
);
|
|
4329
4642
|
}
|
|
4330
4643
|
const handleSignal = async () => {
|
|
@@ -4440,9 +4753,9 @@ async function run(options) {
|
|
|
4440
4753
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
4441
4754
|
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
4442
4755
|
if (versionWarning) {
|
|
4443
|
-
log2(state, versionWarning,
|
|
4756
|
+
log2(state, versionWarning, "warn");
|
|
4444
4757
|
if (state.interactive && !state.json) {
|
|
4445
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
4758
|
+
logActivity(state, { type: "info", level: "warn", message: versionWarning });
|
|
4446
4759
|
}
|
|
4447
4760
|
}
|
|
4448
4761
|
} catch (error2) {
|
|
@@ -4457,11 +4770,17 @@ async function run(options) {
|
|
|
4457
4770
|
getAuthHeader: () => state.authHeader,
|
|
4458
4771
|
conversationFilter: state.conversationFilter,
|
|
4459
4772
|
stuckQueuedMs: CHANNEL_STUCK_QUEUED_MS,
|
|
4460
|
-
log: (entry) =>
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4773
|
+
log: (entry) => (
|
|
4774
|
+
// Thread the driver's real level straight through so `debug`/`warn`
|
|
4775
|
+
// survive the sink filter (they no longer collapse to info). `type`
|
|
4776
|
+
// stays the coarse error/non-error split the activity log renders with.
|
|
4777
|
+
logActivity(state, {
|
|
4778
|
+
type: entry.level === "error" ? "error" : "info",
|
|
4779
|
+
level: entry.level,
|
|
4780
|
+
message: entry.message,
|
|
4781
|
+
error: entry.level === "error" ? entry.message : void 0
|
|
4782
|
+
})
|
|
4783
|
+
)
|
|
4465
4784
|
});
|
|
4466
4785
|
state.channelDriver = channelDriver;
|
|
4467
4786
|
const connection = new RunnerConnection({
|
|
@@ -4615,7 +4934,10 @@ program.name("evident").description("Run OpenCode locally and connect it to Evid
|
|
|
4615
4934
|
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
4935
|
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
4936
|
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]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
4937
|
+
program.command("run").description("Connect to Evident and process messages").option("-a, --agent [id]", "Agent ID to connect to (optional when EVIDENT_AGENT_KEY is set)").option("-p, --port <port>", "OpenCode port (default: 4096)", "4096").option(
|
|
4938
|
+
"--log-level <level>",
|
|
4939
|
+
"Log verbosity: debug | info | warn | error (default: info). Env: EVIDENT_LOG_LEVEL"
|
|
4940
|
+
).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
4941
|
"--session-cleanup-max-age <duration>",
|
|
4620
4942
|
"Delete OpenCode sessions idle longer than this (e.g. 7d, 24h). Enables cleanup. Env: EVIDENT_SESSION_CLEANUP_MAX_AGE"
|
|
4621
4943
|
).option(
|
|
@@ -4629,6 +4951,9 @@ program.command("run").description("Connect to Evident and process messages").op
|
|
|
4629
4951
|
run({
|
|
4630
4952
|
agent: options.agent,
|
|
4631
4953
|
port: parseInt(options.port, 10),
|
|
4954
|
+
// Raw string — validation/precedence is single-sourced in run.ts's
|
|
4955
|
+
// resolveLogLevel (flag > -v > EVIDENT_LOG_LEVEL > info).
|
|
4956
|
+
logLevel: options.logLevel,
|
|
4632
4957
|
verbose: options.verbose,
|
|
4633
4958
|
conversation: options.conversation,
|
|
4634
4959
|
idleTimeout: options.idleTimeout ? parseInt(options.idleTimeout, 10) : void 0,
|