@indianic/dialout 1.8.1 → 1.10.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.
Files changed (43) hide show
  1. package/dist/ai-attachments.d.ts +18 -0
  2. package/dist/ai-attachments.d.ts.map +1 -0
  3. package/dist/ai-attachments.js +110 -0
  4. package/dist/ai-attachments.js.map +1 -0
  5. package/dist/ai-sessions.d.ts +14 -1
  6. package/dist/ai-sessions.d.ts.map +1 -1
  7. package/dist/ai-sessions.js +76 -5
  8. package/dist/ai-sessions.js.map +1 -1
  9. package/dist/tmux-manager.d.ts +1 -0
  10. package/dist/tmux-manager.d.ts.map +1 -1
  11. package/dist/tmux-manager.js +11 -0
  12. package/dist/tmux-manager.js.map +1 -1
  13. package/dist/websocket.d.ts.map +1 -1
  14. package/dist/websocket.js +20 -0
  15. package/dist/websocket.js.map +1 -1
  16. package/node_modules/@indianic/dialout-shared/dist/attachments.d.ts +33 -0
  17. package/node_modules/@indianic/dialout-shared/dist/attachments.d.ts.map +1 -0
  18. package/node_modules/@indianic/dialout-shared/dist/attachments.js +84 -0
  19. package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts +7 -0
  20. package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts.map +1 -1
  21. package/node_modules/@indianic/dialout-shared/dist/index.d.ts +5 -1
  22. package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
  23. package/node_modules/@indianic/dialout-shared/dist/index.js +15 -1
  24. package/node_modules/@indianic/dialout-shared/dist/notify.d.ts +7 -0
  25. package/node_modules/@indianic/dialout-shared/dist/notify.d.ts.map +1 -1
  26. package/node_modules/@indianic/dialout-shared/dist/notify.js +16 -0
  27. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts +46 -0
  28. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts.map +1 -0
  29. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.js +227 -0
  30. package/node_modules/@indianic/dialout-shared/dist/session.d.ts +7 -0
  31. package/node_modules/@indianic/dialout-shared/dist/session.d.ts.map +1 -1
  32. package/node_modules/@indianic/dialout-shared/package.json +1 -1
  33. package/node_modules/@indianic/dialout-shared/src/.omc/state/sessions/ebad4a54-74e6-4430-8f1c-4e4268aa5d4d/pre-tool-advisory-throttle.json +10 -0
  34. package/node_modules/@indianic/dialout-shared/src/attachments.test.ts +33 -0
  35. package/node_modules/@indianic/dialout-shared/src/attachments.ts +82 -0
  36. package/node_modules/@indianic/dialout-shared/src/event-merge.ts +7 -0
  37. package/node_modules/@indianic/dialout-shared/src/index.ts +10 -1
  38. package/node_modules/@indianic/dialout-shared/src/notify.test.ts +12 -1
  39. package/node_modules/@indianic/dialout-shared/src/notify.ts +18 -0
  40. package/node_modules/@indianic/dialout-shared/src/pane-prompt.test.ts +179 -0
  41. package/node_modules/@indianic/dialout-shared/src/pane-prompt.ts +218 -0
  42. package/node_modules/@indianic/dialout-shared/src/session.ts +7 -0
  43. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,QAAQ,CAAC;IAEjB,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IAC5B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAGlB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,eAAO,MAAM,YAAY,MAAM,CAAC"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,QAAQ,CAAC;IAEjB,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IAC5B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAGlB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;CAC5B;AAID,eAAO,MAAM,YAAY,MAAM,CAAC"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indianic/dialout-shared",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Types and constants shared by the Dialout server, agent and mobile app.",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org"
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "entries": {
4
+ "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
5
+ "last_emitted_at_ms": 1792572432915,
6
+ "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
7
+ }
8
+ },
9
+ "updated_at": "2026-10-21T08:47:12.915Z"
10
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { composeWithAttachments, safeAttachmentName, splitAttachments } from './attachments';
3
+
4
+ const P1 = '/Users/me/.dialout/attachments/2026-10-21/a1b2c3d4-shot.png';
5
+ const P2 = '/Users/me/.dialout/attachments/2026-10-21/0f0f0f0f-spec.pdf';
6
+
7
+ describe('attachments', () => {
8
+ it('makes names safe for disk and for a CLI line', () => {
9
+ expect(safeAttachmentName('Screen Shot 2026-10-21 at 3.04 PM.PNG')).toBe('Screen-Shot-2026-10-21-at-3.04-PM.png');
10
+ expect(safeAttachmentName('../../etc/passwd')).toBe('passwd');
11
+ expect(safeAttachmentName('.env')).toBe('env');
12
+ expect(safeAttachmentName('')).toBe('file');
13
+ expect(safeAttachmentName('a'.repeat(200) + '.jpeg').length).toBeLessThanOrEqual(80);
14
+ expect(safeAttachmentName('résumé (final).pdf')).toBe('resume-final.pdf');
15
+ });
16
+
17
+ it('references files the way each CLI reads them, on one line', () => {
18
+ expect(composeWithAttachments('claude', 'Fix this\nlayout', [P1])).toBe(`Fix this layout @${P1}`);
19
+ expect(composeWithAttachments('codex', 'Look', [P1, P2])).toBe(`Look (attached: ${P1}, ${P2})`);
20
+ expect(composeWithAttachments('claude', '', [P1])).toBe(`@${P1}`);
21
+ expect(composeWithAttachments('grok', 'hi', [])).toBe('hi');
22
+ });
23
+
24
+ it('turns references back into chips, leaving other paths alone', () => {
25
+ const a = splitAttachments(composeWithAttachments('claude', 'Fix /src/app.ts please', [P1, P2]));
26
+ expect(a.text).toBe('Fix /src/app.ts please');
27
+ expect(a.files.map((f) => [f.name, f.image])).toEqual([['shot.png', true], ['spec.pdf', false]]);
28
+ const b = splitAttachments(composeWithAttachments('codex', 'Look', [P1]));
29
+ expect(b.text).toBe('Look');
30
+ expect(b.files[0].path).toBe(P1);
31
+ expect(splitAttachments('nothing here (attached: soon)').text).toBe('nothing here (attached: soon)');
32
+ });
33
+ });
@@ -0,0 +1,82 @@
1
+ // Files and images attached to an AI chat message.
2
+ //
3
+ // A coding CLI cannot receive a file over its TUI, but every one of them can
4
+ // read a file by path: Claude Code natively (`@/path` mentions, images
5
+ // included), Codex and Grok through their read/view tools. So an attachment
6
+ // is uploaded to the machine the agent runs on, saved under
7
+ // ~/.dialout/attachments, and the message carries its path. The same rules
8
+ // are used by the web app, the phone app and the agent.
9
+
10
+ /** Per file. Images from a phone camera fit comfortably; videos do not belong here. */
11
+ export const ATTACHMENT_MAX_BYTES = 15 * 1024 * 1024;
12
+ export const ATTACHMENT_MAX_FILES = 8;
13
+ /** Where the agent keeps them, relative to the user's home. */
14
+ export const ATTACHMENT_DIR = '.dialout/attachments';
15
+
16
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)$/i;
17
+
18
+ export function isImageName(name: string): boolean {
19
+ return IMAGE_EXT.test(name);
20
+ }
21
+
22
+ /**
23
+ * A name safe to put on disk and inside a message: letters, digits, dot,
24
+ * dash and underscore only, no leading dots, at most 80 characters with the
25
+ * extension kept. No spaces, so a path never has to be quoted for a CLI.
26
+ */
27
+ export function safeAttachmentName(name: string): string {
28
+ const base = String(name || '').split(/[\\/]/).pop() || '';
29
+ const dot = base.lastIndexOf('.');
30
+ const ext = dot > 0 ? base.slice(dot).toLowerCase().replace(/[^a-z0-9.]/g, '').slice(0, 10) : '';
31
+ const stem = (dot > 0 ? base.slice(0, dot) : base)
32
+ .normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\w.-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '').replace(/-{2,}/g, '-')
33
+ .slice(0, 80 - ext.length);
34
+ return `${stem || 'file'}${ext}`;
35
+ }
36
+
37
+ /**
38
+ * The message with its attachments referenced the way each CLI reads them.
39
+ * One line on purpose: a newline typed into a TUI can submit the message
40
+ * half-written.
41
+ */
42
+ export function composeWithAttachments(kind: string, text: string, paths: string[]): string {
43
+ const body = text.replace(/\s*\n+\s*/g, ' ').trim();
44
+ if (!paths.length) return body;
45
+ const refs = kind === 'claude'
46
+ ? paths.map((p) => `@${p}`).join(' ')
47
+ : `(attached: ${paths.join(', ')})`;
48
+ return body ? `${body} ${refs}` : refs;
49
+ }
50
+
51
+ export type AttachmentRef = { path: string; name: string; image: boolean };
52
+
53
+ const IN_DIR = /^(?:~|\/[^\s,()]*?)\/\.dialout\/attachments\/[^\s,()]+$/;
54
+
55
+ /**
56
+ * Pull attachment paths back out of a sent message, so a chat bubble can show
57
+ * them as chips instead of long paths. Only paths inside the attachments
58
+ * folder count; any other path the user typed stays text.
59
+ */
60
+ export function splitAttachments(message: string): { text: string; files: AttachmentRef[] } {
61
+ const files: AttachmentRef[] = [];
62
+ const seen = new Set<string>();
63
+ const text = message
64
+ .replace(/\(attached:\s*([^)]*)\)/g, (whole, list: string) => {
65
+ const paths = list.split(',').map((s) => s.trim()).filter((s) => IN_DIR.test(s));
66
+ if (!paths.length) return whole;
67
+ for (const p of paths) add(p);
68
+ return '';
69
+ })
70
+ .replace(/@?((?:~|\/[^\s,()]*?)\/\.dialout\/attachments\/[^\s,()]+)/g, (_w, p: string) => { add(p); return ''; })
71
+ .replace(/\s{2,}/g, ' ')
72
+ .trim();
73
+ function add(p: string) {
74
+ if (seen.has(p)) return;
75
+ seen.add(p);
76
+ const file = p.split('/').pop() || p;
77
+ // Saved names carry an 8-character id prefix ("a1b2c3d4-shot.png").
78
+ const name = file.replace(/^[0-9a-f]{8}-/, '');
79
+ files.push({ path: p, name, image: isImageName(name) });
80
+ }
81
+ return { text, files };
82
+ }
@@ -1,3 +1,4 @@
1
+ import type { PanePrompt } from './pane-prompt';
1
2
  import type { AiEvent } from './events';
2
3
 
3
4
  // How a client folds ai_session_events messages into its transcript.
@@ -37,6 +38,12 @@ export interface EventsMeta {
37
38
  sessionKey?: string;
38
39
  /** Who/where, sent with a replay so a captured log can be labelled. */
39
40
  session?: { kind: string; folder: string; gitBranch: string; title: string };
41
+ /**
42
+ * What the agent's TUI is asking right now, read from its pane (see
43
+ * pane-prompt.ts). null = nothing asked any more; absent = not reported
44
+ * (agents before 1.9, or a message that is only about events).
45
+ */
46
+ prompt?: PanePrompt | null;
40
47
  }
41
48
 
42
49
  export const EMPTY_TRANSCRIPT: TranscriptState = { events: [], firstSeq: null, earlier: 0 };
@@ -12,7 +12,7 @@ export { REPLAY_LIMIT } from './session';
12
12
 
13
13
  export type { CommandSource, AiCommand, McpServerInfo, AiCapabilities } from './capabilities';
14
14
 
15
- export { NOTIFY_COOLDOWN_MS, shouldNotifyAi } from './notify';
15
+ export { NOTIFY_COOLDOWN_MS, shouldNotifyAi, shouldNotifyPrompt } from './notify';
16
16
 
17
17
  export type { ToolItem, ChatBlock } from './chat-blocks';
18
18
  export { groupEvents, toolGroupStartsOpen } from './chat-blocks';
@@ -40,3 +40,12 @@ export {
40
40
 
41
41
  export type { TranscriptState, EventsMeta } from './event-merge';
42
42
  export { EMPTY_TRANSCRIPT, mergeEvents } from './event-merge';
43
+
44
+ export type { PanePrompt, PanePromptOption, PromptKey } from './pane-prompt';
45
+ export { PROMPT_KEYS, detectPanePrompt, isSafeKeyList, promptKeys } from './pane-prompt';
46
+
47
+ export type { AttachmentRef } from './attachments';
48
+ export {
49
+ ATTACHMENT_DIR, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_FILES,
50
+ composeWithAttachments, isImageName, safeAttachmentName, splitAttachments,
51
+ } from './attachments';
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { shouldNotifyAi } from './notify';
2
+ import { NOTIFY_COOLDOWN_MS, shouldNotifyAi, shouldNotifyPrompt } from './notify';
3
3
 
4
4
  // This is the logic most capable of annoying someone into turning
5
5
  // notifications off, so every rule gets a case.
@@ -47,3 +47,14 @@ describe('shouldNotifyAi', () => {
47
47
  expect(shouldNotifyAi('working', 'waiting_input', NOW - 200_000, NOW)).toBe(true);
48
48
  });
49
49
  });
50
+
51
+ describe('shouldNotifyPrompt', () => {
52
+ it('fires for a new question, not on first sight, not twice, not inside the cooldown', () => {
53
+ expect(shouldNotifyPrompt(undefined, 'a', undefined, 1000)).toBe(false);
54
+ expect(shouldNotifyPrompt(null, 'a', undefined, 1000)).toBe(true);
55
+ expect(shouldNotifyPrompt('a', 'a', undefined, 1000)).toBe(false);
56
+ expect(shouldNotifyPrompt('a', null, undefined, 1000)).toBe(false);
57
+ expect(shouldNotifyPrompt('a', 'b', 1000, 1000 + NOTIFY_COOLDOWN_MS - 1)).toBe(false);
58
+ expect(shouldNotifyPrompt('a', 'b', 1000, 1000 + NOTIFY_COOLDOWN_MS)).toBe(true);
59
+ });
60
+ });
@@ -28,3 +28,21 @@ export function shouldNotifyAi(
28
28
  if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
29
29
  return true;
30
30
  }
31
+
32
+ /**
33
+ * A new question on an agent's screen (a pane prompt id not seen before) is
34
+ * always news, whatever the status said: a session nobody has open reports
35
+ * 'idle' until asked, so the working → waiting rule above never sees it.
36
+ * Still not on first sight (reconnect bursts) and still rate-limited.
37
+ */
38
+ export function shouldNotifyPrompt(
39
+ previousPromptId: string | null | undefined,
40
+ nextPromptId: string | null | undefined,
41
+ lastNotifiedAt: number | undefined,
42
+ nowMs: number,
43
+ ): boolean {
44
+ if (previousPromptId === undefined) return false;
45
+ if (!nextPromptId || nextPromptId === previousPromptId) return false;
46
+ if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
47
+ return true;
48
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { detectPanePrompt, isSafeKeyList, promptKeys } from './pane-prompt';
3
+
4
+ // Shapes as the CLIs draw them (spacing kept), plus the noise around them.
5
+ const CLAUDE_BASH = `
6
+ ⏺ I'll run the tests.
7
+
8
+ ╭──────────────────────────────────────────────────────────────╮
9
+ │ Bash command │
10
+ │ │
11
+ │ npm test │
12
+ │ Run the test suite │
13
+ │ │
14
+ │ Do you want to proceed? │
15
+ │ ❯ 1. Yes │
16
+ │ 2. Yes, and don't ask again for npm test commands in /app │
17
+ │ 3. No, and tell Claude what to do differently (esc) │
18
+ ╰──────────────────────────────────────────────────────────────╯
19
+ `;
20
+
21
+ const CLAUDE_QUESTION = `
22
+ ⏺ Let me ask a couple of things first.
23
+
24
+ ────────────────────────────────────────────────────────────────
25
+ ☐ Colour ☐ Pet ✔ Submit →
26
+
27
+ Which colour do you prefer?
28
+
29
+ ❯ 1. Red
30
+ Warm and loud
31
+ 2. Green
32
+ Calm
33
+ 3. Type something.
34
+ ────────────────────────────────────────────────────────────────
35
+ 4. Chat about this
36
+
37
+ Enter to select · Tab/Arrow keys to navigate · Esc to cancel
38
+ `;
39
+
40
+ const CLAUDE_TRUST = `
41
+ Accessing workspace:
42
+
43
+ /Users/me/code/app
44
+
45
+ Quick safety check: Is this a project you created or one you trust?
46
+
47
+ ❯ No, exit
48
+ Yes, I trust this folder
49
+
50
+ Enter to confirm · Esc to cancel
51
+ `;
52
+
53
+ const CODEX_APPROVAL = `
54
+ • Running tests
55
+
56
+ Would you like to run the following command?
57
+
58
+ $ npm test
59
+
60
+ › 1. Yes, proceed (y)
61
+ 2. Yes, and don't ask again for this command (a)
62
+ 3. No, and tell Codex what to do differently (esc)
63
+
64
+ Press enter to confirm or esc to cancel
65
+ `;
66
+
67
+ const MULTI = `
68
+ Which features should I add?
69
+
70
+ ❯ 1. [ ] Dark mode
71
+ 2. [✔] Search
72
+ 3. [ ] Export
73
+ 4. Submit
74
+
75
+ Enter to select · ↑/↓ to navigate · Esc to cancel
76
+ `;
77
+
78
+ const IDLE_CLAUDE = `
79
+ ⏺ Done. Here is what changed:
80
+
81
+ 1. Fixed the parser
82
+ 2. Added tests
83
+ 3. Updated docs
84
+
85
+ ────────────────────────────────────────────────────────────────
86
+ ❯
87
+ ────────────────────────────────────────────────────────────────
88
+ ⏵⏵ auto mode on (shift+tab to cycle)
89
+ `;
90
+
91
+ const SHELL = `
92
+ ~/code/app main
93
+ ❯ ls
94
+ README.md package.json src
95
+ ~/code/app main
96
+ ❯
97
+ `;
98
+
99
+ describe('detectPanePrompt', () => {
100
+ it('reads a Claude permission prompt inside a box', () => {
101
+ const p = detectPanePrompt(CLAUDE_BASH)!;
102
+ expect(p.question).toBe('Do you want to proceed?');
103
+ expect(p.detail).toEqual(['Bash command', 'npm test', 'Run the test suite']);
104
+ expect(p.options.map((o) => o.label)).toEqual([
105
+ 'Yes', "Yes, and don't ask again for npm test commands in /app", 'No, and tell Claude what to do differently (esc)',
106
+ ]);
107
+ expect(p.options[0].selected).toBe(true);
108
+ expect(p.approval).toBe(true);
109
+ expect(p.multi).toBe(false);
110
+ });
111
+
112
+ it('reads a question menu with hints, a separator and a footer', () => {
113
+ const p = detectPanePrompt(CLAUDE_QUESTION)!;
114
+ expect(p.question).toBe('Which colour do you prefer?');
115
+ expect(p.options.map((o) => o.label)).toEqual(['Red', 'Green', 'Type something.', 'Chat about this']);
116
+ expect(p.options[0].hint).toBe('Warm and loud');
117
+ expect(p.options[1].hint).toBe('Calm');
118
+ expect(p.footer).toMatch(/Enter to select/);
119
+ expect(p.approval).toBe(false);
120
+ });
121
+
122
+ it('reads an unnumbered menu when a footer vouches for it', () => {
123
+ const p = detectPanePrompt(CLAUDE_TRUST)!;
124
+ expect(p.options.map((o) => [o.n, o.label, o.selected])).toEqual([[1, 'No, exit', true], [2, 'Yes, I trust this folder', false]]);
125
+ expect(p.question).toMatch(/one you trust\?$/);
126
+ });
127
+
128
+ it('reads a Codex approval', () => {
129
+ const p = detectPanePrompt(CODEX_APPROVAL)!;
130
+ expect(p.question).toBe('Would you like to run the following command?');
131
+ expect(p.detail).toContain('$ npm test');
132
+ expect(p.options).toHaveLength(3);
133
+ expect(p.options[0].selected).toBe(true);
134
+ });
135
+
136
+ it('marks multi-select rows', () => {
137
+ const p = detectPanePrompt(MULTI)!;
138
+ expect(p.multi).toBe(true);
139
+ expect(p.options.map((o) => o.checked)).toEqual([false, true, false, null]);
140
+ expect(p.options[1].label).toBe('Search');
141
+ });
142
+
143
+ it('finds a prompt under several status lines', () => {
144
+ const status = '\n [Opus 5.5] │ app │ main\n Context ░░░░ 12%\n Usage ok\n ⏵⏵ auto mode on (shift+tab to cycle)\n';
145
+ const noFooter = CLAUDE_BASH.trimEnd() + status;
146
+ expect(detectPanePrompt(noFooter)?.question).toBe('Do you want to proceed?');
147
+ expect(detectPanePrompt(CLAUDE_TRUST.trimEnd() + status)?.options).toHaveLength(2);
148
+ });
149
+
150
+ it('ignores an idle agent, a numbered reply and a shell prompt', () => {
151
+ expect(detectPanePrompt(IDLE_CLAUDE)).toBeNull();
152
+ expect(detectPanePrompt(SHELL)).toBeNull();
153
+ expect(detectPanePrompt('')).toBeNull();
154
+ });
155
+
156
+ it('keeps its id while the cursor moves, changes it for a new question', () => {
157
+ const a = detectPanePrompt(CLAUDE_BASH)!;
158
+ expect(detectPanePrompt(CLAUDE_BASH.replace('❯ 1. Yes', ' 1. Yes').replace('│ 2. Yes,', '│ ❯ 2. Yes,'))!.id).toBe(a.id);
159
+ expect(detectPanePrompt(CLAUDE_BASH.replace('npm test ', 'npm run x'))!.id).not.toBe(a.id);
160
+ });
161
+ });
162
+
163
+ describe('promptKeys', () => {
164
+ it('moves from the cursor to the target, then Enter', () => {
165
+ const p = detectPanePrompt(CLAUDE_BASH)!;
166
+ expect(promptKeys(p, 1)).toEqual(['Enter']);
167
+ expect(promptKeys(p, 3)).toEqual(['Down', 'Down', 'Enter']);
168
+ const moved = detectPanePrompt(CLAUDE_BASH.replace('❯ 1. Yes', ' 1. Yes').replace('│ 2. Yes,', '│ ❯ 2. Yes,'))!;
169
+ expect(promptKeys(moved, 1)).toEqual(['Up', 'Enter']);
170
+ });
171
+
172
+ it('only lets named keys and single characters through', () => {
173
+ expect(isSafeKeyList(['Down', 'Enter', 'y', '1'])).toBe(true);
174
+ expect(isSafeKeyList(['rm -rf /'])).toBe(false);
175
+ expect(isSafeKeyList([';', 'Enter'])).toBe(false);
176
+ expect(isSafeKeyList([])).toBe(false);
177
+ expect(isSafeKeyList('Enter')).toBe(false);
178
+ });
179
+ });
@@ -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.8.1",
3
+ "version": "1.10.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"