nfo-cli 0.0.4-improve-prompting → 0.0.5

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 (159) hide show
  1. package/dist/claude-command.js +6 -1
  2. package/dist/claude-command.js.map +1 -1
  3. package/dist/claude-trust.js +46 -0
  4. package/dist/claude-trust.js.map +1 -0
  5. package/dist/cli.js +0 -0
  6. package/dist/mcp/handlers.js +5 -0
  7. package/dist/mcp/handlers.js.map +1 -1
  8. package/dist/mcp/tool-defs.js +10 -0
  9. package/dist/mcp/tool-defs.js.map +1 -1
  10. package/dist/musicians/dismiss.js +1 -1
  11. package/dist/musicians/dismiss.js.map +1 -1
  12. package/dist/musicians/roles.js +15 -0
  13. package/dist/musicians/roles.js.map +1 -0
  14. package/dist/musicians/spawn.js +53 -18
  15. package/dist/musicians/spawn.js.map +1 -1
  16. package/dist/permission.js +6 -0
  17. package/dist/permission.js.map +1 -1
  18. package/dist/prompts/musician-role.js +2 -1
  19. package/dist/prompts/musician-role.js.map +1 -1
  20. package/dist/prompts/orchestrator-role.js +18 -6
  21. package/dist/prompts/orchestrator-role.js.map +1 -1
  22. package/dist/prompts/tool-discipline.js +7 -3
  23. package/dist/prompts/tool-discipline.js.map +1 -1
  24. package/package.json +8 -1
  25. package/assets/agent-screen.png +0 -0
  26. package/assets/main-screen.png +0 -0
  27. package/assets/orche-clawd.png +0 -0
  28. package/dist/tui/App.js +0 -428
  29. package/dist/tui/App.js.map +0 -1
  30. package/dist/tui/AppView.js +0 -13
  31. package/dist/tui/AppView.js.map +0 -1
  32. package/dist/tui/Auditorium.js +0 -17
  33. package/dist/tui/Auditorium.js.map +0 -1
  34. package/dist/tui/ConcertHall.js +0 -11
  35. package/dist/tui/ConcertHall.js.map +0 -1
  36. package/dist/tui/Help.js +0 -49
  37. package/dist/tui/Help.js.map +0 -1
  38. package/dist/tui/OrchestratorPane.js +0 -34
  39. package/dist/tui/OrchestratorPane.js.map +0 -1
  40. package/dist/tui/SidebarHeader.js +0 -6
  41. package/dist/tui/SidebarHeader.js.map +0 -1
  42. package/dist/tui/StatusBar.js +0 -6
  43. package/dist/tui/StatusBar.js.map +0 -1
  44. package/docs/plans/2026-05-29-nfo-phase-1-bootstrap.md +0 -2152
  45. package/docs/plans/2026-05-29-nfo-phase-2-mcp-musicians.md +0 -2467
  46. package/docs/plans/2026-05-29-nfo-phase-3-ink-tui.md +0 -1611
  47. package/docs/plans/2026-05-29-nfo-phase-4-permission-prompts.md +0 -460
  48. package/docs/plans/2026-05-29-nfo-phase-5-help-and-notify.md +0 -933
  49. package/docs/specs/2026-05-29-nfo-design.md +0 -468
  50. package/plan-explorer-musician-hardening.md +0 -56
  51. package/src/claude-command.ts +0 -35
  52. package/src/claude-detect.ts +0 -42
  53. package/src/cli.ts +0 -197
  54. package/src/commands/attach.ts +0 -24
  55. package/src/commands/dashboard-window.ts +0 -33
  56. package/src/commands/kill.ts +0 -50
  57. package/src/commands/launch.ts +0 -134
  58. package/src/commands/list.ts +0 -43
  59. package/src/commands/mcp-server.ts +0 -18
  60. package/src/commands/notes.ts +0 -18
  61. package/src/commands/restore.ts +0 -153
  62. package/src/commands/tui.tsx +0 -22
  63. package/src/config.ts +0 -44
  64. package/src/dashboard.ts +0 -1
  65. package/src/mcp/config.ts +0 -39
  66. package/src/mcp/handlers.ts +0 -141
  67. package/src/mcp/server.ts +0 -50
  68. package/src/mcp/tool-defs.ts +0 -151
  69. package/src/musicians/dismiss.ts +0 -60
  70. package/src/musicians/ids.ts +0 -21
  71. package/src/musicians/lookup.ts +0 -13
  72. package/src/musicians/message-log.ts +0 -152
  73. package/src/musicians/message.ts +0 -99
  74. package/src/musicians/query.ts +0 -19
  75. package/src/musicians/spawn.ts +0 -139
  76. package/src/notes.ts +0 -39
  77. package/src/notify.ts +0 -62
  78. package/src/orchestrator/report-back.ts +0 -33
  79. package/src/permission.ts +0 -30
  80. package/src/project-key.ts +0 -12
  81. package/src/prompts/musician-role.ts +0 -22
  82. package/src/prompts/orchestrator-role.ts +0 -84
  83. package/src/prompts/tool-discipline.ts +0 -41
  84. package/src/repo.ts +0 -14
  85. package/src/shell-quote.ts +0 -7
  86. package/src/state-updaters.ts +0 -132
  87. package/src/state.ts +0 -49
  88. package/src/state.types.ts +0 -67
  89. package/src/tmux.ts +0 -226
  90. package/src/tui/activity-line.ts +0 -16
  91. package/src/tui/components/App.tsx +0 -534
  92. package/src/tui/components/AppView.tsx +0 -98
  93. package/src/tui/components/Auditorium.tsx +0 -56
  94. package/src/tui/components/ConcertHall.tsx +0 -31
  95. package/src/tui/components/Help.tsx +0 -63
  96. package/src/tui/components/OrchestratorPane.tsx +0 -98
  97. package/src/tui/components/SidebarHeader.tsx +0 -34
  98. package/src/tui/components/StatusBar.tsx +0 -42
  99. package/src/tui/detect-permission.ts +0 -93
  100. package/src/tui/embedded-session-lifecycle.ts +0 -44
  101. package/src/tui/embedded-terminal.ts +0 -325
  102. package/src/tui/format-time.ts +0 -25
  103. package/src/tui/keymap.ts +0 -104
  104. package/src/tui/poll-activity.ts +0 -25
  105. package/src/tui/poll-idle.ts +0 -149
  106. package/src/tui/poll-permission.ts +0 -50
  107. package/src/tui/status-icon.ts +0 -35
  108. package/src/tui/terminal-input.ts +0 -136
  109. package/src/tui/watch-state.ts +0 -43
  110. package/src/worktree.ts +0 -41
  111. package/tests/claude-command.test.ts +0 -30
  112. package/tests/claude-detect.test.ts +0 -14
  113. package/tests/commands/attach.test.ts +0 -60
  114. package/tests/commands/kill.test.ts +0 -66
  115. package/tests/commands/launch.test.ts +0 -75
  116. package/tests/commands/list.test.ts +0 -47
  117. package/tests/commands/notes.test.ts +0 -53
  118. package/tests/commands/restore.test.ts +0 -126
  119. package/tests/helpers/tmp-config.ts +0 -16
  120. package/tests/helpers/tmp-repo.ts +0 -29
  121. package/tests/integration/orchestrator-spawn.test.ts +0 -108
  122. package/tests/mcp/handlers.test.ts +0 -163
  123. package/tests/mcp/tool-defs.test.ts +0 -35
  124. package/tests/musicians/dismiss.test.ts +0 -102
  125. package/tests/musicians/message.test.ts +0 -159
  126. package/tests/musicians/query.test.ts +0 -65
  127. package/tests/musicians/spawn.test.ts +0 -125
  128. package/tests/notes.test.ts +0 -56
  129. package/tests/notify.test.ts +0 -80
  130. package/tests/orchestrator/report-back.test.ts +0 -18
  131. package/tests/permission.test.ts +0 -39
  132. package/tests/project-key.test.ts +0 -33
  133. package/tests/prompts/tool-discipline.test.ts +0 -25
  134. package/tests/repo.test.ts +0 -38
  135. package/tests/state-updaters.test.ts +0 -126
  136. package/tests/state.test.ts +0 -85
  137. package/tests/tmux.test.ts +0 -126
  138. package/tests/tui/AppView.test.tsx +0 -92
  139. package/tests/tui/Auditorium.test.tsx +0 -67
  140. package/tests/tui/ConcertHall.test.tsx +0 -22
  141. package/tests/tui/Help.test.tsx +0 -38
  142. package/tests/tui/OrchestratorPane.test.ts +0 -30
  143. package/tests/tui/SidebarHeader.test.tsx +0 -20
  144. package/tests/tui/StatusBar.test.tsx +0 -51
  145. package/tests/tui/activity-line.test.ts +0 -21
  146. package/tests/tui/detect-permission.test.ts +0 -92
  147. package/tests/tui/embedded-session-lifecycle.test.ts +0 -55
  148. package/tests/tui/embedded-terminal.test.ts +0 -80
  149. package/tests/tui/format-time.test.ts +0 -25
  150. package/tests/tui/keymap.test.ts +0 -93
  151. package/tests/tui/poll-activity.test.ts +0 -81
  152. package/tests/tui/poll-idle.test.ts +0 -159
  153. package/tests/tui/poll-permission.test.ts +0 -222
  154. package/tests/tui/status-icon.test.ts +0 -27
  155. package/tests/tui/terminal-input.test.ts +0 -113
  156. package/tests/tui/watch-state.test.ts +0 -54
  157. package/tests/worktree.test.ts +0 -73
  158. package/tsconfig.json +0 -19
  159. package/vitest.config.ts +0 -12
@@ -1,21 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { extractActivityLine } from '../../src/tui/activity-line.js';
3
-
4
- describe('extractActivityLine', () => {
5
- it('returns the last non-empty trimmed line', () => {
6
- const pane = 'first line\nsecond line\n\n \nthird line\n\n';
7
- expect(extractActivityLine(pane)).toBe('third line');
8
- });
9
- it('returns empty string for all-blank input', () => {
10
- expect(extractActivityLine('\n \n\t\n')).toBe('');
11
- });
12
- it('truncates very long lines to 60 chars with an ellipsis', () => {
13
- const long = 'x'.repeat(100);
14
- const out = extractActivityLine(long);
15
- expect(out.length).toBe(60);
16
- expect(out.endsWith('…')).toBe(true);
17
- });
18
- it('handles empty string', () => {
19
- expect(extractActivityLine('')).toBe('');
20
- });
21
- });
@@ -1,92 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { detectPermissionPrompt } from '../../src/tui/detect-permission.js';
3
-
4
- const BASH_PROMPT_WITH_BACKTICKS = [
5
- 'Allow Bash to run `rm -rf node_modules`?',
6
- '',
7
- ' 1. Yes',
8
- ' 2. Yes, and don\'t ask again for Bash commands',
9
- ' 3. No, and tell Claude what to do differently (esc)',
10
- ].join('\n');
11
-
12
- const BASH_PROMPT_NO_BACKTICKS = [
13
- 'Allow Bash to run a command?',
14
- '',
15
- ' 1. Yes',
16
- ' 2. Yes, and don\'t ask again for Bash commands',
17
- ' 3. No, and tell Claude what to do differently (esc)',
18
- ].join('\n');
19
-
20
- const EDIT_PROMPT = [
21
- 'Allow Edit to modify `src/index.ts`?',
22
- '',
23
- ' 1. Yes',
24
- ' 2. Yes, and don\'t ask again for Edit operations',
25
- ' 3. No, and tell Claude what to do differently (esc)',
26
- ].join('\n');
27
-
28
- const NUMBERED_CHOICES_NO_INTRO = [
29
- 'Some chat content here',
30
- '',
31
- ' 1. Yes',
32
- ' 3. No, something else',
33
- ].join('\n');
34
-
35
- const ALLOW_IN_CHAT_NO_CHOICES = [
36
- 'Allow me to explain how this works.',
37
- 'The function takes a callback and invokes it.',
38
- 'That is all there is to it.',
39
- ].join('\n');
40
-
41
- describe('detectPermissionPrompt', () => {
42
- it('returns pending:false and tool:null for an empty string', () => {
43
- const result = detectPermissionPrompt('');
44
- expect(result).toEqual({ pending: false, tool: null });
45
- });
46
-
47
- it('detects a realistic Bash prompt with backticked command', () => {
48
- const result = detectPermissionPrompt(BASH_PROMPT_WITH_BACKTICKS);
49
- expect(result.pending).toBe(true);
50
- expect(result.tool).not.toBeNull();
51
- expect(result.tool!.startsWith('Bash')).toBe(true);
52
- expect(result.tool!).toContain('rm -rf node_modules');
53
- });
54
-
55
- it('detects a Bash prompt without backticks and returns just the tool name', () => {
56
- const result = detectPermissionPrompt(BASH_PROMPT_NO_BACKTICKS);
57
- expect(result.pending).toBe(true);
58
- expect(result.tool).toBe('Bash');
59
- });
60
-
61
- it('detects an Edit-tool prompt and returns a tool starting with "Edit"', () => {
62
- const result = detectPermissionPrompt(EDIT_PROMPT);
63
- expect(result.pending).toBe(true);
64
- expect(result.tool!.startsWith('Edit')).toBe(true);
65
- });
66
-
67
- it('returns pending:false when "Allow" appears in chat text but no numbered choices are present', () => {
68
- const result = detectPermissionPrompt(ALLOW_IN_CHAT_NO_CHOICES);
69
- expect(result).toEqual({ pending: false, tool: null });
70
- });
71
-
72
- it('returns pending:false when numbered 1/3 choices are present but no intro pattern matches', () => {
73
- const result = detectPermissionPrompt(NUMBERED_CHOICES_NO_INTRO);
74
- expect(result).toEqual({ pending: false, tool: null });
75
- });
76
-
77
- it('truncates a tool string built from a 200-char backtick descriptor to at most 60 chars ending with "…"', () => {
78
- const longCommand = 'x'.repeat(200);
79
- const pane = [
80
- `Allow Bash to run \`${longCommand}\`?`,
81
- '',
82
- ' 1. Yes',
83
- ' 2. Yes, and don\'t ask again for Bash commands',
84
- ' 3. No, and tell Claude what to do differently (esc)',
85
- ].join('\n');
86
- const result = detectPermissionPrompt(pane);
87
- expect(result.pending).toBe(true);
88
- expect(result.tool).not.toBeNull();
89
- expect(result.tool!.length).toBeLessThanOrEqual(60);
90
- expect(result.tool!.endsWith('…')).toBe(true);
91
- });
92
- });
@@ -1,55 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import {
4
- claimEmbeddedSessionLease,
5
- embeddedSessionLeaseIsCurrent,
6
- runEmbeddedSessionOperation,
7
- } from '../../src/tui/embedded-session-lifecycle.js';
8
-
9
- function createDeferred(): {
10
- promise: Promise<void>;
11
- resolve: () => void;
12
- } {
13
- let resolve: (() => void) | undefined;
14
- const promise = new Promise<void>((res) => {
15
- resolve = res;
16
- });
17
-
18
- return {
19
- promise,
20
- resolve: () => resolve?.(),
21
- };
22
- }
23
-
24
- describe('embedded-session-lifecycle', () => {
25
- it('serializes operations for the same embedded session', async () => {
26
- const events: string[] = [];
27
- const first = createDeferred();
28
-
29
- const firstOperation = runEmbeddedSessionOperation('embed', async () => {
30
- events.push('first:start');
31
- await first.promise;
32
- events.push('first:end');
33
- });
34
-
35
- const secondOperation = runEmbeddedSessionOperation('embed', async () => {
36
- events.push('second');
37
- });
38
-
39
- await Promise.resolve();
40
- expect(events).toEqual(['first:start']);
41
-
42
- first.resolve();
43
-
44
- await Promise.all([firstOperation, secondOperation]);
45
- expect(events).toEqual(['first:start', 'first:end', 'second']);
46
- });
47
-
48
- it('marks stale embedded session leases as no longer current', () => {
49
- const firstLease = claimEmbeddedSessionLease('embed');
50
- const secondLease = claimEmbeddedSessionLease('embed');
51
-
52
- expect(embeddedSessionLeaseIsCurrent(firstLease)).toBe(false);
53
- expect(embeddedSessionLeaseIsCurrent(secondLease)).toBe(true);
54
- });
55
- });
@@ -1,80 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import xtermHeadless from '@xterm/headless';
3
- import { buildSnapshot } from '../../src/tui/embedded-terminal.js';
4
-
5
- const { Terminal } = xtermHeadless;
6
-
7
- async function write(terminal: InstanceType<typeof Terminal>, data: string): Promise<void> {
8
- await new Promise<void>((resolve) => {
9
- terminal.write(data, () => {
10
- resolve();
11
- });
12
- });
13
- }
14
-
15
- describe('buildSnapshot', () => {
16
- it('preserves ANSI colors and text styles from the xterm buffer', async () => {
17
- const terminal = new Terminal({
18
- allowProposedApi: true,
19
- cols: 20,
20
- rows: 1,
21
- });
22
-
23
- await write(terminal, '\u001b[31mred\u001b[39m plain \u001b[38;2;1;2;3m\u001b[48;5;4m\u001b[1;3;4;9mrgb\u001b[0m');
24
-
25
- const snapshot = buildSnapshot(terminal, 'Claude', true);
26
-
27
- expect(snapshot.lines[0]).toEqual({
28
- spans: [
29
- { text: 'red', color: 'ansi256(1)' },
30
- { text: ' plain ' },
31
- {
32
- text: 'rgb',
33
- color: 'rgb(1, 2, 3)',
34
- backgroundColor: 'ansi256(4)',
35
- underline: true,
36
- },
37
- { text: ' ', cursor: true },
38
- ],
39
- });
40
- });
41
-
42
- it('trims trailing plain whitespace while keeping the visible text', async () => {
43
- const terminal = new Terminal({
44
- allowProposedApi: true,
45
- cols: 10,
46
- rows: 1,
47
- });
48
-
49
- await write(terminal, 'hello');
50
-
51
- const snapshot = buildSnapshot(terminal, 'Claude', true);
52
-
53
- expect(snapshot.lines[0]).toEqual({
54
- spans: [
55
- { text: 'hello' },
56
- { text: ' ', cursor: true },
57
- ],
58
- });
59
- });
60
-
61
- it('tracks the cursor over the active terminal cell', async () => {
62
- const terminal = new Terminal({
63
- allowProposedApi: true,
64
- cols: 10,
65
- rows: 1,
66
- });
67
-
68
- await write(terminal, 'hello\u001b[2D');
69
-
70
- const snapshot = buildSnapshot(terminal, 'Claude', true);
71
-
72
- expect(snapshot.lines[0]).toEqual({
73
- spans: [
74
- { text: 'hel' },
75
- { text: 'l', cursor: true },
76
- { text: 'o' },
77
- ],
78
- });
79
- });
80
- });
@@ -1,25 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { formatRelativeTime } from '../../src/tui/format-time.js';
3
-
4
- const NOW = '2026-05-29T12:00:00Z';
5
-
6
- describe('formatRelativeTime', () => {
7
- it('shows <1s for sub-second deltas', () => {
8
- expect(formatRelativeTime('2026-05-29T11:59:59.500Z', NOW)).toBe('<1s');
9
- });
10
- it('shows seconds', () => {
11
- expect(formatRelativeTime('2026-05-29T11:59:52Z', NOW)).toBe('8s');
12
- });
13
- it('shows minutes', () => {
14
- expect(formatRelativeTime('2026-05-29T11:58:00Z', NOW)).toBe('2m');
15
- });
16
- it('shows hours', () => {
17
- expect(formatRelativeTime('2026-05-29T09:00:00Z', NOW)).toBe('3h');
18
- });
19
- it('shows days', () => {
20
- expect(formatRelativeTime('2026-05-27T12:00:00Z', NOW)).toBe('2d');
21
- });
22
- it('returns ? for an unparseable timestamp', () => {
23
- expect(formatRelativeTime('not-a-date', NOW)).toBe('?');
24
- });
25
- });
@@ -1,93 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { reduceKey, type UiState, type KeyInput } from '../../src/tui/keymap.js';
3
-
4
- function ui(over: Partial<UiState> = {}): UiState {
5
- return { selectedIndex: 0, musicianCount: 3, pendingDismissIndex: null, ...over };
6
- }
7
- function key(over: Partial<KeyInput> = {}): KeyInput {
8
- return {
9
- input: '',
10
- downArrow: false,
11
- upArrow: false,
12
- tab: false,
13
- shiftTab: false,
14
- return: false,
15
- escape: false,
16
- ...over,
17
- };
18
- }
19
-
20
- describe('reduceKey', () => {
21
- it('down arrow / j moves selection down, clamped', () => {
22
- expect(reduceKey(ui({ selectedIndex: 0 }), key({ downArrow: true })).ui.selectedIndex).toBe(1);
23
- expect(reduceKey(ui({ selectedIndex: 0 }), key({ input: 'j' })).ui.selectedIndex).toBe(1);
24
- expect(reduceKey(ui({ selectedIndex: 3, musicianCount: 3 }), key({ downArrow: true })).ui.selectedIndex).toBe(3);
25
- });
26
- it('up arrow / k moves selection up, clamped', () => {
27
- expect(reduceKey(ui({ selectedIndex: 3 }), key({ upArrow: true })).ui.selectedIndex).toBe(2);
28
- expect(reduceKey(ui({ selectedIndex: 0 }), key({ input: 'k' })).ui.selectedIndex).toBe(0);
29
- });
30
- it('Enter emits an open-target action for the selected index', () => {
31
- const r = reduceKey(ui({ selectedIndex: 1 }), key({ return: true }));
32
- expect(r.action).toEqual({ kind: 'open-target', selectedIndex: 1 });
33
- });
34
- it('Enter with zero musicians still opens the orchestrator target', () => {
35
- const r = reduceKey(ui({ selectedIndex: 0, musicianCount: 0 }), key({ return: true }));
36
- expect(r.action).toEqual({ kind: 'open-target', selectedIndex: 0 });
37
- });
38
- it('Tab emits next-orchestra, Shift-Tab prev-orchestra', () => {
39
- expect(reduceKey(ui(), key({ tab: true })).action).toEqual({ kind: 'next-orchestra' });
40
- expect(reduceKey(ui(), key({ shiftTab: true })).action).toEqual({ kind: 'prev-orchestra' });
41
- });
42
- it('n emits open-notes and q emits detach-session', () => {
43
- expect(reduceKey(ui(), key({ input: 'n' })).action).toEqual({ kind: 'open-notes' });
44
- expect(reduceKey(ui(), key({ input: 'q' })).action).toEqual({ kind: 'detach-session' });
45
- });
46
- it('d on the orchestrator row is a no-op', () => {
47
- const r = reduceKey(ui({ selectedIndex: 0 }), key({ input: 'd' }));
48
- expect(r.ui.pendingDismissIndex).toBeNull();
49
- expect(r.action).toBeUndefined();
50
- });
51
- it('d arms dismiss; second d confirms dismiss', () => {
52
- const armed = reduceKey(ui({ selectedIndex: 2 }), key({ input: 'd' }));
53
- expect(armed.ui.pendingDismissIndex).toBe(1);
54
- expect(armed.action).toEqual({ kind: 'request-dismiss-musician', index: 1 });
55
-
56
- const confirmed = reduceKey(armed.ui, key({ input: 'd' }));
57
- expect(confirmed.ui.pendingDismissIndex).toBeNull();
58
- expect(confirmed.action).toEqual({ kind: 'dismiss-musician', index: 1 });
59
- });
60
- it('pending dismiss confirms on y/Enter and cancels on n/Esc', () => {
61
- const pending = ui({ selectedIndex: 2, pendingDismissIndex: 1 });
62
- expect(reduceKey(pending, key({ input: 'y' })).action).toEqual({ kind: 'dismiss-musician', index: 1 });
63
- expect(reduceKey(pending, key({ return: true })).action).toEqual({ kind: 'dismiss-musician', index: 1 });
64
-
65
- const canceledByN = reduceKey(pending, key({ input: 'n' }));
66
- expect(canceledByN.ui.pendingDismissIndex).toBeNull();
67
- expect(canceledByN.action).toBeUndefined();
68
-
69
- const canceledByEsc = reduceKey(pending, key({ escape: true }));
70
- expect(canceledByEsc.ui.pendingDismissIndex).toBeNull();
71
- expect(canceledByEsc.action).toBeUndefined();
72
- });
73
- it('unknown key is a no-op', () => {
74
- const r = reduceKey(ui({ selectedIndex: 1 }), key({ input: 'z' }));
75
- expect(r.ui.selectedIndex).toBe(1);
76
- expect(r.action).toBeUndefined();
77
- });
78
- it('p with non-zero musicians emits jump-to-pending', () => {
79
- const r = reduceKey(ui({ musicianCount: 3 }), key({ input: 'p' }));
80
- expect(r.action).toEqual({ kind: 'jump-to-pending' });
81
- });
82
- it('p with zero musicians still emits jump-to-pending (App resolves the no-pending case)', () => {
83
- const r = reduceKey(ui({ musicianCount: 0 }), key({ input: 'p' }));
84
- expect(r.action).toEqual({ kind: 'jump-to-pending' });
85
- });
86
- it("'?' emits toggle-help", () => {
87
- const result = reduceKey(
88
- { selectedIndex: 0, musicianCount: 0 },
89
- { input: '?', downArrow: false, upArrow: false, tab: false, shiftTab: false, return: false, escape: false },
90
- );
91
- expect(result.action).toEqual({ kind: 'toggle-help' });
92
- });
93
- });
@@ -1,81 +0,0 @@
1
- import { describe, it, expect, afterEach, beforeEach } from 'vitest';
2
- import { pollActivity } from '../../src/tui/poll-activity.js';
3
- import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
4
- import { makeTmpConfig } from '../helpers/tmp-config.js';
5
- import { ensureOrchestraDir, writeState, readState } from '../../src/state.js';
6
- import { makeInitialState } from '../../src/state.types.js';
7
- import { projectKeyFromPath } from '../../src/project-key.js';
8
- import { addMusician } from '../../src/state-updaters.js';
9
- import { createDetachedSession, sessionName, killSession, sendKeys } from '../../src/tmux.js';
10
- import { execa } from 'execa';
11
-
12
- describe('pollActivity', () => {
13
- const cleanups: Array<() => Promise<void>> = [];
14
- const sessionsToKill: string[] = [];
15
-
16
- beforeEach(() => { process.env.NFO_HOME = ''; });
17
- afterEach(async () => {
18
- for (const s of sessionsToKill) {
19
- try { await killSession(s); } catch { /* ignore */ }
20
- }
21
- sessionsToKill.length = 0;
22
- for (const c of cleanups) { await c(); }
23
- cleanups.length = 0;
24
- delete process.env.NFO_HOME;
25
- });
26
-
27
- it('returns the last activity line per active musician', async () => {
28
- const cfg = await makeTmpConfig();
29
- cleanups.push(cfg.cleanup);
30
- process.env.NFO_HOME = cfg.path;
31
- const repo: TmpRepo = await makeTmpRepo();
32
- cleanups.push(repo.cleanup);
33
- const orchId = projectKeyFromPath(repo.path);
34
- await ensureOrchestraDir(orchId);
35
- await writeState(orchId, makeInitialState({
36
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
37
- }));
38
- const sess = sessionName(orchId);
39
- sessionsToKill.push(sess);
40
- await createDetachedSession(sess, repo.path, 220, 50);
41
- const { stdout: winId } = await execa('tmux', [
42
- 'new-window', '-t', sess, '-n', 'mus-001-x', '-c', repo.path, '-d',
43
- '-P', '-F', '#{window_id}',
44
- ]);
45
- await addMusician(orchId, {
46
- id: 'mus-001', name: 'x', task_summary: 't', status: 'working',
47
- tmux_window_id: winId.trim(), claude_session_id: null,
48
- worktree_path: null, branch: null,
49
- spawned_at: '2026-05-29T10:00:00Z', last_activity: '2026-05-29T10:00:00Z',
50
- });
51
- await sendKeys(`${sess}:${winId.trim()}`, 'echo nfo-activity-xyz', true);
52
- await new Promise((r) => { setTimeout(r, 250); });
53
-
54
- const state = await readState(orchId);
55
- const activity = await pollActivity(state!);
56
- expect(activity['mus-001']).toContain('nfo-activity-xyz');
57
- });
58
-
59
- it('skips stopped musicians', async () => {
60
- const cfg = await makeTmpConfig();
61
- cleanups.push(cfg.cleanup);
62
- process.env.NFO_HOME = cfg.path;
63
- const repo: TmpRepo = await makeTmpRepo();
64
- cleanups.push(repo.cleanup);
65
- const orchId = projectKeyFromPath(repo.path);
66
- await ensureOrchestraDir(orchId);
67
- const initial = makeInitialState({
68
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
69
- });
70
- initial.musicians.push({
71
- id: 'mus-001', name: 'x', task_summary: 't', status: 'stopped',
72
- tmux_window_id: '@gone', claude_session_id: null,
73
- worktree_path: null, branch: null,
74
- spawned_at: '2026-05-29T10:00:00Z', last_activity: '2026-05-29T10:00:00Z',
75
- });
76
- await writeState(orchId, initial);
77
- const state = await readState(orchId);
78
- const activity = await pollActivity(state!);
79
- expect(activity['mus-001']).toBeUndefined();
80
- });
81
- });
@@ -1,159 +0,0 @@
1
- import { describe, it, expect, afterEach, beforeEach } from 'vitest';
2
- import { readFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { execa } from 'execa';
5
- import { ensureOrchestraDir, readState, writeState } from '../../src/state.js';
6
- import { addMusician } from '../../src/state-updaters.js';
7
- import { makeInitialState } from '../../src/state.types.js';
8
- import { messageMusician } from '../../src/musicians/message.js';
9
- import { messageLogsDir } from '../../src/config.js';
10
- import { projectKeyFromPath } from '../../src/project-key.js';
11
- import {
12
- capturePane,
13
- createDetachedSession,
14
- killSession,
15
- sessionName,
16
- } from '../../src/tmux.js';
17
- import { hasClaudeInputPrompt, syncMusicianIdleState } from '../../src/tui/poll-idle.js';
18
- import { makeTmpConfig } from '../helpers/tmp-config.js';
19
- import { makeTmpRepo, type TmpRepo } from '../helpers/tmp-repo.js';
20
-
21
- describe('pollIdle', () => {
22
- const cleanups: Array<() => Promise<void>> = [];
23
- const sessionsToKill: string[] = [];
24
-
25
- beforeEach(() => { process.env.NFO_HOME = ''; });
26
-
27
- afterEach(async () => {
28
- for (const session of sessionsToKill) {
29
- try { await killSession(session); } catch { /* ignore */ }
30
- }
31
- sessionsToKill.length = 0;
32
- for (const cleanup of cleanups) {
33
- await cleanup();
34
- }
35
- cleanups.length = 0;
36
- delete process.env.NFO_HOME;
37
- });
38
-
39
- it('detects the Claude input prompt only when it is near the pane bottom', () => {
40
- expect(hasClaudeInputPrompt([
41
- 'Claude transcript',
42
- '❯ Ask alpha to wrap up',
43
- 'Processing...',
44
- ].join('\n'))).toBe(false);
45
-
46
- expect(hasClaudeInputPrompt([
47
- 'Completed work.',
48
- '────────────────────────────────────────────────────────────',
49
- '❯ ',
50
- '────────────────────────────────────────────────────────────',
51
- '⏵⏵ bypass permissions on (shift+tab to cycle)',
52
- ].join('\n'))).toBe(true);
53
- });
54
-
55
- const promptCommand = "bash -lc \"printf '\\n❯\\n'; cat\"";
56
-
57
- it('marks a quiet musician idle when Claude is back at the prompt', async () => {
58
- const cfg = await makeTmpConfig();
59
- cleanups.push(cfg.cleanup);
60
- process.env.NFO_HOME = cfg.path;
61
- const repo: TmpRepo = await makeTmpRepo();
62
- cleanups.push(repo.cleanup);
63
-
64
- const orchId = projectKeyFromPath(repo.path);
65
- await ensureOrchestraDir(orchId);
66
- await writeState(orchId, makeInitialState({
67
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
68
- }));
69
-
70
- const sess = sessionName(orchId);
71
- sessionsToKill.push(sess);
72
- await createDetachedSession(sess, repo.path, 220, 50);
73
- const { stdout: winId } = await execa('tmux', [
74
- 'new-window', '-t', sess, '-n', 'mus-001-alpha', '-c', repo.path, '-d',
75
- '-P', '-F', '#{window_id}',
76
- promptCommand,
77
- ]);
78
-
79
- await addMusician(orchId, {
80
- id: 'mus-001',
81
- name: 'alpha',
82
- task_summary: 'wait for follow-up',
83
- status: 'working',
84
- tmux_window_id: winId.trim(),
85
- claude_session_id: null,
86
- worktree_path: null,
87
- branch: null,
88
- spawned_at: '2026-05-29T10:00:00Z',
89
- last_activity: '2026-05-29T10:00:00Z',
90
- });
91
-
92
- await new Promise((resolve) => { setTimeout(resolve, 250); });
93
-
94
- const before = await capturePane(`${sess}:${winId.trim()}`, 20);
95
- expect(hasClaudeInputPrompt(before)).toBe(true);
96
-
97
- await syncMusicianIdleState(orchId, {}, '2026-05-29T10:00:31Z');
98
-
99
- const state = await readState(orchId);
100
- expect(state?.musicians[0].status).toBe('idle');
101
- });
102
-
103
- it('flushes queued messages once a working musician is visibly idle', async () => {
104
- const cfg = await makeTmpConfig();
105
- cleanups.push(cfg.cleanup);
106
- process.env.NFO_HOME = cfg.path;
107
- const repo: TmpRepo = await makeTmpRepo();
108
- cleanups.push(repo.cleanup);
109
-
110
- const orchId = projectKeyFromPath(repo.path);
111
- await ensureOrchestraDir(orchId);
112
- await writeState(orchId, makeInitialState({
113
- orchestraId: orchId, projectPath: repo.path, permissionLevel: 'supervised',
114
- }));
115
-
116
- const sess = sessionName(orchId);
117
- sessionsToKill.push(sess);
118
- await createDetachedSession(sess, repo.path, 220, 50);
119
- const { stdout: winId } = await execa('tmux', [
120
- 'new-window', '-t', sess, '-n', 'mus-001-alpha', '-c', repo.path, '-d',
121
- '-P', '-F', '#{window_id}',
122
- promptCommand,
123
- ]);
124
-
125
- await addMusician(orchId, {
126
- id: 'mus-001',
127
- name: 'alpha',
128
- task_summary: 'wait for follow-up',
129
- status: 'working',
130
- tmux_window_id: winId.trim(),
131
- claude_session_id: null,
132
- worktree_path: null,
133
- branch: null,
134
- spawned_at: '2026-05-29T10:00:00Z',
135
- last_activity: '2026-05-29T10:00:00Z',
136
- });
137
-
138
- await new Promise((resolve) => { setTimeout(resolve, 250); });
139
-
140
- const queued = await messageMusician({
141
- orchestraId: orchId,
142
- musicianId: 'mus-001',
143
- message: 'echo idle-drain-test',
144
- });
145
- expect(queued.delivery).toBe('queued');
146
-
147
- await syncMusicianIdleState(orchId, {}, '2026-05-29T10:00:31Z');
148
- await new Promise((resolve) => { setTimeout(resolve, 250); });
149
-
150
- const out = await capturePane(`${sess}:${winId.trim()}`, 30);
151
- expect(out).toContain('idle-drain-test');
152
-
153
- const state = await readState(orchId);
154
- expect(state?.musicians[0].status).toBe('working');
155
-
156
- const log = await readFile(join(messageLogsDir(orchId), 'mus-001.jsonl'), 'utf8');
157
- expect(log).toContain('"type":"message_delivered"');
158
- });
159
- });