zcode-acp-server 0.31.1 → 0.33.0
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/code-fingerprint.json +2 -2
- package/dist/goal-loop/driver.d.ts +112 -0
- package/dist/goal-loop/driver.d.ts.map +1 -0
- package/dist/goal-loop/driver.js +556 -0
- package/dist/goal-loop/driver.js.map +1 -0
- package/dist/goal-loop/state.d.ts +53 -0
- package/dist/goal-loop/state.d.ts.map +1 -0
- package/dist/goal-loop/state.js +80 -0
- package/dist/goal-loop/state.js.map +1 -0
- package/dist/goal-loop/templates.d.ts +79 -0
- package/dist/goal-loop/templates.d.ts.map +1 -0
- package/dist/goal-loop/templates.js +187 -0
- package/dist/goal-loop/templates.js.map +1 -0
- package/dist/handlers/sandbox-allow.d.ts +3 -0
- package/dist/handlers/sandbox-allow.d.ts.map +1 -1
- package/dist/handlers/sandbox-allow.js +21 -6
- package/dist/handlers/sandbox-allow.js.map +1 -1
- package/dist/handlers/session.d.ts +68 -1
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +291 -149
- package/dist/handlers/session.js.map +1 -1
- package/dist/handlers/slash.d.ts.map +1 -1
- package/dist/handlers/slash.js +58 -0
- package/dist/handlers/slash.js.map +1 -1
- package/dist/i18n.d.ts +18 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +34 -0
- package/dist/i18n.js.map +1 -1
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -1
- package/dist/translators/event-translator.d.ts +23 -1
- package/dist/translators/event-translator.d.ts.map +1 -1
- package/dist/translators/event-translator.js +34 -4
- package/dist/translators/event-translator.js.map +1 -1
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -0
- package/dist/utils.js.map +1 -1
- package/docs/BACKLOG.md +2 -2
- package/docs/PROTOCOL.md +17 -1
- package/docs/RELEASING.md +2 -1
- package/package.json +1 -1
package/dist/handlers/session.js
CHANGED
|
@@ -891,6 +891,24 @@ export async function loadSession(server, params, cx, opts = {}) {
|
|
|
891
891
|
log(`session/load → ${zcodeSid}`);
|
|
892
892
|
server.ensureBackgroundListener(zcodeSid);
|
|
893
893
|
await adoptStoredTitle(server, acpSid, zcodeSid);
|
|
894
|
+
// Goal-loop restart recovery (ADR-0022 §6): surface a recovery hint at most
|
|
895
|
+
// once per bridge process per session — never auto-resume a spend-incurring
|
|
896
|
+
// loop the user may have meant to stop. A LIVE driver means a second client
|
|
897
|
+
// attached mid-loop: no hint at all. Only a persisted "running" status
|
|
898
|
+
// (running without a driver = the bridge died mid-loop) gets the
|
|
899
|
+
// interrupted wording; user-visible paused states get a neutral line.
|
|
900
|
+
{
|
|
901
|
+
const { GoalLoopDriver } = await import("../goal-loop/driver.js");
|
|
902
|
+
const { readGoalState } = await import("../goal-loop/state.js");
|
|
903
|
+
if (!GoalLoopDriver.live(server, zcodeSid) && !server.goalLoopLoadHints.has(zcodeSid)) {
|
|
904
|
+
const prior = readGoalState(cwd, zcodeSid);
|
|
905
|
+
const interrupted = prior?.status === "running";
|
|
906
|
+
if (prior && (interrupted || prior.status.startsWith("paused"))) {
|
|
907
|
+
server.goalLoopLoadHints.add(zcodeSid);
|
|
908
|
+
await sendTextChunk(cx, acpSid, interrupted ? messages().goalHintInterrupted(prior.objective) : messages().goalHintPaused, `goalhint_${randomUUID().slice(0, 8)}`).catch(() => undefined);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
894
912
|
// Named `history` — a local `messages` would shadow the i18n `messages()`
|
|
895
913
|
// helper used in the error paths above (TDZ crash from the catch block).
|
|
896
914
|
const history = await fetchMessages(server, zcodeSid);
|
|
@@ -1016,120 +1034,25 @@ export async function prompt(server, params, cx, requestId, client) {
|
|
|
1016
1034
|
server.sandboxContinuations.delete(params.sessionId);
|
|
1017
1035
|
return result;
|
|
1018
1036
|
}
|
|
1019
|
-
/**
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
const attachments =
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
// Boot-resume banner handshake (see BOOT_RESUME_TRIGGER): martty queues the
|
|
1036
|
-
// auto-submitted trigger until its boot bind, so it arrives as the FIRST
|
|
1037
|
-
// prompt OF THAT CONNECTION — ack with one chunk and end the turn without
|
|
1038
|
-
// touching the backend. Scoped to the arming connection's identity: a
|
|
1039
|
-
// phone app attached to the same bridge may prompt inside the boot window
|
|
1040
|
-
// and must neither spend nor disarm the handshake (observed live: its
|
|
1041
|
-
// prompt raced ahead and the trigger leaked to the model). The same
|
|
1042
|
-
// connection submitting anything else first means the auto-submit was
|
|
1043
|
-
// lost — disarm so the trigger text typed manually later stays a normal
|
|
1044
|
-
// prompt.
|
|
1045
|
-
if (server.bootResumeTriggerConnection !== null &&
|
|
1046
|
-
clientConnectionRoot(client) === server.bootResumeTriggerConnection) {
|
|
1047
|
-
server.bootResumeTriggerConnection = null;
|
|
1048
|
-
if (text === BOOT_RESUME_TRIGGER) {
|
|
1049
|
-
await sendTextChunk(cx, params.sessionId, messages().bootResumeAck, randomUUID());
|
|
1050
|
-
log("session/prompt: boot-resume banner handshake acknowledged");
|
|
1051
|
-
return { stopReason: "end_turn" };
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
// Materialize a lazy session/new placeholder on first use. Placed after the
|
|
1055
|
-
// empty-prompt check so an invalid request doesn't create a backend session.
|
|
1056
|
-
const zcodeSid = await ensureRealSession(server, params.sessionId);
|
|
1057
|
-
// Slash-command interception: dispatches directly to ZCode methods and
|
|
1058
|
-
// returns end_turn without entering the turn loop. Known passthrough
|
|
1059
|
-
// commands and unknown /x both return null for the normal turn loop.
|
|
1060
|
-
const { handleSlashCommand, neutralizeSlashText } = await import("./slash.js");
|
|
1061
|
-
const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text);
|
|
1062
|
-
if (intercepted)
|
|
1063
|
-
return intercepted;
|
|
1064
|
-
// Wire text for the backend: unknown `/x` prompts (not advertised commands)
|
|
1065
|
-
// are neutralized so the backend's command resolver never sees them — an
|
|
1066
|
-
// unresolvable name can hard-fail the turn. Known commands pass through
|
|
1067
|
-
// unchanged. The title/auto-compact paths below keep using the raw `text`.
|
|
1068
|
-
const sendText = neutralizeSlashText(text);
|
|
1069
|
-
// Register self + preempt others under a per-session lock. The lock
|
|
1070
|
-
// serializes the critical section so that two concurrent prompts (B, C) for
|
|
1071
|
-
// the same session can't both miss each other and register at once: C waits
|
|
1072
|
-
// for B's section, by which point B is in pendingTurns, so C's preempt finds
|
|
1073
|
-
// and cancels B. Registering INSIDE the lock is what makes the new turn
|
|
1074
|
-
// visible to the next prompt's preempt scan.
|
|
1075
|
-
const turn = {
|
|
1076
|
-
zcodeSid,
|
|
1077
|
-
cancelled: false,
|
|
1078
|
-
};
|
|
1079
|
-
// True when this send cancelled another in-flight prompt (preempt/stop).
|
|
1080
|
-
// Drives the turn-attribution gate: only a preempted prompt can see leftover
|
|
1081
|
-
// events from a prior turn in its listener queue; without preemption any
|
|
1082
|
-
// events before this turn's turn.started belong to a backend-owned turn
|
|
1083
|
-
// (e.g. auto-resumed after compaction) that this send was steered into.
|
|
1084
|
-
let preempted = false;
|
|
1085
|
-
await withPreemptLock(server, zcodeSid, async () => {
|
|
1086
|
-
server.pendingTurns.set(requestId, turn);
|
|
1087
|
-
preempted = preemptInFlightTurn(server, zcodeSid, requestId);
|
|
1088
|
-
});
|
|
1089
|
-
// Discovery: the session is live the moment its turn STARTS — mark it active
|
|
1090
|
-
// here instead of only at turn end, so a freshly created conversation shows
|
|
1091
|
-
// up in remote lists within one heartbeat even while its first (possibly
|
|
1092
|
-
// minutes-long) turn is still running. Until the backend's auto-title lands
|
|
1093
|
-
// at end_turn, seed a provisional title from the prompt text (auto-title
|
|
1094
|
-
// stays authoritative — its set-once gate is the separate sessionTitles).
|
|
1095
|
-
server.markSessionActive(params.sessionId);
|
|
1096
|
-
// Session title: set EXACTLY ONCE, here, from the first prompt of a
|
|
1097
|
-
// freshly created session — immediately, not at end_turn (a preempted
|
|
1098
|
-
// first turn ends "cancelled" and would never be titled; and the
|
|
1099
|
-
// completing prompt must not steal the title). After this, no automatic
|
|
1100
|
-
// path may change the title again: sessionTitles is set-once and a manual
|
|
1101
|
-
// rename is the only later modifier. Resumed/loaded sessions are not
|
|
1102
|
-
// title-eligible — their stored title was adopted on load, or left unset.
|
|
1103
|
-
if (server.titleEligibleSessions.has(params.sessionId) &&
|
|
1104
|
-
text &&
|
|
1105
|
-
!server.sessionTitles.has(params.sessionId)) {
|
|
1106
|
-
// Title = first non-empty line of the prompt, truncated to 80 chars.
|
|
1107
|
-
// Multi-line prompts must not leak newlines into the session title.
|
|
1108
|
-
// Split on any line break (\r\n, \n, \r) so all platforms are covered.
|
|
1109
|
-
const title = text
|
|
1110
|
-
.split(/\r\n|\r|\n/)
|
|
1111
|
-
.map((l) => l.trim())
|
|
1112
|
-
.find((l) => l.length > 0)
|
|
1113
|
-
?.slice(0, 80) ?? text.slice(0, 80);
|
|
1114
|
-
server.sessionTitles.set(params.sessionId, title);
|
|
1115
|
-
server.touchSessionSummary(params.sessionId, title);
|
|
1116
|
-
const { updateSessionTitle } = await import("../tasks-index.js");
|
|
1117
|
-
void updateSessionTitle(zcodeSid, title, text);
|
|
1118
|
-
void sendSessionUpdate(cx, params.sessionId, {
|
|
1119
|
-
sessionUpdate: "session_info_update",
|
|
1120
|
-
title,
|
|
1121
|
-
updatedAt: new Date().toISOString(),
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
// Out-of-band running indicator: clients that did not send this prompt
|
|
1125
|
-
// (re-attached mobile, second editor) learn the turn started here — the
|
|
1126
|
-
// session/load replayMeta only snapshots attach time. Emitted per attached
|
|
1127
|
-
// alias (see server.sessionAliases) so a client holding this conversation
|
|
1128
|
-
// under a different ACP id opens its live turn too. Best-effort: a dead
|
|
1129
|
-
// client must not fail the turn.
|
|
1037
|
+
/**
|
|
1038
|
+
* One backend round, shared by the prompt path and the goal-loop driver
|
|
1039
|
+
* (ADR-0022): listener + differ baseline, subscribe with -32004 eviction
|
|
1040
|
+
* recovery, send-busy retry, drain gate after a recent cancel, the
|
|
1041
|
+
* event-driven turn loop with transient-failure retry, and the finally
|
|
1042
|
+
* cleanup (pendingTurns deregistration, discovery/quota refresh, turnState).
|
|
1043
|
+
* Every line here encodes an observed production failure — never reimplement
|
|
1044
|
+
* a second send/subscribe/retry path.
|
|
1045
|
+
*/
|
|
1046
|
+
export async function runOneTurn(server, opts) {
|
|
1047
|
+
const { backend, cx, acpSid, zcodeSid, requestId, turn, preempted, sendText } = opts;
|
|
1048
|
+
const attachments = opts.attachments ?? [];
|
|
1049
|
+
// Out-of-band running indicator: emitted per attached alias (see
|
|
1050
|
+
// server.sessionAliases) so a client holding this conversation under a
|
|
1051
|
+
// different ACP id opens its live turn too. Best-effort: a dead client must
|
|
1052
|
+
// not fail the turn.
|
|
1130
1053
|
const emitTurnState = async (running) => {
|
|
1131
1054
|
const results = await Promise.allSettled(server
|
|
1132
|
-
.sessionAliases(
|
|
1055
|
+
.sessionAliases(acpSid)
|
|
1133
1056
|
.map((sid) => cx.notify("$/zcode/turnState", { sessionId: sid, running })));
|
|
1134
1057
|
for (const r of results) {
|
|
1135
1058
|
if (r.status === "rejected") {
|
|
@@ -1137,7 +1060,6 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1137
1060
|
}
|
|
1138
1061
|
}
|
|
1139
1062
|
};
|
|
1140
|
-
await emitTurnState(true);
|
|
1141
1063
|
const listener = new EventStreamListener(backend, zcodeSid);
|
|
1142
1064
|
const monitor = new TurnMonitor(backend, zcodeSid, () => server.nextId());
|
|
1143
1065
|
// Per-session ProjectionDiffer (persists across turns). The baseline mark_seen
|
|
@@ -1148,8 +1070,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1148
1070
|
// Subscribe BEFORE send so we don't lose early turn.completed on short turns.
|
|
1149
1071
|
// subscribe() throws on failure, surfacing the backend's real error (reader
|
|
1150
1072
|
// dead, timeout, pipe broken, method-not-found on old CLI, session error) so
|
|
1151
|
-
// the cause is distinguishable. Clean up the pending turn before propagating
|
|
1152
|
-
// — this call site is outside the try/finally below.
|
|
1073
|
+
// the cause is distinguishable. Clean up the pending turn before propagating.
|
|
1153
1074
|
let snapshot;
|
|
1154
1075
|
try {
|
|
1155
1076
|
try {
|
|
@@ -1165,8 +1086,8 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1165
1086
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1166
1087
|
if (!/session is not active/i.test(msg))
|
|
1167
1088
|
throw e;
|
|
1168
|
-
log(`
|
|
1169
|
-
await reloadBackendSession(server,
|
|
1089
|
+
log(`turn: session ${zcodeSid} no longer active in backend — reloading via session/resume`);
|
|
1090
|
+
await reloadBackendSession(server, acpSid, zcodeSid);
|
|
1170
1091
|
// The pre-subscribe fetchMessages ran against the evicted session and
|
|
1171
1092
|
// came back empty — re-baseline the differ so turn completion doesn't
|
|
1172
1093
|
// diff-replay the whole history as new output.
|
|
@@ -1175,7 +1096,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1175
1096
|
}
|
|
1176
1097
|
// A successful subscribe proves the resident runtime is live — refresh
|
|
1177
1098
|
// the verification so concurrent/later entry points skip a reload.
|
|
1178
|
-
server.markBackendLoaded(
|
|
1099
|
+
server.markBackendLoaded(acpSid);
|
|
1179
1100
|
}
|
|
1180
1101
|
catch (e) {
|
|
1181
1102
|
server.pendingTurns.delete(requestId);
|
|
@@ -1212,7 +1133,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1212
1133
|
return { stopReason: "cancelled" };
|
|
1213
1134
|
}
|
|
1214
1135
|
differ.markSeen(await fetchMessages(server, zcodeSid));
|
|
1215
|
-
await sendTextChunk(cx,
|
|
1136
|
+
await sendTextChunk(cx, acpSid, messages().networkRetry(attempt - 1, MAX_TURN_ATTEMPTS - 1), randomUUID());
|
|
1216
1137
|
log(` [retry] transient turn failed, re-sending (attempt ${attempt}/${MAX_TURN_ATTEMPTS})`);
|
|
1217
1138
|
await sleep(backoffMs(attempt - 1));
|
|
1218
1139
|
}
|
|
@@ -1223,7 +1144,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1223
1144
|
Date.now() - server.lastCancelledAt.get(zcodeSid) < DRAIN_WINDOW_MS;
|
|
1224
1145
|
if (cancelledRecently) {
|
|
1225
1146
|
const drained = await drainBackendAfterCancel(server, {
|
|
1226
|
-
acpSid
|
|
1147
|
+
acpSid,
|
|
1227
1148
|
zcodeSid,
|
|
1228
1149
|
turn,
|
|
1229
1150
|
listener,
|
|
@@ -1303,8 +1224,8 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1303
1224
|
// bare thinking block with no context. Announce the resumed turn at
|
|
1304
1225
|
// send-accept so every phase of the allow→restart→continue flow is
|
|
1305
1226
|
// visibly accounted for (the allow-time hint covers the restart start).
|
|
1306
|
-
if (continuationRound) {
|
|
1307
|
-
await sendTextChunk(cx,
|
|
1227
|
+
if (opts.continuationRound) {
|
|
1228
|
+
await sendTextChunk(cx, acpSid, messages().sandboxResumedStatus, randomUUID());
|
|
1308
1229
|
}
|
|
1309
1230
|
try {
|
|
1310
1231
|
// Event-driven turn loop: translate events via EventTranslator + dispatch.
|
|
@@ -1314,24 +1235,21 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1314
1235
|
// of this new prompt (see the gate comment in runEventTurn).
|
|
1315
1236
|
const gateArmed = preempted ||
|
|
1316
1237
|
Date.now() - (server.lastCancelledAt.get(zcodeSid) ?? 0) < CANCEL_RESIDUE_WINDOW_MS;
|
|
1317
|
-
const result = await runEventTurn(server, listener, monitor, differ, cx,
|
|
1318
|
-
// (Session title: already set once at the FIRST prompt, before the
|
|
1319
|
-
// turn loop — nothing here may change it again.)
|
|
1238
|
+
const result = await runEventTurn(server, listener, monitor, differ, cx, acpSid, chunkMsgId, turn, gateArmed);
|
|
1320
1239
|
// Auto-compact: if context usage exceeds the threshold, compact before
|
|
1321
1240
|
// returning so the next prompt has room. Configured via
|
|
1322
1241
|
// ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset =
|
|
1323
1242
|
// disabled). Only on end_turn — cancelled/max_turn_requests skips
|
|
1324
1243
|
// compaction, as does a stall-recovered end_turn (the completion was
|
|
1325
1244
|
// inferred by the stall heuristic, not confirmed by turn.completed —
|
|
1326
|
-
// compressing an in-flight task's
|
|
1245
|
+
// compressing an in-flight task's would destroy the work).
|
|
1327
1246
|
// Best-effort: failures are logged inside maybeAutoCompact, never thrown.
|
|
1328
|
-
if (
|
|
1247
|
+
if (opts.autoCompact !== false &&
|
|
1248
|
+
result.stopReason === "end_turn" &&
|
|
1249
|
+
!turn.stallRecovered) {
|
|
1329
1250
|
const { maybeAutoCompact } = await import("../config/auto-compact.js");
|
|
1330
|
-
await maybeAutoCompact(server, cx,
|
|
1251
|
+
await maybeAutoCompact(server, cx, acpSid, zcodeSid);
|
|
1331
1252
|
}
|
|
1332
|
-
// (Sandbox allow-restart continuation: handled by the prompt() wrapper
|
|
1333
|
-
// — the chained round keeps the editor's original request pending so
|
|
1334
|
-
// the restart window stays visibly "running".)
|
|
1335
1253
|
return result;
|
|
1336
1254
|
}
|
|
1337
1255
|
catch (e) {
|
|
@@ -1355,7 +1273,7 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1355
1273
|
// surfacing a hard error and stopping. Skip auto-compact here: compaction
|
|
1356
1274
|
// after a failed turn is more likely to confuse state than help.
|
|
1357
1275
|
const errMsg = formatTurnError(lastTurnError) || "turn failed after retries";
|
|
1358
|
-
await sendTextChunk(cx,
|
|
1276
|
+
await sendTextChunk(cx, acpSid, messages().requestFailed(errMsg), randomUUID());
|
|
1359
1277
|
return { stopReason: "end_turn" };
|
|
1360
1278
|
}
|
|
1361
1279
|
finally {
|
|
@@ -1365,8 +1283,8 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1365
1283
|
// session discoverable regardless of outcome (end_turn, cancelled, retries
|
|
1366
1284
|
// exhausted). Also refresh the backend-loaded verification: the resident
|
|
1367
1285
|
// runtime was demonstrably live through this turn.
|
|
1368
|
-
server.markSessionActive(
|
|
1369
|
-
server.markBackendLoaded(
|
|
1286
|
+
server.markSessionActive(acpSid);
|
|
1287
|
+
server.markBackendLoaded(acpSid);
|
|
1370
1288
|
// Turn end = quota refresh point (ADR-0021): usage moved, the dock should
|
|
1371
1289
|
// catch up immediately instead of waiting for the 60s interval.
|
|
1372
1290
|
void forceRefreshQuota();
|
|
@@ -1376,6 +1294,158 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
|
|
|
1376
1294
|
await emitTurnState(stillBusy);
|
|
1377
1295
|
}
|
|
1378
1296
|
}
|
|
1297
|
+
/** One prompt round: subscribe-before-send, run the event-driven turn loop. */
|
|
1298
|
+
async function runPrompt(server, params, cx, requestId, continuationRound = false, client) {
|
|
1299
|
+
// Project-level sandbox flip (ADR-0011): a .zcode/acp/sandbox.json created
|
|
1300
|
+
// after the backend spawned unsandboxed must arm on THIS prompt, not on the
|
|
1301
|
+
// next bridge restart — kill the old process; the ensureBackend() below
|
|
1302
|
+
// respawns under the profile and the subscribe-recovery path reloads the
|
|
1303
|
+
// session. No-op unless the config appeared mid-run.
|
|
1304
|
+
await server.applySandboxFlip();
|
|
1305
|
+
const backend = server.ensureBackend();
|
|
1306
|
+
// Extract prompt text + image attachments from ACP ContentBlock[].
|
|
1307
|
+
const text = extractPromptText(params.prompt);
|
|
1308
|
+
const attachments = extractAttachments(params.prompt);
|
|
1309
|
+
// A prompt is valid if it has text OR at least one image attachment (a user
|
|
1310
|
+
// may drag in an image with no accompanying text).
|
|
1311
|
+
if (!text && attachments.length === 0)
|
|
1312
|
+
throw new Error("empty prompt");
|
|
1313
|
+
// Boot-resume banner handshake (see BOOT_RESUME_TRIGGER): martty queues the
|
|
1314
|
+
// auto-submitted trigger until its boot bind, so it arrives as the FIRST
|
|
1315
|
+
// prompt OF THAT CONNECTION — ack with one chunk and end the turn without
|
|
1316
|
+
// touching the backend. Scoped to the arming connection's identity: a
|
|
1317
|
+
// phone app attached to the same bridge may prompt inside the boot window
|
|
1318
|
+
// and must neither spend nor disarm the handshake (observed live: its
|
|
1319
|
+
// prompt raced ahead and the trigger leaked to the model). The same
|
|
1320
|
+
// connection submitting anything else first means the auto-submit was
|
|
1321
|
+
// lost — disarm so the trigger text typed manually later stays a normal
|
|
1322
|
+
// prompt.
|
|
1323
|
+
if (server.bootResumeTriggerConnection !== null &&
|
|
1324
|
+
clientConnectionRoot(client) === server.bootResumeTriggerConnection) {
|
|
1325
|
+
server.bootResumeTriggerConnection = null;
|
|
1326
|
+
if (text === BOOT_RESUME_TRIGGER) {
|
|
1327
|
+
await sendTextChunk(cx, params.sessionId, messages().bootResumeAck, randomUUID());
|
|
1328
|
+
log("session/prompt: boot-resume banner handshake acknowledged");
|
|
1329
|
+
return { stopReason: "end_turn" };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
// Materialize a lazy session/new placeholder on first use. Placed after the
|
|
1333
|
+
// empty-prompt check so an invalid request doesn't create a backend session.
|
|
1334
|
+
const zcodeSid = await ensureRealSession(server, params.sessionId);
|
|
1335
|
+
// Slash-command interception: dispatches directly to ZCode methods and
|
|
1336
|
+
// returns end_turn without entering the turn loop. Known passthrough
|
|
1337
|
+
// commands and unknown /x both return null for the normal turn loop.
|
|
1338
|
+
const { handleSlashCommand, neutralizeSlashText } = await import("./slash.js");
|
|
1339
|
+
const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text);
|
|
1340
|
+
if (intercepted)
|
|
1341
|
+
return intercepted;
|
|
1342
|
+
// Wire text for the backend: unknown `/x` prompts (not advertised commands)
|
|
1343
|
+
// are neutralized so the backend's command resolver never sees them — an
|
|
1344
|
+
// unresolvable name can hard-fail the turn. Known commands pass through
|
|
1345
|
+
// unchanged. The title/auto-compact paths below keep using the raw `text`.
|
|
1346
|
+
const sendText = neutralizeSlashText(text);
|
|
1347
|
+
// Register self + preempt others under a per-session lock. The lock
|
|
1348
|
+
// serializes the critical section so that two concurrent prompts (B, C) for
|
|
1349
|
+
// the same session can't both miss each other and register at once: C waits
|
|
1350
|
+
// for B's section, by which point B is in pendingTurns, so C's preempt finds
|
|
1351
|
+
// and cancels B. Registering INSIDE the lock is what makes the new turn
|
|
1352
|
+
// visible to the next prompt's preempt scan.
|
|
1353
|
+
const turn = {
|
|
1354
|
+
zcodeSid,
|
|
1355
|
+
cancelled: false,
|
|
1356
|
+
};
|
|
1357
|
+
// True when this send cancelled another in-flight prompt (preempt/stop).
|
|
1358
|
+
// Drives the turn-attribution gate: only a preempted prompt can see leftover
|
|
1359
|
+
// events from a prior turn in its listener queue; without preemption any
|
|
1360
|
+
// events before this turn's turn.started belong to a backend-owned turn
|
|
1361
|
+
// (e.g. auto-resumed after compaction) that this send was steered into.
|
|
1362
|
+
let preempted = false;
|
|
1363
|
+
// Goal-loop parking (ADR-0022 §3): a live loop owns this session's turn
|
|
1364
|
+
// cadence. Park the prompt on the driver — its text merges into the next
|
|
1365
|
+
// round, and the parked request resolves when that round completes —
|
|
1366
|
+
// instead of preempting and cancelling the in-flight goal turn. Checked
|
|
1367
|
+
// INSIDE the preempt lock: the check and the registration are one critical
|
|
1368
|
+
// section, so an /auto start racing this prompt can't register its round
|
|
1369
|
+
// after the check yet before the park (the driver registers through the
|
|
1370
|
+
// same lock). Slash commands /auto pause|resume|stop|status were
|
|
1371
|
+
// intercepted above, so anything reaching here is conversational input.
|
|
1372
|
+
let parked;
|
|
1373
|
+
await withPreemptLock(server, zcodeSid, async () => {
|
|
1374
|
+
const goalLoop = server.goalLoops?.get(zcodeSid);
|
|
1375
|
+
if (goalLoop) {
|
|
1376
|
+
parked = goalLoop.parkPrompt(sendText);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
server.pendingTurns.set(requestId, turn);
|
|
1380
|
+
preempted = preemptInFlightTurn(server, zcodeSid, requestId);
|
|
1381
|
+
});
|
|
1382
|
+
if (parked)
|
|
1383
|
+
return parked;
|
|
1384
|
+
// Discovery: the session is live the moment its turn STARTS — mark it active
|
|
1385
|
+
// here instead of only at turn end, so a freshly created conversation shows
|
|
1386
|
+
// up in remote lists within one heartbeat even while its first (possibly
|
|
1387
|
+
// minutes-long) turn is still running. Until the backend's auto-title lands
|
|
1388
|
+
// at end_turn, seed a provisional title from the prompt text (auto-title
|
|
1389
|
+
// stays authoritative — its set-once gate is the separate sessionTitles).
|
|
1390
|
+
server.markSessionActive(params.sessionId);
|
|
1391
|
+
// Session title: set EXACTLY ONCE, here, from the first prompt of a
|
|
1392
|
+
// freshly created session — immediately, not at end_turn (a preempted
|
|
1393
|
+
// first turn ends "cancelled" and would never be titled; and the
|
|
1394
|
+
// completing prompt must not steal the title). After this, no automatic
|
|
1395
|
+
// path may change the title again: sessionTitles is set-once and a manual
|
|
1396
|
+
// rename is the only later modifier. Resumed/loaded sessions are not
|
|
1397
|
+
// title-eligible — their stored title was adopted on load, or left unset.
|
|
1398
|
+
if (server.titleEligibleSessions.has(params.sessionId) &&
|
|
1399
|
+
text &&
|
|
1400
|
+
!server.sessionTitles.has(params.sessionId)) {
|
|
1401
|
+
// Title = first non-empty line of the prompt, truncated to 80 chars.
|
|
1402
|
+
// Multi-line prompts must not leak newlines into the session title.
|
|
1403
|
+
// Split on any line break (\r\n, \n, \r) so all platforms are covered.
|
|
1404
|
+
const title = text
|
|
1405
|
+
.split(/\r\n|\r|\n/)
|
|
1406
|
+
.map((l) => l.trim())
|
|
1407
|
+
.find((l) => l.length > 0)
|
|
1408
|
+
?.slice(0, 80) ?? text.slice(0, 80);
|
|
1409
|
+
server.sessionTitles.set(params.sessionId, title);
|
|
1410
|
+
server.touchSessionSummary(params.sessionId, title);
|
|
1411
|
+
const { updateSessionTitle } = await import("../tasks-index.js");
|
|
1412
|
+
void updateSessionTitle(zcodeSid, title, text);
|
|
1413
|
+
void sendSessionUpdate(cx, params.sessionId, {
|
|
1414
|
+
sessionUpdate: "session_info_update",
|
|
1415
|
+
title,
|
|
1416
|
+
updatedAt: new Date().toISOString(),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
// Out-of-band running indicator: clients that did not send this prompt
|
|
1420
|
+
// (re-attached mobile, second editor) learn the turn started here — the
|
|
1421
|
+
// session/load replayMeta only snapshots attach time. Emitted per attached
|
|
1422
|
+
// alias (see server.sessionAliases) so a client holding this conversation
|
|
1423
|
+
// under a different ACP id opens its live turn too. Best-effort: a dead
|
|
1424
|
+
// client must not fail the turn.
|
|
1425
|
+
const emitTurnState = async (running) => {
|
|
1426
|
+
const results = await Promise.allSettled(server
|
|
1427
|
+
.sessionAliases(params.sessionId)
|
|
1428
|
+
.map((sid) => cx.notify("$/zcode/turnState", { sessionId: sid, running })));
|
|
1429
|
+
for (const r of results) {
|
|
1430
|
+
if (r.status === "rejected") {
|
|
1431
|
+
log(`turnState notify failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
await emitTurnState(true);
|
|
1436
|
+
return runOneTurn(server, {
|
|
1437
|
+
backend,
|
|
1438
|
+
cx,
|
|
1439
|
+
acpSid: params.sessionId,
|
|
1440
|
+
zcodeSid,
|
|
1441
|
+
requestId,
|
|
1442
|
+
turn,
|
|
1443
|
+
preempted,
|
|
1444
|
+
sendText,
|
|
1445
|
+
attachments,
|
|
1446
|
+
continuationRound,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1379
1449
|
/** How long after a cancel a new prompt's attribution gate stays armed (the
|
|
1380
1450
|
* abandoned turn may still be streaming its finalisation into the backend). */
|
|
1381
1451
|
const CANCEL_RESIDUE_WINDOW_MS = 120_000;
|
|
@@ -1440,8 +1510,10 @@ export async function cancel(server, params) {
|
|
|
1440
1510
|
// could leave the live one running. Each turn guards its own stopSent, so
|
|
1441
1511
|
// multiple matching turns may each fire the stop pair once — the backend
|
|
1442
1512
|
// treats both as idempotent, so the duplicate is harmless.
|
|
1513
|
+
let matched = false;
|
|
1443
1514
|
for (const [, turn] of server.pendingTurns) {
|
|
1444
1515
|
if (turn.zcodeSid === zcodeSid) {
|
|
1516
|
+
matched = true;
|
|
1445
1517
|
turn.cancelled = true;
|
|
1446
1518
|
if (!turn.stopSent) {
|
|
1447
1519
|
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
@@ -1452,6 +1524,18 @@ export async function cancel(server, params) {
|
|
|
1452
1524
|
server.lastCancelledAt.set(zcodeSid, Date.now());
|
|
1453
1525
|
}
|
|
1454
1526
|
}
|
|
1527
|
+
// ESC during a goal loop's quiet windows (compact()'s internal wait, the
|
|
1528
|
+
// judge/announce awaits) has no pendingTurns entry to flag — park the pause
|
|
1529
|
+
// on the driver instead; it takes effect at the next round boundary.
|
|
1530
|
+
// Best-effort: never break cancel for non-goal sessions.
|
|
1531
|
+
if (!matched) {
|
|
1532
|
+
try {
|
|
1533
|
+
server.goalLoops?.get(zcodeSid)?.requestPause();
|
|
1534
|
+
}
|
|
1535
|
+
catch (e) {
|
|
1536
|
+
log(`goal-loop pause request failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1455
1539
|
log(`session/cancel → ${zcodeSid}`);
|
|
1456
1540
|
}
|
|
1457
1541
|
/**
|
|
@@ -1667,7 +1751,7 @@ export async function drainBackendAfterCancel(server, deps) {
|
|
|
1667
1751
|
* synchronous; preempt no longer waits). The turn loop itself runs OUTSIDE
|
|
1668
1752
|
* this lock — only registration + preempt are serialized.
|
|
1669
1753
|
*/
|
|
1670
|
-
function withPreemptLock(server, zcodeSid, body) {
|
|
1754
|
+
export function withPreemptLock(server, zcodeSid, body) {
|
|
1671
1755
|
const prev = server.preemptLocks.get(zcodeSid) ?? Promise.resolve();
|
|
1672
1756
|
const next = prev.then(body, body); // run body regardless of prior rejection
|
|
1673
1757
|
server.preemptLocks.set(zcodeSid, next);
|
|
@@ -1719,6 +1803,12 @@ export function preemptInFlightTurn(server, zcodeSid, selfRequestId) {
|
|
|
1719
1803
|
for (const [reqId, turn] of server.pendingTurns) {
|
|
1720
1804
|
if (turn.zcodeSid !== zcodeSid || reqId === selfRequestId)
|
|
1721
1805
|
continue;
|
|
1806
|
+
// Goal-loop rounds (ADR-0022): user prompts PARK on the driver (see the
|
|
1807
|
+
// parking hook in runPrompt) instead of preempting — a goalLoop turn in
|
|
1808
|
+
// this scan means a path that bypassed the hook (defensive); never
|
|
1809
|
+
// cancel it here. ESC/cancel() still cancels it like any pending turn.
|
|
1810
|
+
if (turn.goalLoop)
|
|
1811
|
+
continue;
|
|
1722
1812
|
turn.cancelled = true; // signal the old turn to stop its retry loops
|
|
1723
1813
|
if (!turn.stopSent) {
|
|
1724
1814
|
stopBackendTurn(server, zcodeSid, turn.foregroundExecutionId);
|
|
@@ -1974,6 +2064,52 @@ function getOrCreateDiffer(server, zcodeSid) {
|
|
|
1974
2064
|
differ.resetTurn();
|
|
1975
2065
|
return differ;
|
|
1976
2066
|
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Map the backend's merged per-turn usage (`EventTranslator.turnUsage`) onto
|
|
2069
|
+
* the ACP `PromptResponse.usage` shape (UNSTABLE in agent-client-protocol;
|
|
2070
|
+
* per-turn semantics per its "Token usage for this turn" description). The
|
|
2071
|
+
* three required counters are always present in the backend object (its
|
|
2072
|
+
* reducer 0-fills them); the optional ones pass through as null when
|
|
2073
|
+
* unreported. Field renames: reasoningTokens→thoughtTokens,
|
|
2074
|
+
* cacheRead/cacheWriteTokens→cachedRead/cachedWriteTokens.
|
|
2075
|
+
*/
|
|
2076
|
+
export function toAcpTurnUsage(u) {
|
|
2077
|
+
if (!u)
|
|
2078
|
+
return undefined;
|
|
2079
|
+
const num = (k) => (typeof u[k] === "number" ? u[k] : 0);
|
|
2080
|
+
const numOrNull = (k) => typeof u[k] === "number" ? u[k] : null;
|
|
2081
|
+
return {
|
|
2082
|
+
totalTokens: num("totalTokens"),
|
|
2083
|
+
inputTokens: num("inputTokens"),
|
|
2084
|
+
outputTokens: num("outputTokens"),
|
|
2085
|
+
thoughtTokens: numOrNull("reasoningTokens"),
|
|
2086
|
+
cachedReadTokens: numOrNull("cacheReadTokens"),
|
|
2087
|
+
cachedWriteTokens: numOrNull("cacheWriteTokens"),
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Build the ACP `session/prompt` result for a concluded turn, attaching the
|
|
2092
|
+
* turn's usage when the backend reported one (absent otherwise — no synthetic
|
|
2093
|
+
* zeros). Spec fields carry the standard counters; backend extras (source,
|
|
2094
|
+
* modelRequestCount, web request counts) ride in `_meta.zcode.usage` per the
|
|
2095
|
+
* bridge's extension policy.
|
|
2096
|
+
*/
|
|
2097
|
+
export function turnResult(translator, stopReason) {
|
|
2098
|
+
const raw = translator.turnUsage;
|
|
2099
|
+
const usage = toAcpTurnUsage(raw);
|
|
2100
|
+
if (!usage || !raw)
|
|
2101
|
+
return { stopReason };
|
|
2102
|
+
const extras = {};
|
|
2103
|
+
for (const k of ["source", "modelRequestCount", "webFetchRequests", "webSearchRequests"]) {
|
|
2104
|
+
if (raw[k] !== undefined)
|
|
2105
|
+
extras[k] = raw[k];
|
|
2106
|
+
}
|
|
2107
|
+
return {
|
|
2108
|
+
stopReason,
|
|
2109
|
+
usage,
|
|
2110
|
+
_meta: { zcode: { usage: extras } },
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
1977
2113
|
/**
|
|
1978
2114
|
* Event-driven turn loop: translate zcode events via EventTranslator and
|
|
1979
2115
|
* dispatch each internal event to the ACP client. No-progress timeout is 120s
|
|
@@ -2093,7 +2229,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2093
2229
|
// Preserve the pre-existing bounded cancel behaviour. A stuck prompt
|
|
2094
2230
|
// lock after stop must not keep a user-cancelled turn alive forever.
|
|
2095
2231
|
stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
|
|
2096
|
-
return
|
|
2232
|
+
return turnResult(translator, "max_turn_requests");
|
|
2097
2233
|
}
|
|
2098
2234
|
else {
|
|
2099
2235
|
const frozenMs = Date.now() - lastWatermarkAdvanceAt;
|
|
@@ -2121,7 +2257,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2121
2257
|
// (never compress its context; the completion is inferred).
|
|
2122
2258
|
turn.stallRecovered = true;
|
|
2123
2259
|
log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s; ending turn after delivered output`);
|
|
2124
|
-
return
|
|
2260
|
+
return turnResult(translator, "end_turn");
|
|
2125
2261
|
}
|
|
2126
2262
|
else {
|
|
2127
2263
|
const reply = await fetchLastReply(server, turn.zcodeSid, differ);
|
|
@@ -2130,11 +2266,11 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2130
2266
|
await sendTextChunk(cx, acpSid, reply.text, chunkMsgId);
|
|
2131
2267
|
turn.stallRecovered = true;
|
|
2132
2268
|
log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s; recovered reply via session/messages`);
|
|
2133
|
-
return
|
|
2269
|
+
return turnResult(translator, "end_turn");
|
|
2134
2270
|
}
|
|
2135
2271
|
log(` [stall] watermark frozen ${Math.round(frozenMs / 1000)}s with no output; stopping backend turn`);
|
|
2136
2272
|
stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
|
|
2137
|
-
return
|
|
2273
|
+
return turnResult(translator, "max_turn_requests");
|
|
2138
2274
|
}
|
|
2139
2275
|
}
|
|
2140
2276
|
}
|
|
@@ -2169,7 +2305,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2169
2305
|
stopBackendTurn(server, turn.zcodeSid, turn.foregroundExecutionId);
|
|
2170
2306
|
turn.stopSent = true;
|
|
2171
2307
|
}
|
|
2172
|
-
return
|
|
2308
|
+
return turnResult(translator, "cancelled");
|
|
2173
2309
|
}
|
|
2174
2310
|
const ev = await listener.pollEvent(500);
|
|
2175
2311
|
if (ev === null) {
|
|
@@ -2237,7 +2373,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2237
2373
|
// turn — the completion was inferred, and compressing an
|
|
2238
2374
|
// in-flight task's context would destroy the work.
|
|
2239
2375
|
turn.stallRecovered = true;
|
|
2240
|
-
return
|
|
2376
|
+
return turnResult(translator, "end_turn");
|
|
2241
2377
|
}
|
|
2242
2378
|
// Second probe says the backend is still working (or events arrived
|
|
2243
2379
|
// mid-probe) — keep waiting; queued events are consumed by the next
|
|
@@ -2267,7 +2403,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2267
2403
|
// swallow: report it at once so the user can resend immediately.
|
|
2268
2404
|
if (ev.type === "turn.steerQueued" && !translator.turnStarted && gateArmed) {
|
|
2269
2405
|
await sendTextChunk(cx, acpSid, messages().messageSwallowedByTurn, chunkMsgId);
|
|
2270
|
-
return
|
|
2406
|
+
return turnResult(translator, "max_turn_requests");
|
|
2271
2407
|
}
|
|
2272
2408
|
// Turn-attribution gate: before this turn's own turn.started arrives, any
|
|
2273
2409
|
// event is leftover from a prior turn (cancelled/preempted but still
|
|
@@ -2342,7 +2478,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2342
2478
|
if (denial) {
|
|
2343
2479
|
await handleSandboxDenial(server, cx, acpSid, denial, toolCallId);
|
|
2344
2480
|
if (turn.cancelled)
|
|
2345
|
-
return
|
|
2481
|
+
return turnResult(translator, "cancelled");
|
|
2346
2482
|
}
|
|
2347
2483
|
else {
|
|
2348
2484
|
// No path parsed — one generic hint per session, not one per retry.
|
|
@@ -2423,7 +2559,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2423
2559
|
// terminal resultType (cancelled / success / failed), honour the user's
|
|
2424
2560
|
// intent and report cancelled.
|
|
2425
2561
|
if (turn.cancelled || translator.turnResultType === "cancelled") {
|
|
2426
|
-
return
|
|
2562
|
+
return turnResult(translator, "cancelled");
|
|
2427
2563
|
}
|
|
2428
2564
|
if (translator.turnFailed) {
|
|
2429
2565
|
// Best-effort stop in case the failed turn left a residual lock.
|
|
@@ -2461,10 +2597,16 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2461
2597
|
const snapshot = await buildSnapshot(server, turn.zcodeSid);
|
|
2462
2598
|
const completionEvents = differ.diff(snapshot);
|
|
2463
2599
|
for (const iev of completionEvents) {
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2600
|
+
// Per-kind dedup (see deliveredReasoningMessageIds): text and reasoning
|
|
2601
|
+
// share one assistant message id, so the kind that streamed live must
|
|
2602
|
+
// be suppressed while the other kind — typically GLM reasoning that
|
|
2603
|
+
// only exists in the completion snapshot — must still go through.
|
|
2604
|
+
if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta") {
|
|
2605
|
+
const delivered = iev.kind === "TextDelta"
|
|
2606
|
+
? translator.deliveredMessageIds
|
|
2607
|
+
: translator.deliveredReasoningMessageIds;
|
|
2608
|
+
if (iev.messageId && delivered.has(iev.messageId))
|
|
2609
|
+
continue;
|
|
2468
2610
|
}
|
|
2469
2611
|
await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
|
|
2470
2612
|
}
|
|
@@ -2474,7 +2616,7 @@ export async function runEventTurn(server, listener, monitor, differ, cx, acpSid
|
|
|
2474
2616
|
// push current_mode_update + config_option_update when it changed since
|
|
2475
2617
|
// the last value advertised to the client.
|
|
2476
2618
|
await emitModeIfChanged(server, cx, acpSid, turn.zcodeSid);
|
|
2477
|
-
return
|
|
2619
|
+
return turnResult(translator, "end_turn");
|
|
2478
2620
|
}
|
|
2479
2621
|
}
|
|
2480
2622
|
}
|