@indianic/dialout 1.13.2 → 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 (54) 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/package.json +1 -1
  48. package/node_modules/@indianic/dialout-shared/src/.impeccable/hook.cache.json +1 -0
  49. package/node_modules/@indianic/dialout-shared/src/.omc/state/sessions/e864cc5c-dad7-49c4-8666-6d7fb2297d2a/pre-tool-advisory-throttle.json +14 -0
  50. package/node_modules/@indianic/dialout-shared/src/custom-agents.test.ts +185 -0
  51. package/node_modules/@indianic/dialout-shared/src/custom-agents.ts +198 -0
  52. package/node_modules/@indianic/dialout-shared/src/index.ts +5 -0
  53. package/node_modules/@indianic/dialout-shared/src/kinds.ts +1 -1
  54. 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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indianic/dialout",
3
- "version": "1.13.2",
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"