@evident-ai/cli 3.0.1-dev.101ed1a → 3.0.1-dev.223a670
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/dist/index.js +651 -260
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/commands/login.ts
|
|
@@ -470,6 +471,12 @@ import chalk6 from "chalk";
|
|
|
470
471
|
import ora3 from "ora";
|
|
471
472
|
import { select as select3 } from "@inquirer/prompts";
|
|
472
473
|
|
|
474
|
+
// ../../packages/types/src/opencode/index.ts
|
|
475
|
+
function opencodeMessageIdFor(queuedMessageId) {
|
|
476
|
+
const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
|
|
477
|
+
return `msg_${sanitized}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
473
480
|
// ../../packages/types/src/telemetry/index.ts
|
|
474
481
|
var TelemetryEventTypes = {
|
|
475
482
|
// Agent activity events (shown in web UI activity log)
|
|
@@ -484,6 +491,29 @@ var TelemetryEventTypes = {
|
|
|
484
491
|
var MAX_FRAME_BYTES = 256 * 1024;
|
|
485
492
|
var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
|
|
486
493
|
|
|
494
|
+
// ../../packages/types/src/logging/index.ts
|
|
495
|
+
var CORRELATION_ID_HEADER = "x-evident-correlation-id";
|
|
496
|
+
function log(level, event, fields) {
|
|
497
|
+
const method = level === "debug" ? "log" : level;
|
|
498
|
+
try {
|
|
499
|
+
console[method]("[evident]", JSON.stringify({ level, event, ...fields }));
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error(
|
|
502
|
+
"[evident] log_serialize_failed",
|
|
503
|
+
event,
|
|
504
|
+
err instanceof Error ? err.message : String(err)
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function stripQuery(url) {
|
|
509
|
+
try {
|
|
510
|
+
return new URL(url).pathname;
|
|
511
|
+
} catch {
|
|
512
|
+
const q = url.indexOf("?");
|
|
513
|
+
return q === -1 ? url : url.slice(0, q);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
487
517
|
// src/lib/telemetry.ts
|
|
488
518
|
var CLI_VERSION = process.env.npm_package_version || "unknown";
|
|
489
519
|
var eventBuffer = [];
|
|
@@ -684,6 +714,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
684
714
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
685
715
|
}
|
|
686
716
|
|
|
717
|
+
// src/lib/opencode/opencode-version-gate.ts
|
|
718
|
+
var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
|
|
719
|
+
function isQueueValidatedVersion(version2) {
|
|
720
|
+
if (!version2) return false;
|
|
721
|
+
return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version2);
|
|
722
|
+
}
|
|
723
|
+
function buildOpenCodeVersionWarning(version2) {
|
|
724
|
+
if (isQueueValidatedVersion(version2)) return null;
|
|
725
|
+
const detected = version2 ? `v${version2}` : "unknown";
|
|
726
|
+
const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
|
|
727
|
+
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack/WhatsApp) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
|
|
728
|
+
}
|
|
729
|
+
|
|
687
730
|
// src/lib/opencode/process.ts
|
|
688
731
|
import { execSync, spawn } from "child_process";
|
|
689
732
|
var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
|
|
@@ -998,21 +1041,23 @@ function completedOf(m) {
|
|
|
998
1041
|
if (!m || typeof m !== "object") return void 0;
|
|
999
1042
|
return m.info?.time?.completed;
|
|
1000
1043
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
return Array.isArray(body) ? body : null;
|
|
1007
|
-
} catch {
|
|
1008
|
-
return null;
|
|
1009
|
-
}
|
|
1044
|
+
function idOf(m) {
|
|
1045
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1046
|
+
if (typeof m.id === "string") return m.id;
|
|
1047
|
+
const infoId = m.info?.id;
|
|
1048
|
+
return typeof infoId === "string" ? infoId : void 0;
|
|
1010
1049
|
}
|
|
1011
|
-
function
|
|
1012
|
-
if (!
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
return
|
|
1050
|
+
function parentIdOf(m) {
|
|
1051
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1052
|
+
if (typeof m.parentID === "string") return m.parentID;
|
|
1053
|
+
const infoParent = m.info?.parentID;
|
|
1054
|
+
return typeof infoParent === "string" ? infoParent : void 0;
|
|
1055
|
+
}
|
|
1056
|
+
function finishOf(m) {
|
|
1057
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1058
|
+
if (typeof m.finish === "string") return m.finish;
|
|
1059
|
+
const infoFinish = m.info?.finish;
|
|
1060
|
+
return typeof infoFinish === "string" ? infoFinish : void 0;
|
|
1016
1061
|
}
|
|
1017
1062
|
async function createOpenCodeSession(port, directory) {
|
|
1018
1063
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
@@ -1031,8 +1076,9 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1031
1076
|
const data = await response.json();
|
|
1032
1077
|
return data.id;
|
|
1033
1078
|
}
|
|
1034
|
-
async function
|
|
1079
|
+
async function sendPromptAsync(port, sessionId, content, options, messageId) {
|
|
1035
1080
|
const body = {
|
|
1081
|
+
messageID: messageId,
|
|
1036
1082
|
parts: [{ type: "text", text: content }]
|
|
1037
1083
|
};
|
|
1038
1084
|
if (options?.agent) {
|
|
@@ -1047,79 +1093,59 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1047
1093
|
};
|
|
1048
1094
|
}
|
|
1049
1095
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
const sessionRes = await fetch(`${opencodeBase(port)}/session/${sessionId}`).catch(
|
|
1104
|
-
() => null
|
|
1105
|
-
);
|
|
1106
|
-
const session = sessionRes?.ok ? await sessionRes.json() : null;
|
|
1107
|
-
const reportedInteraction = reportedQuestions.size > 0 || reportedPermissions.size > 0;
|
|
1108
|
-
const turnComplete = isTurnComplete(await getSessionMessages(port, sessionId));
|
|
1109
|
-
const awaitingInteraction = reportedInteraction && !turnComplete;
|
|
1110
|
-
return { title: session?.title, awaitingInteraction };
|
|
1111
|
-
} catch (err) {
|
|
1112
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
1113
|
-
throw new Error("Message processing timed out");
|
|
1114
|
-
}
|
|
1115
|
-
throw err;
|
|
1116
|
-
} finally {
|
|
1117
|
-
clearTimeout(timer);
|
|
1118
|
-
pollDone = true;
|
|
1119
|
-
}
|
|
1120
|
-
};
|
|
1121
|
-
const [result] = await Promise.all([sendMessage(), pollInteractive()]);
|
|
1122
|
-
return result;
|
|
1096
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
|
|
1097
|
+
method: "POST",
|
|
1098
|
+
headers: { "Content-Type": "application/json" },
|
|
1099
|
+
body: JSON.stringify(body)
|
|
1100
|
+
});
|
|
1101
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1102
|
+
const text = await res.text().catch(() => "");
|
|
1103
|
+
throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function findAssistantReplyAfter(messages, userMessageId) {
|
|
1107
|
+
if (!messages || messages.length === 0) return null;
|
|
1108
|
+
const byParent = messages.find(
|
|
1109
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1110
|
+
);
|
|
1111
|
+
if (byParent) return byParent;
|
|
1112
|
+
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1113
|
+
if (userIndex === -1) return null;
|
|
1114
|
+
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1115
|
+
if (roleOf(messages[i]) === "assistant") return messages[i];
|
|
1116
|
+
}
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
function findLastAssistantReplyFor(messages, userMessageId) {
|
|
1120
|
+
if (!messages || messages.length === 0) return null;
|
|
1121
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1122
|
+
const m = messages[i];
|
|
1123
|
+
if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
|
|
1124
|
+
}
|
|
1125
|
+
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1126
|
+
if (userIndex === -1) return null;
|
|
1127
|
+
let last = null;
|
|
1128
|
+
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1129
|
+
const role = roleOf(messages[i]);
|
|
1130
|
+
if (role === "user") break;
|
|
1131
|
+
if (role === "assistant") last = messages[i];
|
|
1132
|
+
}
|
|
1133
|
+
return last;
|
|
1134
|
+
}
|
|
1135
|
+
function messageRunState(messages, userMessageId) {
|
|
1136
|
+
if (!messages || messages.length === 0) return "unknown";
|
|
1137
|
+
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
1138
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1139
|
+
if (!hasUser) {
|
|
1140
|
+
if (!reply) return "unknown";
|
|
1141
|
+
}
|
|
1142
|
+
if (!reply) return "queued";
|
|
1143
|
+
if (completedOf(reply) == null) return "running";
|
|
1144
|
+
if (finishOf(reply) === "tool-calls") return "running";
|
|
1145
|
+
return "done";
|
|
1146
|
+
}
|
|
1147
|
+
function opencodeMessageIdFor2(queuedMessageId) {
|
|
1148
|
+
return opencodeMessageIdFor(queuedMessageId);
|
|
1123
1149
|
}
|
|
1124
1150
|
|
|
1125
1151
|
// src/lib/tunnel/connection.ts
|
|
@@ -1190,12 +1216,20 @@ var StreamForwarder = class {
|
|
|
1190
1216
|
}
|
|
1191
1217
|
async handleOpen(frame) {
|
|
1192
1218
|
const { sid, method, path, headers, has_body } = frame;
|
|
1219
|
+
const correlationId = headers?.[CORRELATION_ID_HEADER];
|
|
1220
|
+
const startedAt = Date.now();
|
|
1193
1221
|
if (path === TUNNEL_DRAIN_PING_PATH) {
|
|
1194
1222
|
this.callbacks.onDrainPing?.();
|
|
1195
1223
|
this.send({ type: "head", sid, status: 204, headers: {} });
|
|
1196
1224
|
this.send({ type: "res_end", sid });
|
|
1197
1225
|
return;
|
|
1198
1226
|
}
|
|
1227
|
+
log("info", "agent_request", {
|
|
1228
|
+
correlation_id: correlationId,
|
|
1229
|
+
sid,
|
|
1230
|
+
method,
|
|
1231
|
+
path: stripQuery(path)
|
|
1232
|
+
});
|
|
1199
1233
|
const ac = new AbortController();
|
|
1200
1234
|
let bodyPromise;
|
|
1201
1235
|
let pushBody;
|
|
@@ -1242,6 +1276,12 @@ var StreamForwarder = class {
|
|
|
1242
1276
|
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1243
1277
|
});
|
|
1244
1278
|
this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
|
|
1279
|
+
log("info", "agent_response", {
|
|
1280
|
+
correlation_id: correlationId,
|
|
1281
|
+
sid,
|
|
1282
|
+
status: upstream.status,
|
|
1283
|
+
duration_ms: Date.now() - startedAt
|
|
1284
|
+
});
|
|
1245
1285
|
this.callbacks.onHead?.(sid, upstream.status);
|
|
1246
1286
|
try {
|
|
1247
1287
|
if (upstream.body) {
|
|
@@ -1498,6 +1538,12 @@ var RunnerConnection = class {
|
|
|
1498
1538
|
};
|
|
1499
1539
|
|
|
1500
1540
|
// src/lib/channels/driver.ts
|
|
1541
|
+
function messageIdOf(m) {
|
|
1542
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1543
|
+
if (typeof m.id === "string") return m.id;
|
|
1544
|
+
const infoId = m.info?.id;
|
|
1545
|
+
return typeof infoId === "string" ? infoId : void 0;
|
|
1546
|
+
}
|
|
1501
1547
|
var DEFAULT_RETRY_POLICY = {
|
|
1502
1548
|
maxAttempts: 6,
|
|
1503
1549
|
baseDelayMs: 500,
|
|
@@ -1505,12 +1551,21 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1505
1551
|
};
|
|
1506
1552
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1507
1553
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1554
|
+
var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
|
|
1508
1555
|
var ChannelAuthError = class extends Error {
|
|
1509
1556
|
constructor(message) {
|
|
1510
1557
|
super(message);
|
|
1511
1558
|
this.name = "ChannelAuthError";
|
|
1512
1559
|
}
|
|
1513
1560
|
};
|
|
1561
|
+
var ChannelTerminalError = class extends Error {
|
|
1562
|
+
status;
|
|
1563
|
+
constructor(message, status) {
|
|
1564
|
+
super(message);
|
|
1565
|
+
this.name = "ChannelTerminalError";
|
|
1566
|
+
this.status = status;
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1514
1569
|
function backoffDelay(attempt, policy) {
|
|
1515
1570
|
const exp = policy.baseDelayMs * Math.pow(2, attempt);
|
|
1516
1571
|
const capped = Math.min(policy.maxDelayMs, exp);
|
|
@@ -1531,15 +1586,26 @@ var ChannelDriver = class {
|
|
|
1531
1586
|
sleep;
|
|
1532
1587
|
pausedPollIntervalMs;
|
|
1533
1588
|
pausedMaxWaitMs;
|
|
1589
|
+
dispatchConfirmMs;
|
|
1590
|
+
now;
|
|
1534
1591
|
/** Cache of conversationId → opencode sessionId. */
|
|
1535
1592
|
sessions = /* @__PURE__ */ new Map();
|
|
1536
1593
|
/**
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
*
|
|
1540
|
-
*
|
|
1594
|
+
* Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
|
|
1595
|
+
* session: one polling loop services all of that session's in-flight messages.
|
|
1596
|
+
* A session entry exists while it has any in-flight (dispatched-but-not-done)
|
|
1597
|
+
* message; it is removed once its in-flight set empties.
|
|
1541
1598
|
*/
|
|
1542
1599
|
watchers = /* @__PURE__ */ new Map();
|
|
1600
|
+
/**
|
|
1601
|
+
* AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
|
|
1602
|
+
* dispatched and are still in-flight. A message in this set is never
|
|
1603
|
+
* re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
|
|
1604
|
+
* Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
|
|
1605
|
+
* idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
|
|
1606
|
+
* a steady-state-poll re-dispatch will not double-run the message.
|
|
1607
|
+
*/
|
|
1608
|
+
dispatched = /* @__PURE__ */ new Set();
|
|
1543
1609
|
/**
|
|
1544
1610
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1545
1611
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1562,6 +1628,8 @@ var ChannelDriver = class {
|
|
|
1562
1628
|
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1563
1629
|
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1564
1630
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1631
|
+
this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
|
|
1632
|
+
this.now = config2.now ?? (() => Date.now());
|
|
1565
1633
|
}
|
|
1566
1634
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1567
1635
|
get opencodeBase() {
|
|
@@ -1571,16 +1639,16 @@ var ChannelDriver = class {
|
|
|
1571
1639
|
// Public API
|
|
1572
1640
|
// -------------------------------------------------------------------------
|
|
1573
1641
|
/**
|
|
1574
|
-
* Drain all pending channel conversations once: poll →
|
|
1642
|
+
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1575
1643
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
1576
1644
|
* Re-entrant calls while a drain is in flight are skipped (return 0).
|
|
1577
1645
|
*
|
|
1578
|
-
* @returns the number of messages
|
|
1646
|
+
* @returns the number of messages NEWLY dispatched to opencode's native queue.
|
|
1579
1647
|
*/
|
|
1580
1648
|
async drainPending() {
|
|
1581
1649
|
if (this.draining) return 0;
|
|
1582
1650
|
this.draining = true;
|
|
1583
|
-
let
|
|
1651
|
+
let dispatched = 0;
|
|
1584
1652
|
try {
|
|
1585
1653
|
const conversations = await this.getPendingConversations();
|
|
1586
1654
|
if (conversations.length > 0) {
|
|
@@ -1591,95 +1659,104 @@ var ChannelDriver = class {
|
|
|
1591
1659
|
});
|
|
1592
1660
|
}
|
|
1593
1661
|
for (const conv of conversations) {
|
|
1594
|
-
|
|
1662
|
+
dispatched += await this.processConversation(conv);
|
|
1595
1663
|
}
|
|
1596
1664
|
} finally {
|
|
1597
1665
|
this.draining = false;
|
|
1598
1666
|
}
|
|
1599
|
-
return
|
|
1667
|
+
return dispatched;
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* True while any per-session watcher has a non-empty in-flight dispatched set
|
|
1671
|
+
* (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
|
|
1672
|
+
* the process while a dispatched message is still queued/running — which would
|
|
1673
|
+
* kill the turn and orphan its reply.
|
|
1674
|
+
*/
|
|
1675
|
+
hasInFlightWatchers() {
|
|
1676
|
+
for (const watcher of this.watchers.values()) {
|
|
1677
|
+
if (watcher.inFlight.size > 0) return true;
|
|
1678
|
+
}
|
|
1679
|
+
return false;
|
|
1600
1680
|
}
|
|
1601
1681
|
/**
|
|
1602
|
-
* Await all outstanding
|
|
1682
|
+
* Await all outstanding per-session watchers (WI-3).
|
|
1603
1683
|
*
|
|
1604
|
-
* In production the
|
|
1605
|
-
* loop never blocks on them and process exit is not held up (the cron
|
|
1606
|
-
* any abandoned ones). This helper exists primarily for deterministic
|
|
1607
|
-
* that need to observe
|
|
1608
|
-
* after a non-blocking `drainPending`.
|
|
1684
|
+
* In production the watcher loops are deliberately started-not-awaited so the
|
|
1685
|
+
* drain loop never blocks on them and process exit is not held up (the cron
|
|
1686
|
+
* recovers any abandoned ones). This helper exists primarily for deterministic
|
|
1687
|
+
* tests that need to observe a watcher's effect (the `processing`/`done` PATCH
|
|
1688
|
+
* or its giving up) after a non-blocking `drainPending`. Watcher loops never
|
|
1689
|
+
* reject, so this resolves.
|
|
1609
1690
|
*/
|
|
1610
1691
|
async flushPausedWatchers() {
|
|
1611
|
-
|
|
1692
|
+
while (true) {
|
|
1693
|
+
const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
|
|
1694
|
+
if (loops.length === 0) return;
|
|
1695
|
+
await Promise.all(loops);
|
|
1696
|
+
const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
|
|
1697
|
+
if (!stillLive) return;
|
|
1698
|
+
}
|
|
1612
1699
|
}
|
|
1613
1700
|
// -------------------------------------------------------------------------
|
|
1614
|
-
// Conversation processing
|
|
1701
|
+
// Conversation processing (WI-3 — async dispatch)
|
|
1615
1702
|
// -------------------------------------------------------------------------
|
|
1703
|
+
/**
|
|
1704
|
+
* Dispatch each pending message for a conversation to opencode's native queue
|
|
1705
|
+
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
1706
|
+
* per-session watcher. Does NOT block on the turn and does NOT call
|
|
1707
|
+
* `markProcessing` here — that fires from the watcher on running-start.
|
|
1708
|
+
*
|
|
1709
|
+
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
1710
|
+
*/
|
|
1616
1711
|
async processConversation(conv) {
|
|
1617
1712
|
const sessionId = await this.ensureSession(conv);
|
|
1618
1713
|
const messages = await this.getPendingMessages(conv.id);
|
|
1619
|
-
let
|
|
1714
|
+
let dispatched = 0;
|
|
1715
|
+
let skippedAlreadyDispatched = 0;
|
|
1620
1716
|
for (const message of messages) {
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
this.log({
|
|
1624
|
-
level: "info",
|
|
1625
|
-
message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
|
|
1626
|
-
conversation_id: conv.id,
|
|
1627
|
-
message_id: message.id
|
|
1628
|
-
});
|
|
1717
|
+
if (this.dispatched.has(message.id)) {
|
|
1718
|
+
skippedAlreadyDispatched += 1;
|
|
1629
1719
|
continue;
|
|
1630
1720
|
}
|
|
1721
|
+
const opencodeMessageId = opencodeMessageIdFor2(message.id);
|
|
1722
|
+
const options = {
|
|
1723
|
+
agent: message.opencode_agent ?? void 0,
|
|
1724
|
+
model: message.opencode_model ?? void 0
|
|
1725
|
+
};
|
|
1631
1726
|
try {
|
|
1632
1727
|
this.log({
|
|
1633
1728
|
level: "info",
|
|
1634
|
-
message: `
|
|
1635
|
-
conversation_id: conv.id,
|
|
1636
|
-
message_id: message.id
|
|
1637
|
-
});
|
|
1638
|
-
const result = await sendMessageToOpenCode(
|
|
1639
|
-
this.port,
|
|
1640
|
-
sessionId,
|
|
1641
|
-
message.content,
|
|
1642
|
-
{
|
|
1643
|
-
agent: message.opencode_agent ?? void 0,
|
|
1644
|
-
model: message.opencode_model ?? void 0
|
|
1645
|
-
},
|
|
1646
|
-
{
|
|
1647
|
-
onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
|
|
1648
|
-
onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
|
|
1649
|
-
}
|
|
1650
|
-
);
|
|
1651
|
-
if (result.awaitingInteraction) {
|
|
1652
|
-
this.log({
|
|
1653
|
-
level: "info",
|
|
1654
|
-
message: `Message ${message.id.slice(0, 8)} paused awaiting interaction \u2014 watching session for completion`,
|
|
1655
|
-
conversation_id: conv.id,
|
|
1656
|
-
message_id: message.id
|
|
1657
|
-
});
|
|
1658
|
-
this.startPausedWatcher(conv, message, sessionId);
|
|
1659
|
-
continue;
|
|
1660
|
-
}
|
|
1661
|
-
await this.confirmCompletion(sessionId);
|
|
1662
|
-
await this.markDone(conv.id, message.id, sessionId);
|
|
1663
|
-
processed += 1;
|
|
1664
|
-
this.log({
|
|
1665
|
-
level: "info",
|
|
1666
|
-
message: `Message ${message.id.slice(0, 8)} processed`,
|
|
1729
|
+
message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
|
|
1667
1730
|
conversation_id: conv.id,
|
|
1668
1731
|
message_id: message.id
|
|
1669
1732
|
});
|
|
1733
|
+
await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
|
|
1670
1734
|
} catch (err) {
|
|
1671
1735
|
if (err instanceof ChannelAuthError) throw err;
|
|
1736
|
+
this.dispatched.delete(message.id);
|
|
1672
1737
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1673
1738
|
});
|
|
1674
1739
|
this.log({
|
|
1675
1740
|
level: "error",
|
|
1676
|
-
message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1741
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1677
1742
|
conversation_id: conv.id,
|
|
1678
1743
|
message_id: message.id
|
|
1679
1744
|
});
|
|
1745
|
+
continue;
|
|
1680
1746
|
}
|
|
1747
|
+
this.dispatched.add(message.id);
|
|
1748
|
+
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
1749
|
+
dispatched += 1;
|
|
1750
|
+
}
|
|
1751
|
+
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
1752
|
+
this.log({
|
|
1753
|
+
level: "error",
|
|
1754
|
+
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).`,
|
|
1755
|
+
conversation_id: conv.id
|
|
1756
|
+
});
|
|
1681
1757
|
}
|
|
1682
|
-
|
|
1758
|
+
this.ensureWatcherRunning(sessionId);
|
|
1759
|
+
return dispatched;
|
|
1683
1760
|
}
|
|
1684
1761
|
async ensureSession(conv) {
|
|
1685
1762
|
const cached = this.sessions.get(conv.id);
|
|
@@ -1711,111 +1788,349 @@ var ChannelDriver = class {
|
|
|
1711
1788
|
}
|
|
1712
1789
|
return this.opencodeDirectory;
|
|
1713
1790
|
}
|
|
1714
|
-
/**
|
|
1715
|
-
* Local reconcile: re-query `GET /session/:id/message` and check whether the
|
|
1716
|
-
* last assistant message is message-level complete (`isTurnComplete`).
|
|
1717
|
-
*
|
|
1718
|
-
* This is a PURE OBSERVABILITY probe on the normal (non-paused) path: the
|
|
1719
|
-
* blocking POST already returned, and `markDone` causes the server to re-fetch
|
|
1720
|
-
* the messages itself (via `extractTextFromMessages`) when delivering the
|
|
1721
|
-
* reply — so this round-trip never gates delivery. We keep it only to surface a
|
|
1722
|
-
* truthful diagnostic when opencode hasn't yet recorded a completed assistant
|
|
1723
|
-
* turn at reconcile time, then proceed to `markDone` regardless. Uses the
|
|
1724
|
-
* injected `fetchImpl` and reuses only the pure `isTurnComplete` predicate.
|
|
1725
|
-
*/
|
|
1726
|
-
async confirmCompletion(sessionId) {
|
|
1727
|
-
try {
|
|
1728
|
-
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1729
|
-
if (!res.ok) return;
|
|
1730
|
-
const body = await res.json();
|
|
1731
|
-
const messages = Array.isArray(body) ? body : null;
|
|
1732
|
-
if (!isTurnComplete(messages)) {
|
|
1733
|
-
this.log({
|
|
1734
|
-
level: "info",
|
|
1735
|
-
message: `Session ${sessionId.slice(0, 8)} messages do not yet show a completed assistant turn on reconcile \u2014 delivering anyway`
|
|
1736
|
-
});
|
|
1737
|
-
}
|
|
1738
|
-
} catch {
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
1791
|
// -------------------------------------------------------------------------
|
|
1742
|
-
//
|
|
1792
|
+
// Per-session watcher (WI-3)
|
|
1743
1793
|
// -------------------------------------------------------------------------
|
|
1794
|
+
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
1795
|
+
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
1796
|
+
let watcher = this.watchers.get(sessionId);
|
|
1797
|
+
if (!watcher) {
|
|
1798
|
+
watcher = {
|
|
1799
|
+
conv,
|
|
1800
|
+
inFlight: /* @__PURE__ */ new Map(),
|
|
1801
|
+
loop: null,
|
|
1802
|
+
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1803
|
+
reportedPermissions: /* @__PURE__ */ new Set()
|
|
1804
|
+
};
|
|
1805
|
+
this.watchers.set(sessionId, watcher);
|
|
1806
|
+
}
|
|
1807
|
+
const now = this.now();
|
|
1808
|
+
watcher.inFlight.set(message.id, {
|
|
1809
|
+
evidentMessageId: message.id,
|
|
1810
|
+
opencodeMessageId,
|
|
1811
|
+
message,
|
|
1812
|
+
dispatchedAt: now,
|
|
1813
|
+
deadline: now + this.pausedMaxWaitMs,
|
|
1814
|
+
started: false,
|
|
1815
|
+
done: false
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1744
1818
|
/**
|
|
1745
|
-
* Start (but do NOT await)
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
* poll/markDone can never crash the run loop — the cron stays as the safety net.
|
|
1819
|
+
* Start (but do NOT await) the per-session watcher loop if it has in-flight
|
|
1820
|
+
* work and is not already running. Single-flight per session. The loop is
|
|
1821
|
+
* tracked on the watcher and cleared when it settles; it never rejects (fully
|
|
1822
|
+
* guarded), so a failed poll/callback can never crash the run loop — the cron
|
|
1823
|
+
* stays as the safety net.
|
|
1751
1824
|
*/
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1825
|
+
ensureWatcherRunning(sessionId) {
|
|
1826
|
+
const watcher = this.watchers.get(sessionId);
|
|
1827
|
+
if (!watcher) return;
|
|
1828
|
+
if (watcher.loop) return;
|
|
1829
|
+
if (watcher.inFlight.size === 0) {
|
|
1830
|
+
this.watchers.delete(sessionId);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
|
|
1834
|
+
watcher.loop = null;
|
|
1835
|
+
if (watcher.inFlight.size === 0) {
|
|
1836
|
+
this.watchers.delete(sessionId);
|
|
1837
|
+
}
|
|
1756
1838
|
});
|
|
1757
|
-
|
|
1839
|
+
watcher.loop = loop;
|
|
1758
1840
|
}
|
|
1759
1841
|
/**
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
* `
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
1765
|
-
* re-
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
* `fetch`) here.
|
|
1772
|
-
*
|
|
1773
|
-
* Bounded by `pausedMaxWaitMs` (default 10 min, strictly < the 15-min cron
|
|
1774
|
-
* reset): on timeout we STOP and leave the message `processing` so the cron
|
|
1775
|
-
* remains the last-resort safety net. The whole body is wrapped so any
|
|
1776
|
-
* poll/markDone failure is logged and swallowed — a watcher MUST NEVER throw
|
|
1777
|
-
* out of the run loop.
|
|
1842
|
+
* The per-session polling loop (WI-3). Once per tick it:
|
|
1843
|
+
* 1. polls `GET /session/:id/message` once and, per in-flight message,
|
|
1844
|
+
* computes `messageRunState` and fires markProcessing (queued→running) /
|
|
1845
|
+
* markDone (done) exactly once per transition;
|
|
1846
|
+
* 2. applies the idle-path re-dispatch guard (a dispatched message that never
|
|
1847
|
+
* APPEARS → re-dispatch — D1 obligation 2);
|
|
1848
|
+
* 3. polls `/question` + `/permission` (scoped to the session) and surfaces
|
|
1849
|
+
* NEW ones via `reportInteraction`, carrying the PAUSED message's own
|
|
1850
|
+
* `source_message_id`;
|
|
1851
|
+
* 4. drops messages that completed or timed out from the in-flight set.
|
|
1852
|
+
* Exits when the in-flight set empties. Never throws.
|
|
1778
1853
|
*/
|
|
1779
|
-
async
|
|
1780
|
-
const deadline = Date.now() + this.pausedMaxWaitMs;
|
|
1854
|
+
async runWatcherLoop(sessionId, watcher) {
|
|
1781
1855
|
try {
|
|
1782
|
-
while (
|
|
1856
|
+
while (watcher.inFlight.size > 0) {
|
|
1783
1857
|
await this.sleep(this.pausedPollIntervalMs);
|
|
1784
|
-
let
|
|
1858
|
+
let messages = null;
|
|
1785
1859
|
try {
|
|
1786
1860
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1787
1861
|
if (res.ok) {
|
|
1788
1862
|
const body = await res.json();
|
|
1789
|
-
|
|
1790
|
-
completed = isTurnComplete(messages);
|
|
1863
|
+
messages = Array.isArray(body) ? body : null;
|
|
1791
1864
|
}
|
|
1792
1865
|
} catch {
|
|
1793
1866
|
continue;
|
|
1794
1867
|
}
|
|
1795
|
-
|
|
1868
|
+
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
1869
|
+
await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
|
|
1870
|
+
}
|
|
1871
|
+
await this.pollInteractions(sessionId, watcher, messages);
|
|
1872
|
+
}
|
|
1873
|
+
} catch (err) {
|
|
1874
|
+
if (err instanceof ChannelAuthError) {
|
|
1796
1875
|
this.log({
|
|
1797
|
-
level: "
|
|
1798
|
-
message: `
|
|
1876
|
+
level: "error",
|
|
1877
|
+
message: `Session watcher aborted on auth failure for session ${sessionId.slice(0, 8)} \u2014 clearing in-flight state for re-drive after re-auth: ${err.message}`,
|
|
1878
|
+
conversation_id: watcher.conv.id
|
|
1879
|
+
});
|
|
1880
|
+
for (const evidentMessageId of [...watcher.inFlight.keys()]) {
|
|
1881
|
+
this.removeInFlight(watcher, evidentMessageId);
|
|
1882
|
+
}
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
this.log({
|
|
1886
|
+
level: "error",
|
|
1887
|
+
message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1888
|
+
conversation_id: watcher.conv.id
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
1894
|
+
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
1895
|
+
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
1896
|
+
* in-flight set on completion or timeout.
|
|
1897
|
+
*/
|
|
1898
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
1899
|
+
const conv = watcher.conv;
|
|
1900
|
+
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
1901
|
+
if ((state === "running" || state === "done") && !inFlight.started) {
|
|
1902
|
+
let claimed;
|
|
1903
|
+
try {
|
|
1904
|
+
claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1907
|
+
this.log({
|
|
1908
|
+
level: "error",
|
|
1909
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1799
1910
|
conversation_id: conv.id,
|
|
1800
|
-
message_id:
|
|
1911
|
+
message_id: inFlight.evidentMessageId
|
|
1801
1912
|
});
|
|
1802
|
-
await this.markDone(conv.id, message.id, sessionId);
|
|
1803
1913
|
return;
|
|
1804
1914
|
}
|
|
1915
|
+
inFlight.started = true;
|
|
1916
|
+
if (!claimed) {
|
|
1917
|
+
this.log({
|
|
1918
|
+
level: "info",
|
|
1919
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
1920
|
+
conversation_id: conv.id,
|
|
1921
|
+
message_id: inFlight.evidentMessageId
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
if (state === "done") {
|
|
1926
|
+
if (!inFlight.done) {
|
|
1927
|
+
this.log({
|
|
1928
|
+
level: "info",
|
|
1929
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
1930
|
+
conversation_id: conv.id,
|
|
1931
|
+
message_id: inFlight.evidentMessageId
|
|
1932
|
+
});
|
|
1933
|
+
try {
|
|
1934
|
+
await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
|
|
1935
|
+
} catch (err) {
|
|
1936
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1937
|
+
if (err instanceof ChannelTerminalError) {
|
|
1938
|
+
this.log({
|
|
1939
|
+
level: "error",
|
|
1940
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
1941
|
+
conversation_id: conv.id,
|
|
1942
|
+
message_id: inFlight.evidentMessageId
|
|
1943
|
+
});
|
|
1944
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
if (this.now() >= inFlight.deadline) {
|
|
1948
|
+
this.log({
|
|
1949
|
+
level: "error",
|
|
1950
|
+
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)}`,
|
|
1951
|
+
conversation_id: conv.id,
|
|
1952
|
+
message_id: inFlight.evidentMessageId
|
|
1953
|
+
});
|
|
1954
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
this.log({
|
|
1958
|
+
level: "error",
|
|
1959
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1960
|
+
conversation_id: conv.id,
|
|
1961
|
+
message_id: inFlight.evidentMessageId
|
|
1962
|
+
});
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
inFlight.done = true;
|
|
1966
|
+
}
|
|
1967
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (state === "unknown") {
|
|
1971
|
+
if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
|
|
1972
|
+
await this.redispatchInFlight(sessionId, inFlight);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (this.now() >= inFlight.deadline) {
|
|
1805
1976
|
this.log({
|
|
1806
1977
|
level: "info",
|
|
1807
|
-
message: `
|
|
1978
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
1808
1979
|
conversation_id: conv.id,
|
|
1809
|
-
message_id:
|
|
1980
|
+
message_id: inFlight.evidentMessageId
|
|
1810
1981
|
});
|
|
1982
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
|
|
1987
|
+
* opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
|
|
1988
|
+
* fact 9) — one user message + one reply even if the original DID land. Resets
|
|
1989
|
+
* the dispatch timestamp so the guard doesn't immediately fire again.
|
|
1990
|
+
*/
|
|
1991
|
+
async redispatchInFlight(sessionId, inFlight) {
|
|
1992
|
+
const options = {
|
|
1993
|
+
agent: inFlight.message.opencode_agent ?? void 0,
|
|
1994
|
+
model: inFlight.message.opencode_model ?? void 0
|
|
1995
|
+
};
|
|
1996
|
+
this.log({
|
|
1997
|
+
level: "info",
|
|
1998
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
|
|
1999
|
+
message_id: inFlight.evidentMessageId
|
|
2000
|
+
});
|
|
2001
|
+
try {
|
|
2002
|
+
await sendPromptAsync(
|
|
2003
|
+
this.port,
|
|
2004
|
+
sessionId,
|
|
2005
|
+
inFlight.message.content,
|
|
2006
|
+
options,
|
|
2007
|
+
inFlight.opencodeMessageId
|
|
2008
|
+
);
|
|
1811
2009
|
} catch (err) {
|
|
1812
2010
|
this.log({
|
|
1813
2011
|
level: "error",
|
|
1814
|
-
message: `
|
|
1815
|
-
|
|
1816
|
-
|
|
2012
|
+
message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2013
|
+
message_id: inFlight.evidentMessageId
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
inFlight.dispatchedAt = this.now();
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Remove a message from the in-flight set AND the authoritative dispatched
|
|
2020
|
+
* set. Once the in-flight set empties, the watcher loop's `while` guard exits
|
|
2021
|
+
* and its `.finally` removes the session entry from `this.watchers`.
|
|
2022
|
+
*/
|
|
2023
|
+
removeInFlight(watcher, evidentMessageId) {
|
|
2024
|
+
watcher.inFlight.delete(evidentMessageId);
|
|
2025
|
+
this.dispatched.delete(evidentMessageId);
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
|
|
2029
|
+
* via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
|
|
2030
|
+
* `source_message_id` so the server @mentions the correct person under
|
|
2031
|
+
* concurrency. Dedups by interaction id across ticks (reused per-session sets).
|
|
2032
|
+
*
|
|
2033
|
+
* The interaction is attributed to the in-flight message it paused on. opencode
|
|
2034
|
+
* stamps a `messageID` on a permission (and `tool.messageID` on a question) =
|
|
2035
|
+
* the assistant message id, whose `parentID` is the user message id — but the
|
|
2036
|
+
* simplest robust attribution here is: the single in-flight message that is
|
|
2037
|
+
* RUNNING (not done) is the one that paused. With one running message that is
|
|
2038
|
+
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
2039
|
+
* oldest running message.
|
|
2040
|
+
*/
|
|
2041
|
+
async pollInteractions(sessionId, watcher, messages) {
|
|
2042
|
+
let questions = [];
|
|
2043
|
+
try {
|
|
2044
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
2045
|
+
if (res.ok) {
|
|
2046
|
+
const body = await res.json();
|
|
2047
|
+
questions = Array.isArray(body) ? body : [];
|
|
2048
|
+
}
|
|
2049
|
+
} catch {
|
|
2050
|
+
}
|
|
2051
|
+
for (const q of questions) {
|
|
2052
|
+
if (q.sessionID !== sessionId) continue;
|
|
2053
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2054
|
+
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
2055
|
+
const reported = await this.reportInteraction(
|
|
2056
|
+
watcher.conv.id,
|
|
2057
|
+
"question",
|
|
2058
|
+
q,
|
|
2059
|
+
paused?.message.source_message_id ?? void 0
|
|
2060
|
+
);
|
|
2061
|
+
if (reported) watcher.reportedQuestions.add(q.id);
|
|
2062
|
+
}
|
|
2063
|
+
let permissions = [];
|
|
2064
|
+
try {
|
|
2065
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2066
|
+
if (res.ok) {
|
|
2067
|
+
const body = await res.json();
|
|
2068
|
+
permissions = Array.isArray(body) ? body : [];
|
|
2069
|
+
}
|
|
2070
|
+
} catch {
|
|
2071
|
+
}
|
|
2072
|
+
for (const p of permissions) {
|
|
2073
|
+
if (p.sessionID !== sessionId) continue;
|
|
2074
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2075
|
+
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
2076
|
+
const reported = await this.reportInteraction(
|
|
2077
|
+
watcher.conv.id,
|
|
2078
|
+
"permission",
|
|
2079
|
+
p,
|
|
2080
|
+
paused?.message.source_message_id ?? void 0
|
|
2081
|
+
);
|
|
2082
|
+
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
2087
|
+
*
|
|
2088
|
+
* The interaction carries `interactionMessageId` — the ASSISTANT message id
|
|
2089
|
+
* that raised it (a question's `tool.messageID` / a permission's `messageID`).
|
|
2090
|
+
* That assistant message is the reply to ONE of our minted user messages
|
|
2091
|
+
* (correlated by `parentID`, GATE-B). So when we have the tick's message
|
|
2092
|
+
* snapshot, we resolve each running in-flight message's correlated assistant
|
|
2093
|
+
* reply (`findAssistantReplyAfter`) and match its id against
|
|
2094
|
+
* `interactionMessageId` — giving an EXACT attribution even with several
|
|
2095
|
+
* messages in flight concurrently in one session.
|
|
2096
|
+
*
|
|
2097
|
+
* We fall back to the oldest running message ONLY when no exact match is
|
|
2098
|
+
* possible (the id is absent, the snapshot is missing, or the reply has not yet
|
|
2099
|
+
* been correlated). With a single running message either path is exact. Never
|
|
2100
|
+
* throws.
|
|
2101
|
+
*
|
|
2102
|
+
* Attribution must NOT depend on our own `started` PATCH flag: opencode can
|
|
2103
|
+
* START a turn AND raise a question/permission BEFORE our next tick fires
|
|
2104
|
+
* `markProcessing` (which sets `started`). Relying on `started` would leave the
|
|
2105
|
+
* running set empty in that window and let the server fall back to "newest
|
|
2106
|
+
* processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
|
|
2107
|
+
* person whose active turn actually paused. So we derive "running" from the
|
|
2108
|
+
* tick's `messages` snapshot via `messageRunState` instead.
|
|
2109
|
+
*/
|
|
2110
|
+
attributeInteraction(watcher, interactionMessageId, messages) {
|
|
2111
|
+
const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
|
|
2112
|
+
if (inFlight.length === 0) return void 0;
|
|
2113
|
+
if (interactionMessageId && messages) {
|
|
2114
|
+
const exact = inFlight.find((m) => {
|
|
2115
|
+
const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
|
|
2116
|
+
return reply != null && messageIdOf(reply) === interactionMessageId;
|
|
1817
2117
|
});
|
|
2118
|
+
if (exact) return exact;
|
|
2119
|
+
}
|
|
2120
|
+
const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
|
|
2121
|
+
if (messages) {
|
|
2122
|
+
const runningPerSnapshot = inFlight.filter(
|
|
2123
|
+
(m) => messageRunState(messages, m.opencodeMessageId) === "running"
|
|
2124
|
+
);
|
|
2125
|
+
if (runningPerSnapshot.length > 0) {
|
|
2126
|
+
return runningPerSnapshot.sort(byOldest)[0];
|
|
2127
|
+
}
|
|
1818
2128
|
}
|
|
2129
|
+
const startedRunning = inFlight.filter((m) => m.started);
|
|
2130
|
+
if (startedRunning.length > 0) {
|
|
2131
|
+
return startedRunning.sort(byOldest)[0];
|
|
2132
|
+
}
|
|
2133
|
+
return inFlight.sort(byOldest)[0];
|
|
1819
2134
|
}
|
|
1820
2135
|
// -------------------------------------------------------------------------
|
|
1821
2136
|
// Evident API calls (combinedAuth thread routes)
|
|
@@ -1849,39 +2164,86 @@ var ChannelDriver = class {
|
|
|
1849
2164
|
}
|
|
1850
2165
|
return await res.json();
|
|
1851
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
2169
|
+
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
2170
|
+
* opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
|
|
2171
|
+
* deep-linked "View in Evident" notice).
|
|
2172
|
+
*
|
|
2173
|
+
* Return/throw contract (consumed by the watcher's swap-to-running guard):
|
|
2174
|
+
* - returns `true` → the server transitioned the row to processing;
|
|
2175
|
+
* - returns `false` → the server gave a DEFINITIVE "already-processing"
|
|
2176
|
+
* answer (a non-retryable, non-auth status — e.g. a
|
|
2177
|
+
* conflict because a duplicate already transitioned it),
|
|
2178
|
+
* so the caller treats it as already-started and does NOT
|
|
2179
|
+
* retry;
|
|
2180
|
+
* - throws `ChannelAuthError` on 401/403 (terminal auth failure);
|
|
2181
|
+
* - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
|
|
2182
|
+
* network-level error from `fetch`) — i.e. NO definitive server response —
|
|
2183
|
+
* so the caller leaves the message un-started and retries the swap on the
|
|
2184
|
+
* next tick.
|
|
2185
|
+
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2186
|
+
* retry vehicle for the swap-to-running.
|
|
2187
|
+
*/
|
|
1852
2188
|
async markProcessing(conversationId, messageId, sessionId) {
|
|
1853
2189
|
const res = await this.fetchImpl(
|
|
1854
2190
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1855
2191
|
{
|
|
1856
2192
|
method: "PATCH",
|
|
1857
2193
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1858
|
-
// Include the opencode session id so the server can deep-link the
|
|
1859
|
-
// "View in Evident" notice straight to the conversation (the session
|
|
1860
|
-
// already exists by now — ensureSession runs before claiming).
|
|
1861
2194
|
body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
|
|
1862
2195
|
}
|
|
1863
2196
|
);
|
|
1864
2197
|
this.assertAuth(res, "marking message as processing");
|
|
1865
|
-
|
|
2198
|
+
if (res.ok) return true;
|
|
2199
|
+
if (isRetryableStatus(res.status)) {
|
|
2200
|
+
throw new Error(`marking message as processing: HTTP ${res.status}`);
|
|
2201
|
+
}
|
|
2202
|
+
return false;
|
|
1866
2203
|
}
|
|
1867
2204
|
/**
|
|
1868
|
-
* EXISTING combinedAuth completion route — idempotent
|
|
1869
|
-
*
|
|
2205
|
+
* EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
|
|
2206
|
+
* .../messages/:id {status:'done', opencode_session_id}`. The server's
|
|
1870
2207
|
* `queued_conversation_messages.status`/`processed_at` gate makes a re-call
|
|
1871
|
-
* for an already-`done` message a no-op (no double Slack post).
|
|
2208
|
+
* for an already-`done` message a no-op (no double Slack post). Fired by the
|
|
2209
|
+
* watcher on per-message completion (Task 3.4) — no `confirmCompletion`
|
|
2210
|
+
* round-trip (we already observed completion via the message list).
|
|
2211
|
+
*
|
|
2212
|
+
* SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
|
|
2213
|
+
* services its in-flight messages SEQUENTIALLY within a tick
|
|
2214
|
+
* (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
|
|
2215
|
+
* backoff here would BLOCK sibling messages in the SAME session/tick: while
|
|
2216
|
+
* message A's done PATCH burned its internal retries, message B could not be
|
|
2217
|
+
* swapped to running even though opencode had already started it. Instead this
|
|
2218
|
+
* does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
|
|
2219
|
+
* handler already relies on, leaning on the per-tick retry across ticks
|
|
2220
|
+
* (bounded by `inFlight.deadline`) rather than an in-call retry:
|
|
2221
|
+
* - resolves (`void`) → the server transitioned the row to done
|
|
2222
|
+
* (or idempotently confirmed already-done);
|
|
2223
|
+
* - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
|
|
2224
|
+
* cleanup, Finding 1);
|
|
2225
|
+
* - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
|
|
2226
|
+
* succeed → straight to the cron, Finding 4);
|
|
2227
|
+
* - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
|
|
2228
|
+
* (no definitive server response → the
|
|
2229
|
+
* watcher retries next tick within the
|
|
2230
|
+
* deadline, Finding 4).
|
|
1872
2231
|
*/
|
|
1873
2232
|
async markDone(conversationId, messageId, sessionId) {
|
|
1874
|
-
await this.
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
{
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
|
|
1882
|
-
}
|
|
1883
|
-
)
|
|
2233
|
+
const res = await this.fetchImpl(
|
|
2234
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2235
|
+
{
|
|
2236
|
+
method: "PATCH",
|
|
2237
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2238
|
+
body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
|
|
2239
|
+
}
|
|
1884
2240
|
);
|
|
2241
|
+
this.assertAuth(res, "marking message as done");
|
|
2242
|
+
if (res.ok) return;
|
|
2243
|
+
if (isRetryableStatus(res.status)) {
|
|
2244
|
+
throw new Error(`marking message as done: HTTP ${res.status}`);
|
|
2245
|
+
}
|
|
2246
|
+
throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
|
|
1885
2247
|
}
|
|
1886
2248
|
async markFailed(conversationId, messageId) {
|
|
1887
2249
|
await this.callWithRetry(
|
|
@@ -1909,10 +2271,17 @@ var ChannelDriver = class {
|
|
|
1909
2271
|
}
|
|
1910
2272
|
/**
|
|
1911
2273
|
* EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
|
|
1912
|
-
* `POST .../interactive-event {type, data}`. The server
|
|
1913
|
-
* interaction and posts a link to the proxied opencode-web
|
|
2274
|
+
* `POST .../interactive-event {type, data, source_message_id?}`. The server
|
|
2275
|
+
* persists the interaction and posts a link to the proxied opencode-web
|
|
2276
|
+
* conversation, @mentioning the user who triggered THIS message's turn.
|
|
2277
|
+
*
|
|
2278
|
+
* WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
|
|
2279
|
+
* ts (`message.source_message_id`). The server resolves the @mention from that
|
|
2280
|
+
* message's user FIRST (falling back to the old "newest processing" precedence
|
|
2281
|
+
* only when absent), so the correct person is mentioned under concurrency. It
|
|
2282
|
+
* is OPTIONAL for back-compat with older clients / legacy rows.
|
|
1914
2283
|
*/
|
|
1915
|
-
async reportInteraction(conversationId, type, data) {
|
|
2284
|
+
async reportInteraction(conversationId, type, data, sourceMessageId) {
|
|
1916
2285
|
try {
|
|
1917
2286
|
await this.callWithRetry(
|
|
1918
2287
|
"reporting interactive event",
|
|
@@ -1921,7 +2290,9 @@ var ChannelDriver = class {
|
|
|
1921
2290
|
{
|
|
1922
2291
|
method: "POST",
|
|
1923
2292
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1924
|
-
body: JSON.stringify(
|
|
2293
|
+
body: JSON.stringify(
|
|
2294
|
+
sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
|
|
2295
|
+
)
|
|
1925
2296
|
}
|
|
1926
2297
|
)
|
|
1927
2298
|
);
|
|
@@ -1930,6 +2301,7 @@ var ChannelDriver = class {
|
|
|
1930
2301
|
message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
|
|
1931
2302
|
conversation_id: conversationId
|
|
1932
2303
|
});
|
|
2304
|
+
return true;
|
|
1933
2305
|
} catch (err) {
|
|
1934
2306
|
if (err instanceof ChannelAuthError) throw err;
|
|
1935
2307
|
this.log({
|
|
@@ -1937,6 +2309,7 @@ var ChannelDriver = class {
|
|
|
1937
2309
|
message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1938
2310
|
conversation_id: conversationId
|
|
1939
2311
|
});
|
|
2312
|
+
return false;
|
|
1940
2313
|
}
|
|
1941
2314
|
}
|
|
1942
2315
|
// -------------------------------------------------------------------------
|
|
@@ -1975,8 +2348,9 @@ var ChannelDriver = class {
|
|
|
1975
2348
|
await this.sleep(backoffDelay(attempt, this.retry));
|
|
1976
2349
|
continue;
|
|
1977
2350
|
}
|
|
2351
|
+
break;
|
|
1978
2352
|
}
|
|
1979
|
-
throw new
|
|
2353
|
+
throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
|
|
1980
2354
|
}
|
|
1981
2355
|
throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
|
|
1982
2356
|
}
|
|
@@ -2199,7 +2573,7 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
2199
2573
|
// src/commands/run.ts
|
|
2200
2574
|
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
2201
2575
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
2202
|
-
function
|
|
2576
|
+
function log2(state, message, isError = false) {
|
|
2203
2577
|
if (state.json) {
|
|
2204
2578
|
console.log(
|
|
2205
2579
|
JSON.stringify({
|
|
@@ -2224,9 +2598,9 @@ function logActivity(state, entry) {
|
|
|
2224
2598
|
}
|
|
2225
2599
|
if (!state.interactive) {
|
|
2226
2600
|
if (entry.type === "error") {
|
|
2227
|
-
|
|
2601
|
+
log2(state, entry.error ?? "Unknown error", true);
|
|
2228
2602
|
} else if (entry.type === "info" && entry.message) {
|
|
2229
|
-
|
|
2603
|
+
log2(state, entry.message);
|
|
2230
2604
|
}
|
|
2231
2605
|
}
|
|
2232
2606
|
}
|
|
@@ -2312,6 +2686,7 @@ async function handleAuthError(state, error2) {
|
|
|
2312
2686
|
}
|
|
2313
2687
|
async function driveChannels(state, driver) {
|
|
2314
2688
|
let idlePolls = 0;
|
|
2689
|
+
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
2315
2690
|
while (state.running) {
|
|
2316
2691
|
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
2317
2692
|
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
@@ -2321,9 +2696,11 @@ async function driveChannels(state, driver) {
|
|
|
2321
2696
|
try {
|
|
2322
2697
|
const processed = await driver.drainPending();
|
|
2323
2698
|
state.messageCount += processed;
|
|
2324
|
-
|
|
2699
|
+
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
2700
|
+
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
2701
|
+
if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity) {
|
|
2325
2702
|
idlePolls = 0;
|
|
2326
|
-
if (state.interactive) displayStatus(state);
|
|
2703
|
+
if (processed > 0 && state.interactive) displayStatus(state);
|
|
2327
2704
|
} else if (state.idleTimeout !== null) {
|
|
2328
2705
|
idlePolls++;
|
|
2329
2706
|
if (idlePolls === 1) {
|
|
@@ -2373,7 +2750,7 @@ async function cleanup(state) {
|
|
|
2373
2750
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
2374
2751
|
displayStatus(state);
|
|
2375
2752
|
} else {
|
|
2376
|
-
|
|
2753
|
+
log2(state, "Stopped OpenCode process");
|
|
2377
2754
|
}
|
|
2378
2755
|
state.opencodeProcess = null;
|
|
2379
2756
|
}
|
|
@@ -2396,10 +2773,11 @@ async function run(options) {
|
|
|
2396
2773
|
running: true,
|
|
2397
2774
|
activityLog: [],
|
|
2398
2775
|
messageCount: 0,
|
|
2776
|
+
lastProxiedActivityAt: null,
|
|
2399
2777
|
authHeader: ""
|
|
2400
2778
|
};
|
|
2401
2779
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
2402
|
-
|
|
2780
|
+
log2(
|
|
2403
2781
|
state,
|
|
2404
2782
|
"Warning: 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.",
|
|
2405
2783
|
false
|
|
@@ -2410,7 +2788,7 @@ async function run(options) {
|
|
|
2410
2788
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
2411
2789
|
displayStatus(state);
|
|
2412
2790
|
} else {
|
|
2413
|
-
|
|
2791
|
+
log2(state, "Shutting down...");
|
|
2414
2792
|
}
|
|
2415
2793
|
await cleanup(state);
|
|
2416
2794
|
await shutdownTelemetry();
|
|
@@ -2443,7 +2821,7 @@ async function run(options) {
|
|
|
2443
2821
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
2444
2822
|
if (resolved.agent_id) {
|
|
2445
2823
|
state.agentId = resolved.agent_id;
|
|
2446
|
-
|
|
2824
|
+
log2(state, `Resolved agent ID from key: ${state.agentId}`);
|
|
2447
2825
|
if (state.interactive && !state.json) {
|
|
2448
2826
|
logActivity(state, {
|
|
2449
2827
|
type: "info",
|
|
@@ -2506,14 +2884,21 @@ async function run(options) {
|
|
|
2506
2884
|
port: state.port,
|
|
2507
2885
|
interactive: state.interactive,
|
|
2508
2886
|
agentId: state.agentId,
|
|
2509
|
-
log: (message) =>
|
|
2887
|
+
log: (message) => log2(state, message)
|
|
2510
2888
|
});
|
|
2511
2889
|
state.port = oc.port;
|
|
2512
2890
|
state.opencodeProcess = oc.process;
|
|
2513
2891
|
state.opencodeVersion = oc.version;
|
|
2514
2892
|
state.opencodeConnected = oc.process !== null || oc.version !== null;
|
|
2515
|
-
const
|
|
2516
|
-
ocSpinner?.succeed(`OpenCode running on port ${state.port}${
|
|
2893
|
+
const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
2894
|
+
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
2895
|
+
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
2896
|
+
if (versionWarning) {
|
|
2897
|
+
log2(state, versionWarning, false);
|
|
2898
|
+
if (state.interactive && !state.json) {
|
|
2899
|
+
logActivity(state, { type: "info", message: versionWarning });
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2517
2902
|
} catch (error2) {
|
|
2518
2903
|
ocSpinner?.fail(error2.message);
|
|
2519
2904
|
throw error2;
|
|
@@ -2578,9 +2963,14 @@ async function run(options) {
|
|
|
2578
2963
|
logActivity(state, { type: "error", error: error2 });
|
|
2579
2964
|
if (state.interactive) displayStatus(state);
|
|
2580
2965
|
},
|
|
2581
|
-
// Web traffic is proxied transparently;
|
|
2966
|
+
// Web traffic is proxied transparently; note opencode is live and stamp
|
|
2967
|
+
// proxied activity so the idle loop treats interactive proxy use as work.
|
|
2968
|
+
// Fires per forwarded response head (incl. every SSE open) and excludes
|
|
2969
|
+
// the internal drain-ping, so an actively-used proxy keeps the timer
|
|
2970
|
+
// fresh while a lone idle SSE with no follow-up requests still ages out.
|
|
2582
2971
|
onResponse: () => {
|
|
2583
2972
|
state.opencodeConnected = true;
|
|
2973
|
+
state.lastProxiedActivityAt = Date.now();
|
|
2584
2974
|
},
|
|
2585
2975
|
// A channel message was queued and the api-worker pinged us over the
|
|
2586
2976
|
// tunnel to drain immediately instead of waiting for the next poll tick.
|
|
@@ -2619,7 +3009,7 @@ async function run(options) {
|
|
|
2619
3009
|
throw error2;
|
|
2620
3010
|
}
|
|
2621
3011
|
if (!interactive || state.json) {
|
|
2622
|
-
|
|
3012
|
+
log2(state, "Driving channel messages...");
|
|
2623
3013
|
}
|
|
2624
3014
|
await driveChannels(state, channelDriver);
|
|
2625
3015
|
await cleanup(state);
|
|
@@ -2631,7 +3021,7 @@ async function run(options) {
|
|
|
2631
3021
|
})
|
|
2632
3022
|
);
|
|
2633
3023
|
} else if (!interactive) {
|
|
2634
|
-
|
|
3024
|
+
log2(state, `Completed. Processed ${state.messageCount} message(s).`);
|
|
2635
3025
|
}
|
|
2636
3026
|
await shutdownTelemetry();
|
|
2637
3027
|
process.exit(0);
|
|
@@ -2653,8 +3043,9 @@ async function run(options) {
|
|
|
2653
3043
|
}
|
|
2654
3044
|
|
|
2655
3045
|
// src/index.ts
|
|
3046
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
2656
3047
|
var program = new Command();
|
|
2657
|
-
program.name("evident").description("Run OpenCode locally and connect it to Evident").version(
|
|
3048
|
+
program.name("evident").description("Run OpenCode locally and connect it to Evident").version(version).option(
|
|
2658
3049
|
"--endpoint <url>",
|
|
2659
3050
|
"Evident API base URL (default: production; e.g. http://localhost:3001)"
|
|
2660
3051
|
).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
|