@threadbase-sh/streamer 1.16.1 → 1.17.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/dist/cli.cjs +515 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +513 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +57 -1
- package/dist/index.d.ts +57 -1
- package/dist/index.js +513 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -702,6 +702,162 @@ function getLogger(component) {
|
|
|
702
702
|
}
|
|
703
703
|
var logger = build(baseLogger);
|
|
704
704
|
|
|
705
|
+
// src/services/questions/detectPermissionGate.ts
|
|
706
|
+
var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
|
|
707
|
+
function hasPermissionOsc(rawData) {
|
|
708
|
+
return OSC_777_RE.test(rawData);
|
|
709
|
+
}
|
|
710
|
+
var OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(.+?)\s*$/;
|
|
711
|
+
var FOOTER_RE = /Enter to select|Esc to cancel|↑|↓|to navigate|to cancel/i;
|
|
712
|
+
var BOX_ONLY_RE = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
|
|
713
|
+
var PROMPT_ARROW_RE = /^[\s]*[❯›>]\s*$/;
|
|
714
|
+
function stripGutter(line) {
|
|
715
|
+
return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
|
|
716
|
+
}
|
|
717
|
+
function scrapePermissionGate(lines) {
|
|
718
|
+
const options = [];
|
|
719
|
+
let cursor;
|
|
720
|
+
let firstOptionLine = -1;
|
|
721
|
+
for (let i = 0; i < lines.length; i++) {
|
|
722
|
+
const m = OPTION_RE.exec(stripGutter(lines[i]));
|
|
723
|
+
if (!m) continue;
|
|
724
|
+
const index = Number.parseInt(m[2], 10);
|
|
725
|
+
if (!Number.isFinite(index)) continue;
|
|
726
|
+
if (firstOptionLine === -1) firstOptionLine = i;
|
|
727
|
+
if (m[1]) cursor = index;
|
|
728
|
+
options.push({ index, label: m[3] });
|
|
729
|
+
}
|
|
730
|
+
if (options.length === 0) return null;
|
|
731
|
+
let prompt;
|
|
732
|
+
for (let i = firstOptionLine - 1; i >= 0; i--) {
|
|
733
|
+
const t = stripGutter(lines[i]).trim();
|
|
734
|
+
if (t.length === 0) continue;
|
|
735
|
+
if (BOX_ONLY_RE.test(lines[i].trim()) || FOOTER_RE.test(t) || PROMPT_ARROW_RE.test(t)) continue;
|
|
736
|
+
prompt = t || void 0;
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
return { ...prompt ? { prompt } : {}, options, ...cursor !== void 0 ? { cursor } : {} };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/services/questions/detectQuestionFromScreen.ts
|
|
743
|
+
var ASK_FOOTER_RE = /Enter to select/i;
|
|
744
|
+
var ESC_FOOTER_RE = /Esc to cancel|to navigate/i;
|
|
745
|
+
var OPTION_RE2 = /^\s*(?:❯)?\s*(\d+)\.\s+(.+?)\s*$/;
|
|
746
|
+
var QUESTION_RE = /\?\s*$/;
|
|
747
|
+
var BOX_ONLY_RE2 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
|
|
748
|
+
var PERMISSION_LABEL_RE = /^(Yes|No)\b/i;
|
|
749
|
+
function stripBoxGutter(line) {
|
|
750
|
+
return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
|
|
751
|
+
}
|
|
752
|
+
function detectQuestionFromScreen(lines) {
|
|
753
|
+
let footerIdx = -1;
|
|
754
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
755
|
+
if (ASK_FOOTER_RE.test(lines[i])) {
|
|
756
|
+
footerIdx = i;
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (footerIdx === -1) return null;
|
|
761
|
+
const options = [];
|
|
762
|
+
let firstOptionIdx = -1;
|
|
763
|
+
for (let i = footerIdx - 1; i >= 0; i--) {
|
|
764
|
+
const line = lines[i];
|
|
765
|
+
const inner = stripBoxGutter(line);
|
|
766
|
+
const trimmed = inner.trim();
|
|
767
|
+
if (trimmed.length === 0) {
|
|
768
|
+
if (options.length === 0) continue;
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
if (BOX_ONLY_RE2.test(line.trim())) continue;
|
|
772
|
+
if (ESC_FOOTER_RE.test(line) && options.length === 0) continue;
|
|
773
|
+
if (options.length > 0 && QUESTION_RE.test(trimmed) && !OPTION_RE2.test(inner)) break;
|
|
774
|
+
const m = OPTION_RE2.exec(inner);
|
|
775
|
+
if (m) {
|
|
776
|
+
const label = m[2].trim();
|
|
777
|
+
if (PERMISSION_LABEL_RE.test(label)) return null;
|
|
778
|
+
options.unshift({ label, description: "" });
|
|
779
|
+
firstOptionIdx = i;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (options.length < 2 || firstOptionIdx === -1) return null;
|
|
783
|
+
let question;
|
|
784
|
+
for (let i = firstOptionIdx - 1; i >= 0; i--) {
|
|
785
|
+
const raw = stripBoxGutter(lines[i]);
|
|
786
|
+
const trimmed = raw.trim();
|
|
787
|
+
if (trimmed.length === 0) continue;
|
|
788
|
+
if (BOX_ONLY_RE2.test(lines[i].trim())) continue;
|
|
789
|
+
if (QUESTION_RE.test(trimmed)) {
|
|
790
|
+
question = trimmed;
|
|
791
|
+
}
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
if (!question) return null;
|
|
795
|
+
return {
|
|
796
|
+
questions: [{ question, header: "", multiSelect: false, options }]
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function questionContentKey(questions) {
|
|
800
|
+
return questions.map((q) => `${q.question} ${q.options.map((o) => o.label).join(",")}`).join("::");
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/services/questions/detectShellPrompt.ts
|
|
804
|
+
var ENTER = "\r";
|
|
805
|
+
var YN_RE = /[[(]\s*y\s*\/\s*n\s*[\])]/i;
|
|
806
|
+
var PRESS_ENTER_RE = /press\s+(enter|return|any key)/i;
|
|
807
|
+
var CONTINUE_RE = /\bcontinue\b\s*\??\s*$/i;
|
|
808
|
+
var NUMBERED_RE = /^\s*(?:❯|>)?\s*(\d+)[.)]\s+(.+?)\s*$/;
|
|
809
|
+
var CLAUDE_CHROME_RE = /Enter to select|Esc to cancel|╭|╰|│.*│/;
|
|
810
|
+
var BOX_ONLY_RE3 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
|
|
811
|
+
function lastNonBlank(lines) {
|
|
812
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
813
|
+
const t = lines[i].trim();
|
|
814
|
+
if (t.length > 0) return { text: t, idx: i };
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
function detectShellPrompt(lines) {
|
|
819
|
+
if (lines.some((l) => CLAUDE_CHROME_RE.test(l))) return null;
|
|
820
|
+
const last = lastNonBlank(lines);
|
|
821
|
+
if (!last) return null;
|
|
822
|
+
if (YN_RE.test(last.text)) {
|
|
823
|
+
return {
|
|
824
|
+
prompt: last.text,
|
|
825
|
+
options: [
|
|
826
|
+
{ index: 1, label: "Yes", answerKeys: `y${ENTER}` },
|
|
827
|
+
{ index: 2, label: "No", answerKeys: `n${ENTER}` }
|
|
828
|
+
]
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
if (NUMBERED_RE.test(last.text)) {
|
|
832
|
+
const options = [];
|
|
833
|
+
for (let i = 0; i <= last.idx; i++) {
|
|
834
|
+
const m = NUMBERED_RE.exec(lines[i]);
|
|
835
|
+
if (!m) continue;
|
|
836
|
+
const num = Number.parseInt(m[1], 10);
|
|
837
|
+
if (!Number.isFinite(num)) continue;
|
|
838
|
+
options.push({ index: num, label: m[2].trim(), answerKeys: `${num}${ENTER}` });
|
|
839
|
+
}
|
|
840
|
+
if (options.length >= 2) {
|
|
841
|
+
const firstRow = lines.findIndex((l) => NUMBERED_RE.test(l));
|
|
842
|
+
let prompt = "";
|
|
843
|
+
for (let i = firstRow - 1; i >= 0; i--) {
|
|
844
|
+
const t = lines[i].trim();
|
|
845
|
+
if (t.length === 0 || BOX_ONLY_RE3.test(t)) continue;
|
|
846
|
+
prompt = t;
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
return { prompt: prompt || "Select an option", options };
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (PRESS_ENTER_RE.test(last.text) || CONTINUE_RE.test(last.text)) {
|
|
853
|
+
return {
|
|
854
|
+
prompt: last.text,
|
|
855
|
+
options: [{ index: 1, label: "Continue", answerKeys: ENTER }]
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
|
|
705
861
|
// src/pty-manager.ts
|
|
706
862
|
var OUTPUT_BUFFER_MAX = 65536;
|
|
707
863
|
var PTY_COLS = 120;
|
|
@@ -752,6 +908,21 @@ var PTYManager = class {
|
|
|
752
908
|
onOutput;
|
|
753
909
|
onStatusChange;
|
|
754
910
|
onReady;
|
|
911
|
+
onPermissionChange;
|
|
912
|
+
onLiveQuestion;
|
|
913
|
+
onLiveQuestionGone;
|
|
914
|
+
// Per-session permission-gate state. True between an OSC 777 (gate open) and
|
|
915
|
+
// the next prompt-ready without a fresh 777 (gate closed). Prevents
|
|
916
|
+
// re-broadcasting open/close on every chunk.
|
|
917
|
+
permissionOpen = /* @__PURE__ */ new Set();
|
|
918
|
+
// Content key of the last AskUserQuestion broadcast from the rendered screen,
|
|
919
|
+
// per session — de-dupes the same menu firing on consecutive repaints.
|
|
920
|
+
lastScreenQuestionKey = /* @__PURE__ */ new Map();
|
|
921
|
+
// Content key of the last unstructured shell prompt (detectShellPrompt)
|
|
922
|
+
// broadcast per session — present between open and resolve so we can clear it
|
|
923
|
+
// on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
|
|
924
|
+
// on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
|
|
925
|
+
shellPromptOpen = /* @__PURE__ */ new Map();
|
|
755
926
|
// Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
|
|
756
927
|
// hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
|
|
757
928
|
pendingReady = /* @__PURE__ */ new Set();
|
|
@@ -774,6 +945,9 @@ var PTYManager = class {
|
|
|
774
945
|
this.onOutput = options.onOutput;
|
|
775
946
|
this.onStatusChange = options.onStatusChange;
|
|
776
947
|
this.onReady = options.onReady;
|
|
948
|
+
this.onPermissionChange = options.onPermissionChange;
|
|
949
|
+
this.onLiveQuestion = options.onLiveQuestion;
|
|
950
|
+
this.onLiveQuestionGone = options.onLiveQuestionGone;
|
|
777
951
|
this.log = options.logger ?? getLogger("pty");
|
|
778
952
|
}
|
|
779
953
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
@@ -1016,6 +1190,9 @@ var PTYManager = class {
|
|
|
1016
1190
|
this.pendingReady.delete(sessionId);
|
|
1017
1191
|
this.queuedInputs.delete(sessionId);
|
|
1018
1192
|
this.firstChunkAt.delete(sessionId);
|
|
1193
|
+
this.permissionOpen.delete(sessionId);
|
|
1194
|
+
this.lastScreenQuestionKey.delete(sessionId);
|
|
1195
|
+
this.shellPromptOpen.delete(sessionId);
|
|
1019
1196
|
try {
|
|
1020
1197
|
session.process.kill("SIGINT");
|
|
1021
1198
|
} catch {
|
|
@@ -1075,6 +1252,9 @@ var PTYManager = class {
|
|
|
1075
1252
|
this.firstChunkAt.clear();
|
|
1076
1253
|
this.chunkIndex.clear();
|
|
1077
1254
|
this.lastChunkAt.clear();
|
|
1255
|
+
this.permissionOpen.clear();
|
|
1256
|
+
this.lastScreenQuestionKey.clear();
|
|
1257
|
+
this.shellPromptOpen.clear();
|
|
1078
1258
|
}
|
|
1079
1259
|
handleOutput(sessionId, data) {
|
|
1080
1260
|
const session = this.sessions.get(sessionId);
|
|
@@ -1118,6 +1298,87 @@ var PTYManager = class {
|
|
|
1118
1298
|
this.markReady(sessionId, session, "fallback:timeout");
|
|
1119
1299
|
}
|
|
1120
1300
|
this.onOutput?.(sessionId, data);
|
|
1301
|
+
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
1302
|
+
this.log.warn("[pty.prompt_detect] failed", {
|
|
1303
|
+
event: "pty.prompt_detect_failed",
|
|
1304
|
+
sessionId,
|
|
1305
|
+
err
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
// Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
|
|
1310
|
+
// menus from the rendered screen, firing the additive callbacks. Async because
|
|
1311
|
+
// reading the rendered buffer needs the xterm write queue flushed. Pure
|
|
1312
|
+
// detection lives in services/questions/*; this only orchestrates triggers,
|
|
1313
|
+
// per-session debounce, and the callbacks.
|
|
1314
|
+
async detectLivePrompts(sessionId, rawData, stripped) {
|
|
1315
|
+
const session = this.sessions.get(sessionId);
|
|
1316
|
+
if (!session) return;
|
|
1317
|
+
const oscPermission = hasPermissionOsc(rawData);
|
|
1318
|
+
const hasAskFooter = /Enter to select/i.test(stripped);
|
|
1319
|
+
const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m) => stripped.includes(m));
|
|
1320
|
+
const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
|
|
1321
|
+
stripped
|
|
1322
|
+
);
|
|
1323
|
+
if (!oscPermission && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const lines = await this.getOutputLines(sessionId, 60);
|
|
1327
|
+
const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
|
|
1328
|
+
if (oscPermission || hasAskFooter || askFooterOnScreen) {
|
|
1329
|
+
this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
|
|
1330
|
+
event: "pty.prompt_detect",
|
|
1331
|
+
sessionId,
|
|
1332
|
+
oscPermission,
|
|
1333
|
+
hasAskFooter,
|
|
1334
|
+
askFooterOnScreen,
|
|
1335
|
+
permGate: oscPermission && !askFooterOnScreen ? scrapePermissionGate(lines) : void 0,
|
|
1336
|
+
askQuestion: askFooterOnScreen ? detectQuestionFromScreen(lines) : void 0,
|
|
1337
|
+
renderedTail: lines.slice(-25)
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
if (oscPermission && !askFooterOnScreen) {
|
|
1341
|
+
const gate = scrapePermissionGate(lines);
|
|
1342
|
+
this.permissionOpen.add(sessionId);
|
|
1343
|
+
this.onPermissionChange?.(sessionId, gate ?? { options: [] });
|
|
1344
|
+
} else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
|
|
1345
|
+
const gate = scrapePermissionGate(lines);
|
|
1346
|
+
if (gate) {
|
|
1347
|
+
this.onPermissionChange?.(sessionId, gate);
|
|
1348
|
+
} else if (hasPromptMarker) {
|
|
1349
|
+
this.permissionOpen.delete(sessionId);
|
|
1350
|
+
this.onPermissionChange?.(sessionId, null);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (askFooterOnScreen) {
|
|
1354
|
+
const detected = detectQuestionFromScreen(lines);
|
|
1355
|
+
if (detected) {
|
|
1356
|
+
const key = questionContentKey(detected.questions);
|
|
1357
|
+
if (this.lastScreenQuestionKey.get(sessionId) !== key) {
|
|
1358
|
+
this.lastScreenQuestionKey.set(sessionId, key);
|
|
1359
|
+
this.onLiveQuestion?.(sessionId, detected.questions);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
} else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
|
|
1363
|
+
this.lastScreenQuestionKey.delete(sessionId);
|
|
1364
|
+
this.onLiveQuestionGone?.(sessionId);
|
|
1365
|
+
}
|
|
1366
|
+
if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
|
|
1367
|
+
const shell = detectShellPrompt(lines);
|
|
1368
|
+
if (shell) {
|
|
1369
|
+
const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
|
|
1370
|
+
if (this.shellPromptOpen.get(sessionId) !== key) {
|
|
1371
|
+
this.shellPromptOpen.set(sessionId, key);
|
|
1372
|
+
this.onPermissionChange?.(sessionId, {
|
|
1373
|
+
prompt: shell.prompt,
|
|
1374
|
+
options: shell.options
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
} else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
|
|
1378
|
+
this.shellPromptOpen.delete(sessionId);
|
|
1379
|
+
this.onPermissionChange?.(sessionId, null);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1121
1382
|
}
|
|
1122
1383
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
1123
1384
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
@@ -1156,6 +1417,9 @@ var PTYManager = class {
|
|
|
1156
1417
|
this.sessions.delete(sessionId);
|
|
1157
1418
|
this.queuedInputs.delete(sessionId);
|
|
1158
1419
|
this.firstChunkAt.delete(sessionId);
|
|
1420
|
+
this.permissionOpen.delete(sessionId);
|
|
1421
|
+
this.lastScreenQuestionKey.delete(sessionId);
|
|
1422
|
+
this.shellPromptOpen.delete(sessionId);
|
|
1159
1423
|
}
|
|
1160
1424
|
};
|
|
1161
1425
|
function toPublicSession(s) {
|
|
@@ -1830,6 +2094,10 @@ var createSessionRoutes = (deps) => {
|
|
|
1830
2094
|
await deps.handleSendInput(c.req.param("id"), c.env.incoming, c.env.outgoing);
|
|
1831
2095
|
return alreadyHandled6();
|
|
1832
2096
|
});
|
|
2097
|
+
app.post("/:id/answer", async (c) => {
|
|
2098
|
+
await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
|
|
2099
|
+
return alreadyHandled6();
|
|
2100
|
+
});
|
|
1833
2101
|
app.post("/:id/files", async (c) => {
|
|
1834
2102
|
await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
|
|
1835
2103
|
return alreadyHandled6();
|
|
@@ -3452,6 +3720,129 @@ function pruneAgentConversations(cache) {
|
|
|
3452
3720
|
return { scanned: rows.length, pruned, missing };
|
|
3453
3721
|
}
|
|
3454
3722
|
|
|
3723
|
+
// src/services/questions/detectAskUserQuestion.ts
|
|
3724
|
+
function normalizeContent2(raw) {
|
|
3725
|
+
if (Array.isArray(raw)) return raw;
|
|
3726
|
+
if (typeof raw === "string") return [{ type: "text", text: raw }];
|
|
3727
|
+
return [];
|
|
3728
|
+
}
|
|
3729
|
+
function coerceOptions(raw) {
|
|
3730
|
+
if (!Array.isArray(raw)) return null;
|
|
3731
|
+
const out = [];
|
|
3732
|
+
for (const o of raw) {
|
|
3733
|
+
if (o && typeof o === "object" && typeof o.label === "string") {
|
|
3734
|
+
const opt = o;
|
|
3735
|
+
out.push({
|
|
3736
|
+
label: opt.label,
|
|
3737
|
+
description: typeof opt.description === "string" ? opt.description : "",
|
|
3738
|
+
...typeof opt.preview === "string" ? { preview: opt.preview } : {}
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
return out.length > 0 ? out : null;
|
|
3743
|
+
}
|
|
3744
|
+
function coerceQuestions(raw) {
|
|
3745
|
+
if (!Array.isArray(raw)) return null;
|
|
3746
|
+
const out = [];
|
|
3747
|
+
for (const q of raw) {
|
|
3748
|
+
if (!q || typeof q !== "object") continue;
|
|
3749
|
+
const qq = q;
|
|
3750
|
+
const options = coerceOptions(qq.options);
|
|
3751
|
+
if (typeof qq.question !== "string" || !options) continue;
|
|
3752
|
+
out.push({
|
|
3753
|
+
question: qq.question,
|
|
3754
|
+
header: typeof qq.header === "string" ? qq.header : "",
|
|
3755
|
+
multiSelect: qq.multiSelect === true,
|
|
3756
|
+
options
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
return out.length > 0 ? out : null;
|
|
3760
|
+
}
|
|
3761
|
+
function detectAskUserQuestion(rawLine) {
|
|
3762
|
+
let parsed;
|
|
3763
|
+
try {
|
|
3764
|
+
parsed = JSON.parse(rawLine);
|
|
3765
|
+
} catch {
|
|
3766
|
+
return null;
|
|
3767
|
+
}
|
|
3768
|
+
const blocks = normalizeContent2(parsed.message?.content ?? parsed.content);
|
|
3769
|
+
for (const b of blocks) {
|
|
3770
|
+
if (b.type === "tool_use" && b.name === "AskUserQuestion" && typeof b.id === "string") {
|
|
3771
|
+
const input = b.input;
|
|
3772
|
+
const questions = coerceQuestions(input?.questions);
|
|
3773
|
+
if (questions) return { toolUseId: b.id, questions };
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
return null;
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
// src/services/questions/questionBroadcast.ts
|
|
3780
|
+
function questionsFromLines(sessionId, lines) {
|
|
3781
|
+
const messages = [];
|
|
3782
|
+
const pending = [];
|
|
3783
|
+
for (const line of lines) {
|
|
3784
|
+
const detected = detectAskUserQuestion(line);
|
|
3785
|
+
if (detected) {
|
|
3786
|
+
messages.push({
|
|
3787
|
+
type: "question",
|
|
3788
|
+
sessionId,
|
|
3789
|
+
toolUseId: detected.toolUseId,
|
|
3790
|
+
questions: detected.questions
|
|
3791
|
+
});
|
|
3792
|
+
pending.push(detected);
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
return { messages, pending };
|
|
3796
|
+
}
|
|
3797
|
+
function shouldBroadcastQuestion(args) {
|
|
3798
|
+
const alreadyShown = args.lastContentKey === args.newContentKey;
|
|
3799
|
+
if (!alreadyShown) return true;
|
|
3800
|
+
return args.priorToolUseId !== args.newToolUseId;
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
// src/services/questions/answersToKeystrokes.ts
|
|
3804
|
+
var DOWN = "\x1B[B";
|
|
3805
|
+
var ENTER2 = "\r";
|
|
3806
|
+
var UnknownOptionError = class extends Error {
|
|
3807
|
+
constructor(question, value) {
|
|
3808
|
+
super(`No option labelled "${value}" for question "${question}"`);
|
|
3809
|
+
this.question = question;
|
|
3810
|
+
this.value = value;
|
|
3811
|
+
this.name = "UnknownOptionError";
|
|
3812
|
+
}
|
|
3813
|
+
question;
|
|
3814
|
+
value;
|
|
3815
|
+
};
|
|
3816
|
+
function answersToKeystrokes(questions, answers) {
|
|
3817
|
+
let out = "";
|
|
3818
|
+
for (const q of questions) {
|
|
3819
|
+
const raw = answers[q.question];
|
|
3820
|
+
if (raw === void 0) {
|
|
3821
|
+
throw new Error(`Missing answer for question "${q.question}"`);
|
|
3822
|
+
}
|
|
3823
|
+
const label = Array.isArray(raw) ? raw[0] : raw;
|
|
3824
|
+
const target = q.options.findIndex((o) => o.label === label);
|
|
3825
|
+
if (target < 0) throw new UnknownOptionError(q.question, label);
|
|
3826
|
+
out += DOWN.repeat(target) + ENTER2;
|
|
3827
|
+
}
|
|
3828
|
+
return out;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
// src/services/questions/resolveAnswer.ts
|
|
3832
|
+
function resolveAnswer(pending, body) {
|
|
3833
|
+
if (!pending) return { ok: false, reason: "no_pending_question" };
|
|
3834
|
+
if (typeof body.toolUseId !== "string" || body.toolUseId !== pending.toolUseId) {
|
|
3835
|
+
return { ok: false, reason: "tool_use_mismatch" };
|
|
3836
|
+
}
|
|
3837
|
+
const answers = body.answers ?? {};
|
|
3838
|
+
try {
|
|
3839
|
+
return { ok: true, keys: answersToKeystrokes(pending.questions, answers) };
|
|
3840
|
+
} catch (e) {
|
|
3841
|
+
if (e instanceof UnknownOptionError) return { ok: false, reason: "unknown_option" };
|
|
3842
|
+
throw e;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3455
3846
|
// src/session-store.ts
|
|
3456
3847
|
var SessionStore = class {
|
|
3457
3848
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -3882,6 +4273,16 @@ var StreamerServer = class {
|
|
|
3882
4273
|
fileWatcher;
|
|
3883
4274
|
sessionFileMap = /* @__PURE__ */ new Map();
|
|
3884
4275
|
// sessionId → JSONL filePath
|
|
4276
|
+
pendingQuestions = /* @__PURE__ */ new Map();
|
|
4277
|
+
// Content key of the AskUserQuestion currently broadcast for a session (from
|
|
4278
|
+
// either the rendered screen or JSONL), used to de-dupe the two paths: when
|
|
4279
|
+
// the screen detection fires first, the later JSONL flush of the same question
|
|
4280
|
+
// is suppressed. Cleared alongside pendingQuestions.
|
|
4281
|
+
pendingQuestionKey = /* @__PURE__ */ new Map();
|
|
4282
|
+
// Per-session permission gate currently open (scraped via OSC 777). Parallel
|
|
4283
|
+
// to pendingQuestions; mobile answers it by sending the option index via
|
|
4284
|
+
// /input { keys }. Cleared when the gate closes.
|
|
4285
|
+
pendingPermission = /* @__PURE__ */ new Map();
|
|
3885
4286
|
scanner = null;
|
|
3886
4287
|
scannerReady = null;
|
|
3887
4288
|
// Set by onConversationChanged while a scan is in-flight; getScanner() does
|
|
@@ -3977,6 +4378,28 @@ var StreamerServer = class {
|
|
|
3977
4378
|
this.cache?.updateFromLines(filePath, lines);
|
|
3978
4379
|
for (const [sessionId, watchedPath] of this.sessionFileMap) {
|
|
3979
4380
|
if (watchedPath === filePath) {
|
|
4381
|
+
const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
|
|
4382
|
+
const { messages, pending } = questionsFromLines(sessionId, lines);
|
|
4383
|
+
for (const p of pending) {
|
|
4384
|
+
this.pendingQuestions.set(sessionId, p);
|
|
4385
|
+
const t = setTimeout(() => {
|
|
4386
|
+
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
4387
|
+
this.cancelPendingQuestion(sessionId);
|
|
4388
|
+
}
|
|
4389
|
+
}, 6e4);
|
|
4390
|
+
t.unref();
|
|
4391
|
+
}
|
|
4392
|
+
for (const m of messages) {
|
|
4393
|
+
const key = questionContentKey(m.questions);
|
|
4394
|
+
const broadcast = shouldBroadcastQuestion({
|
|
4395
|
+
newContentKey: key,
|
|
4396
|
+
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
4397
|
+
newToolUseId: m.toolUseId,
|
|
4398
|
+
priorToolUseId
|
|
4399
|
+
});
|
|
4400
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
4401
|
+
if (broadcast) this.wsHub.broadcast(m);
|
|
4402
|
+
}
|
|
3980
4403
|
this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
|
|
3981
4404
|
for (const line of lines) {
|
|
3982
4405
|
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
@@ -4008,6 +4431,19 @@ var StreamerServer = class {
|
|
|
4008
4431
|
onOutput: (sessionId, data) => {
|
|
4009
4432
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
4010
4433
|
},
|
|
4434
|
+
onPermissionChange: (sessionId, gate) => {
|
|
4435
|
+
this.handlePermissionChange(sessionId, gate);
|
|
4436
|
+
},
|
|
4437
|
+
onLiveQuestion: (sessionId, questions) => {
|
|
4438
|
+
this.handleLiveQuestion(sessionId, questions);
|
|
4439
|
+
},
|
|
4440
|
+
onLiveQuestionGone: (sessionId) => {
|
|
4441
|
+
this.pendingQuestionKey.delete(sessionId);
|
|
4442
|
+
const pq = this.pendingQuestions.get(sessionId);
|
|
4443
|
+
if (pq?.toolUseId.startsWith("screen:")) {
|
|
4444
|
+
this.cancelPendingQuestion(sessionId);
|
|
4445
|
+
}
|
|
4446
|
+
},
|
|
4011
4447
|
onReady: (session) => {
|
|
4012
4448
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
4013
4449
|
if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
|
|
@@ -4045,7 +4481,9 @@ var StreamerServer = class {
|
|
|
4045
4481
|
if (filePath) {
|
|
4046
4482
|
this.fileWatcher.unwatch(filePath);
|
|
4047
4483
|
this.sessionFileMap.delete(session.id);
|
|
4484
|
+
this.cancelPendingQuestion(session.id);
|
|
4048
4485
|
}
|
|
4486
|
+
this.pendingPermission.delete(session.id);
|
|
4049
4487
|
}
|
|
4050
4488
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
4051
4489
|
if (resp) {
|
|
@@ -4096,6 +4534,7 @@ var StreamerServer = class {
|
|
|
4096
4534
|
handleGetSession: (id, res) => this.handleGetSession(id, res),
|
|
4097
4535
|
handleGetOutput: (id, res) => this.handleGetOutput(id, res),
|
|
4098
4536
|
handleSendInput: (id, req, res) => this.handleSendInput(id, req, res),
|
|
4537
|
+
handleSendAnswer: (id, req, res) => this.handleSendAnswer(id, req, res),
|
|
4099
4538
|
handleCancel: (id, res) => this.handleCancel(id, res),
|
|
4100
4539
|
handleStopSession: (id, res) => this.handleStopSession(id, res),
|
|
4101
4540
|
handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
|
|
@@ -4231,16 +4670,28 @@ var StreamerServer = class {
|
|
|
4231
4670
|
if (existing) clearTimeout(existing);
|
|
4232
4671
|
const timer = setTimeout(() => {
|
|
4233
4672
|
this.ptyGraceTimers.delete(sessionId);
|
|
4234
|
-
this.sessionSubscribers.delete(sessionId);
|
|
4235
4673
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
4674
|
+
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
4675
|
+
if (resp?.status === "running") {
|
|
4676
|
+
this.log.info(
|
|
4677
|
+
`[grace] session ${sessionId} still running, deferring hold`,
|
|
4678
|
+
{ sessionId, event: "pty.grace_defer" },
|
|
4679
|
+
"pino"
|
|
4680
|
+
);
|
|
4681
|
+
this.startGraceTimer(sessionId, delayMs);
|
|
4682
|
+
return;
|
|
4683
|
+
}
|
|
4684
|
+
this.sessionSubscribers.delete(sessionId);
|
|
4236
4685
|
this.log.info(
|
|
4237
4686
|
`[grace] killing idle PTY for ${sessionId}`,
|
|
4238
4687
|
{ sessionId, event: "pty.grace_kill" },
|
|
4239
4688
|
"pino"
|
|
4240
4689
|
);
|
|
4241
4690
|
this.ptyManager.putOnHold(sessionId);
|
|
4242
|
-
const
|
|
4243
|
-
if (
|
|
4691
|
+
const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
4692
|
+
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
4693
|
+
} else {
|
|
4694
|
+
this.sessionSubscribers.delete(sessionId);
|
|
4244
4695
|
}
|
|
4245
4696
|
}, delayMs);
|
|
4246
4697
|
this.ptyGraceTimers.set(sessionId, timer);
|
|
@@ -5219,6 +5670,65 @@ var StreamerServer = class {
|
|
|
5219
5670
|
json(res, 400, { error: message });
|
|
5220
5671
|
}
|
|
5221
5672
|
}
|
|
5673
|
+
cancelPendingQuestion(sessionId) {
|
|
5674
|
+
const pq = this.pendingQuestions.get(sessionId);
|
|
5675
|
+
if (!pq) return;
|
|
5676
|
+
this.pendingQuestions.delete(sessionId);
|
|
5677
|
+
this.pendingQuestionKey.delete(sessionId);
|
|
5678
|
+
this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId: pq.toolUseId });
|
|
5679
|
+
}
|
|
5680
|
+
// Live AskUserQuestion detected from the rendered screen (ahead of JSONL).
|
|
5681
|
+
// Broadcasts the `question` event immediately and records the content key so
|
|
5682
|
+
// the later JSONL flush of the same question is de-duped. We synthesize a
|
|
5683
|
+
// screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
|
|
5684
|
+
// real toolUseId when it lands, so answering works once JSONL catches up.
|
|
5685
|
+
handleLiveQuestion(sessionId, questions) {
|
|
5686
|
+
const key = questionContentKey(questions);
|
|
5687
|
+
if (this.pendingQuestionKey.get(sessionId) === key) return;
|
|
5688
|
+
const toolUseId = `screen:${sessionId}:${key.length}`;
|
|
5689
|
+
this.pendingQuestions.set(sessionId, { toolUseId, questions });
|
|
5690
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
5691
|
+
this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
|
|
5692
|
+
}
|
|
5693
|
+
// Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
|
|
5694
|
+
// additive `permission` / `permission_cancelled` events. Mobile answers by
|
|
5695
|
+
// sending the chosen option index via /input { keys } (e.g. "2\r").
|
|
5696
|
+
handlePermissionChange(sessionId, gate) {
|
|
5697
|
+
if (gate === null) {
|
|
5698
|
+
if (!this.pendingPermission.has(sessionId)) return;
|
|
5699
|
+
this.pendingPermission.delete(sessionId);
|
|
5700
|
+
this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
|
|
5701
|
+
return;
|
|
5702
|
+
}
|
|
5703
|
+
this.pendingPermission.set(sessionId, gate);
|
|
5704
|
+
this.wsHub.broadcast({
|
|
5705
|
+
type: "permission",
|
|
5706
|
+
sessionId,
|
|
5707
|
+
...gate.prompt ? { prompt: gate.prompt } : {},
|
|
5708
|
+
options: gate.options,
|
|
5709
|
+
...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
|
|
5710
|
+
});
|
|
5711
|
+
}
|
|
5712
|
+
async handleSendAnswer(sessionId, req, res) {
|
|
5713
|
+
const body = await readBody(req);
|
|
5714
|
+
const pending = this.pendingQuestions.get(sessionId);
|
|
5715
|
+
const resolution = resolveAnswer(pending, body);
|
|
5716
|
+
if (!resolution.ok) {
|
|
5717
|
+
json(res, 400, { ok: false, reason: resolution.reason });
|
|
5718
|
+
return;
|
|
5719
|
+
}
|
|
5720
|
+
const toolUseId = pending?.toolUseId ?? "";
|
|
5721
|
+
try {
|
|
5722
|
+
this.ptyManager.sendKeys(sessionId, resolution.keys);
|
|
5723
|
+
} catch (err) {
|
|
5724
|
+
const message = err instanceof Error ? err.message : "Failed to send answer";
|
|
5725
|
+
json(res, 400, { ok: false, reason: message });
|
|
5726
|
+
return;
|
|
5727
|
+
}
|
|
5728
|
+
this.pendingQuestions.delete(sessionId);
|
|
5729
|
+
this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId });
|
|
5730
|
+
json(res, 200, { ok: true });
|
|
5731
|
+
}
|
|
5222
5732
|
async handleUploadFile(sessionId, req, res) {
|
|
5223
5733
|
const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
5224
5734
|
if (!session) {
|