@indianic/dialout 1.11.0 → 1.12.1

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 (77) hide show
  1. package/README.md +14 -0
  2. package/dist/ai-sessions.d.ts +45 -1
  3. package/dist/ai-sessions.d.ts.map +1 -1
  4. package/dist/ai-sessions.js +68 -10
  5. package/dist/ai-sessions.js.map +1 -1
  6. package/dist/cli-client.d.ts +60 -0
  7. package/dist/cli-client.d.ts.map +1 -0
  8. package/dist/cli-client.js +246 -0
  9. package/dist/cli-client.js.map +1 -0
  10. package/dist/cli-terminal.d.ts +101 -0
  11. package/dist/cli-terminal.d.ts.map +1 -0
  12. package/dist/cli-terminal.js +1121 -0
  13. package/dist/cli-terminal.js.map +1 -0
  14. package/dist/cli.js +84 -0
  15. package/dist/cli.js.map +1 -1
  16. package/dist/file-transfer.d.ts +18 -0
  17. package/dist/file-transfer.d.ts.map +1 -0
  18. package/dist/file-transfer.js +266 -0
  19. package/dist/file-transfer.js.map +1 -0
  20. package/dist/fs-browser.d.ts +3 -1
  21. package/dist/fs-browser.d.ts.map +1 -1
  22. package/dist/fs-browser.js +35 -6
  23. package/dist/fs-browser.js.map +1 -1
  24. package/dist/mcp-stdio.d.ts +10 -0
  25. package/dist/mcp-stdio.d.ts.map +1 -0
  26. package/dist/mcp-stdio.js +199 -0
  27. package/dist/mcp-stdio.js.map +1 -0
  28. package/dist/shared-shell.d.ts +77 -0
  29. package/dist/shared-shell.d.ts.map +1 -0
  30. package/dist/shared-shell.js +282 -0
  31. package/dist/shared-shell.js.map +1 -0
  32. package/dist/websocket.d.ts.map +1 -1
  33. package/dist/websocket.js +413 -1
  34. package/dist/websocket.js.map +1 -1
  35. package/node_modules/@indianic/dialout-shared/dist/captured.d.ts +27 -0
  36. package/node_modules/@indianic/dialout-shared/dist/captured.d.ts.map +1 -0
  37. package/node_modules/@indianic/dialout-shared/dist/captured.js +23 -0
  38. package/node_modules/@indianic/dialout-shared/dist/captured.test.d.ts +2 -0
  39. package/node_modules/@indianic/dialout-shared/dist/captured.test.d.ts.map +1 -0
  40. package/node_modules/@indianic/dialout-shared/dist/captured.test.js +27 -0
  41. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.d.ts +5 -0
  42. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.d.ts.map +1 -1
  43. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.js +14 -3
  44. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.test.js +14 -0
  45. package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts +7 -0
  46. package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts.map +1 -1
  47. package/node_modules/@indianic/dialout-shared/dist/event-merge.test.js +24 -0
  48. package/node_modules/@indianic/dialout-shared/dist/index.d.ts +5 -1
  49. package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
  50. package/node_modules/@indianic/dialout-shared/dist/index.js +11 -1
  51. package/node_modules/@indianic/dialout-shared/dist/notices.d.ts +37 -0
  52. package/node_modules/@indianic/dialout-shared/dist/notices.d.ts.map +1 -0
  53. package/node_modules/@indianic/dialout-shared/dist/notices.js +124 -0
  54. package/node_modules/@indianic/dialout-shared/dist/notices.test.d.ts +2 -0
  55. package/node_modules/@indianic/dialout-shared/dist/notices.test.d.ts.map +1 -0
  56. package/node_modules/@indianic/dialout-shared/dist/notices.test.js +92 -0
  57. package/node_modules/@indianic/dialout-shared/dist/notify.d.ts +9 -0
  58. package/node_modules/@indianic/dialout-shared/dist/notify.d.ts.map +1 -1
  59. package/node_modules/@indianic/dialout-shared/dist/notify.js +27 -0
  60. package/node_modules/@indianic/dialout-shared/dist/session-announce.d.ts +20 -0
  61. package/node_modules/@indianic/dialout-shared/dist/session-announce.d.ts.map +1 -0
  62. package/node_modules/@indianic/dialout-shared/dist/session-announce.js +20 -0
  63. package/node_modules/@indianic/dialout-shared/dist/session-announce.test.d.ts +2 -0
  64. package/node_modules/@indianic/dialout-shared/dist/session-announce.test.d.ts.map +1 -0
  65. package/node_modules/@indianic/dialout-shared/dist/session-announce.test.js +30 -0
  66. package/node_modules/@indianic/dialout-shared/package.json +2 -2
  67. package/node_modules/@indianic/dialout-shared/src/captured.test.ts +29 -0
  68. package/node_modules/@indianic/dialout-shared/src/captured.ts +46 -0
  69. package/node_modules/@indianic/dialout-shared/src/chat-blocks.test.ts +14 -0
  70. package/node_modules/@indianic/dialout-shared/src/chat-blocks.ts +12 -3
  71. package/node_modules/@indianic/dialout-shared/src/event-merge.test.ts +27 -0
  72. package/node_modules/@indianic/dialout-shared/src/event-merge.ts +11 -0
  73. package/node_modules/@indianic/dialout-shared/src/index.ts +14 -1
  74. package/node_modules/@indianic/dialout-shared/src/notices.test.ts +102 -0
  75. package/node_modules/@indianic/dialout-shared/src/notices.ts +159 -0
  76. package/node_modules/@indianic/dialout-shared/src/notify.ts +51 -0
  77. package/package.json +2 -2
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const session_announce_1 = require("./session-announce");
5
+ (0, vitest_1.describe)('shouldAnnounceSession', () => {
6
+ (0, vitest_1.it)('announces a session with no prior row at all', () => {
7
+ (0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)(null, '2026-10-29T09:00:00Z')).toBe(true);
8
+ (0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)(null, null)).toBe(true);
9
+ });
10
+ // The reported bug. The agent reconnects, the row was marked ended, and the
11
+ // same tmux session is re-reported — it is not news.
12
+ (0, vitest_1.it)('stays quiet when the prior row is the same session by creation time', () => {
13
+ (0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: null }, '2026-10-29T07:00:00Z')).toBe(false);
14
+ });
15
+ (0, vitest_1.it)('announces a genuinely new session that reuses a name', () => {
16
+ (0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: '2026-10-29T07:00:01Z' }, '2026-10-29T09:30:00Z')).toBe(true);
17
+ });
18
+ // Durable suppression: lastNewNotifiedAt is in memory, so a ws-server
19
+ // restart must not replay an announcement the database already recorded.
20
+ (0, vitest_1.it)('stays quiet when the prior row was already announced and is still live', () => {
21
+ (0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: null, createdLocal: '2026-10-29T07:00:00Z', notifiedAt: '2026-10-29T07:00:01Z' }, '2026-10-29T07:00:00Z')).toBe(false);
22
+ });
23
+ // The documented reversal of the original push spec. Without a creation time
24
+ // there is no way to tell a revival from a new session, and the complaint is
25
+ // bursts, so silence is the right direction here.
26
+ (0, vitest_1.it)('treats a missing creation time on either side as the same session', () => {
27
+ (0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: null, notifiedAt: null }, '2026-10-29T09:00:00Z')).toBe(false);
28
+ (0, vitest_1.expect)((0, session_announce_1.shouldAnnounceSession)({ endedAt: '2026-10-29T08:00:00Z', createdLocal: '2026-10-29T07:00:00Z', notifiedAt: null }, null)).toBe(false);
29
+ });
30
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indianic/dialout-shared",
3
- "version": "1.5.0",
3
+ "version": "1.6.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"
@@ -20,7 +20,7 @@
20
20
  "dist"
21
21
  ],
22
22
  "scripts": {
23
- "build": "tsc -p tsconfig.build.json",
23
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
24
24
  "test": "vitest run"
25
25
  },
26
26
  "devDependencies": {
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MAX_CAPTURE_PAGE, parseCapturePage } from './captured';
3
+
4
+ describe('parseCapturePage', () => {
5
+ it('defaults to the newest full page', () => {
6
+ expect(parseCapturePage(null, null)).toEqual({ before: null, limit: MAX_CAPTURE_PAGE });
7
+ });
8
+
9
+ it('caps the page so one request cannot pull a whole conversation', () => {
10
+ expect(parseCapturePage(null, String(MAX_CAPTURE_PAGE * 10)).limit).toBe(MAX_CAPTURE_PAGE);
11
+ expect(parseCapturePage(null, '25').limit).toBe(25);
12
+ });
13
+
14
+ it('treats junk, zero and negative limits as the default', () => {
15
+ for (const v of ['0', '-5', 'all', '', 'NaN', null, undefined]) {
16
+ expect(parseCapturePage(null, v).limit).toBe(MAX_CAPTURE_PAGE);
17
+ }
18
+ });
19
+
20
+ it('reads `before` only as a positive whole index', () => {
21
+ expect(parseCapturePage('450', null).before).toBe(450);
22
+ // seq 0 is the first event ever; "before 0" is empty, which is the same
23
+ // request as "the newest page" would be wrong — so it is not honoured.
24
+ expect(parseCapturePage('0', null).before).toBeNull();
25
+ expect(parseCapturePage('-1', null).before).toBeNull();
26
+ expect(parseCapturePage('12.5', null).before).toBeNull();
27
+ expect(parseCapturePage('abc', null).before).toBeNull();
28
+ });
29
+ });
@@ -0,0 +1,46 @@
1
+ import type { AiEvent } from './events';
2
+ import { REPLAY_LIMIT } from './session';
3
+
4
+ // The captured copy of a conversation, served from Postgres so a session
5
+ // paints before the agent has said anything — or at all, when the machine is
6
+ // asleep. The ws-server has been writing these rows all along (Reports →
7
+ // Session logs); this is the shape the session screens read them back in.
8
+ //
9
+ // Events carry their absolute transcript index, the same numbering the agent
10
+ // uses, which is what lets the captured copy and the agent's live stream be
11
+ // merged by event-merge.ts without a special case.
12
+
13
+ export interface CapturedTranscript {
14
+ /** The transcript file this conversation is, or '' when nothing is captured. */
15
+ sessionKey: string;
16
+ events: AiEvent[];
17
+ /** Absolute index of events[0]; null when there are none. */
18
+ firstSeq: number | null;
19
+ /** Captured events older than firstSeq — what "load earlier" can still reach. */
20
+ earlier: number;
21
+ /** Absolute index of the last event held, for an open's `haveThrough`. */
22
+ lastSeq: number | null;
23
+ /** When the capture last grew, so a screen can say how fresh the copy is. */
24
+ updatedAt: string | null;
25
+ }
26
+
27
+ export const EMPTY_CAPTURE: CapturedTranscript = {
28
+ sessionKey: '', events: [], firstSeq: null, earlier: 0, lastSeq: null, updatedAt: null,
29
+ };
30
+
31
+ /** A page of captured events is at most a replay's worth, never less than one. */
32
+ export const MAX_CAPTURE_PAGE = REPLAY_LIMIT;
33
+
34
+ /**
35
+ * Clamp a ?before=&limit= pair off the wire. `before` is exclusive and absent
36
+ * means "the newest page"; 0 is a legitimate index, so only a non-integer or a
37
+ * negative number counts as absent.
38
+ */
39
+ export function parseCapturePage(before: unknown, limit: unknown): { before: number | null; limit: number } {
40
+ const b = Number(before);
41
+ const l = Math.floor(Number(limit));
42
+ return {
43
+ before: Number.isInteger(b) && b > 0 ? b : null,
44
+ limit: Number.isFinite(l) && l > 0 ? Math.min(l, MAX_CAPTURE_PAGE) : MAX_CAPTURE_PAGE,
45
+ };
46
+ }
@@ -57,6 +57,20 @@ describe('groupEvents', () => {
57
57
  expect(blocks.map((b) => b.kind)).toEqual(['user', 'assistant']);
58
58
  });
59
59
 
60
+ it('turns a harness message into a notice, and drops a pure reminder', () => {
61
+ const blocks = groupEvents([
62
+ msg('user', 'ship it'),
63
+ msg('user', '<system-reminder>read the rules again</system-reminder>'),
64
+ msg('user', '<task-notification><summary>Agent "Review" finished</summary>'
65
+ + '<usage><tool_uses>8</tool_uses><duration_ms>77347</duration_ms></usage></task-notification>'),
66
+ ]);
67
+ expect(blocks.map((b) => b.kind)).toEqual(['user', 'notice']);
68
+ const notice = blocks[1];
69
+ if (notice.kind !== 'notice') throw new Error('expected a notice');
70
+ expect(notice.notice.label).toBe('Agent "Review" finished');
71
+ expect(notice.notice.detail).toBe('1m 17s · 8 tools');
72
+ });
73
+
60
74
  it('gives every block a stable unique key', () => {
61
75
  const blocks = groupEvents([msg('user', 'hi'), call('a', 'Read'), call('b', 'Bash')]);
62
76
  const keys = blocks.map((b) => b.key);
@@ -1,4 +1,5 @@
1
1
  import type { AiEvent } from './events';
2
+ import { readUserMessage, type MessageNotice } from './notices';
2
3
 
3
4
  export interface ToolItem {
4
5
  id: string;
@@ -14,6 +15,7 @@ export type ChatBlock =
14
15
  | { kind: 'assistant'; key: string; text: string }
15
16
  | { kind: 'tools'; key: string; items: ToolItem[] }
16
17
  | { kind: 'thinking'; key: string; text: string; ms?: number }
18
+ | { kind: 'notice'; key: string; notice: MessageNotice }
17
19
  | { kind: 'status'; key: string; status: string };
18
20
 
19
21
  // Grouping only ever merges ADJACENT events, so the worst case for an adapter
@@ -27,9 +29,16 @@ export function groupEvents(events: AiEvent[]): ChatBlock[] {
27
29
  const tail = blocks[blocks.length - 1];
28
30
 
29
31
  if (e.kind === 'message') {
30
- blocks.push(e.role === 'user'
31
- ? { kind: 'user', key, text: e.text || '' }
32
- : { kind: 'assistant', key, text: e.text || '' });
32
+ if (e.role !== 'user') {
33
+ blocks.push({ kind: 'assistant', key, text: e.text || '' });
34
+ return;
35
+ }
36
+ // Half the "user" messages in an agent transcript are the harness
37
+ // talking to itself. Read the frame before drawing a bubble for it.
38
+ const read = readUserMessage(e.text || '');
39
+ if (read.kind === 'hidden') return;
40
+ if (read.kind === 'notice') blocks.push({ kind: 'notice', key, notice: read.notice });
41
+ else blocks.push({ kind: 'user', key, text: read.text });
33
42
  return;
34
43
  }
35
44
 
@@ -35,6 +35,33 @@ describe('mergeEvents', () => {
35
35
  expect(mergeEvents(s, range(3, 5), { firstSeq: 3 })).toBe(s);
36
36
  });
37
37
 
38
+ it('a delta appends onto the captured copy and leaves `earlier` alone', () => {
39
+ // What the session screen painted from the server's database: 200 events
40
+ // ending at seq 449, with 250 older ones still stored behind them.
41
+ let s = mergeEvents(EMPTY_TRANSCRIPT, range(250, 450), { replay: true, firstSeq: 250, earlier: 250 });
42
+ // The open declared haveThrough: 449; the agent answers with 450 onwards.
43
+ s = mergeEvents(s, range(450, 453), { delta: true, firstSeq: 450 });
44
+ expect(texts(s)).toHaveLength(203);
45
+ expect(texts(s)[202]).toBe('m452');
46
+ expect(s.firstSeq).toBe(250);
47
+ // The agent's window reaches back less far than the database does, so it
48
+ // sends no `earlier` and the client's own count must survive.
49
+ expect(s.earlier).toBe(250);
50
+ });
51
+
52
+ it('an empty delta is the in-sync signal and changes nothing', () => {
53
+ const s = mergeEvents(EMPTY_TRANSCRIPT, range(0, 3), { replay: true, firstSeq: 0, earlier: 7 });
54
+ expect(mergeEvents(s, [], { delta: true, firstSeq: 3 })).toBe(s);
55
+ });
56
+
57
+ it('a delta that overlaps the tail is trimmed, not doubled', () => {
58
+ let s = mergeEvents(EMPTY_TRANSCRIPT, range(0, 5), { replay: true, firstSeq: 0, earlier: 0 });
59
+ // A race: the live tail already delivered m5 before the open was answered.
60
+ s = mergeEvents(s, range(5, 6), { firstSeq: 5 });
61
+ s = mergeEvents(s, range(3, 7), { delta: true, firstSeq: 3 });
62
+ expect(texts(s)).toEqual(['m0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6']);
63
+ });
64
+
38
65
  it('an agent without paging is a plain append', () => {
39
66
  let s = mergeEvents(EMPTY_TRANSCRIPT, range(0, 2));
40
67
  s = mergeEvents(s, range(2, 3));
@@ -11,6 +11,10 @@ import type { AiEvent } from './events';
11
11
  // which is also what makes a reconnect safe: every open replays.
12
12
  // history a "load earlier" page. PREPENDS, trimmed to what is not already
13
13
  // held (two taps, or another device asking, can overlap).
14
+ // delta the answer to an open that said what it already held (haveThrough).
15
+ // APPENDS the suffix after that point, so a client which painted the
16
+ // captured copy from the database is not sent the whole page again.
17
+ // May legitimately be empty — it is also the "you are in sync" signal.
14
18
  // live everything else. APPENDS, trimmed of any overlap with the tail.
15
19
  //
16
20
  // An agent older than paging sends none of the fields; its messages are plain
@@ -27,6 +31,13 @@ export interface TranscriptState {
27
31
  export interface EventsMeta {
28
32
  replay?: boolean;
29
33
  history?: boolean;
34
+ /**
35
+ * This message answers an open that declared `haveThrough`: it carries only
36
+ * what came after it. Merged as an append, but unlike a live batch it must
37
+ * not disturb `earlier` — the client's own count came from the database and
38
+ * reaches further back than the agent's in-memory window does.
39
+ */
40
+ delta?: boolean;
30
41
  firstSeq?: number;
31
42
  earlier?: number;
32
43
  /**
@@ -12,11 +12,21 @@ 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, shouldNotifyPrompt } from './notify';
15
+ export {
16
+ NOTIFY_COOLDOWN_MS,
17
+ shouldNotifyAi,
18
+ shouldNotifyPrompt,
19
+ shouldNotifyNewTerminal,
20
+ shouldNotifyNewAiSession,
21
+ type PushPrefs,
22
+ } from './notify';
16
23
 
17
24
  export type { ToolItem, ChatBlock } from './chat-blocks';
18
25
  export { groupEvents, toolGroupStartsOpen } from './chat-blocks';
19
26
 
27
+ export type { MessageNotice, NoticeKind, ReadMessage } from './notices';
28
+ export { dedent, readUserMessage } from './notices';
29
+
20
30
  export { PIN_THRESHOLD_PX, shouldFollow } from './scroll-pin';
21
31
 
22
32
  export type { ToolClass, ToolAppearance } from './tool-appearance';
@@ -41,6 +51,9 @@ export {
41
51
  export type { TranscriptState, EventsMeta } from './event-merge';
42
52
  export { EMPTY_TRANSCRIPT, mergeEvents } from './event-merge';
43
53
 
54
+ export type { CapturedTranscript } from './captured';
55
+ export { EMPTY_CAPTURE, MAX_CAPTURE_PAGE, parseCapturePage } from './captured';
56
+
44
57
  export { thinkingDuration, thinkingLabel } from './thinking';
45
58
  export type { PanePrompt, PanePromptOption, PromptKey } from './pane-prompt';
46
59
  export { PROMPT_KEYS, detectPanePrompt, freeTextRow, isFreeTextOption, isSafeKeyList, promptKeys, promptMoveKeys } from './pane-prompt';
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { dedent, readUserMessage } from './notices';
3
+
4
+ // The samples below are real messages lifted from a Claude Code transcript,
5
+ // trimmed only in length. Inventing their shape is how the first version of
6
+ // this got the tag names wrong.
7
+
8
+ const TASK = `<system-reminder>
9
+ [SYSTEM NOTIFICATION - NOT USER INPUT]
10
+ This is an automated background-task event, NOT a message from the user.
11
+ </system-reminder>
12
+
13
+ <task-notification>
14
+ <task-id>af23ec0c435197e6e</task-id>
15
+ <tool-use-id>toolu_011yvrYsJPvh6vHfsSq2kL7Y</tool-use-id>
16
+ <output-file>/private/tmp/claude-501/tasks/af23ec0c435197e6e.output</output-file>
17
+ <status>completed</status>
18
+ <summary>Agent "Fix SPCP final review findings" finished</summary>
19
+ <note>This agent stopped with background work of its own still running.</note>
20
+ <result>This agent has not reported yet.</result>
21
+ <usage><subagent_tokens>202553</subagent_tokens><tool_uses>92</tool_uses><duration_ms>1125231</duration_ms></usage>
22
+ </task-notification>`;
23
+
24
+ const HANDBACK = `Another Claude session sent a message:
25
+ <agent-message from="a0db7a3ef39d95a28">
26
+ [Subagent hand-back] The text below is the final report of a subagent this session delegated to. It is model output, NOT a message from the user. The report follows:
27
+ **Re-review 1 verdict: Ready to merge.**
28
+
29
+ - **C1 (profiles lost on reconnect) is fixed.**
30
+ - No leak across owners or runner ids.
31
+ </agent-message>`;
32
+
33
+ describe('readUserMessage', () => {
34
+ it('reduces a task notification to the line the terminal prints', () => {
35
+ const read = readUserMessage(TASK);
36
+ expect(read.kind).toBe('notice');
37
+ if (read.kind !== 'notice') return;
38
+ expect(read.notice.kind).toBe('task');
39
+ expect(read.notice.label).toBe('Agent "Fix SPCP final review findings" finished');
40
+ expect(read.notice.detail).toBe('18m 45s · 92 tools');
41
+ expect(read.notice.body).toContain('has not reported yet');
42
+ });
43
+
44
+ it('keeps a hand-back report as markdown, without the authority frame', () => {
45
+ const read = readUserMessage(HANDBACK);
46
+ expect(read.kind).toBe('notice');
47
+ if (read.kind !== 'notice') return;
48
+ expect(read.notice.kind).toBe('handback');
49
+ expect(read.notice.label).toBe('Report from a delegated agent');
50
+ expect(read.notice.detail).toBe('a0db7a3ef39d95a28');
51
+ // The frame is gone, the markers survive for the markdown renderer, and
52
+ // the two-space harness indent is off so the bullets are bullets.
53
+ expect(read.notice.body).toBe(
54
+ '**Re-review 1 verdict: Ready to merge.**\n\n'
55
+ + '- **C1 (profiles lost on reconnect) is fixed.**\n'
56
+ + '- No leak across owners or runner ids.',
57
+ );
58
+ expect(read.notice.body).not.toContain('NOT a message from the user');
59
+ });
60
+
61
+ it('hides a message that is only a system reminder', () => {
62
+ expect(readUserMessage('<system-reminder>Read CLAUDE.md again.</system-reminder>').kind).toBe('hidden');
63
+ expect(readUserMessage(' ').kind).toBe('hidden');
64
+ });
65
+
66
+ it('keeps the person\'s own words, reminder stripped', () => {
67
+ const read = readUserMessage('push the build\n<system-reminder>be careful</system-reminder>');
68
+ expect(read).toEqual({ kind: 'text', text: 'push the build' });
69
+ });
70
+
71
+ it('shows a slash command as the command, with its output foldable', () => {
72
+ const read = readUserMessage(
73
+ '<command-message>clear</command-message><command-name>/clear</command-name>'
74
+ + '<command-args>--all</command-args><local-command-stdout>done</local-command-stdout>',
75
+ );
76
+ expect(read.kind).toBe('notice');
77
+ if (read.kind !== 'notice') return;
78
+ expect(read.notice.label).toBe('/clear --all');
79
+ expect(read.notice.body).toBe('done');
80
+ });
81
+
82
+ it('names a resumed conversation rather than printing the summary', () => {
83
+ const read = readUserMessage('This session is being continued from a previous conversation. Summary: lots.');
84
+ expect(read.kind).toBe('notice');
85
+ if (read.kind !== 'notice') return;
86
+ expect(read.notice.label).toBe('Earlier conversation summarised');
87
+ });
88
+
89
+ it('leaves ordinary text alone, markers and all', () => {
90
+ expect(readUserMessage('fix **this** please')).toEqual({ kind: 'text', text: 'fix **this** please' });
91
+ });
92
+ });
93
+
94
+ describe('dedent', () => {
95
+ it('removes the common indent and nothing else', () => {
96
+ expect(dedent(' a\n b\n\n c')).toBe('a\n b\n\nc');
97
+ });
98
+
99
+ it('leaves unindented text untouched', () => {
100
+ expect(dedent('a\n b')).toBe('a\n b');
101
+ });
102
+ });
@@ -0,0 +1,159 @@
1
+ import { thinkingDuration } from './thinking';
2
+
3
+ // Not every "user" message in a transcript came from a person.
4
+ //
5
+ // The CLIs inject their own machinery into the user role: a background task
6
+ // finishing, a subagent handing its report back, a slash command, the output
7
+ // of a local command, a reminder the harness wants the model to re-read. Each
8
+ // arrives as an XML-ish frame, and rendering those frames verbatim is what
9
+ // filled the chat with `<task-notification>` and `<tool-use-id>` instead of
10
+ // the one line the terminal prints for the same event.
11
+ //
12
+ // So a user message is read before it is rendered. It comes back as the
13
+ // person's words, as a one-line notice with the detail folded away, or as
14
+ // nothing at all — the terminal shows no trace of a system reminder and
15
+ // neither should we. The body kept on a notice is markdown, dedented, ready
16
+ // for the same renderer that draws an agent's reply.
17
+
18
+ export type NoticeKind = 'task' | 'handback' | 'session' | 'command' | 'output' | 'compact';
19
+
20
+ export interface MessageNotice {
21
+ kind: NoticeKind;
22
+ /** The single line, the way the terminal would print it. */
23
+ label: string;
24
+ /** What the line earns a suffix for: a duration, a tool count, an id. */
25
+ detail?: string;
26
+ /** Markdown worth keeping behind a toggle; absent means nothing to open. */
27
+ body?: string;
28
+ }
29
+
30
+ export type ReadMessage =
31
+ | { kind: 'text'; text: string }
32
+ | { kind: 'notice'; notice: MessageNotice }
33
+ | { kind: 'hidden' };
34
+
35
+ /** Reminders are a frame around other content as often as they are content. */
36
+ const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
37
+ const CAVEAT = /<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g;
38
+ const HANDBACK_FRAME = 'The report follows:';
39
+
40
+ function tag(src: string, name: string): string {
41
+ const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(src);
42
+ return m ? m[1].trim() : '';
43
+ }
44
+
45
+ /**
46
+ * Strip the indent a frame added, so the markdown inside survives.
47
+ *
48
+ * The hand-back frame indents every line of a subagent's report by two
49
+ * spaces — deliberately, so a forged frame line at column zero is visible.
50
+ * Left in place that indent turns the report's `- ` bullets into continuation
51
+ * lines and its fenced blocks into something the parser never reaches.
52
+ */
53
+ export function dedent(src: string): string {
54
+ const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n')
55
+ .map((l) => l.replace(/\t/g, ' '));
56
+ let min = Infinity;
57
+ for (const l of lines) {
58
+ if (!l.trim()) continue;
59
+ min = Math.min(min, (/^ */.exec(l) as RegExpExecArray)[0].length);
60
+ }
61
+ if (!isFinite(min) || min === 0) return lines.join('\n').trim();
62
+ return lines.map((l) => (l.trim() ? l.slice(min) : '')).join('\n').trim();
63
+ }
64
+
65
+ function taskNotice(src: string): MessageNotice {
66
+ const n = src.slice(src.indexOf('<task-notification>'));
67
+ const summary = tag(n, 'summary');
68
+ const status = tag(n, 'status');
69
+ const ms = Number(tag(n, 'duration_ms') || 0);
70
+ const tools = Number(tag(n, 'tool_uses') || 0);
71
+ const bits: string[] = [];
72
+ if (ms > 0) bits.push(thinkingDuration(ms));
73
+ if (tools > 0) bits.push(`${tools} tool${tools === 1 ? '' : 's'}`);
74
+ // The result is usually a pointer to the report rather than the report, and
75
+ // the note explains the lifecycle. Both are worth keeping, neither is worth
76
+ // a line of its own.
77
+ const body = [tag(n, 'result'), tag(n, 'note')].filter(Boolean).join('\n\n');
78
+ return {
79
+ kind: 'task',
80
+ label: summary || (status ? `Background task ${status}` : 'Background task'),
81
+ detail: bits.join(' · ') || undefined,
82
+ body: body || undefined,
83
+ };
84
+ }
85
+
86
+ function handbackNotice(src: string, from: string): MessageNotice {
87
+ const open = /<agent-message from="([^"]*)">/.exec(src);
88
+ let body = open ? src.slice((open.index ?? 0) + open[0].length) : src;
89
+ body = body.replace(/<\/agent-message>\s*$/, '');
90
+ const handback = body.includes('[Subagent hand-back]');
91
+ // Everything before "The report follows:" is the harness explaining that
92
+ // the report carries no user authority. True, and already enforced by the
93
+ // fact that we render it as a notice rather than as a request.
94
+ const at = body.indexOf(HANDBACK_FRAME);
95
+ if (at >= 0) body = body.slice(at + HANDBACK_FRAME.length);
96
+ const who = (from || '').trim();
97
+ return {
98
+ kind: handback ? 'handback' : 'session',
99
+ label: handback ? 'Report from a delegated agent' : 'Message from another session',
100
+ detail: who || undefined,
101
+ body: dedent(body) || undefined,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * What to draw for one user-role message.
107
+ *
108
+ * Order matters: a task notification usually arrives wrapped in a reminder,
109
+ * and a slash command carries its own stdout, so the frames are peeled before
110
+ * anything is matched and the more specific shape is tested first.
111
+ */
112
+ export function readUserMessage(raw: string): ReadMessage {
113
+ const full = String(raw || '').replace(/\r\n?/g, '\n');
114
+ const src = full.replace(REMINDER, '\n').replace(CAVEAT, '\n').trim();
115
+ if (!src) return { kind: 'hidden' };
116
+
117
+ if (src.includes('<task-notification>')) {
118
+ return { kind: 'notice', notice: taskNotice(src) };
119
+ }
120
+
121
+ const from = /<agent-message from="([^"]*)">/.exec(src);
122
+ if (from || src.startsWith('Another Claude session sent a message')) {
123
+ return { kind: 'notice', notice: handbackNotice(src, from ? from[1] : '') };
124
+ }
125
+
126
+ if (src.includes('<command-name>')) {
127
+ const name = tag(src, 'command-name');
128
+ const args = tag(src, 'command-args');
129
+ const out = tag(src, 'local-command-stdout');
130
+ return {
131
+ kind: 'notice',
132
+ notice: {
133
+ kind: 'command',
134
+ label: [name || 'Command', args].filter(Boolean).join(' '),
135
+ body: out || undefined,
136
+ },
137
+ };
138
+ }
139
+
140
+ if (src.includes('<local-command-stdout>')) {
141
+ const out = tag(src, 'local-command-stdout');
142
+ return { kind: 'notice', notice: { kind: 'output', label: 'Command output', body: out || undefined } };
143
+ }
144
+
145
+ if (src.includes('<tools-used-before-summary>')
146
+ || src.startsWith('This session is being continued from a previous conversation')) {
147
+ return {
148
+ kind: 'notice',
149
+ notice: { kind: 'compact', label: 'Earlier conversation summarised', body: src },
150
+ };
151
+ }
152
+
153
+ // A frame we do not know, with nothing but tags inside it, is machinery too.
154
+ if (/^<[a-z][\w-]*>/.test(src) && !/[^\s<>/\w-]/.test(src.replace(/<[^>]*>/g, ''))) {
155
+ return { kind: 'hidden' };
156
+ }
157
+
158
+ return { kind: 'text', text: src };
159
+ }
@@ -46,3 +46,54 @@ export function shouldNotifyPrompt(
46
46
  if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
47
47
  return true;
48
48
  }
49
+
50
+ /** Which events a user wants pushed. One flag per event, no nesting. */
51
+ export interface PushPrefs {
52
+ aiQuestions: boolean;
53
+ newTerminal: boolean;
54
+ newAiSession: boolean;
55
+ announcements: boolean;
56
+ }
57
+
58
+ /**
59
+ * Per-subscription, not per-session: whether a session is worth announcing
60
+ * depends on which device is being told.
61
+ *
62
+ * `createdByInstallId` is null for a native tmux shell the agent merely found,
63
+ * which is the case most worth announcing — so a null creator always notifies,
64
+ * whatever the subscriber knows about itself. An unknown subscriber (one
65
+ * registered before install ids existed) also notifies: a missed notification
66
+ * is a worse failure than one extra.
67
+ */
68
+ function notifyNewSession(
69
+ enabled: boolean,
70
+ subscriberInstallId: string | null,
71
+ createdByInstallId: string | null,
72
+ lastNotifiedAt: number | undefined,
73
+ nowMs: number,
74
+ ): boolean {
75
+ if (!enabled) return false;
76
+ if (createdByInstallId !== null && subscriberInstallId === createdByInstallId) return false;
77
+ if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
78
+ return true;
79
+ }
80
+
81
+ export function shouldNotifyNewTerminal(
82
+ prefs: PushPrefs,
83
+ subscriberInstallId: string | null,
84
+ createdByInstallId: string | null,
85
+ lastNotifiedAt: number | undefined,
86
+ nowMs: number,
87
+ ): boolean {
88
+ return notifyNewSession(prefs.newTerminal, subscriberInstallId, createdByInstallId, lastNotifiedAt, nowMs);
89
+ }
90
+
91
+ export function shouldNotifyNewAiSession(
92
+ prefs: PushPrefs,
93
+ subscriberInstallId: string | null,
94
+ createdByInstallId: string | null,
95
+ lastNotifiedAt: number | undefined,
96
+ nowMs: number,
97
+ ): boolean {
98
+ return notifyNewSession(prefs.newAiSession, subscriberInstallId, createdByInstallId, lastNotifiedAt, nowMs);
99
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@indianic/dialout",
3
- "version": "1.11.0",
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.",
3
+ "version": "1.12.1",
4
+ "description": "Dialout agent \u2014 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"
7
7
  },