@indianic/dialout 1.13.1 → 1.14.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 (59) hide show
  1. package/dist/ai-adapters/augment.d.ts +3 -0
  2. package/dist/ai-adapters/augment.d.ts.map +1 -0
  3. package/dist/ai-adapters/augment.js +147 -0
  4. package/dist/ai-adapters/augment.js.map +1 -0
  5. package/dist/ai-adapters/index.d.ts.map +1 -1
  6. package/dist/ai-adapters/index.js +2 -0
  7. package/dist/ai-adapters/index.js.map +1 -1
  8. package/dist/ai-json-tail.d.ts +26 -0
  9. package/dist/ai-json-tail.d.ts.map +1 -0
  10. package/dist/ai-json-tail.js +153 -0
  11. package/dist/ai-json-tail.js.map +1 -0
  12. package/dist/ai-session-detector.d.ts +22 -4
  13. package/dist/ai-session-detector.d.ts.map +1 -1
  14. package/dist/ai-session-detector.js +43 -6
  15. package/dist/ai-session-detector.js.map +1 -1
  16. package/dist/ai-sessions.d.ts +4 -2
  17. package/dist/ai-sessions.d.ts.map +1 -1
  18. package/dist/ai-sessions.js +24 -8
  19. package/dist/ai-sessions.js.map +1 -1
  20. package/dist/ai-transcript-locator.d.ts +74 -0
  21. package/dist/ai-transcript-locator.d.ts.map +1 -1
  22. package/dist/ai-transcript-locator.js +181 -0
  23. package/dist/ai-transcript-locator.js.map +1 -1
  24. package/dist/ai-transcript-tail.d.ts +17 -1
  25. package/dist/ai-transcript-tail.d.ts.map +1 -1
  26. package/dist/ai-transcript-tail.js.map +1 -1
  27. package/dist/cli-client.d.ts.map +1 -1
  28. package/dist/cli-client.js +1 -0
  29. package/dist/cli-client.js.map +1 -1
  30. package/dist/cli.js +96 -0
  31. package/dist/cli.js.map +1 -1
  32. package/dist/config.d.ts +21 -1
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +28 -2
  35. package/dist/config.js.map +1 -1
  36. package/node_modules/@indianic/dialout-shared/dist/custom-agents.d.ts +97 -0
  37. package/node_modules/@indianic/dialout-shared/dist/custom-agents.d.ts.map +1 -0
  38. package/node_modules/@indianic/dialout-shared/dist/custom-agents.js +147 -0
  39. package/node_modules/@indianic/dialout-shared/dist/custom-agents.test.d.ts +2 -0
  40. package/node_modules/@indianic/dialout-shared/dist/custom-agents.test.d.ts.map +1 -0
  41. package/node_modules/@indianic/dialout-shared/dist/custom-agents.test.js +155 -0
  42. package/node_modules/@indianic/dialout-shared/dist/index.d.ts +2 -0
  43. package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
  44. package/node_modules/@indianic/dialout-shared/dist/index.js +11 -2
  45. package/node_modules/@indianic/dialout-shared/dist/kinds.d.ts +1 -1
  46. package/node_modules/@indianic/dialout-shared/dist/kinds.d.ts.map +1 -1
  47. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts.map +1 -1
  48. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.js +20 -5
  49. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.test.js +75 -0
  50. package/node_modules/@indianic/dialout-shared/package.json +1 -1
  51. package/node_modules/@indianic/dialout-shared/src/.impeccable/hook.cache.json +1 -0
  52. package/node_modules/@indianic/dialout-shared/src/.omc/state/sessions/e864cc5c-dad7-49c4-8666-6d7fb2297d2a/pre-tool-advisory-throttle.json +14 -0
  53. package/node_modules/@indianic/dialout-shared/src/custom-agents.test.ts +185 -0
  54. package/node_modules/@indianic/dialout-shared/src/custom-agents.ts +198 -0
  55. package/node_modules/@indianic/dialout-shared/src/index.ts +5 -0
  56. package/node_modules/@indianic/dialout-shared/src/kinds.ts +1 -1
  57. package/node_modules/@indianic/dialout-shared/src/pane-prompt.test.ts +82 -0
  58. package/node_modules/@indianic/dialout-shared/src/pane-prompt.ts +21 -2
  59. package/package.json +1 -1
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ parseCustomAgent,
4
+ parseCustomAgents,
5
+ customAgentRule,
6
+ expandTranscript,
7
+ transcriptMatcher,
8
+ isAiKind,
9
+ } from './custom-agents';
10
+
11
+ const good = {
12
+ name: 'sagent',
13
+ kind: 'claude' as const,
14
+ transcript: '{home}/.myagent/sessions/*.jsonl',
15
+ };
16
+
17
+ describe('parseCustomAgent', () => {
18
+ it('accepts a well-formed declaration', () => {
19
+ const parsed = parseCustomAgent(good);
20
+ expect(parsed).toEqual({ ok: true, agent: good });
21
+ });
22
+
23
+ it('trims and lowercases the kind, so "Claude" is not a mystery failure', () => {
24
+ const parsed = parseCustomAgent({ ...good, kind: ' Claude ' });
25
+ expect(parsed.ok && parsed.agent.kind).toBe('claude');
26
+ });
27
+
28
+ it('rejects a kind with no adapter behind it', () => {
29
+ // The whole point: `kind` chooses which parser runs. A kind we have no
30
+ // adapter for would produce a session with no conversation in it.
31
+ const parsed = parseCustomAgent({ ...good, kind: 'augment-next' });
32
+ expect(parsed.ok).toBe(false);
33
+ });
34
+
35
+ describe('the path rules, which are the security boundary', () => {
36
+ // The agent runs as the user and streams whatever it is pointed at into a
37
+ // chat. These rules are what stop a declaration being a way to read
38
+ // arbitrary files.
39
+
40
+ it('requires the path to start at {home}', () => {
41
+ for (const transcript of ['/etc/passwd', '~/.ssh/id_rsa', '/{home}/x.jsonl', 'relative/x.jsonl']) {
42
+ const parsed = parseCustomAgent({ ...good, transcript });
43
+ expect(parsed.ok, transcript).toBe(false);
44
+ }
45
+ });
46
+
47
+ it('rejects traversal outright', () => {
48
+ const parsed = parseCustomAgent({ ...good, transcript: '{home}/../../etc/hosts.jsonl' });
49
+ expect(parsed.ok).toBe(false);
50
+ });
51
+
52
+ it('requires a .json or .jsonl file, so {home}/.ssh/* is not declarable', () => {
53
+ const parsed = parseCustomAgent({ ...good, transcript: '{home}/.ssh/*' });
54
+ expect(parsed.ok).toBe(false);
55
+ expect(parseCustomAgent({ ...good, transcript: '{home}/.x/a.json' }).ok).toBe(true);
56
+ expect(parseCustomAgent({ ...good, transcript: '{home}/.x/a.jsonl' }).ok).toBe(true);
57
+ });
58
+
59
+ it('allows a glob only in the file name', () => {
60
+ // A '*' in a directory segment would walk the home directory.
61
+ expect(parseCustomAgent({ ...good, transcript: '{home}/*/sessions/a.jsonl' }).ok).toBe(false);
62
+ expect(parseCustomAgent({ ...good, transcript: '{home}/.x/*.jsonl' }).ok).toBe(true);
63
+ });
64
+
65
+ it('allows at most one glob', () => {
66
+ expect(parseCustomAgent({ ...good, transcript: '{home}/.x/*-*.jsonl' }).ok).toBe(false);
67
+ });
68
+
69
+ it('reports an unknown placeholder instead of looking up a literal directory', () => {
70
+ const parsed = parseCustomAgent({ ...good, transcript: '{home}/.x/{sessionid}/a.jsonl' });
71
+ expect(parsed.ok).toBe(false);
72
+ expect(parsed.ok === false && parsed.error).toContain('{sessionid}');
73
+ });
74
+ });
75
+
76
+ describe('the name rules', () => {
77
+ it('rejects a name that would match half the process table', () => {
78
+ for (const name of ['', ' ', '*', '.*', 'my agent', 'bin/agent', 'a'.repeat(65)]) {
79
+ expect(parseCustomAgent({ ...good, name }).ok, JSON.stringify(name)).toBe(false);
80
+ }
81
+ });
82
+
83
+ it('accepts the characters real binaries use', () => {
84
+ for (const name of ['agy', 'my-agent', 'my_agent', 'agent.sh', 'claude2', 'c++agent']) {
85
+ expect(parseCustomAgent({ ...good, name }).ok, name).toBe(true);
86
+ }
87
+ });
88
+ });
89
+
90
+ it('rejects junk rather than throwing', () => {
91
+ for (const bad of [null, undefined, 42, 'str', [], {}]) {
92
+ expect(parseCustomAgent(bad).ok).toBe(false);
93
+ }
94
+ });
95
+ });
96
+
97
+ describe('parseCustomAgents', () => {
98
+ it('treats absent as none', () => {
99
+ expect(parseCustomAgents(undefined)).toEqual({ ok: true, agents: [] });
100
+ expect(parseCustomAgents(null)).toEqual({ ok: true, agents: [] });
101
+ });
102
+
103
+ it('reports the first bad entry rather than silently dropping it', () => {
104
+ // Silently dropping would leave the user with a config they can see and a
105
+ // session they cannot, and nothing connecting the two.
106
+ const parsed = parseCustomAgents([good, { ...good, name: 'x', kind: 'nope' }]);
107
+ expect(parsed.ok).toBe(false);
108
+ });
109
+
110
+ it('rejects two entries claiming one name', () => {
111
+ const parsed = parseCustomAgents([good, { ...good, transcript: '{home}/.other/a.json' }]);
112
+ expect(parsed.ok).toBe(false);
113
+ expect(parsed.ok === false && parsed.error).toContain('duplicate');
114
+ });
115
+
116
+ it('rejects a non-array', () => {
117
+ expect(parseCustomAgents({ name: 'x' }).ok).toBe(false);
118
+ });
119
+ });
120
+
121
+ describe('customAgentRule', () => {
122
+ const rule = (name: string) => customAgentRule({ ...good, name });
123
+
124
+ it('anchors on a path-segment boundary, like the built-in rules', () => {
125
+ expect(rule('sagent').test('sagent')).toBe(true);
126
+ expect(rule('sagent').test('sagent --resume')).toBe(true);
127
+ expect(rule('sagent').test('/usr/local/bin/sagent')).toBe(true);
128
+ // A bare space before the name is neither start-of-string nor a slash.
129
+ expect(rule('sagent').test('vim sagent')).toBe(false);
130
+ expect(rule('sagent').test('sagentry')).toBe(false);
131
+ });
132
+
133
+ it('escapes the name, so a declaration cannot inject a pattern', () => {
134
+ // '.' must match a literal dot and nothing else; without escaping,
135
+ // 'a.y' would match 'agy' — and a name of '.*' would match everything.
136
+ expect(rule('a.y').test('a.y')).toBe(true);
137
+ expect(rule('a.y').test('agy')).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe('expandTranscript', () => {
142
+ it('fills the placeholders it is given', () => {
143
+ expect(expandTranscript('{home}/.x/{pid}/a.jsonl', { home: '/h', pid: 42 }))
144
+ .toBe('/h/.x/42/a.jsonl');
145
+ expect(expandTranscript('{home}/.x/{tty}.json', { home: '/h', tty: '/dev/ttys1' }))
146
+ .toBe('/h/.x//dev/ttys1.json');
147
+ });
148
+
149
+ it('returns null when a placeholder it needs has no value', () => {
150
+ // Otherwise '{cwd}' is looked up as a literal directory of that name,
151
+ // which never matches and reads as "your declaration is wrong".
152
+ expect(expandTranscript('{home}/.x/{cwd}/a.jsonl', { home: '/h' })).toBeNull();
153
+ expect(expandTranscript('{home}/.x/a.jsonl', { home: '' })).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe('transcriptMatcher', () => {
158
+ it('matches an exact file name when there is no glob', () => {
159
+ const m = transcriptMatcher('/h/.x/chat.jsonl');
160
+ expect(m.dir).toBe('/h/.x');
161
+ expect(m.match('chat.jsonl')).toBe(true);
162
+ expect(m.match('other.jsonl')).toBe(false);
163
+ });
164
+
165
+ it('expands a glob within the file name only', () => {
166
+ const m = transcriptMatcher('/h/.x/*.jsonl');
167
+ expect(m.dir).toBe('/h/.x');
168
+ expect(m.match('a.jsonl')).toBe(true);
169
+ expect(m.match('deep/a.jsonl')).toBe(false);
170
+ expect(m.match('a.json')).toBe(false);
171
+ });
172
+
173
+ it('treats the non-glob part of the name literally', () => {
174
+ const m = transcriptMatcher('/h/.x/roll.out-*.jsonl');
175
+ expect(m.match('roll.out-1.jsonl')).toBe(true);
176
+ expect(m.match('rollXout-1.jsonl')).toBe(false);
177
+ });
178
+ });
179
+
180
+ describe('isAiKind', () => {
181
+ it('knows the four vendors with adapters', () => {
182
+ for (const k of ['claude', 'codex', 'grok', 'augment']) expect(isAiKind(k)).toBe(true);
183
+ for (const k of ['gemini', '', null, undefined, 7]) expect(isAiKind(k)).toBe(false);
184
+ });
185
+ });
@@ -0,0 +1,198 @@
1
+ import type { AiKind } from './kinds';
2
+
3
+ /**
4
+ * A coding agent this machine's owner has declared by hand.
5
+ *
6
+ * Two cases this exists for, and one it deliberately does not cover.
7
+ *
8
+ * It covers a RENAMED BINARY — a copy of a supported CLI living under another
9
+ * name, where argv really is `sagent` and the detector's rules cannot see it.
10
+ * And it covers a supported format written somewhere we do not look.
11
+ *
12
+ * It does NOT make a genuinely new vendor readable. `kind` chooses which
13
+ * existing parser runs, so a custom agent must write a transcript one of the
14
+ * adapters already understands. There is no way around that: knowing a process
15
+ * is an agent tells you nothing about how to read its conversation.
16
+ *
17
+ * Worth being clear about what is NOT needed here: a shell alias, function or
18
+ * wrapper script needs no entry at all. `sagent() { claude "$@"; }` runs a
19
+ * process whose argv is `claude`, and findAgentInPane walks descendants, so it
20
+ * is already detected. Only a renamed binary is invisible.
21
+ *
22
+ * ---
23
+ *
24
+ * MACHINE-LOCAL BY DESIGN. These live in ~/.dialout/config.json and are never
25
+ * accepted from the server.
26
+ *
27
+ * The agent runs as the user, with the user's full read access, and it streams
28
+ * whatever it is pointed at into a chat. A server-supplied path would therefore
29
+ * be a file-read primitive: point it at ~/.ssh/id_rsa or a .env and the
30
+ * contents leave the machine. Keeping the declaration on the machine means
31
+ * compromising a web account cannot turn every agent into a file reader — the
32
+ * same reasoning that keeps the ws-server bound to 127.0.0.1 and filters every
33
+ * value interpolated into the cowork rc block.
34
+ *
35
+ * The validation below is the second line of defence, not the first.
36
+ */
37
+ export interface CustomAgent {
38
+ /** The command name as the process table shows it, e.g. 'sagent'. */
39
+ name: string;
40
+ /** Which existing adapter reads this agent's transcript. */
41
+ kind: AiKind;
42
+ /** Where the transcript lives; see expandTranscript for the placeholders. */
43
+ transcript: string;
44
+ }
45
+
46
+ export const AI_KINDS: readonly AiKind[] = ['claude', 'codex', 'grok', 'augment'] as const;
47
+
48
+ export function isAiKind(value: unknown): value is AiKind {
49
+ return typeof value === 'string' && (AI_KINDS as readonly string[]).includes(value);
50
+ }
51
+
52
+ /**
53
+ * A command name, not a pattern.
54
+ *
55
+ * No spaces and no path separators: the value is matched against one argv
56
+ * token, and anything looser invites an entry that matches every process on
57
+ * the machine. The characters allowed are the ones real binaries use.
58
+ */
59
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
60
+
61
+ /** The placeholders expandTranscript understands. Anything else is a typo worth reporting. */
62
+ export const TRANSCRIPT_VARS = ['home', 'cwd', 'tty', 'pid'] as const;
63
+ const VAR_RE = /\{([a-z]+)\}/g;
64
+
65
+ export type CustomAgentParse =
66
+ | { ok: true; agent: CustomAgent }
67
+ | { ok: false; error: string };
68
+
69
+ export function parseCustomAgent(input: unknown): CustomAgentParse {
70
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
71
+ return { ok: false, error: 'A custom agent must be an object' };
72
+ }
73
+ const raw = input as Record<string, unknown>;
74
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
75
+ const kind = typeof raw.kind === 'string' ? raw.kind.trim().toLowerCase() : '';
76
+ const transcript = typeof raw.transcript === 'string' ? raw.transcript.trim() : '';
77
+
78
+ if (!NAME_RE.test(name)) {
79
+ return { ok: false, error: 'name must be a bare command name, e.g. sagent (letters, digits, . _ + -)' };
80
+ }
81
+ if (!isAiKind(kind)) {
82
+ return { ok: false, error: `kind must be one of ${AI_KINDS.join(', ')}` };
83
+ }
84
+ if (!transcript) {
85
+ return { ok: false, error: 'transcript is required' };
86
+ }
87
+ // Anchored at {home} so a declaration cannot reach outside the user's own
88
+ // files even before the resolved path is re-checked.
89
+ if (!transcript.startsWith('{home}/')) {
90
+ return { ok: false, error: 'transcript must start with {home}/' };
91
+ }
92
+ if (transcript.includes('..')) {
93
+ return { ok: false, error: 'transcript must not contain ..' };
94
+ }
95
+ // A transcript is a file we parse, so the extension is part of the contract.
96
+ // Without this, `{home}/.ssh/*` is a legal declaration.
97
+ if (!/\.jsonl?$/.test(transcript)) {
98
+ return { ok: false, error: 'transcript must end in .json or .jsonl' };
99
+ }
100
+ // At most one glob, and only in the filename: a `*` in a directory segment
101
+ // would walk the home directory.
102
+ const slash = transcript.lastIndexOf('/');
103
+ if (transcript.slice(0, slash).includes('*')) {
104
+ return { ok: false, error: 'a * may only appear in the file name, not in a directory' };
105
+ }
106
+ if ((transcript.match(/\*/g) || []).length > 1) {
107
+ return { ok: false, error: 'transcript may contain at most one *' };
108
+ }
109
+ const unknown = [...transcript.matchAll(VAR_RE)]
110
+ .map((m) => m[1])
111
+ .filter((v) => !(TRANSCRIPT_VARS as readonly string[]).includes(v));
112
+ if (unknown.length) {
113
+ return {
114
+ ok: false,
115
+ error: `unknown placeholder {${unknown[0]}}; use ${TRANSCRIPT_VARS.map((v) => `{${v}}`).join(', ')}`,
116
+ };
117
+ }
118
+ return { ok: true, agent: { name, kind, transcript } };
119
+ }
120
+
121
+ /** Parses a list, reporting the first bad entry rather than silently dropping it. */
122
+ export function parseCustomAgents(input: unknown): { ok: true; agents: CustomAgent[] } | { ok: false; error: string } {
123
+ if (input === undefined || input === null) return { ok: true, agents: [] };
124
+ if (!Array.isArray(input)) return { ok: false, error: 'customAgents must be an array' };
125
+ const agents: CustomAgent[] = [];
126
+ const seen = new Set<string>();
127
+ for (const entry of input) {
128
+ const parsed = parseCustomAgent(entry);
129
+ if (!parsed.ok) return parsed;
130
+ if (seen.has(parsed.agent.name)) {
131
+ return { ok: false, error: `duplicate custom agent name: ${parsed.agent.name}` };
132
+ }
133
+ seen.add(parsed.agent.name);
134
+ agents.push(parsed.agent);
135
+ }
136
+ return { ok: true, agents };
137
+ }
138
+
139
+ function escapeRegExp(text: string): string {
140
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
141
+ }
142
+
143
+ /**
144
+ * The detector rule for a custom name.
145
+ *
146
+ * Built with the same path-segment anchoring as the built-in rules, so
147
+ * `sagent` and `/usr/local/bin/sagent` match while `vim sagent` and
148
+ * `sagentry` do not. The name is escaped, so a declaration can never inject
149
+ * a pattern — `.*` is matched literally, not as a wildcard.
150
+ */
151
+ export function customAgentRule(agent: CustomAgent): RegExp {
152
+ return new RegExp(`(^|/)${escapeRegExp(agent.name)}(\\s|$)`);
153
+ }
154
+
155
+ export interface TranscriptVars {
156
+ home: string;
157
+ cwd?: string;
158
+ tty?: string;
159
+ pid?: number;
160
+ }
161
+
162
+ /**
163
+ * Fill in a transcript template.
164
+ *
165
+ * Returns null when a placeholder the template uses has no value — an
166
+ * unresolved `{cwd}` would otherwise be looked up as a literal directory
167
+ * called "{cwd}", which silently never matches and looks like the user's
168
+ * declaration was wrong.
169
+ */
170
+ export function expandTranscript(template: string, vars: TranscriptVars): string | null {
171
+ let missing = false;
172
+ const out = template.replace(VAR_RE, (_whole, name: string) => {
173
+ const value = name === 'home' ? vars.home
174
+ : name === 'cwd' ? vars.cwd
175
+ : name === 'tty' ? vars.tty
176
+ : name === 'pid' ? (vars.pid === undefined ? undefined : String(vars.pid))
177
+ : undefined;
178
+ if (!value) { missing = true; return ''; }
179
+ return value;
180
+ });
181
+ return missing ? null : out;
182
+ }
183
+
184
+ /**
185
+ * Split an expanded template into the directory to list and a matcher for the
186
+ * file name. A template with no `*` still goes through this, so one code path
187
+ * handles both.
188
+ */
189
+ export function transcriptMatcher(expanded: string): { dir: string; match: (name: string) => boolean } {
190
+ const slash = expanded.lastIndexOf('/');
191
+ const dir = expanded.slice(0, slash) || '/';
192
+ const base = expanded.slice(slash + 1);
193
+ if (!base.includes('*')) {
194
+ return { dir, match: (name: string) => name === base };
195
+ }
196
+ const re = new RegExp(`^${base.split('*').map(escapeRegExp).join('[^/]*')}$`);
197
+ return { dir, match: (name: string) => re.test(name) };
198
+ }
@@ -1,4 +1,9 @@
1
1
  export type { AiKind } from './kinds';
2
+ export type { CustomAgent, CustomAgentParse, TranscriptVars } from './custom-agents';
3
+ export {
4
+ AI_KINDS, isAiKind, parseCustomAgent, parseCustomAgents,
5
+ customAgentRule, expandTranscript, transcriptMatcher, TRANSCRIPT_VARS,
6
+ } from './custom-agents';
2
7
 
3
8
  export type { AiStatus, AiEvent, AiAdapter } from './events';
4
9
  export { PREVIEW_LIMIT, preview } from './events';
@@ -2,4 +2,4 @@
2
2
  // break the week a fourth is added — treat an unknown kind as generic at
3
3
  // runtime (string on the wire, this union at compile time for the ones we
4
4
  // have adapters for).
5
- export type AiKind = 'claude' | 'codex' | 'grok';
5
+ export type AiKind = 'claude' | 'codex' | 'grok' | 'augment';
@@ -199,3 +199,85 @@ describe('free-text rows', () => {
199
199
  expect(freeTextRow(detectPanePrompt(CLAUDE_BASH)!)).toBe(0);
200
200
  });
201
201
  });
202
+
203
+ /**
204
+ * A real Claude Code question, captured 2026-10-30 from a pane whose session
205
+ * showed "Claude is working…" in the app while the terminal sat waiting on it.
206
+ *
207
+ * Two guards rejected it, and the symptom of either is the same: detectPanePrompt
208
+ * returns null, statusWithPrompt leaves the status as whatever transcript timing
209
+ * said, and a blocked agent reads as a busy one. Nothing is logged, because "no
210
+ * prompt" is indistinguishable from "no prompt yet".
211
+ *
212
+ * - a blank line BETWEEN options ended the upward scan, so only the last row
213
+ * was collected and the two-option minimum failed;
214
+ * - an explanation longer than three lines ended it too, truncating the menu
215
+ * to whatever had been collected below.
216
+ */
217
+ describe('a menu with blank lines between options and long explanations', () => {
218
+ const pane = [
219
+ " That's the right test design — you can't test \"the runner refuses to",
220
+ ' continue when an applied migration has been edited" against your',
221
+ ' real dev database. The question is how to give it a cluster.',
222
+ '',
223
+ ' □ Test cluster',
224
+ ' The new migration tests need a throwaway Postgres cluster, and',
225
+ " macOS's default kernel limits are too small for `initdb` to",
226
+ ' start one. How should I unblock it?',
227
+ '',
228
+ '❯ 1. Use Docker instead (Recommended)',
229
+ ' Redo database-schema so the ephemeral-cluster harness launches a',
230
+ ' disposable postgres:17-alpine container instead of calling',
231
+ ' initdb. Slower per test (~2-3s startup), but it needs no kernel',
232
+ ' change, works on any machine, and matches how the project already',
233
+ ' runs its dev database. Costs one more redo round.',
234
+ ' 2. Raise the kernel limits',
235
+ ' Run sudo sysctl to raise kern.sysv.shmmax/shmall/shmseg on your',
236
+ ' Mac so initdb works. Fastest path — no code change, possibly',
237
+ ' unblocks your project right now. But it is a machine-wide change,',
238
+ ' needs your password, reverts on reboot unless written to',
239
+ ' /etc/sysctl.conf, and the same failure will reappear on any other',
240
+ ' machine or CI runner.',
241
+ ' 3. Both',
242
+ ' Raise the limits now to get P4B moving today, and separately',
243
+ ' record a task to move the harness onto Docker before P9',
244
+ ' launch-readiness, so CI is not left with a host-specific',
245
+ ' dependency.',
246
+ ' 4. Type something.',
247
+ '',
248
+ ' 5. Chat about this',
249
+ '',
250
+ 'Enter to select · ↑/↓ to navigate · Esc to cancel',
251
+ ].join('\n');
252
+
253
+ it('is detected at all', () => {
254
+ expect(detectPanePrompt(pane)).not.toBeNull();
255
+ });
256
+
257
+ it('collects every option, not just the ones below the blank line', () => {
258
+ const p = detectPanePrompt(pane)!;
259
+ expect(p.options.map((o) => o.n)).toEqual([1, 2, 3, 4, 5]);
260
+ expect(p.options[4].label).toBe('Chat about this');
261
+ });
262
+
263
+ it('keeps the cursor on the row the TUI has it on', () => {
264
+ const p = detectPanePrompt(pane)!;
265
+ expect(p.options.filter((o) => o.selected).map((o) => o.n)).toEqual([1]);
266
+ });
267
+
268
+ it('reads it as a question to answer, not an approval to grant', () => {
269
+ expect(detectPanePrompt(pane)!.approval).toBe(false);
270
+ });
271
+
272
+ // The row that takes typed text rather than being an answer itself: picking
273
+ // it with Enter alone would answer with nothing.
274
+ it('still finds the free-text row behind the long explanations', () => {
275
+ expect(freeTextRow(detectPanePrompt(pane)!)).toBe(4);
276
+ });
277
+
278
+ it('keeps an explanation with each option that has one', () => {
279
+ const p = detectPanePrompt(pane)!;
280
+ expect(p.options[0].hint).toBeTruthy();
281
+ expect(p.options[3].hint).toBeUndefined();
282
+ });
283
+ });
@@ -101,7 +101,18 @@ function numberedMenu(lines: string[], bottom: number): Menu | null {
101
101
  let separators = 0;
102
102
  for (let i = bottom; i >= 0; i--) {
103
103
  const raw = lines[i];
104
- if (!raw.trim()) { if (opts.length || hints.length) break; continue; }
104
+ if (!raw.trim()) {
105
+ if (!opts.length && !hints.length) continue;
106
+ // A blank line BETWEEN options is normal and must not end the scan.
107
+ // Claude Code separates its trailing rows ("Type something.", "Chat
108
+ // about this") from the numbered choices with one, and breaking here
109
+ // collected a single option, failed the two-option minimum, and returned
110
+ // null — so a question sitting on screen was reported as no prompt at
111
+ // all and the session read "working" while the agent waited. Measured
112
+ // against a real pane on 2026-10-30.
113
+ if (i > 0 && NUMBERED.test(lines[i - 1])) continue;
114
+ break;
115
+ }
105
116
  if (SEPARATOR.test(raw.trim())) { if (++separators > 2) break; continue; }
106
117
  const m = raw.match(NUMBERED);
107
118
  if (m) {
@@ -114,7 +125,15 @@ function numberedMenu(lines: string[], bottom: number): Menu | null {
114
125
  }
115
126
  // Deeper than an option's label: that option's explanation (it sits
116
127
  // 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; }
128
+ //
129
+ // Keep only the first few for display, but keep SCANNING past the rest. A
130
+ // long explanation is not the top of the menu, and stopping at the fourth
131
+ // line of one truncated the menu to whatever had been collected below it.
132
+ // Claude Code routinely writes five- and six-line explanations.
133
+ if (raw.search(/\S/) >= Math.max(4, labelCol)) {
134
+ if (hints.length < 3) hints.unshift(raw.trim());
135
+ continue;
136
+ }
118
137
  break;
119
138
  }
120
139
  if (opts.length < 2) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indianic/dialout",
3
- "version": "1.13.1",
3
+ "version": "1.14.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"