@indianic/dialout 1.8.0 → 1.9.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/ai-sessions.d.ts +14 -1
- package/dist/ai-sessions.d.ts.map +1 -1
- package/dist/ai-sessions.js +76 -5
- package/dist/ai-sessions.js.map +1 -1
- package/dist/tmux-manager.d.ts +1 -0
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +11 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/websocket.js +6 -0
- package/dist/websocket.js.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts +7 -0
- package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts +3 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.js +7 -1
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts +7 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/notify.js +16 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts +46 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.js +227 -0
- package/node_modules/@indianic/dialout-shared/dist/session.d.ts +7 -0
- package/node_modules/@indianic/dialout-shared/dist/session.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/package.json +1 -1
- package/node_modules/@indianic/dialout-shared/src/.omc/state/sessions/ebad4a54-74e6-4430-8f1c-4e4268aa5d4d/pre-tool-advisory-throttle.json +10 -0
- package/node_modules/@indianic/dialout-shared/src/event-merge.ts +7 -0
- package/node_modules/@indianic/dialout-shared/src/index.ts +4 -1
- package/node_modules/@indianic/dialout-shared/src/notify.test.ts +12 -1
- package/node_modules/@indianic/dialout-shared/src/notify.ts +18 -0
- package/node_modules/@indianic/dialout-shared/src/pane-prompt.test.ts +179 -0
- package/node_modules/@indianic/dialout-shared/src/pane-prompt.ts +218 -0
- package/node_modules/@indianic/dialout-shared/src/session.ts +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// A coding agent waiting on the user, read from what its TUI is showing.
|
|
2
|
+
//
|
|
3
|
+
// Permission prompts, plan approvals, trust prompts and question menus are
|
|
4
|
+
// drawn in the terminal and most never reach the transcript, so a chat built
|
|
5
|
+
// from the transcript alone looks stuck while the agent is asking something.
|
|
6
|
+
// The agent captures the pane and runs it through detectPanePrompt; clients
|
|
7
|
+
// render the result as tappable choices and answer with promptKeys.
|
|
8
|
+
//
|
|
9
|
+
// One parser for every CLI on purpose: Claude Code, Codex and Grok all draw
|
|
10
|
+
// the same shape — a question, then a list with a cursor on one row
|
|
11
|
+
// (❯ / › / >), usually numbered, then a footer like "Enter to confirm · Esc to
|
|
12
|
+
// cancel". Recognising the shape rather than each vendor's wording is what
|
|
13
|
+
// keeps this working across their releases.
|
|
14
|
+
|
|
15
|
+
export interface PanePromptOption {
|
|
16
|
+
/** 1-based position in the menu. */
|
|
17
|
+
n: number;
|
|
18
|
+
label: string;
|
|
19
|
+
/** Indented line(s) under the option: its explanation. */
|
|
20
|
+
hint?: string;
|
|
21
|
+
/** The row the cursor is on. */
|
|
22
|
+
selected: boolean;
|
|
23
|
+
/** Multi-select rows: ticked or not. null for an ordinary row. */
|
|
24
|
+
checked: boolean | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PanePrompt {
|
|
28
|
+
/** Changes when the question or its choices change (not when the cursor moves). */
|
|
29
|
+
id: string;
|
|
30
|
+
/** Context above the question: "Bash command", the command itself, a file name. */
|
|
31
|
+
detail: string[];
|
|
32
|
+
question: string;
|
|
33
|
+
options: PanePromptOption[];
|
|
34
|
+
/** Some rows are checkboxes: a tap toggles rather than answers. */
|
|
35
|
+
multi: boolean;
|
|
36
|
+
/** The menu's own key hint, shown small under the choices. */
|
|
37
|
+
footer: string;
|
|
38
|
+
/** Approval-shaped (run/edit/trust) rather than a question to answer. */
|
|
39
|
+
approval: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Box-drawing borders some CLIs draw around a prompt: stripped before parsing,
|
|
43
|
+
// keeping the indentation inside the box (it tells options from their hints).
|
|
44
|
+
const BORDER_LEFT = /^\s*[│┃║] ?/u;
|
|
45
|
+
const BORDER_RIGHT = /\s*[│┃║]\s*$/u;
|
|
46
|
+
/** Lines under a prompt that may still belong to the TUI (status lines). */
|
|
47
|
+
const TRAILING = 14;
|
|
48
|
+
const SEPARATOR = /^[\s─━═╌┄┈\-_╭╮╰╯┌┐└┘╔╗╚╝]{4,}$/u;
|
|
49
|
+
const FOOTER = /(enter to (confirm|select|submit)|esc to (cancel|exit|interrupt)|to navigate|press enter|↑\/↓|tab\/arrow|y\/n)/i;
|
|
50
|
+
const CHECK_ON = /^\[(x|✔|✓|■|●)\]|^[◉☑✔✓■]/iu;
|
|
51
|
+
const CHECK_OFF = /^\[ \]|^[◯○☐□]/u;
|
|
52
|
+
const APPROVAL = /\b(proceed|allow|approve|permission|trust|run|execute|edit|create|delete|make this edit|overwrite|apply)\b/i;
|
|
53
|
+
|
|
54
|
+
// "❯ 1. Yes" " 2. No (esc)" "› 1. Yes, proceed (y)"
|
|
55
|
+
const NUMBERED = /^(\s*)([❯›>▶→➜●])?\s*(\d{1,2})[.)]\s+(.+?)\s*$/u;
|
|
56
|
+
// "❯ Yes, I trust this folder" / " No, exit" — unnumbered rows
|
|
57
|
+
const BARE = /^(\s*)(?:([❯›>▶→➜●])\s+)?(\S.*?)\s*$/u;
|
|
58
|
+
|
|
59
|
+
function clean(line: string): string {
|
|
60
|
+
return line.replace(BORDER_LEFT, '').replace(BORDER_RIGHT, '').replace(/\s+$/, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hash(s: string): string {
|
|
64
|
+
let h = 0x811c9dc5;
|
|
65
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
|
66
|
+
return (h >>> 0).toString(36);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function checkState(label: string): { label: string; checked: boolean | null } {
|
|
70
|
+
if (CHECK_ON.test(label)) return { label: label.replace(CHECK_ON, '').trim(), checked: true };
|
|
71
|
+
if (CHECK_OFF.test(label)) return { label: label.replace(CHECK_OFF, '').trim(), checked: false };
|
|
72
|
+
return { label, checked: null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pull the question and its context from the lines above the menu. */
|
|
76
|
+
function above(lines: string[], end: number): { question: string; detail: string[] } {
|
|
77
|
+
const block: string[] = [];
|
|
78
|
+
for (let i = end - 1; i >= 0 && block.length < 8; i--) {
|
|
79
|
+
const l = lines[i].trim();
|
|
80
|
+
if (SEPARATOR.test(l)) break;
|
|
81
|
+
if (!l) { if (block.length) { const prev = lines[i - 1]?.trim(); if (!prev || SEPARATOR.test(prev)) break; } continue; }
|
|
82
|
+
// A tab strip like "☐ Colour ☐ Pet ✔ Submit" is navigation, not content.
|
|
83
|
+
if (/[☐☒✔✓].*\s{2,}[☐☒✔✓]/u.test(l)) continue;
|
|
84
|
+
block.unshift(l);
|
|
85
|
+
}
|
|
86
|
+
if (!block.length) return { question: '', detail: [] };
|
|
87
|
+
let qi = -1;
|
|
88
|
+
for (let i = block.length - 1; i >= 0; i--) if (/\?\s*$/.test(block[i])) { qi = i; break; }
|
|
89
|
+
if (qi < 0) qi = block.length - 1;
|
|
90
|
+
return { question: block[qi], detail: block.filter((_, i) => i !== qi) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Menu = { opts: PanePromptOption[]; start: number };
|
|
94
|
+
|
|
95
|
+
/** "❯ 1. Yes / 2. No" with indented hints under rows, scanning up from `bottom`. */
|
|
96
|
+
function numberedMenu(lines: string[], bottom: number): Menu | null {
|
|
97
|
+
const opts: PanePromptOption[] = [];
|
|
98
|
+
let hints: string[] = [];
|
|
99
|
+
let start = -1;
|
|
100
|
+
let labelCol = 4;
|
|
101
|
+
let separators = 0;
|
|
102
|
+
for (let i = bottom; i >= 0; i--) {
|
|
103
|
+
const raw = lines[i];
|
|
104
|
+
if (!raw.trim()) { if (opts.length || hints.length) break; continue; }
|
|
105
|
+
if (SEPARATOR.test(raw.trim())) { if (++separators > 2) break; continue; }
|
|
106
|
+
const m = raw.match(NUMBERED);
|
|
107
|
+
if (m) {
|
|
108
|
+
const ck = checkState(m[4]);
|
|
109
|
+
opts.unshift({ n: Number(m[3]), label: ck.label, hint: hints.length ? hints.join(' ') : undefined, selected: !!m[2], checked: ck.checked });
|
|
110
|
+
hints = [];
|
|
111
|
+
labelCol = raw.indexOf(m[4]);
|
|
112
|
+
start = i;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Deeper than an option's label: that option's explanation (it sits
|
|
116
|
+
// below its option, so it is seen first when scanning upwards).
|
|
117
|
+
if (raw.search(/\S/) >= Math.max(4, labelCol) && hints.length < 3) { hints.unshift(raw.trim()); continue; }
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (opts.length < 2) return null;
|
|
121
|
+
// Must count 1, 2, 3… — a numbered list in prose is not a menu.
|
|
122
|
+
if (opts[0].n !== 1 || opts.some((o, i) => o.n !== i + 1)) return null;
|
|
123
|
+
return { opts, start };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Unnumbered rows with one cursor, only trusted when a key-hint footer is under them. */
|
|
127
|
+
function bareMenu(lines: string[], bottom: number): Menu | null {
|
|
128
|
+
const opts: PanePromptOption[] = [];
|
|
129
|
+
let start = -1;
|
|
130
|
+
let indent = -1;
|
|
131
|
+
for (let i = bottom; i >= 0 && opts.length < 9; i--) {
|
|
132
|
+
const raw = lines[i];
|
|
133
|
+
if (!raw.trim()) { if (opts.length) break; continue; }
|
|
134
|
+
const b = raw.match(BARE);
|
|
135
|
+
// The question above the rows ends in "?" or ":"; it is not a row.
|
|
136
|
+
if (!b || (!b[2] && /[?:]\s*$/.test(b[3]))) break;
|
|
137
|
+
const col = raw.indexOf(b[3]);
|
|
138
|
+
if (indent >= 0 && Math.abs(col - indent) > 1) break;
|
|
139
|
+
indent = col;
|
|
140
|
+
const ck = checkState(b[3]);
|
|
141
|
+
opts.unshift({ n: 0, label: ck.label, selected: !!b[2], checked: ck.checked });
|
|
142
|
+
start = i;
|
|
143
|
+
}
|
|
144
|
+
if (opts.length < 2 || opts.filter((o) => o.selected).length !== 1) return null;
|
|
145
|
+
opts.forEach((o, i) => { o.n = i + 1; });
|
|
146
|
+
return { opts, start };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The prompt on screen, or null. Looks only at the bottom of the pane (a
|
|
151
|
+
* prompt is always the last thing drawn) and demands a cursor-marked list:
|
|
152
|
+
* either numbered from 1 with the cursor on a row, or any list followed by a
|
|
153
|
+
* key-hint footer. A shell prompt ("❯ ") or a numbered list in the agent's
|
|
154
|
+
* reply has neither, which is what keeps false positives out.
|
|
155
|
+
*/
|
|
156
|
+
export function detectPanePrompt(pane: string): PanePrompt | null {
|
|
157
|
+
const all = pane.replace(/\r/g, '').split('\n').map(clean);
|
|
158
|
+
while (all.length && !all[all.length - 1].trim()) all.pop();
|
|
159
|
+
const lines = all.slice(-45);
|
|
160
|
+
if (lines.length < 2) return null;
|
|
161
|
+
|
|
162
|
+
const footerAt = (() => {
|
|
163
|
+
// Status lines can sit under a prompt (Claude Code's statusline is
|
|
164
|
+
// user-configured and may be several lines), so look a little way up.
|
|
165
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - TRAILING); i--) if (FOOTER.test(lines[i])) return i;
|
|
166
|
+
return -1;
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
let menu: Menu | null = null;
|
|
170
|
+
if (footerAt >= 0) menu = numberedMenu(lines, footerAt - 1) || bareMenu(lines, footerAt - 1);
|
|
171
|
+
else {
|
|
172
|
+
// No footer: the menu may still have status lines under it.
|
|
173
|
+
for (let b = lines.length - 1; b >= Math.max(0, lines.length - TRAILING) && !menu; b--) {
|
|
174
|
+
const m = numberedMenu(lines, b);
|
|
175
|
+
if (m && m.opts.some((o) => o.selected)) menu = m;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!menu) return null;
|
|
179
|
+
const { opts, start } = menu;
|
|
180
|
+
if (!opts.some((o) => o.selected) && footerAt < 0) return null;
|
|
181
|
+
|
|
182
|
+
const { question, detail } = above(lines, start);
|
|
183
|
+
const footer = footerAt >= 0 ? lines[footerAt].trim() : '';
|
|
184
|
+
const multi = opts.some((o) => o.checked !== null);
|
|
185
|
+
const text = [question, ...detail, ...opts.map((o) => `${o.n}${o.label}${o.checked}`), footer].join('\n');
|
|
186
|
+
return {
|
|
187
|
+
id: hash(text),
|
|
188
|
+
detail,
|
|
189
|
+
question: question || 'The agent is waiting for you',
|
|
190
|
+
options: opts,
|
|
191
|
+
multi,
|
|
192
|
+
footer,
|
|
193
|
+
approval: APPROVAL.test([question, ...detail].join(' ')) && !/type something/i.test(opts.map((o) => o.label).join(' ')),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Key names the agent may send to a pane, besides single characters. */
|
|
198
|
+
export const PROMPT_KEYS = ['Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'BTab', 'Space', 'BSpace', 'C-c'] as const;
|
|
199
|
+
export type PromptKey = (typeof PROMPT_KEYS)[number];
|
|
200
|
+
|
|
201
|
+
/** A key list is safe to hand to `tmux send-keys`: named keys or one printable character each. */
|
|
202
|
+
export function isSafeKeyList(keys: unknown): keys is string[] {
|
|
203
|
+
return Array.isArray(keys) && keys.length > 0 && keys.length <= 40
|
|
204
|
+
&& keys.every((k) => typeof k === 'string' && ((PROMPT_KEYS as readonly string[]).includes(k) || /^[A-Za-z0-9]$/.test(k)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Keys that choose option `n` (1-based): move the cursor from where it is to
|
|
209
|
+
* that row with arrows, then Enter. Arrows rather than the digit shortcut
|
|
210
|
+
* because every menu honours arrows, and only some honour digits. On a
|
|
211
|
+
* multi-select row Enter toggles it; the menu stays up and is re-read.
|
|
212
|
+
*/
|
|
213
|
+
export function promptKeys(p: PanePrompt, n: number): string[] {
|
|
214
|
+
const from = Math.max(0, p.options.findIndex((o) => o.selected));
|
|
215
|
+
const to = Math.max(0, Math.min(p.options.length - 1, n - 1));
|
|
216
|
+
const move = to > from ? Array(to - from).fill('Down') : Array(from - to).fill('Up');
|
|
217
|
+
return [...move, 'Enter'];
|
|
218
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AiKind } from './kinds';
|
|
2
2
|
import type { AiStatus } from './events';
|
|
3
3
|
import type { PermissionMode } from './permission';
|
|
4
|
+
import type { PanePrompt } from './pane-prompt';
|
|
4
5
|
|
|
5
6
|
export interface AiSessionSummary {
|
|
6
7
|
tmuxName: string;
|
|
@@ -26,6 +27,12 @@ export interface AiSessionSummary {
|
|
|
26
27
|
// Which file this row was built from, so opening the session resolves to
|
|
27
28
|
// the same one rather than re-racing the other panes.
|
|
28
29
|
transcript: string;
|
|
30
|
+
/**
|
|
31
|
+
* A menu the agent's TUI is showing (permission, plan approval, question),
|
|
32
|
+
* read from the pane — the transcript never records most of these. Absent
|
|
33
|
+
* from agents before 1.9.
|
|
34
|
+
*/
|
|
35
|
+
prompt?: PanePrompt | null;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
// Replay cap. Opening a month-old session must not flood the socket, and a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indianic/dialout",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Dialout agent — reach your office, home or build machine from any browser or phone. Remote tmux terminals, live port checks, filesystem browsing, process control and HTTP tunnels. The agent dials out: no inbound ports, no VPN, no port forwarding.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"dialout": "./bin/devdash-agent.js"
|