@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.cjs
CHANGED
|
@@ -749,6 +749,162 @@ function getLogger(component) {
|
|
|
749
749
|
}
|
|
750
750
|
var logger = build(baseLogger);
|
|
751
751
|
|
|
752
|
+
// src/services/questions/detectPermissionGate.ts
|
|
753
|
+
var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
|
|
754
|
+
function hasPermissionOsc(rawData) {
|
|
755
|
+
return OSC_777_RE.test(rawData);
|
|
756
|
+
}
|
|
757
|
+
var OPTION_RE = /^\s*(❯)?\s*(\d+)\.\s+(.+?)\s*$/;
|
|
758
|
+
var FOOTER_RE = /Enter to select|Esc to cancel|↑|↓|to navigate|to cancel/i;
|
|
759
|
+
var BOX_ONLY_RE = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
|
|
760
|
+
var PROMPT_ARROW_RE = /^[\s]*[❯›>]\s*$/;
|
|
761
|
+
function stripGutter(line) {
|
|
762
|
+
return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
|
|
763
|
+
}
|
|
764
|
+
function scrapePermissionGate(lines) {
|
|
765
|
+
const options = [];
|
|
766
|
+
let cursor;
|
|
767
|
+
let firstOptionLine = -1;
|
|
768
|
+
for (let i = 0; i < lines.length; i++) {
|
|
769
|
+
const m = OPTION_RE.exec(stripGutter(lines[i]));
|
|
770
|
+
if (!m) continue;
|
|
771
|
+
const index = Number.parseInt(m[2], 10);
|
|
772
|
+
if (!Number.isFinite(index)) continue;
|
|
773
|
+
if (firstOptionLine === -1) firstOptionLine = i;
|
|
774
|
+
if (m[1]) cursor = index;
|
|
775
|
+
options.push({ index, label: m[3] });
|
|
776
|
+
}
|
|
777
|
+
if (options.length === 0) return null;
|
|
778
|
+
let prompt;
|
|
779
|
+
for (let i = firstOptionLine - 1; i >= 0; i--) {
|
|
780
|
+
const t = stripGutter(lines[i]).trim();
|
|
781
|
+
if (t.length === 0) continue;
|
|
782
|
+
if (BOX_ONLY_RE.test(lines[i].trim()) || FOOTER_RE.test(t) || PROMPT_ARROW_RE.test(t)) continue;
|
|
783
|
+
prompt = t || void 0;
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
return { ...prompt ? { prompt } : {}, options, ...cursor !== void 0 ? { cursor } : {} };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/services/questions/detectQuestionFromScreen.ts
|
|
790
|
+
var ASK_FOOTER_RE = /Enter to select/i;
|
|
791
|
+
var ESC_FOOTER_RE = /Esc to cancel|to navigate/i;
|
|
792
|
+
var OPTION_RE2 = /^\s*(?:❯)?\s*(\d+)\.\s+(.+?)\s*$/;
|
|
793
|
+
var QUESTION_RE = /\?\s*$/;
|
|
794
|
+
var BOX_ONLY_RE2 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
|
|
795
|
+
var PERMISSION_LABEL_RE = /^(Yes|No)\b/i;
|
|
796
|
+
function stripBoxGutter(line) {
|
|
797
|
+
return line.replace(/^\s*[│|]\s?/, "").replace(/\s*[│|]\s*$/, "");
|
|
798
|
+
}
|
|
799
|
+
function detectQuestionFromScreen(lines) {
|
|
800
|
+
let footerIdx = -1;
|
|
801
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
802
|
+
if (ASK_FOOTER_RE.test(lines[i])) {
|
|
803
|
+
footerIdx = i;
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (footerIdx === -1) return null;
|
|
808
|
+
const options = [];
|
|
809
|
+
let firstOptionIdx = -1;
|
|
810
|
+
for (let i = footerIdx - 1; i >= 0; i--) {
|
|
811
|
+
const line = lines[i];
|
|
812
|
+
const inner = stripBoxGutter(line);
|
|
813
|
+
const trimmed = inner.trim();
|
|
814
|
+
if (trimmed.length === 0) {
|
|
815
|
+
if (options.length === 0) continue;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
if (BOX_ONLY_RE2.test(line.trim())) continue;
|
|
819
|
+
if (ESC_FOOTER_RE.test(line) && options.length === 0) continue;
|
|
820
|
+
if (options.length > 0 && QUESTION_RE.test(trimmed) && !OPTION_RE2.test(inner)) break;
|
|
821
|
+
const m = OPTION_RE2.exec(inner);
|
|
822
|
+
if (m) {
|
|
823
|
+
const label = m[2].trim();
|
|
824
|
+
if (PERMISSION_LABEL_RE.test(label)) return null;
|
|
825
|
+
options.unshift({ label, description: "" });
|
|
826
|
+
firstOptionIdx = i;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (options.length < 2 || firstOptionIdx === -1) return null;
|
|
830
|
+
let question;
|
|
831
|
+
for (let i = firstOptionIdx - 1; i >= 0; i--) {
|
|
832
|
+
const raw = stripBoxGutter(lines[i]);
|
|
833
|
+
const trimmed = raw.trim();
|
|
834
|
+
if (trimmed.length === 0) continue;
|
|
835
|
+
if (BOX_ONLY_RE2.test(lines[i].trim())) continue;
|
|
836
|
+
if (QUESTION_RE.test(trimmed)) {
|
|
837
|
+
question = trimmed;
|
|
838
|
+
}
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
if (!question) return null;
|
|
842
|
+
return {
|
|
843
|
+
questions: [{ question, header: "", multiSelect: false, options }]
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function questionContentKey(questions) {
|
|
847
|
+
return questions.map((q) => `${q.question} ${q.options.map((o) => o.label).join(",")}`).join("::");
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/services/questions/detectShellPrompt.ts
|
|
851
|
+
var ENTER = "\r";
|
|
852
|
+
var YN_RE = /[[(]\s*y\s*\/\s*n\s*[\])]/i;
|
|
853
|
+
var PRESS_ENTER_RE = /press\s+(enter|return|any key)/i;
|
|
854
|
+
var CONTINUE_RE = /\bcontinue\b\s*\??\s*$/i;
|
|
855
|
+
var NUMBERED_RE = /^\s*(?:❯|>)?\s*(\d+)[.)]\s+(.+?)\s*$/;
|
|
856
|
+
var CLAUDE_CHROME_RE = /Enter to select|Esc to cancel|╭|╰|│.*│/;
|
|
857
|
+
var BOX_ONLY_RE3 = /^[\s│─┌┐└┘├┤┬┴┼╭╮╰╯╱╲=_-]+$/;
|
|
858
|
+
function lastNonBlank(lines) {
|
|
859
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
860
|
+
const t = lines[i].trim();
|
|
861
|
+
if (t.length > 0) return { text: t, idx: i };
|
|
862
|
+
}
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
function detectShellPrompt(lines) {
|
|
866
|
+
if (lines.some((l) => CLAUDE_CHROME_RE.test(l))) return null;
|
|
867
|
+
const last = lastNonBlank(lines);
|
|
868
|
+
if (!last) return null;
|
|
869
|
+
if (YN_RE.test(last.text)) {
|
|
870
|
+
return {
|
|
871
|
+
prompt: last.text,
|
|
872
|
+
options: [
|
|
873
|
+
{ index: 1, label: "Yes", answerKeys: `y${ENTER}` },
|
|
874
|
+
{ index: 2, label: "No", answerKeys: `n${ENTER}` }
|
|
875
|
+
]
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if (NUMBERED_RE.test(last.text)) {
|
|
879
|
+
const options = [];
|
|
880
|
+
for (let i = 0; i <= last.idx; i++) {
|
|
881
|
+
const m = NUMBERED_RE.exec(lines[i]);
|
|
882
|
+
if (!m) continue;
|
|
883
|
+
const num = Number.parseInt(m[1], 10);
|
|
884
|
+
if (!Number.isFinite(num)) continue;
|
|
885
|
+
options.push({ index: num, label: m[2].trim(), answerKeys: `${num}${ENTER}` });
|
|
886
|
+
}
|
|
887
|
+
if (options.length >= 2) {
|
|
888
|
+
const firstRow = lines.findIndex((l) => NUMBERED_RE.test(l));
|
|
889
|
+
let prompt = "";
|
|
890
|
+
for (let i = firstRow - 1; i >= 0; i--) {
|
|
891
|
+
const t = lines[i].trim();
|
|
892
|
+
if (t.length === 0 || BOX_ONLY_RE3.test(t)) continue;
|
|
893
|
+
prompt = t;
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
return { prompt: prompt || "Select an option", options };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (PRESS_ENTER_RE.test(last.text) || CONTINUE_RE.test(last.text)) {
|
|
900
|
+
return {
|
|
901
|
+
prompt: last.text,
|
|
902
|
+
options: [{ index: 1, label: "Continue", answerKeys: ENTER }]
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
|
|
752
908
|
// src/pty-manager.ts
|
|
753
909
|
var OUTPUT_BUFFER_MAX = 65536;
|
|
754
910
|
var PTY_COLS = 120;
|
|
@@ -799,6 +955,21 @@ var PTYManager = class {
|
|
|
799
955
|
onOutput;
|
|
800
956
|
onStatusChange;
|
|
801
957
|
onReady;
|
|
958
|
+
onPermissionChange;
|
|
959
|
+
onLiveQuestion;
|
|
960
|
+
onLiveQuestionGone;
|
|
961
|
+
// Per-session permission-gate state. True between an OSC 777 (gate open) and
|
|
962
|
+
// the next prompt-ready without a fresh 777 (gate closed). Prevents
|
|
963
|
+
// re-broadcasting open/close on every chunk.
|
|
964
|
+
permissionOpen = /* @__PURE__ */ new Set();
|
|
965
|
+
// Content key of the last AskUserQuestion broadcast from the rendered screen,
|
|
966
|
+
// per session — de-dupes the same menu firing on consecutive repaints.
|
|
967
|
+
lastScreenQuestionKey = /* @__PURE__ */ new Map();
|
|
968
|
+
// Content key of the last unstructured shell prompt (detectShellPrompt)
|
|
969
|
+
// broadcast per session — present between open and resolve so we can clear it
|
|
970
|
+
// on a prompt-ready/marker return and de-dupe consecutive repaints. Modelled
|
|
971
|
+
// on permissionOpen but keyed by content (a shell prompt has no OSC trigger).
|
|
972
|
+
shellPromptOpen = /* @__PURE__ */ new Map();
|
|
802
973
|
// Tracks sessions (both fresh and resume) whose PTY has spawned but Claude
|
|
803
974
|
// hasn't yet reached an interactive prompt — i.e. onReady hasn't fired.
|
|
804
975
|
pendingReady = /* @__PURE__ */ new Set();
|
|
@@ -821,6 +992,9 @@ var PTYManager = class {
|
|
|
821
992
|
this.onOutput = options.onOutput;
|
|
822
993
|
this.onStatusChange = options.onStatusChange;
|
|
823
994
|
this.onReady = options.onReady;
|
|
995
|
+
this.onPermissionChange = options.onPermissionChange;
|
|
996
|
+
this.onLiveQuestion = options.onLiveQuestion;
|
|
997
|
+
this.onLiveQuestionGone = options.onLiveQuestionGone;
|
|
824
998
|
this.log = options.logger ?? getLogger("pty");
|
|
825
999
|
}
|
|
826
1000
|
// Resume an existing Claude conversation. sessionId is the JSONL UUID.
|
|
@@ -1063,6 +1237,9 @@ var PTYManager = class {
|
|
|
1063
1237
|
this.pendingReady.delete(sessionId);
|
|
1064
1238
|
this.queuedInputs.delete(sessionId);
|
|
1065
1239
|
this.firstChunkAt.delete(sessionId);
|
|
1240
|
+
this.permissionOpen.delete(sessionId);
|
|
1241
|
+
this.lastScreenQuestionKey.delete(sessionId);
|
|
1242
|
+
this.shellPromptOpen.delete(sessionId);
|
|
1066
1243
|
try {
|
|
1067
1244
|
session.process.kill("SIGINT");
|
|
1068
1245
|
} catch {
|
|
@@ -1122,6 +1299,9 @@ var PTYManager = class {
|
|
|
1122
1299
|
this.firstChunkAt.clear();
|
|
1123
1300
|
this.chunkIndex.clear();
|
|
1124
1301
|
this.lastChunkAt.clear();
|
|
1302
|
+
this.permissionOpen.clear();
|
|
1303
|
+
this.lastScreenQuestionKey.clear();
|
|
1304
|
+
this.shellPromptOpen.clear();
|
|
1125
1305
|
}
|
|
1126
1306
|
handleOutput(sessionId, data) {
|
|
1127
1307
|
const session = this.sessions.get(sessionId);
|
|
@@ -1165,6 +1345,87 @@ var PTYManager = class {
|
|
|
1165
1345
|
this.markReady(sessionId, session, "fallback:timeout");
|
|
1166
1346
|
}
|
|
1167
1347
|
this.onOutput?.(sessionId, data);
|
|
1348
|
+
this.detectLivePrompts(sessionId, data, stripped).catch((err) => {
|
|
1349
|
+
this.log.warn("[pty.prompt_detect] failed", {
|
|
1350
|
+
event: "pty.prompt_detect_failed",
|
|
1351
|
+
sessionId,
|
|
1352
|
+
err
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
// Detect permission gates (OSC 777 + scraped options) and AskUserQuestion
|
|
1357
|
+
// menus from the rendered screen, firing the additive callbacks. Async because
|
|
1358
|
+
// reading the rendered buffer needs the xterm write queue flushed. Pure
|
|
1359
|
+
// detection lives in services/questions/*; this only orchestrates triggers,
|
|
1360
|
+
// per-session debounce, and the callbacks.
|
|
1361
|
+
async detectLivePrompts(sessionId, rawData, stripped) {
|
|
1362
|
+
const session = this.sessions.get(sessionId);
|
|
1363
|
+
if (!session) return;
|
|
1364
|
+
const oscPermission = hasPermissionOsc(rawData);
|
|
1365
|
+
const hasAskFooter = /Enter to select/i.test(stripped);
|
|
1366
|
+
const hasPromptMarker = CLAUDE_PROMPT_MARKERS.some((m) => stripped.includes(m));
|
|
1367
|
+
const hasShellPromptHint = /[[(]\s*y\s*\/\s*n\s*[\])]|press\s+(enter|return|any key)|\bcontinue\b\s*\?|^\s*(?:❯|>)?\s*\d+[.)]\s+\S/im.test(
|
|
1368
|
+
stripped
|
|
1369
|
+
);
|
|
1370
|
+
if (!oscPermission && !hasAskFooter && !hasShellPromptHint && !this.permissionOpen.has(sessionId) && !this.shellPromptOpen.has(sessionId) && !this.lastScreenQuestionKey.has(sessionId)) {
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const lines = await this.getOutputLines(sessionId, 60);
|
|
1374
|
+
const askFooterOnScreen = lines.some((l) => /Enter to select/i.test(l));
|
|
1375
|
+
if (oscPermission || hasAskFooter || askFooterOnScreen) {
|
|
1376
|
+
this.log.debug?.(`[pty.prompt_detect] ${sessionId.slice(0, 8)} trigger`, {
|
|
1377
|
+
event: "pty.prompt_detect",
|
|
1378
|
+
sessionId,
|
|
1379
|
+
oscPermission,
|
|
1380
|
+
hasAskFooter,
|
|
1381
|
+
askFooterOnScreen,
|
|
1382
|
+
permGate: oscPermission && !askFooterOnScreen ? scrapePermissionGate(lines) : void 0,
|
|
1383
|
+
askQuestion: askFooterOnScreen ? detectQuestionFromScreen(lines) : void 0,
|
|
1384
|
+
renderedTail: lines.slice(-25)
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
if (oscPermission && !askFooterOnScreen) {
|
|
1388
|
+
const gate = scrapePermissionGate(lines);
|
|
1389
|
+
this.permissionOpen.add(sessionId);
|
|
1390
|
+
this.onPermissionChange?.(sessionId, gate ?? { options: [] });
|
|
1391
|
+
} else if (this.permissionOpen.has(sessionId) && !askFooterOnScreen) {
|
|
1392
|
+
const gate = scrapePermissionGate(lines);
|
|
1393
|
+
if (gate) {
|
|
1394
|
+
this.onPermissionChange?.(sessionId, gate);
|
|
1395
|
+
} else if (hasPromptMarker) {
|
|
1396
|
+
this.permissionOpen.delete(sessionId);
|
|
1397
|
+
this.onPermissionChange?.(sessionId, null);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
if (askFooterOnScreen) {
|
|
1401
|
+
const detected = detectQuestionFromScreen(lines);
|
|
1402
|
+
if (detected) {
|
|
1403
|
+
const key = questionContentKey(detected.questions);
|
|
1404
|
+
if (this.lastScreenQuestionKey.get(sessionId) !== key) {
|
|
1405
|
+
this.lastScreenQuestionKey.set(sessionId, key);
|
|
1406
|
+
this.onLiveQuestion?.(sessionId, detected.questions);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
} else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
|
|
1410
|
+
this.lastScreenQuestionKey.delete(sessionId);
|
|
1411
|
+
this.onLiveQuestionGone?.(sessionId);
|
|
1412
|
+
}
|
|
1413
|
+
if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
|
|
1414
|
+
const shell = detectShellPrompt(lines);
|
|
1415
|
+
if (shell) {
|
|
1416
|
+
const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
|
|
1417
|
+
if (this.shellPromptOpen.get(sessionId) !== key) {
|
|
1418
|
+
this.shellPromptOpen.set(sessionId, key);
|
|
1419
|
+
this.onPermissionChange?.(sessionId, {
|
|
1420
|
+
prompt: shell.prompt,
|
|
1421
|
+
options: shell.options
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
} else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
|
|
1425
|
+
this.shellPromptOpen.delete(sessionId);
|
|
1426
|
+
this.onPermissionChange?.(sessionId, null);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1168
1429
|
}
|
|
1169
1430
|
// Transition a session from "running" to "waiting_input", clear pendingReady,
|
|
1170
1431
|
// and flush any queued input. Idempotent: callers can invoke at any chunk.
|
|
@@ -1203,6 +1464,9 @@ var PTYManager = class {
|
|
|
1203
1464
|
this.sessions.delete(sessionId);
|
|
1204
1465
|
this.queuedInputs.delete(sessionId);
|
|
1205
1466
|
this.firstChunkAt.delete(sessionId);
|
|
1467
|
+
this.permissionOpen.delete(sessionId);
|
|
1468
|
+
this.lastScreenQuestionKey.delete(sessionId);
|
|
1469
|
+
this.shellPromptOpen.delete(sessionId);
|
|
1206
1470
|
}
|
|
1207
1471
|
};
|
|
1208
1472
|
function toPublicSession(s) {
|
|
@@ -1863,6 +2127,10 @@ var createSessionRoutes = (deps) => {
|
|
|
1863
2127
|
await deps.handleSendInput(c.req.param("id"), c.env.incoming, c.env.outgoing);
|
|
1864
2128
|
return alreadyHandled6();
|
|
1865
2129
|
});
|
|
2130
|
+
app.post("/:id/answer", async (c) => {
|
|
2131
|
+
await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
|
|
2132
|
+
return alreadyHandled6();
|
|
2133
|
+
});
|
|
1866
2134
|
app.post("/:id/files", async (c) => {
|
|
1867
2135
|
await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
|
|
1868
2136
|
return alreadyHandled6();
|
|
@@ -3486,6 +3754,129 @@ function pruneAgentConversations(cache) {
|
|
|
3486
3754
|
return { scanned: rows.length, pruned, missing };
|
|
3487
3755
|
}
|
|
3488
3756
|
|
|
3757
|
+
// src/services/questions/detectAskUserQuestion.ts
|
|
3758
|
+
function normalizeContent2(raw) {
|
|
3759
|
+
if (Array.isArray(raw)) return raw;
|
|
3760
|
+
if (typeof raw === "string") return [{ type: "text", text: raw }];
|
|
3761
|
+
return [];
|
|
3762
|
+
}
|
|
3763
|
+
function coerceOptions(raw) {
|
|
3764
|
+
if (!Array.isArray(raw)) return null;
|
|
3765
|
+
const out = [];
|
|
3766
|
+
for (const o of raw) {
|
|
3767
|
+
if (o && typeof o === "object" && typeof o.label === "string") {
|
|
3768
|
+
const opt = o;
|
|
3769
|
+
out.push({
|
|
3770
|
+
label: opt.label,
|
|
3771
|
+
description: typeof opt.description === "string" ? opt.description : "",
|
|
3772
|
+
...typeof opt.preview === "string" ? { preview: opt.preview } : {}
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
return out.length > 0 ? out : null;
|
|
3777
|
+
}
|
|
3778
|
+
function coerceQuestions(raw) {
|
|
3779
|
+
if (!Array.isArray(raw)) return null;
|
|
3780
|
+
const out = [];
|
|
3781
|
+
for (const q of raw) {
|
|
3782
|
+
if (!q || typeof q !== "object") continue;
|
|
3783
|
+
const qq = q;
|
|
3784
|
+
const options = coerceOptions(qq.options);
|
|
3785
|
+
if (typeof qq.question !== "string" || !options) continue;
|
|
3786
|
+
out.push({
|
|
3787
|
+
question: qq.question,
|
|
3788
|
+
header: typeof qq.header === "string" ? qq.header : "",
|
|
3789
|
+
multiSelect: qq.multiSelect === true,
|
|
3790
|
+
options
|
|
3791
|
+
});
|
|
3792
|
+
}
|
|
3793
|
+
return out.length > 0 ? out : null;
|
|
3794
|
+
}
|
|
3795
|
+
function detectAskUserQuestion(rawLine) {
|
|
3796
|
+
let parsed;
|
|
3797
|
+
try {
|
|
3798
|
+
parsed = JSON.parse(rawLine);
|
|
3799
|
+
} catch {
|
|
3800
|
+
return null;
|
|
3801
|
+
}
|
|
3802
|
+
const blocks = normalizeContent2(parsed.message?.content ?? parsed.content);
|
|
3803
|
+
for (const b of blocks) {
|
|
3804
|
+
if (b.type === "tool_use" && b.name === "AskUserQuestion" && typeof b.id === "string") {
|
|
3805
|
+
const input = b.input;
|
|
3806
|
+
const questions = coerceQuestions(input?.questions);
|
|
3807
|
+
if (questions) return { toolUseId: b.id, questions };
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
return null;
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
// src/services/questions/questionBroadcast.ts
|
|
3814
|
+
function questionsFromLines(sessionId, lines) {
|
|
3815
|
+
const messages = [];
|
|
3816
|
+
const pending = [];
|
|
3817
|
+
for (const line of lines) {
|
|
3818
|
+
const detected = detectAskUserQuestion(line);
|
|
3819
|
+
if (detected) {
|
|
3820
|
+
messages.push({
|
|
3821
|
+
type: "question",
|
|
3822
|
+
sessionId,
|
|
3823
|
+
toolUseId: detected.toolUseId,
|
|
3824
|
+
questions: detected.questions
|
|
3825
|
+
});
|
|
3826
|
+
pending.push(detected);
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
return { messages, pending };
|
|
3830
|
+
}
|
|
3831
|
+
function shouldBroadcastQuestion(args) {
|
|
3832
|
+
const alreadyShown = args.lastContentKey === args.newContentKey;
|
|
3833
|
+
if (!alreadyShown) return true;
|
|
3834
|
+
return args.priorToolUseId !== args.newToolUseId;
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
// src/services/questions/answersToKeystrokes.ts
|
|
3838
|
+
var DOWN = "\x1B[B";
|
|
3839
|
+
var ENTER2 = "\r";
|
|
3840
|
+
var UnknownOptionError = class extends Error {
|
|
3841
|
+
constructor(question, value) {
|
|
3842
|
+
super(`No option labelled "${value}" for question "${question}"`);
|
|
3843
|
+
this.question = question;
|
|
3844
|
+
this.value = value;
|
|
3845
|
+
this.name = "UnknownOptionError";
|
|
3846
|
+
}
|
|
3847
|
+
question;
|
|
3848
|
+
value;
|
|
3849
|
+
};
|
|
3850
|
+
function answersToKeystrokes(questions, answers) {
|
|
3851
|
+
let out = "";
|
|
3852
|
+
for (const q of questions) {
|
|
3853
|
+
const raw = answers[q.question];
|
|
3854
|
+
if (raw === void 0) {
|
|
3855
|
+
throw new Error(`Missing answer for question "${q.question}"`);
|
|
3856
|
+
}
|
|
3857
|
+
const label = Array.isArray(raw) ? raw[0] : raw;
|
|
3858
|
+
const target = q.options.findIndex((o) => o.label === label);
|
|
3859
|
+
if (target < 0) throw new UnknownOptionError(q.question, label);
|
|
3860
|
+
out += DOWN.repeat(target) + ENTER2;
|
|
3861
|
+
}
|
|
3862
|
+
return out;
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
// src/services/questions/resolveAnswer.ts
|
|
3866
|
+
function resolveAnswer(pending, body) {
|
|
3867
|
+
if (!pending) return { ok: false, reason: "no_pending_question" };
|
|
3868
|
+
if (typeof body.toolUseId !== "string" || body.toolUseId !== pending.toolUseId) {
|
|
3869
|
+
return { ok: false, reason: "tool_use_mismatch" };
|
|
3870
|
+
}
|
|
3871
|
+
const answers = body.answers ?? {};
|
|
3872
|
+
try {
|
|
3873
|
+
return { ok: true, keys: answersToKeystrokes(pending.questions, answers) };
|
|
3874
|
+
} catch (e) {
|
|
3875
|
+
if (e instanceof UnknownOptionError) return { ok: false, reason: "unknown_option" };
|
|
3876
|
+
throw e;
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3489
3880
|
// src/session-store.ts
|
|
3490
3881
|
var SessionStore = class {
|
|
3491
3882
|
managed = /* @__PURE__ */ new Map();
|
|
@@ -3916,6 +4307,16 @@ var StreamerServer = class {
|
|
|
3916
4307
|
fileWatcher;
|
|
3917
4308
|
sessionFileMap = /* @__PURE__ */ new Map();
|
|
3918
4309
|
// sessionId → JSONL filePath
|
|
4310
|
+
pendingQuestions = /* @__PURE__ */ new Map();
|
|
4311
|
+
// Content key of the AskUserQuestion currently broadcast for a session (from
|
|
4312
|
+
// either the rendered screen or JSONL), used to de-dupe the two paths: when
|
|
4313
|
+
// the screen detection fires first, the later JSONL flush of the same question
|
|
4314
|
+
// is suppressed. Cleared alongside pendingQuestions.
|
|
4315
|
+
pendingQuestionKey = /* @__PURE__ */ new Map();
|
|
4316
|
+
// Per-session permission gate currently open (scraped via OSC 777). Parallel
|
|
4317
|
+
// to pendingQuestions; mobile answers it by sending the option index via
|
|
4318
|
+
// /input { keys }. Cleared when the gate closes.
|
|
4319
|
+
pendingPermission = /* @__PURE__ */ new Map();
|
|
3919
4320
|
scanner = null;
|
|
3920
4321
|
scannerReady = null;
|
|
3921
4322
|
// Set by onConversationChanged while a scan is in-flight; getScanner() does
|
|
@@ -4011,6 +4412,28 @@ var StreamerServer = class {
|
|
|
4011
4412
|
this.cache?.updateFromLines(filePath, lines);
|
|
4012
4413
|
for (const [sessionId, watchedPath] of this.sessionFileMap) {
|
|
4013
4414
|
if (watchedPath === filePath) {
|
|
4415
|
+
const priorToolUseId = this.pendingQuestions.get(sessionId)?.toolUseId;
|
|
4416
|
+
const { messages, pending } = questionsFromLines(sessionId, lines);
|
|
4417
|
+
for (const p of pending) {
|
|
4418
|
+
this.pendingQuestions.set(sessionId, p);
|
|
4419
|
+
const t = setTimeout(() => {
|
|
4420
|
+
if (this.pendingQuestions.get(sessionId)?.toolUseId === p.toolUseId) {
|
|
4421
|
+
this.cancelPendingQuestion(sessionId);
|
|
4422
|
+
}
|
|
4423
|
+
}, 6e4);
|
|
4424
|
+
t.unref();
|
|
4425
|
+
}
|
|
4426
|
+
for (const m of messages) {
|
|
4427
|
+
const key = questionContentKey(m.questions);
|
|
4428
|
+
const broadcast = shouldBroadcastQuestion({
|
|
4429
|
+
newContentKey: key,
|
|
4430
|
+
lastContentKey: this.pendingQuestionKey.get(sessionId),
|
|
4431
|
+
newToolUseId: m.toolUseId,
|
|
4432
|
+
priorToolUseId
|
|
4433
|
+
});
|
|
4434
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
4435
|
+
if (broadcast) this.wsHub.broadcast(m);
|
|
4436
|
+
}
|
|
4014
4437
|
this.wsHub.broadcast({ type: "conversation_events", sessionId, lines });
|
|
4015
4438
|
for (const line of lines) {
|
|
4016
4439
|
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
@@ -4042,6 +4465,19 @@ var StreamerServer = class {
|
|
|
4042
4465
|
onOutput: (sessionId, data) => {
|
|
4043
4466
|
this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
|
|
4044
4467
|
},
|
|
4468
|
+
onPermissionChange: (sessionId, gate) => {
|
|
4469
|
+
this.handlePermissionChange(sessionId, gate);
|
|
4470
|
+
},
|
|
4471
|
+
onLiveQuestion: (sessionId, questions) => {
|
|
4472
|
+
this.handleLiveQuestion(sessionId, questions);
|
|
4473
|
+
},
|
|
4474
|
+
onLiveQuestionGone: (sessionId) => {
|
|
4475
|
+
this.pendingQuestionKey.delete(sessionId);
|
|
4476
|
+
const pq = this.pendingQuestions.get(sessionId);
|
|
4477
|
+
if (pq?.toolUseId.startsWith("screen:")) {
|
|
4478
|
+
this.cancelPendingQuestion(sessionId);
|
|
4479
|
+
}
|
|
4480
|
+
},
|
|
4045
4481
|
onReady: (session) => {
|
|
4046
4482
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
4047
4483
|
if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
|
|
@@ -4079,7 +4515,9 @@ var StreamerServer = class {
|
|
|
4079
4515
|
if (filePath) {
|
|
4080
4516
|
this.fileWatcher.unwatch(filePath);
|
|
4081
4517
|
this.sessionFileMap.delete(session.id);
|
|
4518
|
+
this.cancelPendingQuestion(session.id);
|
|
4082
4519
|
}
|
|
4520
|
+
this.pendingPermission.delete(session.id);
|
|
4083
4521
|
}
|
|
4084
4522
|
const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
4085
4523
|
if (resp) {
|
|
@@ -4130,6 +4568,7 @@ var StreamerServer = class {
|
|
|
4130
4568
|
handleGetSession: (id, res) => this.handleGetSession(id, res),
|
|
4131
4569
|
handleGetOutput: (id, res) => this.handleGetOutput(id, res),
|
|
4132
4570
|
handleSendInput: (id, req, res) => this.handleSendInput(id, req, res),
|
|
4571
|
+
handleSendAnswer: (id, req, res) => this.handleSendAnswer(id, req, res),
|
|
4133
4572
|
handleCancel: (id, res) => this.handleCancel(id, res),
|
|
4134
4573
|
handleStopSession: (id, res) => this.handleStopSession(id, res),
|
|
4135
4574
|
handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
|
|
@@ -4265,16 +4704,28 @@ var StreamerServer = class {
|
|
|
4265
4704
|
if (existing) clearTimeout(existing);
|
|
4266
4705
|
const timer = setTimeout(() => {
|
|
4267
4706
|
this.ptyGraceTimers.delete(sessionId);
|
|
4268
|
-
this.sessionSubscribers.delete(sessionId);
|
|
4269
4707
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
4708
|
+
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
4709
|
+
if (resp?.status === "running") {
|
|
4710
|
+
this.log.info(
|
|
4711
|
+
`[grace] session ${sessionId} still running, deferring hold`,
|
|
4712
|
+
{ sessionId, event: "pty.grace_defer" },
|
|
4713
|
+
"pino"
|
|
4714
|
+
);
|
|
4715
|
+
this.startGraceTimer(sessionId, delayMs);
|
|
4716
|
+
return;
|
|
4717
|
+
}
|
|
4718
|
+
this.sessionSubscribers.delete(sessionId);
|
|
4270
4719
|
this.log.info(
|
|
4271
4720
|
`[grace] killing idle PTY for ${sessionId}`,
|
|
4272
4721
|
{ sessionId, event: "pty.grace_kill" },
|
|
4273
4722
|
"pino"
|
|
4274
4723
|
);
|
|
4275
4724
|
this.ptyManager.putOnHold(sessionId);
|
|
4276
|
-
const
|
|
4277
|
-
if (
|
|
4725
|
+
const held = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
4726
|
+
if (held) this.wsHub.broadcast({ type: "session_update", session: held });
|
|
4727
|
+
} else {
|
|
4728
|
+
this.sessionSubscribers.delete(sessionId);
|
|
4278
4729
|
}
|
|
4279
4730
|
}, delayMs);
|
|
4280
4731
|
this.ptyGraceTimers.set(sessionId, timer);
|
|
@@ -5253,6 +5704,65 @@ var StreamerServer = class {
|
|
|
5253
5704
|
json(res, 400, { error: message });
|
|
5254
5705
|
}
|
|
5255
5706
|
}
|
|
5707
|
+
cancelPendingQuestion(sessionId) {
|
|
5708
|
+
const pq = this.pendingQuestions.get(sessionId);
|
|
5709
|
+
if (!pq) return;
|
|
5710
|
+
this.pendingQuestions.delete(sessionId);
|
|
5711
|
+
this.pendingQuestionKey.delete(sessionId);
|
|
5712
|
+
this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId: pq.toolUseId });
|
|
5713
|
+
}
|
|
5714
|
+
// Live AskUserQuestion detected from the rendered screen (ahead of JSONL).
|
|
5715
|
+
// Broadcasts the `question` event immediately and records the content key so
|
|
5716
|
+
// the later JSONL flush of the same question is de-duped. We synthesize a
|
|
5717
|
+
// screen-scoped toolUseId; the JSONL path overwrites pendingQuestions with the
|
|
5718
|
+
// real toolUseId when it lands, so answering works once JSONL catches up.
|
|
5719
|
+
handleLiveQuestion(sessionId, questions) {
|
|
5720
|
+
const key = questionContentKey(questions);
|
|
5721
|
+
if (this.pendingQuestionKey.get(sessionId) === key) return;
|
|
5722
|
+
const toolUseId = `screen:${sessionId}:${key.length}`;
|
|
5723
|
+
this.pendingQuestions.set(sessionId, { toolUseId, questions });
|
|
5724
|
+
this.pendingQuestionKey.set(sessionId, key);
|
|
5725
|
+
this.wsHub.broadcast({ type: "question", sessionId, toolUseId, questions });
|
|
5726
|
+
}
|
|
5727
|
+
// Permission gate opened/closed (OSC 777 + scraped options). Broadcasts the
|
|
5728
|
+
// additive `permission` / `permission_cancelled` events. Mobile answers by
|
|
5729
|
+
// sending the chosen option index via /input { keys } (e.g. "2\r").
|
|
5730
|
+
handlePermissionChange(sessionId, gate) {
|
|
5731
|
+
if (gate === null) {
|
|
5732
|
+
if (!this.pendingPermission.has(sessionId)) return;
|
|
5733
|
+
this.pendingPermission.delete(sessionId);
|
|
5734
|
+
this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
|
|
5735
|
+
return;
|
|
5736
|
+
}
|
|
5737
|
+
this.pendingPermission.set(sessionId, gate);
|
|
5738
|
+
this.wsHub.broadcast({
|
|
5739
|
+
type: "permission",
|
|
5740
|
+
sessionId,
|
|
5741
|
+
...gate.prompt ? { prompt: gate.prompt } : {},
|
|
5742
|
+
options: gate.options,
|
|
5743
|
+
...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
|
|
5744
|
+
});
|
|
5745
|
+
}
|
|
5746
|
+
async handleSendAnswer(sessionId, req, res) {
|
|
5747
|
+
const body = await readBody(req);
|
|
5748
|
+
const pending = this.pendingQuestions.get(sessionId);
|
|
5749
|
+
const resolution = resolveAnswer(pending, body);
|
|
5750
|
+
if (!resolution.ok) {
|
|
5751
|
+
json(res, 400, { ok: false, reason: resolution.reason });
|
|
5752
|
+
return;
|
|
5753
|
+
}
|
|
5754
|
+
const toolUseId = pending?.toolUseId ?? "";
|
|
5755
|
+
try {
|
|
5756
|
+
this.ptyManager.sendKeys(sessionId, resolution.keys);
|
|
5757
|
+
} catch (err) {
|
|
5758
|
+
const message = err instanceof Error ? err.message : "Failed to send answer";
|
|
5759
|
+
json(res, 400, { ok: false, reason: message });
|
|
5760
|
+
return;
|
|
5761
|
+
}
|
|
5762
|
+
this.pendingQuestions.delete(sessionId);
|
|
5763
|
+
this.wsHub.broadcast({ type: "question_cancelled", sessionId, toolUseId });
|
|
5764
|
+
json(res, 200, { ok: true });
|
|
5765
|
+
}
|
|
5256
5766
|
async handleUploadFile(sessionId, req, res) {
|
|
5257
5767
|
const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
5258
5768
|
if (!session) {
|